Skip to content

Commit 02fa746

Browse files
authored
Merge pull request #2868 from wgqqqqq/wgq/fix-chat-image-preview
fix(chat): load image previews through the host file API
2 parents 7d44590 + 2abd975 commit 02fa746

3 files changed

Lines changed: 124 additions & 8 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// @vitest-environment jsdom
2+
import React, { act } from 'react';
3+
import { createRoot, type Root } from 'react-dom/client';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { UserMessageImage } from './UserMessageImage';
6+
7+
const { readFileContent } = vi.hoisted(() => ({ readFileContent: vi.fn() }));
8+
vi.mock('@/infrastructure/api/service-api/WorkspaceAPI', () => ({ workspaceAPI: { readFileContent } }));
9+
vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (_key: string, values: { message: string }) => `Load failed: ${values.message}` }) }));
10+
vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() }) }));
11+
12+
const image = { id: 'image', name: 'Photo.png', imagePath: '/Lark images/Photo.png', mimeType: 'image/png' };
13+
14+
describe('UserMessageImage', () => {
15+
let container: HTMLDivElement;
16+
let root: Root;
17+
beforeEach(() => {
18+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
19+
readFileContent.mockReset();
20+
container = document.createElement('div');
21+
document.body.appendChild(container);
22+
root = createRoot(container);
23+
});
24+
afterEach(() => { act(() => root.unmount()); container.remove(); });
25+
const render = async (element: React.ReactNode) => { await act(async () => { root.render(element); }); };
26+
27+
28+
it('reads host image bytes through the transport and previews the same data URL', async () => {
29+
readFileContent.mockResolvedValue('aW1hZ2U=');
30+
const onPreview = vi.fn();
31+
await render(<UserMessageImage image={image} onPreview={onPreview} />);
32+
const thumbnail = container.querySelector('img')!;
33+
expect(readFileContent).toHaveBeenCalledWith(image.imagePath, 'base64');
34+
expect(thumbnail.getAttribute('src')).toBe('data:image/png;base64,aW1hZ2U=');
35+
act(() => thumbnail.click());
36+
expect(onPreview).toHaveBeenCalledWith(thumbnail.getAttribute('src'));
37+
});
38+
39+
it('uses embedded clipboard data without reading a host file', async () => {
40+
await render(<UserMessageImage image={{ ...image, dataUrl: 'data:image/png;base64,AA==' }} onPreview={vi.fn()} />);
41+
expect(container.querySelector('img')!.getAttribute('src')).toBe('data:image/png;base64,AA==');
42+
expect(readFileContent).not.toHaveBeenCalled();
43+
});
44+
45+
it('displays read failures without falling back to controller-local URLs', async () => {
46+
readFileContent.mockRejectedValue(new Error('Host offline'));
47+
await render(<UserMessageImage image={image} onPreview={vi.fn()} />);
48+
expect(container.querySelector('[role=alert]')!.textContent).toContain('Host offline');
49+
expect(container.querySelector('img')).toBeNull();
50+
expect(readFileContent).toHaveBeenCalledTimes(1);
51+
});
52+
53+
it('ignores a stale image read after the attachment changes', async () => {
54+
let finishOld!: (content: string) => void;
55+
readFileContent.mockImplementationOnce(() => new Promise<string>(resolve => { finishOld = resolve; }));
56+
readFileContent.mockResolvedValueOnce('NEW');
57+
await render(<UserMessageImage image={image} onPreview={vi.fn()} />);
58+
await render(<UserMessageImage image={{ ...image, imagePath: '/new.png' }} onPreview={vi.fn()} />);
59+
expect(container.querySelector('img')!.getAttribute('src')).toBe('data:image/png;base64,NEW');
60+
await act(async () => { finishOld('OLD'); });
61+
expect(container.querySelector('img')!.getAttribute('src')).toBe('data:image/png;base64,NEW');
62+
});
63+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useEffect, useState } from 'react';
2+
import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI';
3+
import { useI18n } from '@/infrastructure/i18n';
4+
import { createLogger } from '@/shared/utils/logger';
5+
import type { ImageDisplayData } from '../../utils/imagePayload';
6+
import { getMimeTypeFromFilename } from '../../utils/imageUtils';
7+
8+
const log = createLogger('UserMessageImage');
9+
10+
interface UserMessageImageProps {
11+
image: ImageDisplayData;
12+
onPreview: (source: string) => void;
13+
}
14+
15+
export function UserMessageImage({ image, onPreview }: UserMessageImageProps) {
16+
const { t } = useI18n('tools');
17+
const { dataUrl, imagePath, mimeType, name } = image;
18+
const [loaded, setLoaded] = useState<{ path: string; source: string } | null>(null);
19+
const [error, setError] = useState<string | null>(null);
20+
const source = dataUrl || (loaded && loaded.path === imagePath ? loaded.source : undefined);
21+
22+
useEffect(() => {
23+
let disposed = false;
24+
setLoaded(null);
25+
setError(null);
26+
if (!dataUrl && imagePath) {
27+
// Attachment paths belong to the runtime host. The transport routes this
28+
// read to that host, including peer mode; never construct a local asset URL.
29+
void workspaceAPI.readFileContent(imagePath, 'base64').then(content => {
30+
if (!disposed) {
31+
setLoaded({ path: imagePath, source: `data:${mimeType || getMimeTypeFromFilename(imagePath)};base64,${content}` });
32+
}
33+
}).catch(cause => {
34+
if (!disposed) {
35+
log.warn('Failed to load message image', { imagePath, error: cause });
36+
setError(String(cause));
37+
}
38+
});
39+
}
40+
return () => { disposed = true; };
41+
}, [dataUrl, imagePath, mimeType]);
42+
43+
const failure = error ? t('editor.imageViewer.loadImageFailedWithMessage', { message: error }) : null;
44+
return (
45+
<div
46+
data-openbitfun-component="user-message-item"
47+
data-openbitfun-part="image"
48+
className="user-message-item__image-thumb"
49+
onClick={event => { event.stopPropagation(); if (source && !error) onPreview(source); }}
50+
title={failure || name}
51+
>
52+
{failure ? <span role="alert">{failure}</span> : source ? (
53+
<img src={source} alt={name} onError={() => setError(name)} />
54+
) : <span>{name}</span>}
55+
</div>
56+
);
57+
}

‎src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
} from '../../utils/composerPresentation';
5757
import { restoreImageContextsFromPayload } from '../../utils/imageContextRestoration';
5858
import { UserMessagePresentationContent } from './UserMessagePresentationContent';
59+
import { UserMessageImage } from './UserMessageImage';
5960
import './UserMessageItem.scss';
6061

6162
const log = createLogger('UserMessageItem');
@@ -633,14 +634,9 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
633634

634635
{message.images && message.images.length > 0 && (
635636
<div className="user-message-item__images" data-openbitfun-component="user-message-item" data-openbitfun-part="images">
636-
{message.images.map(img => {
637-
const src = img.dataUrl || (img.imagePath ? `https://asset.localhost/${encodeURIComponent(img.imagePath)}` : undefined);
638-
return src ? (
639-
<div data-openbitfun-component="user-message-item" data-openbitfun-part="image" key={img.id} className="user-message-item__image-thumb" onClick={(e) => { e.stopPropagation(); setLightboxImage(src); }}>
640-
<img src={src} alt={img.name} />
641-
</div>
642-
) : null;
643-
})}
637+
{message.images.map(img => (
638+
<UserMessageImage key={img.id} image={img} onPreview={setLightboxImage} />
639+
))}
644640
</div>
645641
)}
646642

0 commit comments

Comments
 (0)