📝 fix: Resolve Markdown Rendering Issues (#8352)

* 🔧 fix: Handle optional arguments in `useParseArgs` and improve tool call condition

* chore: Remove math plugins from `MarkdownLite`

*  feat: Add Error Boundary to Markdown Component for Enhanced Error Handling

- Introduced `MarkdownErrorBoundary` to catch and display errors during Markdown rendering.
- Updated the `Markdown` component to utilize the new error boundary, improving user experience by handling rendering issues gracefully.

* Revert "chore: Remove math plugins from `MarkdownLite`"

This reverts commit d393099d52.

*  feat: Introduce MarkdownErrorBoundary for improved error handling in Markdown components

* refactor: include most markdown elements in error boundary fallback, aside from problematic plugins
This commit is contained in:
Danny Avila 2025-07-10 08:38:14 -04:00 committed by GitHub
parent 4918899c8d
commit 4b32ec42c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 155 additions and 59 deletions

View file

@ -19,6 +19,7 @@ import { Citation, CompositeCitation, HighlightedText } from '~/components/Web/C
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact'; import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils'; import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils';
import CodeBlock from '~/components/Messages/Content/CodeBlock'; import CodeBlock from '~/components/Messages/Content/CodeBlock';
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
import useHasAccess from '~/hooks/Roles/useHasAccess'; import useHasAccess from '~/hooks/Roles/useHasAccess';
import { unicodeCitation } from '~/components/Web'; import { unicodeCitation } from '~/components/Web';
import { useFileDownload } from '~/data-provider'; import { useFileDownload } from '~/data-provider';
@ -219,6 +220,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
} }
return ( return (
<MarkdownErrorBoundary content={content} codeExecution={true}>
<ArtifactProvider> <ArtifactProvider>
<CodeBlockProvider> <CodeBlockProvider>
<ReactMarkdown <ReactMarkdown
@ -244,6 +246,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
</ReactMarkdown> </ReactMarkdown>
</CodeBlockProvider> </CodeBlockProvider>
</ArtifactProvider> </ArtifactProvider>
</MarkdownErrorBoundary>
); );
}); });

View file

@ -0,0 +1,90 @@
import React from 'react';
import remarkGfm from 'remark-gfm';
import supersub from 'remark-supersub';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import type { PluggableList } from 'unified';
import { code, codeNoExecution, a, p } from './Markdown';
import { CodeBlockProvider } from '~/Providers';
import { langSubset } from '~/utils';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface MarkdownErrorBoundaryProps {
children: React.ReactNode;
content: string;
codeExecution?: boolean;
}
class MarkdownErrorBoundary extends React.Component<
MarkdownErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: MarkdownErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Markdown rendering error:', error, errorInfo);
}
componentDidUpdate(prevProps: MarkdownErrorBoundaryProps) {
if (prevProps.content !== this.props.content && this.state.hasError) {
this.setState({ hasError: false, error: undefined });
}
}
render() {
if (this.state.hasError) {
const { content, codeExecution = true } = this.props;
const rehypePlugins: PluggableList = [
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
];
return (
<CodeBlockProvider>
<ReactMarkdown
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
]}
/** @ts-ignore */
rehypePlugins={rehypePlugins}
components={
{
code: codeExecution ? code : codeNoExecution,
a,
p,
} as {
[nodeType: string]: React.ElementType;
}
}
>
{content}
</ReactMarkdown>
</CodeBlockProvider>
);
}
return this.props.children;
}
}
export default MarkdownErrorBoundary;

View file

@ -8,6 +8,7 @@ import rehypeHighlight from 'rehype-highlight';
import type { PluggableList } from 'unified'; import type { PluggableList } from 'unified';
import { code, codeNoExecution, a, p } from './Markdown'; import { code, codeNoExecution, a, p } from './Markdown';
import { CodeBlockProvider, ArtifactProvider } from '~/Providers'; import { CodeBlockProvider, ArtifactProvider } from '~/Providers';
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
import { langSubset } from '~/utils'; import { langSubset } from '~/utils';
const MarkdownLite = memo( const MarkdownLite = memo(
@ -25,6 +26,7 @@ const MarkdownLite = memo(
]; ];
return ( return (
<MarkdownErrorBoundary content={content} codeExecution={codeExecution}>
<ArtifactProvider> <ArtifactProvider>
<CodeBlockProvider> <CodeBlockProvider>
<ReactMarkdown <ReactMarkdown
@ -51,6 +53,7 @@ const MarkdownLite = memo(
</ReactMarkdown> </ReactMarkdown>
</CodeBlockProvider> </CodeBlockProvider>
</ArtifactProvider> </ArtifactProvider>
</MarkdownErrorBoundary>
); );
}, },
); );

View file

@ -85,7 +85,7 @@ const Part = memo(
const isToolCall = const isToolCall =
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL); 'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
if (isToolCall && toolCall.name === Tools.execute_code) { if (isToolCall && toolCall.name === Tools.execute_code && toolCall.args) {
return ( return (
<ExecuteCode <ExecuteCode
args={typeof toolCall.args === 'string' ? toolCall.args : ''} args={typeof toolCall.args === 'string' ? toolCall.args : ''}

View file

@ -10,23 +10,23 @@ import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
interface ParsedArgs { interface ParsedArgs {
lang: string; lang?: string;
code: string; code?: string;
} }
export function useParseArgs(args: string): ParsedArgs { export function useParseArgs(args?: string): ParsedArgs | null {
return useMemo(() => { return useMemo(() => {
let parsedArgs: ParsedArgs | string = args; let parsedArgs: ParsedArgs | string | undefined | null = args;
try { try {
parsedArgs = JSON.parse(args); parsedArgs = JSON.parse(args || '');
} catch { } catch {
// console.error('Failed to parse args:', e); // console.error('Failed to parse args:', e);
} }
if (typeof parsedArgs === 'object') { if (typeof parsedArgs === 'object') {
return parsedArgs; return parsedArgs;
} }
const langMatch = args.match(/"lang"\s*:\s*"(\w+)"/); const langMatch = args?.match(/"lang"\s*:\s*"(\w+)"/);
const codeMatch = args.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"(session_id|args)"|"\s*})/s); const codeMatch = args?.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"(session_id|args)"|"\s*})/s);
let code = ''; let code = '';
if (codeMatch) { if (codeMatch) {
@ -51,7 +51,7 @@ export default function ExecuteCode({
attachments, attachments,
}: { }: {
initialProgress: number; initialProgress: number;
args: string; args?: string;
output?: string; output?: string;
attachments?: TAttachment[]; attachments?: TAttachment[];
}) { }) {
@ -65,7 +65,7 @@ export default function ExecuteCode({
const outputRef = useRef<string>(output); const outputRef = useRef<string>(output);
const prevShowCodeRef = useRef<boolean>(showCode); const prevShowCodeRef = useRef<boolean>(showCode);
const { lang, code } = useParseArgs(args); const { lang, code } = useParseArgs(args) ?? ({} as ParsedArgs);
const progress = useProgress(initialProgress); const progress = useProgress(initialProgress);
useEffect(() => { useEffect(() => {
@ -144,7 +144,7 @@ export default function ExecuteCode({
onClick={() => setShowCode((prev) => !prev)} onClick={() => setShowCode((prev) => !prev)}
inProgressText={localize('com_ui_analyzing')} inProgressText={localize('com_ui_analyzing')}
finishedText={localize('com_ui_analyzing_finished')} finishedText={localize('com_ui_analyzing_finished')}
hasInput={!!code.length} hasInput={!!code?.length}
isExpanded={showCode} isExpanded={showCode}
/> />
</div> </div>