Skip to content

Commit d983012

Browse files
authored
🐛 Add renderings of failed image and video loading #1872
2 parents f0e8e4c + c616655 commit d983012

File tree

6 files changed

+200
-33
lines changed

6 files changed

+200
-33
lines changed

frontend/app/[locale]/agents/components/agent/AgentConfigModal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,13 +1071,13 @@ export default function AgentConfigModal({
10711071
const tooltipText = getButtonTitle();
10721072
return (
10731073
tooltipText ||
1074-
t("businessLogic.config.button.saveToAgentPool")
1074+
t("common.save")
10751075
);
10761076
}
1077-
return t("businessLogic.config.button.saveToAgentPool");
1077+
return t("common.save");
10781078
})()}
10791079
>
1080-
{t("businessLogic.config.button.saveToAgentPool")}
1080+
{t("common.save")}
10811081
</Button>
10821082
) : (
10831083
<Button

frontend/components/homepage/AuthDialogs.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export function AuthDialogs({
7979
<div className="mt-4">
8080
<p className="text-base font-medium">
8181
<Trans i18nKey="page.loginPrompt.githubSupport">
82-
⭐️ Nexent is still growing, please help me by starring on{" "}
82+
⭐️ Nexent is still growing, please help me by starring on
8383
<a
8484
href="https://github.com/ModelEngine-Group/nexent"
8585
target="_blank"
@@ -151,7 +151,7 @@ export function AuthDialogs({
151151
<div className="mt-4">
152152
<p className="text-base font-medium">
153153
<Trans i18nKey="page.adminPrompt.githubSupport">
154-
⭐️ Nexent is still growing, please help me by starring on{" "}
154+
⭐️ Nexent is still growing, please help me by starring on
155155
<a
156156
href="https://github.com/ModelEngine-Group/nexent"
157157
target="_blank"
@@ -165,15 +165,15 @@ export function AuthDialogs({
165165
<br />
166166
<br />
167167
<Trans i18nKey="page.adminPrompt.becomeAdmin">
168-
💡 Want to become an administrator? Please visit the{" "}
168+
💡 Want to become an administrator? Please visit the
169169
<a
170170
href="http://nexent.tech/contact"
171171
target="_blank"
172172
rel="noopener noreferrer"
173173
className="text-blue-600 hover:text-blue-700 font-bold"
174174
>
175175
official contact page
176-
</a>{" "}
176+
</a>
177177
to apply for an administrator account.
178178
</Trans>
179179
</p>

frontend/components/ui/markdownRenderer.tsx

Lines changed: 148 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
1212
// @ts-ignore
1313
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
1414
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
15+
import { visit } from "unist-util-visit";
1516

1617
import { SearchResult } from "@/types/chat";
1718
import {
@@ -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
58100
const 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+
384522
export 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+
};

frontend/public/locales/en/common.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@
188188
"chatStreamMessage.cancelDislike": "Cancel dislike",
189189
"chatStreamMessage.dislike": "Dislike",
190190
"chatStreamMessage.tts": "Text-to-Speech",
191+
"chatStreamMessage.imageTextFallbackTitle": "Media (text view)",
192+
"chatStreamMessage.videoNotSupported": "Sorry, your browser does not support embedded videos.",
193+
"chatStreamMessage.imageLinkUnavailable": "This image link is unavailable",
194+
"chatStreamMessage.videoLinkUnavailable": "This video link is unavailable",
195+
"chatStreamMessage.imageLoadFailed": "Image failed to load",
196+
"chatStreamMessage.videoLoadFailed": "Video failed to load",
191197

192198
"chatRightPanel.imageLoadFailed": "Failed to load image",
193199
"chatRightPanel.imageProxyError": "Failed to request image proxy service:",
@@ -697,7 +703,6 @@
697703
"businessLogic.config.maxSteps": "Max Steps of Agent Run",
698704
"businessLogic.config.button.generatePrompt": "Generate",
699705
"businessLogic.config.button.generating": "Generating...",
700-
"businessLogic.config.button.saveToAgentPool": "Save to Agent Pool",
701706
"businessLogic.config.modal.deleteTitle": "Confirm Delete",
702707
"businessLogic.config.modal.deleteContent": "Are you sure you want to delete this agent? This action cannot be undone.",
703708
"businessLogic.config.modal.button.cancel": "Cancel",

frontend/public/locales/zh/common.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@
188188
"chatStreamMessage.cancelDislike": "取消点踩",
189189
"chatStreamMessage.dislike": "点踩",
190190
"chatStreamMessage.tts": "语音播报",
191+
"chatStreamMessage.imageTextFallbackTitle": "媒体(文本视图)",
192+
"chatStreamMessage.videoNotSupported": "抱歉,您的浏览器不支持嵌入式视频。",
193+
"chatStreamMessage.imageLinkUnavailable": "此图片链接不可用",
194+
"chatStreamMessage.videoLinkUnavailable": "此视频链接不可用",
195+
"chatStreamMessage.imageLoadFailed": "图片加载失败",
196+
"chatStreamMessage.videoLoadFailed": "视频加载失败",
191197

192198
"chatRightPanel.imageLoadFailed": "图片加载失败",
193199
"chatRightPanel.imageProxyError": "请求图片代理服务失败:",
@@ -697,7 +703,6 @@
697703
"businessLogic.config.maxSteps": "Agent运行最大步骤数",
698704
"businessLogic.config.button.generatePrompt": "生成智能体",
699705
"businessLogic.config.button.generating": "智能生成提示词中...",
700-
"businessLogic.config.button.saveToAgentPool": "保存到Agent池",
701706
"businessLogic.config.modal.deleteTitle": "确认删除",
702707
"businessLogic.config.modal.deleteContent": "确定要删除Agent {{name}} 吗?此操作不可恢复。",
703708
"businessLogic.config.modal.button.cancel": "取消",

frontend/styles/react-markdown.css

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,39 @@
277277
line-height: 1.4;
278278
}
279279

280+
/* Media Error Styles */
281+
.markdown-media-error {
282+
margin: 0.5rem 0;
283+
padding: 0.5rem 0.75rem;
284+
border: 1px solid #e5e7eb;
285+
border-radius: 0.5rem;
286+
background-color: #f9fafb;
287+
display: flex;
288+
flex-direction: column;
289+
gap: 0.25rem;
290+
align-items: center;
291+
justify-content: center;
292+
min-height: 60px;
293+
max-width: 800px;
294+
width: fit-content;
295+
margin-left: auto;
296+
margin-right: auto;
297+
}
298+
299+
.markdown-media-error-message {
300+
font-size: 0.875rem;
301+
color: #6b7280;
302+
text-align: center;
303+
font-weight: 500;
304+
}
305+
306+
.markdown-media-error-caption {
307+
font-size: 0.75rem;
308+
color: #9ca3af;
309+
text-align: center;
310+
font-style: italic;
311+
}
312+
280313
/* Global markdown container */
281314
.task-message-content {
282315
color: hsl(var(--foreground)) !important;

0 commit comments

Comments
 (0)