@adecore/agents-react 0.40.4 → 0.40.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/attachments.js +17 -4
- package/dist/chat/image-display.d.ts +4 -0
- package/dist/chat/image-display.js +39 -0
- package/dist/chat/thumbnails.js +6 -3
- package/dist/chat/ui/DisplayImage.d.ts +5 -0
- package/dist/chat/ui/DisplayImage.js +109 -0
- package/dist/chat/ui/ImageView.js +3 -2
- package/dist/chat/ui/Markdown.js +3 -2
- package/dist/chat/ui/VisualFrame.js +2 -1
- package/dist/chat/ui/index.d.ts +1 -0
- package/dist/chat/ui/index.js +1 -0
- package/dist/chat/ui/intelligent-ui/renderers/content.js +3 -2
- package/dist/images.d.ts +2 -0
- package/dist/images.js +2 -0
- package/package.json +14 -8
- package/src/chat/attachments.ts +19 -1
- package/src/chat/image-display.ts +38 -0
- package/src/chat/thumbnails.ts +7 -3
- package/src/chat/ui/DisplayImage.tsx +82 -0
- package/src/chat/ui/ImageView.tsx +3 -2
- package/src/chat/ui/Markdown.tsx +3 -2
- package/src/chat/ui/VisualFrame.tsx +2 -1
- package/src/chat/ui/index.ts +1 -0
- package/src/chat/ui/intelligent-ui/renderers/content.tsx +3 -2
- package/src/images.ts +2 -0
package/dist/chat/attachments.js
CHANGED
|
@@ -80,13 +80,26 @@ function readAsBase64(file) {
|
|
|
80
80
|
function nameFor(file, index) {
|
|
81
81
|
return file.name || `pasted-${Date.now()}-${index + 1}.${file.type.split('/')[1] ?? 'bin'}`;
|
|
82
82
|
}
|
|
83
|
+
function fileMime(file) {
|
|
84
|
+
const image = attachmentImageMime({
|
|
85
|
+
name: file.name,
|
|
86
|
+
mime: file.type
|
|
87
|
+
});
|
|
88
|
+
if (image !== null) {
|
|
89
|
+
return image;
|
|
90
|
+
}
|
|
91
|
+
if (file.type === '' || file.type === 'application/octet-stream' || file.type === 'binary/octet-stream') {
|
|
92
|
+
const extension = /\.(heic|heif)$/i.exec(file.name)?.[1]?.toLowerCase();
|
|
93
|
+
if (extension !== undefined) {
|
|
94
|
+
return `image/${extension}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return file.type || 'application/octet-stream';
|
|
98
|
+
}
|
|
83
99
|
export async function readAttachments(files) {
|
|
84
100
|
return Promise.all(files.map(async (file, index) => ({
|
|
85
101
|
name: nameFor(file, index),
|
|
86
|
-
mime:
|
|
87
|
-
name: file.name,
|
|
88
|
-
mime: file.type
|
|
89
|
-
}) ?? (file.type || 'application/octet-stream'),
|
|
102
|
+
mime: fileMime(file),
|
|
90
103
|
data: await readAsBase64(file)
|
|
91
104
|
})));
|
|
92
105
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Converts HEIC and HEIF to a browser-readable PNG for display, preserving other formats and the source bytes. */
|
|
2
|
+
export declare function imageForDisplay(blob: Blob): Promise<Blob>;
|
|
3
|
+
/** Makes embedded HEIC/HEIF data URLs readable in a sandboxed HTML preview without changing the saved document. */
|
|
4
|
+
export declare function imageDocumentForDisplay(html: string): Promise<string>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Converts HEIC and HEIF to a browser-readable PNG for display, preserving other formats and the source bytes. */
|
|
2
|
+
export async function imageForDisplay(blob) {
|
|
3
|
+
if (!/^image\/(?:heic|heif)(?:-sequence)?$/i.test(blob.type)) {
|
|
4
|
+
return blob;
|
|
5
|
+
}
|
|
6
|
+
// The CSP build decodes in a worker without requiring unsafe-eval on the host page.
|
|
7
|
+
const {
|
|
8
|
+
heicTo
|
|
9
|
+
} = await import('heic-to/csp');
|
|
10
|
+
return heicTo({
|
|
11
|
+
blob,
|
|
12
|
+
type: 'image/png'
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Makes embedded HEIC/HEIF data URLs readable in a sandboxed HTML preview without changing the saved document. */
|
|
16
|
+
export async function imageDocumentForDisplay(html) {
|
|
17
|
+
const sources = [...new Set(html.match(/data:image\/(?:heic|heif)(?:-sequence)?;base64,[A-Za-z0-9+/=]+/gi) ?? [])];
|
|
18
|
+
let result = html;
|
|
19
|
+
for (const source of sources) {
|
|
20
|
+
try {
|
|
21
|
+
const comma = source.indexOf(',');
|
|
22
|
+
const mime = source.slice(5, source.indexOf(';')).toLowerCase();
|
|
23
|
+
const bytes = Uint8Array.from(atob(source.slice(comma + 1)), character => character.charCodeAt(0));
|
|
24
|
+
const blob = await imageForDisplay(new Blob([bytes], {
|
|
25
|
+
type: mime
|
|
26
|
+
}));
|
|
27
|
+
const converted = await new Promise((resolve, reject) => {
|
|
28
|
+
const reader = new FileReader();
|
|
29
|
+
reader.onload = () => resolve(String(reader.result));
|
|
30
|
+
reader.onerror = () => reject(reader.error);
|
|
31
|
+
reader.readAsDataURL(blob);
|
|
32
|
+
});
|
|
33
|
+
result = result.replaceAll(source, converted);
|
|
34
|
+
} catch {
|
|
35
|
+
// A broken picture must not prevent the rest of the document from opening.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
package/dist/chat/thumbnails.js
CHANGED
|
@@ -2,6 +2,7 @@ import { c as _c } from "react/compiler-runtime";
|
|
|
2
2
|
import i18next from 'i18next';
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { uploadPreviewUrl } from './attachments.js';
|
|
5
|
+
import { imageForDisplay } from './image-display.js';
|
|
5
6
|
/* The edge of a composer thumbnail: a few times the 56 px it is drawn at, so a sharp screen still gets a sharp one. */
|
|
6
7
|
export const THUMBNAIL_PX = 256;
|
|
7
8
|
/* The square in the middle of an image that a thumbnail shows, the same part `object-cover` would. */
|
|
@@ -36,16 +37,18 @@ function dataUrlOf(blob) {
|
|
|
36
37
|
* cannot be decoded this way (an SVG, a browser without `OffscreenCanvas`) falls back to the image.
|
|
37
38
|
*/
|
|
38
39
|
async function makeThumbnail(upload) {
|
|
40
|
+
let image = null;
|
|
39
41
|
try {
|
|
40
|
-
|
|
42
|
+
image = await imageForDisplay(new Blob([bytesOf(upload.data)], {
|
|
41
43
|
type: upload.mime
|
|
42
44
|
}));
|
|
45
|
+
const bitmap = await createImageBitmap(image);
|
|
43
46
|
const crop = thumbnailCrop(bitmap.width, bitmap.height);
|
|
44
47
|
const canvas = new OffscreenCanvas(crop.out, crop.out);
|
|
45
48
|
const context = canvas.getContext('2d');
|
|
46
49
|
if (!context) {
|
|
47
50
|
bitmap.close();
|
|
48
|
-
return
|
|
51
|
+
return await dataUrlOf(image);
|
|
49
52
|
}
|
|
50
53
|
context.drawImage(bitmap, crop.sx, crop.sy, crop.size, crop.size, 0, 0, crop.out, crop.out);
|
|
51
54
|
bitmap.close();
|
|
@@ -53,7 +56,7 @@ async function makeThumbnail(upload) {
|
|
|
53
56
|
type: 'image/png'
|
|
54
57
|
}));
|
|
55
58
|
} catch {
|
|
56
|
-
return uploadPreviewUrl(upload);
|
|
59
|
+
return image === null ? uploadPreviewUrl(upload) : dataUrlOf(image);
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
// Keyed by the upload itself, so a composer that mounts again finds it and a removed file lets it go.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ComponentProps } from 'react';
|
|
2
|
+
type ImageProps = ComponentProps<'img'>;
|
|
3
|
+
/** An image with a HEIC/HEIF fallback for browsers without a native decoder. Remote sources must allow cross-origin reads. */
|
|
4
|
+
export declare function DisplayImage({ src, onError, ...props }: ImageProps): import("react").JSX.Element;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { c as _c } from "react/compiler-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { imageDocumentForDisplay, imageForDisplay } from '../image-display.js';
|
|
4
|
+
/** An image with a HEIC/HEIF fallback for browsers without a native decoder. Remote sources must allow cross-origin reads. */
|
|
5
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
6
|
+
export function DisplayImage(t0) {
|
|
7
|
+
const $ = _c(9);
|
|
8
|
+
let onError;
|
|
9
|
+
let props;
|
|
10
|
+
let src;
|
|
11
|
+
if ($[0] !== t0) {
|
|
12
|
+
({
|
|
13
|
+
src,
|
|
14
|
+
onError,
|
|
15
|
+
...props
|
|
16
|
+
} = t0);
|
|
17
|
+
$[0] = t0;
|
|
18
|
+
$[1] = onError;
|
|
19
|
+
$[2] = props;
|
|
20
|
+
$[3] = src;
|
|
21
|
+
} else {
|
|
22
|
+
onError = $[1];
|
|
23
|
+
props = $[2];
|
|
24
|
+
src = $[3];
|
|
25
|
+
}
|
|
26
|
+
const t1 = typeof src === "string" ? src : undefined;
|
|
27
|
+
let t2;
|
|
28
|
+
if ($[4] !== onError || $[5] !== props || $[6] !== src || $[7] !== t1) {
|
|
29
|
+
t2 = /*#__PURE__*/_jsx(ImageSource, {
|
|
30
|
+
...props,
|
|
31
|
+
src: src,
|
|
32
|
+
onError: onError
|
|
33
|
+
}, t1);
|
|
34
|
+
$[4] = onError;
|
|
35
|
+
$[5] = props;
|
|
36
|
+
$[6] = src;
|
|
37
|
+
$[7] = t1;
|
|
38
|
+
$[8] = t2;
|
|
39
|
+
} else {
|
|
40
|
+
t2 = $[8];
|
|
41
|
+
}
|
|
42
|
+
return t2;
|
|
43
|
+
}
|
|
44
|
+
function ImageSource({
|
|
45
|
+
src,
|
|
46
|
+
onError,
|
|
47
|
+
...props
|
|
48
|
+
}) {
|
|
49
|
+
const [retry, setRetry] = useState(null);
|
|
50
|
+
const [decoded, setDecoded] = useState(null);
|
|
51
|
+
const converted = decoded !== null && decoded.source === src ? decoded.url : null;
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (typeof src !== 'string' || retry !== src) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let cancelled = false;
|
|
57
|
+
let url = null;
|
|
58
|
+
const convert = async () => {
|
|
59
|
+
try {
|
|
60
|
+
const embedded = await imageDocumentForDisplay(src);
|
|
61
|
+
if (cancelled) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (embedded !== src) {
|
|
65
|
+
setDecoded({
|
|
66
|
+
source: src,
|
|
67
|
+
url: embedded
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const response = await fetch(src, {
|
|
72
|
+
referrerPolicy: 'no-referrer'
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const blob = await response.blob();
|
|
78
|
+
const image = await imageForDisplay(blob);
|
|
79
|
+
if (cancelled || image === blob) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
url = URL.createObjectURL(image);
|
|
83
|
+
setDecoded({
|
|
84
|
+
source: src,
|
|
85
|
+
url
|
|
86
|
+
});
|
|
87
|
+
} catch {
|
|
88
|
+
// The failed image keeps the browser's alt text when it cannot be read or decoded.
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
void convert();
|
|
92
|
+
return () => {
|
|
93
|
+
cancelled = true;
|
|
94
|
+
if (url !== null) {
|
|
95
|
+
URL.revokeObjectURL(url);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}, [src, retry]);
|
|
99
|
+
return /*#__PURE__*/_jsx("img", {
|
|
100
|
+
src: converted ?? src,
|
|
101
|
+
...props,
|
|
102
|
+
onError: event => {
|
|
103
|
+
if (typeof src === 'string' && retry !== src) {
|
|
104
|
+
setRetry(src);
|
|
105
|
+
}
|
|
106
|
+
onError?.(event);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
4
4
|
import clsx from 'clsx';
|
|
5
5
|
import { ImageOff, Minus, Plus, RotateCcw } from 'lucide-react';
|
|
6
6
|
import { ButtonGroup, CloseButton, Dialog, Icon, IconButton, Tooltip } from '@adecore/ui';
|
|
7
|
+
import { DisplayImage } from './DisplayImage.js';
|
|
7
8
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
8
9
|
const MIN_SCALE = 1;
|
|
9
10
|
const MAX_SCALE = 8;
|
|
@@ -404,7 +405,7 @@ function Lightbox(t0) {
|
|
|
404
405
|
}
|
|
405
406
|
let t39;
|
|
406
407
|
if ($[67] !== alt || $[68] !== src || $[69] !== t38) {
|
|
407
|
-
t39 = /*#__PURE__*/_jsx(
|
|
408
|
+
t39 = /*#__PURE__*/_jsx(DisplayImage, {
|
|
408
409
|
src: src,
|
|
409
410
|
alt: alt,
|
|
410
411
|
draggable: false,
|
|
@@ -581,7 +582,7 @@ export function ImageThumb(t0) {
|
|
|
581
582
|
}
|
|
582
583
|
let t8;
|
|
583
584
|
if ($[19] !== alt || $[20] !== src || $[21] !== t7) {
|
|
584
|
-
t8 = /*#__PURE__*/_jsx(
|
|
585
|
+
t8 = /*#__PURE__*/_jsx(DisplayImage, {
|
|
585
586
|
src: src,
|
|
586
587
|
alt: alt,
|
|
587
588
|
className: t7
|
package/dist/chat/ui/Markdown.js
CHANGED
|
@@ -19,6 +19,7 @@ import { useChatHost } from '../../host.js';
|
|
|
19
19
|
import { useFileLinkCwd, useFileLinkScopeId, useFileLinkTarget, useOpenFileLink } from './file-links.js';
|
|
20
20
|
import { ErrorBoundary, FileIcon, Icon } from '@adecore/ui';
|
|
21
21
|
import { useTranslation } from 'react-i18next';
|
|
22
|
+
import { DisplayImage } from './DisplayImage.js';
|
|
22
23
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
23
24
|
const ShellCodeBlockReplyContext = createContext(null);
|
|
24
25
|
const BlockCodeContext = createContext(false);
|
|
@@ -330,7 +331,7 @@ function MarkdownImage(t0) {
|
|
|
330
331
|
if (HostImage === undefined || path === null) {
|
|
331
332
|
let t2;
|
|
332
333
|
if ($[3] !== alt || $[4] !== src) {
|
|
333
|
-
t2 = /*#__PURE__*/_jsx(
|
|
334
|
+
t2 = /*#__PURE__*/_jsx(DisplayImage, {
|
|
334
335
|
src: src,
|
|
335
336
|
alt: alt
|
|
336
337
|
});
|
|
@@ -366,7 +367,7 @@ function PlainImage(t0) {
|
|
|
366
367
|
} = t0;
|
|
367
368
|
let t1;
|
|
368
369
|
if ($[0] !== alt || $[1] !== src) {
|
|
369
|
-
t1 = /*#__PURE__*/_jsx(
|
|
370
|
+
t1 = /*#__PURE__*/_jsx(DisplayImage, {
|
|
370
371
|
src: src,
|
|
371
372
|
alt: alt
|
|
372
373
|
});
|
|
@@ -7,6 +7,7 @@ import { Spinner, useMeasuredWidth } from '@adecore/ui';
|
|
|
7
7
|
import { useChatHost } from '../../host.js';
|
|
8
8
|
import { useChatScope } from '../../scope.js';
|
|
9
9
|
import { DEFAULT_HEIGHT_PX, initialVisualContentHeight, rememberVisualHeight } from '../logic/visual-height.js';
|
|
10
|
+
import { imageDocumentForDisplay } from '../image-display.js';
|
|
10
11
|
import { VisualBridge } from '../visual-bridge.js';
|
|
11
12
|
import { VisualViewportController } from '../visual-viewport.js';
|
|
12
13
|
import { useVisualTheme } from './visual-theme.js';
|
|
@@ -223,7 +224,7 @@ function PageFrame(t0) {
|
|
|
223
224
|
visual: {
|
|
224
225
|
maxHeight
|
|
225
226
|
},
|
|
226
|
-
page: page(),
|
|
227
|
+
page: page().then(imageDocumentForDisplay),
|
|
227
228
|
theme: opening.theme,
|
|
228
229
|
mayOpenLink: () => personActsIn(frame),
|
|
229
230
|
openLink: url => visuals?.openLink(url),
|
package/dist/chat/ui/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { AccountPill } from './AccountPill.js';
|
|
|
2
2
|
export { AnsiOutput } from './AnsiOutput.js';
|
|
3
3
|
export { BookmarkSubmenu } from './BookmarkSubmenu.js';
|
|
4
4
|
export { ChatActivity, StatusIcon } from './ChatActivity.js';
|
|
5
|
+
export { DisplayImage } from './DisplayImage.js';
|
|
5
6
|
export { CodeBlock } from './CodeBlock.js';
|
|
6
7
|
export { Composer } from './Composer.js';
|
|
7
8
|
export { type ComposerInputHandle, ComposerInput, type InputSelection } from './ComposerInput.js';
|
package/dist/chat/ui/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { AccountPill } from './AccountPill.js';
|
|
|
3
3
|
export { AnsiOutput } from './AnsiOutput.js';
|
|
4
4
|
export { BookmarkSubmenu } from './BookmarkSubmenu.js';
|
|
5
5
|
export { ChatActivity, StatusIcon } from './ChatActivity.js';
|
|
6
|
+
export { DisplayImage } from './DisplayImage.js';
|
|
6
7
|
export { CodeBlock } from './CodeBlock.js';
|
|
7
8
|
export { Composer } from './Composer.js';
|
|
8
9
|
export { ComposerInput } from './ComposerInput.js';
|
|
@@ -12,6 +12,7 @@ import { ImageThumb } from '../../ImageView.js';
|
|
|
12
12
|
import { uiChildrenOf, uiNodeLabel, uiNodeText, uiUrlDomain } from '../node-text.js';
|
|
13
13
|
import { useTruncated } from '../use-truncated.js';
|
|
14
14
|
import { useHostAttachmentUrl } from '../../../../host-hooks.js';
|
|
15
|
+
import { DisplayImage } from '../../DisplayImage.js';
|
|
15
16
|
// The number of every source by its node id, which the list counts in its own order.
|
|
16
17
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
17
18
|
const SourceNumbers = createContext(new Map());
|
|
@@ -445,7 +446,7 @@ export function CompareRenderer(t0) {
|
|
|
445
446
|
}
|
|
446
447
|
let t8;
|
|
447
448
|
if ($[15] !== first.url || $[16] !== t6 || $[17] !== t7) {
|
|
448
|
-
t8 = /*#__PURE__*/_jsx(
|
|
449
|
+
t8 = /*#__PURE__*/_jsx(DisplayImage, {
|
|
449
450
|
src: t5,
|
|
450
451
|
alt: t6,
|
|
451
452
|
draggable: false,
|
|
@@ -471,7 +472,7 @@ export function CompareRenderer(t0) {
|
|
|
471
472
|
}
|
|
472
473
|
let t11;
|
|
473
474
|
if ($[22] !== second.url || $[23] !== t10) {
|
|
474
|
-
t11 = /*#__PURE__*/_jsx(
|
|
475
|
+
t11 = /*#__PURE__*/_jsx(DisplayImage, {
|
|
475
476
|
src: t9,
|
|
476
477
|
alt: t10,
|
|
477
478
|
draggable: false,
|
package/dist/images.d.ts
ADDED
package/dist/images.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents-react",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.5",
|
|
4
4
|
"description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -58,6 +58,11 @@
|
|
|
58
58
|
"source": "./src/theme.css",
|
|
59
59
|
"style": "./src/theme.css",
|
|
60
60
|
"default": "./dist/theme.css"
|
|
61
|
+
},
|
|
62
|
+
"./images": {
|
|
63
|
+
"source": "./src/images.ts",
|
|
64
|
+
"types": "./dist/images.d.ts",
|
|
65
|
+
"default": "./dist/images.js"
|
|
61
66
|
}
|
|
62
67
|
},
|
|
63
68
|
"scripts": {
|
|
@@ -67,11 +72,11 @@
|
|
|
67
72
|
"test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
|
|
68
73
|
},
|
|
69
74
|
"dependencies": {
|
|
70
|
-
"@adecore/agent-contracts": "0.40.
|
|
71
|
-
"@adecore/editor": "0.40.
|
|
72
|
-
"@adecore/editor-react": "0.40.
|
|
73
|
-
"@adecore/intelligent-ui": "0.40.
|
|
74
|
-
"@adecore/merge": "0.40.
|
|
75
|
+
"@adecore/agent-contracts": "0.40.5",
|
|
76
|
+
"@adecore/editor": "0.40.5",
|
|
77
|
+
"@adecore/editor-react": "0.40.5",
|
|
78
|
+
"@adecore/intelligent-ui": "0.40.5",
|
|
79
|
+
"@adecore/merge": "0.40.5",
|
|
75
80
|
"@codemirror/commands": "^6.11.1",
|
|
76
81
|
"@codemirror/language": "^6.13.1",
|
|
77
82
|
"@codemirror/state": "^6.7.6",
|
|
@@ -82,6 +87,7 @@
|
|
|
82
87
|
"@pierre/diffs": "^1.6.1",
|
|
83
88
|
"@tanstack/react-virtual": "^3.14.23",
|
|
84
89
|
"clsx": "^2.1.1",
|
|
90
|
+
"heic-to": "1.6.6",
|
|
85
91
|
"lucide-react": "^1.61.0",
|
|
86
92
|
"maplibre-gl": "^6.16.0",
|
|
87
93
|
"pele": "1.8.0",
|
|
@@ -94,14 +100,14 @@
|
|
|
94
100
|
"zustand": "^5.0.16"
|
|
95
101
|
},
|
|
96
102
|
"peerDependencies": {
|
|
97
|
-
"@adecore/ui": "^0.40.
|
|
103
|
+
"@adecore/ui": "^0.40.5",
|
|
98
104
|
"i18next": "^26.4.2",
|
|
99
105
|
"react": "^19.3.0",
|
|
100
106
|
"react-dom": "^19.3.0",
|
|
101
107
|
"react-i18next": "^17.0.15"
|
|
102
108
|
},
|
|
103
109
|
"devDependencies": {
|
|
104
|
-
"@adecore/ui": "0.40.
|
|
110
|
+
"@adecore/ui": "0.40.5",
|
|
105
111
|
"@types/bun": "^1.4.4",
|
|
106
112
|
"@types/react": "^19.3.0",
|
|
107
113
|
"@types/react-dom": "^19.3.0",
|
package/src/chat/attachments.ts
CHANGED
|
@@ -95,11 +95,29 @@ function nameFor(file: File, index: number): string {
|
|
|
95
95
|
return file.name || `pasted-${Date.now()}-${index + 1}.${file.type.split('/')[1] ?? 'bin'}`;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
function fileMime(file: File): string {
|
|
99
|
+
const image = attachmentImageMime({ name: file.name, mime: file.type });
|
|
100
|
+
|
|
101
|
+
if (image !== null) {
|
|
102
|
+
return image;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (file.type === '' || file.type === 'application/octet-stream' || file.type === 'binary/octet-stream') {
|
|
106
|
+
const extension = /\.(heic|heif)$/i.exec(file.name)?.[1]?.toLowerCase();
|
|
107
|
+
|
|
108
|
+
if (extension !== undefined) {
|
|
109
|
+
return `image/${extension}`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return file.type || 'application/octet-stream';
|
|
114
|
+
}
|
|
115
|
+
|
|
98
116
|
export async function readAttachments(files: File[]): Promise<ChatAttachmentUpload[]> {
|
|
99
117
|
return Promise.all(
|
|
100
118
|
files.map(async (file, index) => ({
|
|
101
119
|
name: nameFor(file, index),
|
|
102
|
-
mime:
|
|
120
|
+
mime: fileMime(file),
|
|
103
121
|
data: await readAsBase64(file)
|
|
104
122
|
}))
|
|
105
123
|
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Converts HEIC and HEIF to a browser-readable PNG for display, preserving other formats and the source bytes. */
|
|
2
|
+
export async function imageForDisplay(blob: Blob): Promise<Blob> {
|
|
3
|
+
if (!/^image\/(?:heic|heif)(?:-sequence)?$/i.test(blob.type)) {
|
|
4
|
+
return blob;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// The CSP build decodes in a worker without requiring unsafe-eval on the host page.
|
|
8
|
+
const { heicTo } = await import('heic-to/csp');
|
|
9
|
+
|
|
10
|
+
return heicTo({ blob, type: 'image/png' });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Makes embedded HEIC/HEIF data URLs readable in a sandboxed HTML preview without changing the saved document. */
|
|
14
|
+
export async function imageDocumentForDisplay(html: string): Promise<string> {
|
|
15
|
+
const sources = [...new Set(html.match(/data:image\/(?:heic|heif)(?:-sequence)?;base64,[A-Za-z0-9+/=]+/gi) ?? [])];
|
|
16
|
+
let result = html;
|
|
17
|
+
|
|
18
|
+
for (const source of sources) {
|
|
19
|
+
try {
|
|
20
|
+
const comma = source.indexOf(',');
|
|
21
|
+
const mime = source.slice(5, source.indexOf(';')).toLowerCase();
|
|
22
|
+
const bytes = Uint8Array.from(atob(source.slice(comma + 1)), (character) => character.charCodeAt(0));
|
|
23
|
+
const blob = await imageForDisplay(new Blob([bytes], { type: mime }));
|
|
24
|
+
const converted = await new Promise<string>((resolve, reject) => {
|
|
25
|
+
const reader = new FileReader();
|
|
26
|
+
reader.onload = () => resolve(String(reader.result));
|
|
27
|
+
reader.onerror = () => reject(reader.error);
|
|
28
|
+
reader.readAsDataURL(blob);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
result = result.replaceAll(source, converted);
|
|
32
|
+
} catch {
|
|
33
|
+
// A broken picture must not prevent the rest of the document from opening.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result;
|
|
38
|
+
}
|
package/src/chat/thumbnails.ts
CHANGED
|
@@ -2,6 +2,7 @@ import i18next from 'i18next';
|
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import type { ChatAttachmentUpload } from '@adecore/agent-contracts';
|
|
4
4
|
import { uploadPreviewUrl } from './attachments';
|
|
5
|
+
import { imageForDisplay } from './image-display';
|
|
5
6
|
|
|
6
7
|
/* The edge of a composer thumbnail: a few times the 56 px it is drawn at, so a sharp screen still gets a sharp one. */
|
|
7
8
|
export const THUMBNAIL_PX = 256;
|
|
@@ -53,8 +54,11 @@ function dataUrlOf(blob: Blob): Promise<string> {
|
|
|
53
54
|
* cannot be decoded this way (an SVG, a browser without `OffscreenCanvas`) falls back to the image.
|
|
54
55
|
*/
|
|
55
56
|
async function makeThumbnail(upload: ChatAttachmentUpload): Promise<string> {
|
|
57
|
+
let image: Blob | null = null;
|
|
58
|
+
|
|
56
59
|
try {
|
|
57
|
-
|
|
60
|
+
image = await imageForDisplay(new Blob([bytesOf(upload.data)], { type: upload.mime }));
|
|
61
|
+
const bitmap = await createImageBitmap(image);
|
|
58
62
|
const crop = thumbnailCrop(bitmap.width, bitmap.height);
|
|
59
63
|
const canvas = new OffscreenCanvas(crop.out, crop.out);
|
|
60
64
|
const context = canvas.getContext('2d');
|
|
@@ -62,7 +66,7 @@ async function makeThumbnail(upload: ChatAttachmentUpload): Promise<string> {
|
|
|
62
66
|
if (!context) {
|
|
63
67
|
bitmap.close();
|
|
64
68
|
|
|
65
|
-
return
|
|
69
|
+
return await dataUrlOf(image);
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
context.drawImage(bitmap, crop.sx, crop.sy, crop.size, crop.size, 0, 0, crop.out, crop.out);
|
|
@@ -70,7 +74,7 @@ async function makeThumbnail(upload: ChatAttachmentUpload): Promise<string> {
|
|
|
70
74
|
|
|
71
75
|
return await dataUrlOf(await canvas.convertToBlob({ type: 'image/png' }));
|
|
72
76
|
} catch {
|
|
73
|
-
return uploadPreviewUrl(upload);
|
|
77
|
+
return image === null ? uploadPreviewUrl(upload) : dataUrlOf(image);
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
80
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useState, type ComponentProps } from 'react';
|
|
2
|
+
import { imageDocumentForDisplay, imageForDisplay } from '../image-display';
|
|
3
|
+
|
|
4
|
+
type ImageProps = ComponentProps<'img'>;
|
|
5
|
+
|
|
6
|
+
/** An image with a HEIC/HEIF fallback for browsers without a native decoder. Remote sources must allow cross-origin reads. */
|
|
7
|
+
export function DisplayImage({ src, onError, ...props }: ImageProps) {
|
|
8
|
+
return <ImageSource key={typeof src === 'string' ? src : undefined} {...props} src={src} onError={onError} />;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function ImageSource({ src, onError, ...props }: ImageProps) {
|
|
12
|
+
const [retry, setRetry] = useState<string | null>(null);
|
|
13
|
+
const [decoded, setDecoded] = useState<{ source: string; url: string } | null>(null);
|
|
14
|
+
const converted = decoded !== null && decoded.source === src ? decoded.url : null;
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (typeof src !== 'string' || retry !== src) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let cancelled = false;
|
|
22
|
+
let url: string | null = null;
|
|
23
|
+
|
|
24
|
+
const convert = async (): Promise<void> => {
|
|
25
|
+
try {
|
|
26
|
+
const embedded = await imageDocumentForDisplay(src);
|
|
27
|
+
|
|
28
|
+
if (cancelled) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (embedded !== src) {
|
|
33
|
+
setDecoded({ source: src, url: embedded });
|
|
34
|
+
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const response = await fetch(src, { referrerPolicy: 'no-referrer' });
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const blob = await response.blob();
|
|
45
|
+
const image = await imageForDisplay(blob);
|
|
46
|
+
|
|
47
|
+
if (cancelled || image === blob) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
url = URL.createObjectURL(image);
|
|
52
|
+
setDecoded({ source: src, url });
|
|
53
|
+
} catch {
|
|
54
|
+
// The failed image keeps the browser's alt text when it cannot be read or decoded.
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
void convert();
|
|
59
|
+
|
|
60
|
+
return () => {
|
|
61
|
+
cancelled = true;
|
|
62
|
+
|
|
63
|
+
if (url !== null) {
|
|
64
|
+
URL.revokeObjectURL(url);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}, [src, retry]);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<img
|
|
71
|
+
src={converted ?? src}
|
|
72
|
+
{...props}
|
|
73
|
+
onError={(event) => {
|
|
74
|
+
if (typeof src === 'string' && retry !== src) {
|
|
75
|
+
setRetry(src);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onError?.(event);
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -4,6 +4,7 @@ import clsx from 'clsx';
|
|
|
4
4
|
import { ImageOff, Minus, Plus, RotateCcw } from 'lucide-react';
|
|
5
5
|
import type { ResourceUrl } from '../../host';
|
|
6
6
|
import { ButtonGroup, CloseButton, Dialog, Icon, IconButton, Tooltip } from '@adecore/ui';
|
|
7
|
+
import { DisplayImage } from './DisplayImage';
|
|
7
8
|
|
|
8
9
|
const MIN_SCALE = 1;
|
|
9
10
|
const MAX_SCALE = 8;
|
|
@@ -137,7 +138,7 @@ function Lightbox({
|
|
|
137
138
|
dragRef.current = null;
|
|
138
139
|
}}
|
|
139
140
|
>
|
|
140
|
-
<
|
|
141
|
+
<DisplayImage
|
|
141
142
|
src={src}
|
|
142
143
|
alt={alt}
|
|
143
144
|
draggable={false}
|
|
@@ -205,7 +206,7 @@ export function ImageThumb({ source, alt, className, aspect, actions, open: open
|
|
|
205
206
|
style={ratio}
|
|
206
207
|
onClick={() => setOpen(true)}
|
|
207
208
|
>
|
|
208
|
-
<
|
|
209
|
+
<DisplayImage src={src} alt={alt} className={clsx(aspect !== undefined && 'size-full object-contain', className)} />
|
|
209
210
|
</button>
|
|
210
211
|
<Lightbox src={src} alt={alt} open={open} onOpenChange={setOpen} actions={actions} />
|
|
211
212
|
</>
|
package/src/chat/ui/Markdown.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import { useChatHost, type ChatHost, type FileRef, type ShellCodeBlockContext }
|
|
|
19
19
|
import { useFileLinkCwd, useFileLinkScopeId, useFileLinkTarget, useOpenFileLink } from './file-links';
|
|
20
20
|
import { ErrorBoundary, FileIcon, Icon } from '@adecore/ui';
|
|
21
21
|
import { useTranslation } from 'react-i18next';
|
|
22
|
+
import { DisplayImage } from './DisplayImage';
|
|
22
23
|
|
|
23
24
|
type ShellCodeBlockReply = Pick<ShellCodeBlockContext, 'scopeId' | 'chatId' | 'itemId'> & { streaming: boolean };
|
|
24
25
|
|
|
@@ -211,14 +212,14 @@ function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
|
|
|
211
212
|
const path = typeof src === 'string' ? localImagePath(src, cwd) : null;
|
|
212
213
|
|
|
213
214
|
if (HostImage === undefined || path === null) {
|
|
214
|
-
return <
|
|
215
|
+
return <DisplayImage src={src} alt={alt} />;
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
return <HostImage path={path} alt={alt ?? ''} />;
|
|
218
219
|
}
|
|
219
220
|
|
|
220
221
|
function PlainImage({ src, alt }: { src?: string; alt?: string }) {
|
|
221
|
-
return <
|
|
222
|
+
return <DisplayImage src={src} alt={alt} />;
|
|
222
223
|
}
|
|
223
224
|
|
|
224
225
|
/* A ```mermaid fence, which draws what it says. */
|
|
@@ -6,6 +6,7 @@ import { Spinner, useMeasuredWidth } from '@adecore/ui';
|
|
|
6
6
|
import { useChatHost } from '../../host';
|
|
7
7
|
import { useChatScope } from '../../scope';
|
|
8
8
|
import { DEFAULT_HEIGHT_PX, initialVisualContentHeight, rememberVisualHeight } from '../logic/visual-height';
|
|
9
|
+
import { imageDocumentForDisplay } from '../image-display';
|
|
9
10
|
import { VisualBridge } from '../visual-bridge';
|
|
10
11
|
import { VisualViewportController } from '../visual-viewport';
|
|
11
12
|
import { useVisualTheme } from './visual-theme';
|
|
@@ -129,7 +130,7 @@ function PageFrame({ page, title, maxHeight, fill = false, className, initialHei
|
|
|
129
130
|
const bridge = new VisualBridge({
|
|
130
131
|
frame: () => frame.contentWindow,
|
|
131
132
|
visual: { maxHeight },
|
|
132
|
-
page: page(),
|
|
133
|
+
page: page().then(imageDocumentForDisplay),
|
|
133
134
|
theme: opening.theme,
|
|
134
135
|
mayOpenLink: () => personActsIn(frame),
|
|
135
136
|
openLink: (url) => visuals?.openLink(url),
|
package/src/chat/ui/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { AccountPill } from './AccountPill';
|
|
|
3
3
|
export { AnsiOutput } from './AnsiOutput';
|
|
4
4
|
export { BookmarkSubmenu } from './BookmarkSubmenu';
|
|
5
5
|
export { ChatActivity, StatusIcon } from './ChatActivity';
|
|
6
|
+
export { DisplayImage } from './DisplayImage';
|
|
6
7
|
export { CodeBlock } from './CodeBlock';
|
|
7
8
|
export { Composer } from './Composer';
|
|
8
9
|
export { type ComposerInputHandle, ComposerInput, type InputSelection } from './ComposerInput';
|
|
@@ -13,6 +13,7 @@ import { uiChildrenOf, uiNodeLabel, uiNodeText, uiUrlDomain } from '../node-text
|
|
|
13
13
|
import type { UiRendererProps } from '../render-context';
|
|
14
14
|
import { useTruncated } from '../use-truncated';
|
|
15
15
|
import { useHostAttachmentUrl } from '../../../../host-hooks';
|
|
16
|
+
import { DisplayImage } from '../../DisplayImage';
|
|
16
17
|
|
|
17
18
|
// The number of every source by its node id, which the list counts in its own order.
|
|
18
19
|
const SourceNumbers = createContext<ReadonlyMap<string, number>>(new Map());
|
|
@@ -153,7 +154,7 @@ export function CompareRenderer({ node, children, context }: UiRendererProps<UiP
|
|
|
153
154
|
onValueChange={setSplit}
|
|
154
155
|
label={label}
|
|
155
156
|
before={
|
|
156
|
-
<
|
|
157
|
+
<DisplayImage
|
|
157
158
|
src={first.url}
|
|
158
159
|
alt={describe(beforeLabel)}
|
|
159
160
|
draggable={false}
|
|
@@ -167,7 +168,7 @@ export function CompareRenderer({ node, children, context }: UiRendererProps<UiP
|
|
|
167
168
|
}}
|
|
168
169
|
/>
|
|
169
170
|
}
|
|
170
|
-
after={<
|
|
171
|
+
after={<DisplayImage src={second.url} alt={describe(afterLabel)} draggable={false} className="size-full object-contain select-none" />}
|
|
171
172
|
/>
|
|
172
173
|
<CompareChip side="left" label={beforeLabel} />
|
|
173
174
|
<CompareChip side="right" label={afterLabel} />
|
package/src/images.ts
ADDED