diff --git a/zeppelin-web-angular/projects/zeppelin-react/README.md b/zeppelin-web-angular/projects/zeppelin-react/README.md
index 4c45e4d7ad6..1a81621ec73 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/README.md
+++ b/zeppelin-web-angular/projects/zeppelin-react/README.md
@@ -201,4 +201,6 @@ Pass `describeParagraph` and `onOpenParagraph` to enable paragraph reference but
HTML is displayed as text and dangerous link URLs are blocked.
-See **Assistant / MarkdownAnswer** in Storybook for complete answers, streaming, and paragraph reference examples in both themes.
+Markdown images load only after you select **Load image**. Review the displayed destination first: loading may transmit information included in the URL. Only HTTP(S) URLs are supported.
+
+See **Assistant / MarkdownAnswer** in Storybook for examples in both themes.
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
index a39fa0701a1..2e54a84c9e0 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
@@ -119,6 +119,58 @@ export const paragraphReference = style({
}
});
+export const imagePlaceholder = style({
+ display: 'inline-flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ gap: vars.space2,
+ maxWidth: '100%',
+ margin: `${vars.space1} 0`,
+ padding: vars.space3,
+ border: `1px solid ${vars.border}`,
+ borderRadius: vars.radius,
+ background: vars.fill,
+ verticalAlign: 'middle'
+});
+
+export const imageDescription = style({
+ fontWeight: 600
+});
+
+export const imageDetails = style({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: vars.space1,
+ maxWidth: '100%',
+ color: vars.textSecondary,
+ fontSize: vars.fontSizeSm,
+ overflowWrap: 'anywhere'
+});
+
+export const loadImage = style({
+ padding: `${vars.space1} ${vars.space2}`,
+ border: `1px solid ${vars.accentBorder}`,
+ borderRadius: vars.radiusSm,
+ background: vars.accentBg,
+ color: vars.accentText,
+ font: 'inherit',
+ cursor: 'pointer',
+ selectors: {
+ '&:hover': { color: vars.accentTextHover }
+ }
+});
+
+export const loadedImage = style({
+ display: 'block',
+ maxWidth: '100%',
+ height: 'auto'
+});
+
+export const imageLink = style({
+ display: 'inline',
+ maxWidth: '100%'
+});
+
export const codeBlock = style({
minWidth: 0,
maxWidth: '100%',
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
index 629c6ae6209..46889eef7dd 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
@@ -16,6 +16,7 @@
*/
import { fireEvent, render, screen, within } from '@testing-library/react';
+import { StrictMode, useLayoutEffect } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { visuallyHidden } from '@/shared/ui/assistant-theme/AssistantScope.css';
import { MarkdownAnswer } from './MarkdownAnswer';
@@ -416,6 +417,274 @@ describe('MarkdownAnswer', () => {
});
});
+ describe('image loading', () => {
+ const sourceA = 'https://images.example.test/plot.svg?q=SYNTHETIC_NOTEBOOK_VALUE';
+ const sourceB = 'https://images.example.test/plot.svg?q=UPDATED_NOTEBOOK_VALUE';
+ const imageMarkdown = (source: string) => ``;
+
+ it('shows the resolved destination and warning without an image until explicitly activated', () => {
+ const { container } = render();
+
+ expect(screen.queryByRole('img')).toBeNull();
+ expect(container.querySelector('img, [src], [srcset], link[rel="preload"]')).toBeNull();
+ expect(screen.getByText(`Image URL: ${sourceA}`)).toBeTruthy();
+ expect(screen.getByText('Host: images.example.test')).toBeTruthy();
+ expect(screen.getByText(/loading.*sends a request.*information from.*answer/i)).toBeTruthy();
+ expect(container.textContent).toContain('Quarterly revenue');
+ const button = screen.getByRole('button', { name: /load image/i });
+
+ fireEvent.click(button);
+
+ const image = screen.getByRole('img', { name: 'Quarterly revenue' });
+ expect(image.getAttribute('src')).toBe(sourceA);
+ expect(image.getAttribute('alt')).toBe('Quarterly revenue');
+ expect(image.getAttribute('title')).toBe('Revenue plot');
+ expect(image.hasAttribute('srcset')).toBe(false);
+ });
+
+ it('accepts activation before passive image registration has run', () => {
+ const ActivateOnMount = () => {
+ useLayoutEffect(() => {
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+ }, []);
+ return ;
+ };
+
+ render();
+
+ expect(screen.getByRole('img', { name: 'Quarterly revenue' }).getAttribute('src')).toBe(sourceA);
+ });
+
+ it.each(['/assistant-plot.svg?q=relative', '//images.example.test/plot.svg?q=protocol-relative'])(
+ 'resolves destination %s against the actual document base URI',
+ relativeSource => {
+ const resolvedSource = new URL(relativeSource, document.baseURI);
+ render();
+
+ expect(screen.getByText(resolvedSource.href, { exact: false })).toBeTruthy();
+ expect(screen.getByText(text => text.endsWith(resolvedSource.hostname))).toBeTruthy();
+ expect(screen.queryByRole('img')).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+ expect(screen.getByRole('img').getAttribute('src')).toBe(resolvedSource.href);
+ }
+ );
+
+ it('requires fresh consent for query changes, including returning to a previously loaded URL', () => {
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+ expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
+
+ rerender();
+ expect(screen.queryByRole('img')).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+ expect(screen.getByRole('img').getAttribute('src')).toBe(sourceB);
+
+ rerender();
+ expect(screen.queryByRole('img')).toBeNull();
+ expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
+ });
+
+ it('revokes approval when a different destination is shown without being activated', () => {
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+ expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
+
+ rerender();
+ expect(screen.queryByRole('img')).toBeNull();
+ expect(screen.getByText(sourceB, { exact: false })).toBeTruthy();
+ expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
+
+ rerender();
+ expect(screen.queryByRole('img')).toBeNull();
+ expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
+ });
+
+ it('requires fresh consent when the destination path changes', () => {
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+
+ rerender();
+
+ expect(screen.queryByRole('img')).toBeNull();
+ expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
+ });
+
+ it('keeps approval for an unchanged URL while more answer text is appended', () => {
+ const content = `Revenue increased.\n\n${imageMarkdown(sourceA)}`;
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+
+ rerender();
+
+ expect(screen.getByRole('img', { name: 'Quarterly revenue' }).getAttribute('src')).toBe(sourceA);
+ expect(screen.queryByRole('button', { name: /load image/i })).toBeNull();
+ expect(screen.getByText('The next quarter also improved.')).toBeTruthy();
+ });
+
+ it('keeps approval and updates the title when a reference definition title is appended', () => {
+ const content = `![Existing][plot]\n\n[plot]: ${sourceA}`;
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+ expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA);
+
+ rerender();
+
+ const image = screen.getByRole('img', { name: 'Existing' });
+ expect(image.getAttribute('src')).toBe(sourceA);
+ expect(image.getAttribute('title')).toBe('Revenue plot');
+ });
+
+ it('keeps consent with its source occurrence when an earlier image reference resolves', () => {
+ const source = 'https://images.example.test/plot.svg';
+ const initial = `![New][later] `;
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole('button', { name: /Existing/ }));
+ expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(source);
+
+ rerender();
+
+ expect(screen.getByRole('button', { name: /New/ })).toBeTruthy();
+ expect(screen.queryByRole('img', { name: 'New' })).toBeNull();
+ expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(source);
+ expect(screen.queryByRole('button', { name: /Existing/ })).toBeNull();
+ expect(screen.getAllByRole('img')).toHaveLength(1);
+ });
+
+ it('keeps existing consent through StrictMode replay when an earlier image reference resolves', () => {
+ const initial = `![New][later] `;
+ const { rerender } = render(
+
+
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /Existing/ }));
+ expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA);
+
+ rerender(
+
+
+
+ );
+
+ expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA);
+ expect(screen.queryByRole('img', { name: 'New' })).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: /New/ }));
+
+ expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA);
+ expect(screen.getByRole('img', { name: 'New' }).getAttribute('src')).toBe(sourceB);
+ });
+
+ it('does not transfer approval to a replacement image at the same source position', () => {
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole('button', { name: /Load image: A/ }));
+ expect(screen.getByRole('img', { name: 'A' })).toBeTruthy();
+
+ rerender();
+
+ expect(screen.queryByRole('img')).toBeNull();
+ expect(screen.getByRole('button', { name: /Load image: B/ })).toBeTruthy();
+ });
+
+ it('does not share approval with another image in the same answer', () => {
+ render();
+ const buttons = screen.getAllByRole('button', { name: /load image/i });
+
+ fireEvent.click(buttons[0]);
+
+ expect(screen.getAllByRole('img')).toHaveLength(1);
+ expect(screen.getByRole('img', { name: 'Quarterly revenue' })).toBeTruthy();
+ expect(screen.queryByRole('img', { name: 'Comparison' })).toBeNull();
+ expect(screen.getAllByRole('button', { name: /load image/i })).toHaveLength(1);
+ });
+
+ it('does not share approval between independently rendered answers', () => {
+ const first = render();
+ fireEvent.click(within(first.container).getByRole('button', { name: /load image/i }));
+ const second = render();
+
+ expect(within(first.container).getByRole('img')).toBeTruthy();
+ expect(within(second.container).queryByRole('img')).toBeNull();
+ expect(within(second.container).getByRole('button', { name: /load image/i })).toBeTruthy();
+ });
+
+ it('requires fresh consent when removed image content is restored in the same answer', () => {
+ const content = imageMarkdown(sourceA);
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+ expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
+
+ rerender();
+ expect(screen.queryByRole('img')).toBeNull();
+ expect(screen.queryByRole('button', { name: /load image/i })).toBeNull();
+ expect(screen.getByText('No answer selected.')).toBeTruthy();
+
+ rerender();
+ expect(screen.queryByRole('img')).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+ expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
+ });
+
+ it('requires fresh consent when a previously displayed answer is restored after unmounting', () => {
+ const content = imageMarkdown(sourceA);
+ const first = render();
+ fireEvent.click(screen.getByRole('button', { name: /load image/i }));
+ expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
+
+ first.unmount();
+ render();
+
+ expect(screen.queryByRole('img')).toBeNull();
+ expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
+ });
+
+ it.each(['javascript:alert%281%29', 'ftp://images.example.test/plot.svg', ''])(
+ 'does not offer loading for the unsupported or empty image destination %s',
+ source => {
+ const { container } = render();
+
+ expect(screen.queryByRole('img')).toBeNull();
+ expect(screen.queryByRole('button', { name: /load image/i })).toBeNull();
+ expect(container.querySelector('[src], [srcset]')).toBeNull();
+ expect(container.textContent).toContain('Quarterly revenue');
+ }
+ );
+
+ it('keeps the original link separate from the image loading button and preserves mixed content', () => {
+ const href = 'https://zeppelin.apache.org/docs/latest/';
+ const { container } = render(
+
+ );
+ const button = screen.getByRole('button', { name: /load image/i });
+ const links = screen.getAllByRole('link');
+
+ expect(button.closest('a')).toBeNull();
+ expect(button.closest('p')).not.toBeNull();
+ expect(container.querySelector('p p, p div')).toBeNull();
+ expect(container.textContent).toContain('Before');
+ expect(screen.getByText('bold').tagName).toBe('STRONG');
+ expect(container.textContent).toContain('after');
+ for (const link of links) {
+ expect(link.getAttribute('href')).toBe(href);
+ expect(link.getAttribute('target')).toBe('_blank');
+ expect(link.getAttribute('rel')?.split(/\s+/)).toEqual(expect.arrayContaining(['noopener', 'noreferrer']));
+ }
+
+ fireEvent.click(button);
+
+ expect(screen.getByRole('img', { name: 'Quarterly revenue' }).getAttribute('src')).toBe(sourceA);
+ expect(screen.getAllByRole('link').map(link => link.getAttribute('href'))).toEqual(links.map(() => href));
+ });
+
+ it('retains a navigation link for an image-only Markdown link without nesting its load button', () => {
+ const href = 'https://zeppelin.apache.org/docs/latest/';
+ render();
+
+ const button = screen.getByRole('button', { name: /load image/i });
+ expect(button.closest('a')).toBeNull();
+ expect(screen.getByRole('link').getAttribute('href')).toBe(href);
+ });
+ });
+
it.each(dangerousUrls)('renders the rejected URL %s as non-navigable text', url => {
const { container } = render();
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
index 58aa97396d6..3268ed88aed 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
@@ -30,6 +30,36 @@ const streamingChunks = [
'GROUP BY product;\n```\n\nRevenue is grouped by product.'
];
+const imageFixtureUrl = new URL('../../../../test/fixtures/markdown-answer-image.svg?no-inline', import.meta.url).href;
+const imageQuerySeparator = imageFixtureUrl.includes('?') ? '&' : '?';
+const streamingImageChunks = [
+ '### Quarterly revenue\n\n![Quarterly revenue chart][revenue-chart]\n\nRevenue increased across the quarter.',
+ '\n\nThe strongest growth came from recurring sales.',
+ '\n\nThe updated chart includes the final month of the quarter.'
+];
+
+const StreamingImagesExample = (args: MarkdownAnswerProps) => {
+ const [step, setStep] = useState(0);
+ const completed = step === streamingImageChunks.length - 1;
+ const version = completed ? 'B' : 'A';
+ const content = `${streamingImageChunks.slice(0, step + 1).join('')}\n\n[revenue-chart]: ${imageFixtureUrl}${imageQuerySeparator}version=${version}`;
+
+ return (
+
+
+
+
+ Chunk {step + 1} of {streamingImageChunks.length}
+
+
+
+ );
+};
+
const ParagraphReferenceExample = ({ onOpenParagraph, ...args }: MarkdownAnswerProps) => {
const [selectedParagraph, setSelectedParagraph] = useState('No paragraph selected.');
@@ -237,3 +267,27 @@ export const Streaming: Story = {
}
}
};
+
+export const ImageLoading: Story = {
+ args: {
+ content: [
+ '### Quarterly revenue',
+ '',
+ 'Revenue increased across the quarter, led by recurring sales.',
+ '',
+ ``,
+ '',
+ 'The regional breakdown is shown in the chart accompanying the documentation:',
+ '',
+ `[Regional comparison: **revenue** ](https://zeppelin.apache.org/docs/latest/)`,
+ '',
+ 'Northern Europe contributed the largest share.'
+ ].join('\n')
+ }
+};
+
+export const StreamingImages: Story = {
+ render: args => ,
+ args: { content: streamingImageChunks[0] },
+ argTypes: { content: { control: false } }
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
index b5c69baf096..56f6d330875 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
@@ -18,11 +18,12 @@
import { createContext, useContext, useId, useMemo, type ComponentPropsWithoutRef, type MouseEvent } from 'react';
import Markdown, { type Components, type ExtraProps } from 'react-markdown';
import remarkGfm from 'remark-gfm';
-import type { Link, Parent, Root, Text } from 'mdast';
-import type { Plugin, PluggableList } from 'unified';
+import type { PluggableList } from 'unified';
import { visuallyHidden } from '@/shared/ui/assistant-theme/AssistantScope.css';
import * as styles from './MarkdownAnswer.css';
+import { MarkdownAnswerImage, MarkdownAnswerImageScope } from './MarkdownAnswerImage';
+import { isParagraphId, remarkParagraphReferences } from './remarkParagraphReferences';
export interface MarkdownAnswerProps {
/** Markdown text of the answer. While it is still arriving, it may end inside a code fence. */
@@ -39,74 +40,26 @@ type AnswerContextValue = Pick({ footnotePrefix: '' });
-const paragraphIdPattern = /^paragraph_\d+_-?\d+$/;
-
-const textReferences = (node: Text): Array | undefined => {
- // Match a complete ID without splitting Unicode letters, numbers or combining marks.
- const pattern = new RegExp('(^|[^\\p{L}\\p{N}\\p{M}_+-])(paragraph_\\d+_-?\\d+)(?![\\p{L}\\p{N}\\p{M}_+-])', 'gu');
- let references: Array | undefined;
- let offset = 0;
- let match: RegExpExecArray | null;
-
- while ((match = pattern.exec(node.value)) !== null) {
- const id = match[2];
- const start = match.index + match[1].length;
- references ??= [];
- if (start > offset) {
- references.push({ type: 'text', value: node.value.slice(offset, start) });
- }
- references.push({ type: 'link', url: `#${id}`, children: [{ type: 'text', value: id }] });
- offset = start + id.length;
- }
-
- if (references && offset < node.value.length) {
- references.push({ type: 'text', value: node.value.slice(offset) });
- }
- return references;
-};
-
-const transformReferences = (parent: Parent): void => {
- let children: Parent['children'] | undefined;
-
- for (let index = 0; index < parent.children.length; index++) {
- const child = parent.children[index];
- let references: Array | undefined;
- if (child.type === 'text') {
- references = textReferences(child);
- } else if (child.type === 'inlineCode' && paragraphIdPattern.test(child.value)) {
- references = [{ type: 'link', url: `#${child.value}`, children: [child] }];
- } else if (child.type !== 'link' && child.type !== 'linkReference' && 'children' in child) {
- transformReferences(child);
- }
-
- if (references) {
- children ??= parent.children.slice(0, index);
- children.push(...references);
- } else {
- children?.push(child);
- }
- }
-
- if (children) parent.children = children;
-};
-
-const remarkParagraphReferences: Plugin<[], Root> = () => transformReferences;
const remarkPlugins: PluggableList = [remarkGfm, remarkParagraphReferences];
// remark-rehype gives the footnotes heading this fixed id and points every footnote reference at it.
const footnoteLabelId = 'footnote-label';
-const AnswerLink = ({ node: _node, href, children, title, ...props }: ComponentPropsWithoutRef<'a'> & ExtraProps) => {
+const containsImage = (node: NonNullable): boolean =>
+ node.tagName === 'img' || node.children.some(child => child.type === 'element' && containsImage(child));
+
+const AnswerLink = ({ node, href, children, title, ...props }: ComponentPropsWithoutRef<'a'> & ExtraProps) => {
const { describeParagraph, onOpenParagraph, footnotePrefix } = useContext(AnswerContext);
if (!href) return <>{children}>;
+ const imageLink = node ? containsImage(node) : false;
const fragment = href.startsWith('#');
const paragraphId = fragment ? href.slice(1) : undefined;
- if (paragraphId && paragraphIdPattern.test(paragraphId)) {
+ if (paragraphId && isParagraphId(paragraphId)) {
const description = describeParagraph?.(paragraphId);
if (description && onOpenParagraph) {
- return (
+ const reference = (