Fix: code supports matplotlib (#13724)

### What problem does this PR solve?

Code as "final" node: 

![img_v3_02vs_aece4caf-8403-4939-9e68-9845a22c2cfg](https://github.com/user-attachments/assets/9d87b8df-da6b-401c-bf6d-8b807fe92c22)

Code as "mid" node:

![img_v3_02vv_f74f331f-d755-44ab-a18c-96fff8cbd34g](https://github.com/user-attachments/assets/c94ef3f9-2a6c-47cb-9d2b-19703d2752e4)


### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Yongteng Lei
2026-03-20 20:32:00 +08:00
committed by GitHub
parent 0507463f4e
commit dd839f30e8
20 changed files with 905 additions and 482 deletions

View File

@ -211,7 +211,7 @@ const MarkdownContent = ({
const renderReference = useCallback(
(text: string) => {
let replacedText = reactStringReplace(text, currentReg, (match, i) => {
const replacedText = reactStringReplace(text, currentReg, (match, i) => {
const chunkIndex = getChunkIndex(match);
return (
@ -242,9 +242,7 @@ const MarkdownContent = ({
remarkPlugins={[remarkGfm, remarkMath]}
components={
{
p: ({ children, node, ...props }: any) => (
<p {...props}>{children}</p>
),
p: ({ children, ...props }: any) => <p {...props}>{children}</p>,
'custom-typography': ({ children }: { children: string }) =>
renderReference(children),
code(props: any) {

View File

@ -79,3 +79,27 @@
display: inline-block;
max-width: 40px;
}
.artifactImageWrapper {
display: block;
margin: 8px 0;
}
.artifactImage {
max-width: 100%;
max-height: 60vh;
border-radius: 8px;
border: 1px solid #e5e7eb;
display: block;
}
.artifactDownload {
display: inline-block;
margin-top: 4px;
font-size: 12px;
color: #1677ff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}

View File

@ -2,8 +2,10 @@ import Image from '@/components/image';
import SvgIcon from '@/components/svg-icon';
import { IReferenceChunk, IReferenceObject } from '@/interfaces/database/chat';
import { getExtension } from '@/utils/document-util';
import { downloadFileFromBlob } from '@/utils/file-util';
import request from '@/utils/request';
import DOMPurify from 'dompurify';
import { memo, useCallback, useEffect, useMemo } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import Markdown from 'react-markdown';
import SyntaxHighlighter from 'react-syntax-highlighter';
import rehypeKatex from 'rehype-katex';
@ -38,9 +40,120 @@ import {
HoverCardContent,
HoverCardTrigger,
} from '../ui/hover-card';
import message from '../ui/message';
import styles from './index.module.less';
const getChunkIndex = (match: string) => parseCitationIndex(match);
const isArtifactUrl = (url?: string) =>
Boolean(url && url.includes('/document/artifact/'));
const fetchArtifactBlob = async (url: string): Promise<Blob> => {
const response = await request(url, {
method: 'GET',
responseType: 'blob',
});
return response.data as Blob;
};
const getArtifactName = (url?: string, fallback?: string) =>
fallback || url?.split('/').pop()?.split('?')[0] || 'artifact';
function ArtifactLink({
href,
className,
children,
}: {
href: string;
className?: string;
children: React.ReactNode;
}) {
const handleClick = useCallback(
async (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
try {
const blob = await fetchArtifactBlob(href);
const objectUrl = URL.createObjectURL(blob);
window.open(objectUrl, '_blank', 'noopener,noreferrer');
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60 * 1000);
} catch {
message.error('Failed to open artifact');
}
},
[href],
);
return (
<a href={href} className={className} onClick={handleClick}>
{children}
</a>
);
}
function ArtifactImage({
src,
alt,
downloadLabel,
}: {
src: string;
alt?: string;
downloadLabel: string;
}) {
const [imageSrc, setImageSrc] = useState('');
useEffect(() => {
let objectUrl = '';
let active = true;
const load = async () => {
try {
const blob = await fetchArtifactBlob(src);
objectUrl = URL.createObjectURL(blob);
if (active) {
setImageSrc(objectUrl);
}
} catch {
message.error('Failed to load artifact image');
}
};
load();
return () => {
active = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [alt, src]);
const handleDownload = useCallback(async () => {
try {
const blob = await fetchArtifactBlob(src);
downloadFileFromBlob(blob, getArtifactName(src, alt));
} catch {
message.error('Failed to download artifact');
}
}, [alt, src]);
return (
<span className={styles.artifactImageWrapper}>
{imageSrc ? (
<img src={imageSrc} alt={alt || ''} className={styles.artifactImage} />
) : (
<span className={styles.artifactImage} />
)}
<button
type="button"
className={styles.artifactDownload}
onClick={handleDownload}
>
{downloadLabel}
</button>
</span>
);
}
// TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
function MarkdownContent({
reference,
@ -213,7 +326,7 @@ function MarkdownContent({
const renderReference = useCallback(
(text: string) => {
let replacedText = reactStringReplace(text, currentReg, (match, i) => {
const replacedText = reactStringReplace(text, currentReg, (match, i) => {
const chunkIndex = getChunkIndex(match);
return (
@ -244,11 +357,44 @@ function MarkdownContent({
remarkPlugins={[remarkGfm, remarkMath]}
components={
{
p: ({ children, node, ...props }: any) => (
<p {...props}>{children}</p>
),
p: ({ children, ...props }: any) => <p {...props}>{children}</p>,
'custom-typography': ({ children }: { children: string }) =>
renderReference(children),
a({ href, children, ...props }: any) {
if (isArtifactUrl(href)) {
return (
<ArtifactLink href={href} className={styles.artifactDownload}>
{children}
</ArtifactLink>
);
}
return (
<a href={href} {...omit(props, 'node')}>
{children}
</a>
);
},
img({ src, alt, ...props }: any) {
if (isArtifactUrl(src)) {
return (
<ArtifactImage
src={src}
alt={alt || ''}
downloadLabel={t('common.download')}
/>
);
}
return (
<span className={styles.artifactImageWrapper}>
<img
src={src}
alt={alt || ''}
className={styles.artifactImage}
{...omit(props, 'node')}
/>
</span>
);
},
code(props: any) {
const { children, className, ...rest } = props;
const restProps = omit(rest, 'node');

View File

@ -42,6 +42,12 @@ const options = [
].map((x) => ({ value: x, label: x }));
const DynamicFieldName = 'outputs';
const CodeSystemOutputs = {
content: {
type: 'string',
value: '',
},
};
function CodeForm({ node }: INextOperatorForm) {
const formData = node?.data.form as ICodeForm;
@ -159,7 +165,12 @@ function CodeForm({ node }: INextOperatorForm) {
)}
</FormWrapper>
<div className="p-5">
<Output list={buildOutputList(formData.outputs)}></Output>
<Output
list={buildOutputList({
...(formData?.outputs ?? {}),
...CodeSystemOutputs,
})}
></Output>
</div>
</Form>
);

View File

@ -61,13 +61,28 @@ export function buildSecondaryOutputOptions(
}));
}
function getNodeOutputs(x: BaseNode) {
const outputs = x.data.form?.outputs ?? {};
if (x.data.label !== Operator.Code) {
return outputs;
}
return {
...outputs,
content: outputs.content ?? {
type: JsonSchemaDataType.String,
value: '',
},
};
}
export function buildOutputOptions(x: BaseNode) {
return {
label: x.data.name,
value: x.id,
title: x.data.name,
options: buildSecondaryOutputOptions(
x.data.form.outputs,
getNodeOutputs(x),
x.id,
x.data.name,
<OperatorIcon name={x.data.label as Operator} />,
@ -83,7 +98,7 @@ export function buildNodeOutputOptions({
nodeIds: string[];
}) {
const nodeWithOutputList = nodes.filter(
(x) => nodeIds.some((y) => y === x.id) && !isEmpty(x.data?.form?.outputs),
(x) => nodeIds.some((y) => y === x.id) && !isEmpty(getNodeOutputs(x)),
);
return nodeWithOutputList.map((x) => buildOutputOptions(x));
@ -114,7 +129,7 @@ export function buildChildOutputOptions({
nodeId?: string;
}) {
const nodeWithOutputList = nodes.filter(
(x) => x.parentId === nodeId && !isEmpty(x.data?.form?.outputs),
(x) => x.parentId === nodeId && !isEmpty(getNodeOutputs(x)),
);
return nodeWithOutputList.map((x) => buildOutputOptions(x));