Compare commits

...
Author SHA1 Message Date
Hanzo AI 1eea55929e feat(images): Fix action + previous-image picker for generated images
Add a 'Fix' affordance on AI-generated images (hover pill on the message
thumbnail and an action in the fullscreen dialog) that attaches the image
to the composer as a reference file and seeds a 'Fix this image: ' prompt,
so the user only describes the fix.

Three attach paths, all reusing the existing file pipeline:
- Fix: attaches the generated image by reference (file_id, no re-upload) via
  useAttachImage, injecting a completed ExtendedFile (attached: true) into the
  composer file map — the send path forwards it verbatim as multimodal content.
- Drag-and-drop: plain image/file drops with no tool-resource choice now attach
  straight into the current conversation instead of a single-option dead-end modal.
- Attach a previous image: new attach-menu item opens a picker of the current
  conversation's images (collectConversationImages) and attaches by reference.

Pure helper collectConversationImages/resolveImageUrl unit-tested (10 cases).
i18n keys added to the default locale. No backend changes.
2026-07-16 12:58:03 -07:00
15 changed files with 609 additions and 30 deletions
@@ -2,6 +2,7 @@ import React, { useRef, useState, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import {
Images,
FileSearch,
ImageUpIcon,
FileType2Icon,
@@ -9,6 +10,7 @@ import {
TerminalSquareIcon,
} from 'lucide-react';
import {
Constants,
Providers,
EToolResources,
EModelEndpoint,
@@ -32,6 +34,7 @@ import {
} from '~/hooks';
import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling';
import { SharePointPickerDialog } from '~/components/SharePoint';
import PreviousImagesDialog from './PreviousImagesDialog';
import { useGetStartupConfig } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import { MenuItemProps } from '~/common';
@@ -76,6 +79,7 @@ const AttachFileMenu = ({
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false);
const [isPreviousImagesOpen, setIsPreviousImagesOpen] = useState(false);
/** TODO: Ephemeral Agent Capabilities
* Allow defining agent capabilities on a per-endpoint basis
@@ -196,6 +200,14 @@ const AttachFileMenu = ({
const localItems = createMenuItems(handleUploadClick);
/** Re-attach an image already in this conversation (no re-upload). */
localItems.push({
label: localize('com_ui_attach_previous_image'),
onClick: () => setIsPreviousImagesOpen(true),
icon: <Images className="icon-md" />,
show: conversationId !== Constants.NEW_CONVO,
});
if (sharePointEnabled) {
const sharePointItems = createMenuItems(() => {
setIsSharePointDialogOpen(true);
@@ -217,6 +229,7 @@ const AttachFileMenu = ({
provider,
endpointType,
capabilities,
conversationId,
useResponsesApi,
setToolResource,
setEphemeralAgent,
@@ -224,6 +237,7 @@ const AttachFileMenu = ({
codeAllowedByAgent,
fileSearchAllowedByAgent,
setIsSharePointDialogOpen,
setIsPreviousImagesOpen,
]);
const menuTrigger = (
@@ -285,6 +299,13 @@ const AttachFileMenu = ({
downloadProgress={downloadProgress}
maxSelectionCount={endpointFileConfig?.fileLimit}
/>
{isPreviousImagesOpen && (
<PreviousImagesDialog
isOpen={isPreviousImagesOpen}
onOpenChange={setIsPreviousImagesOpen}
conversationId={conversationId}
/>
)}
</>
);
};
@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { Constants } from '@hanzochat/data-provider';
import { OGDialog, OGDialogTemplate } from '@hanzochat/client';
import { useGetMessagesByConvoId } from '~/data-provider';
import { collectConversationImages, resolveImageUrl } from '~/utils';
import useAttachImage from '~/hooks/Files/useAttachImage';
import useLocalize from '~/hooks/useLocalize';
interface PreviousImagesDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
conversationId: string;
}
/**
* Picker that lists every image already in the CURRENT conversation (uploads and
* AI generations). Selecting one attaches it to the composer by reference — no
* re-upload — via `useAttachImage`.
*/
export default function PreviousImagesDialog({
isOpen,
onOpenChange,
conversationId,
}: PreviousImagesDialogProps) {
const localize = useLocalize();
const { attachImage } = useAttachImage();
const hasConversation = conversationId !== '' && conversationId !== Constants.NEW_CONVO;
const { data: messages } = useGetMessagesByConvoId(conversationId, {
enabled: isOpen && hasConversation,
});
/** Newest first — the image a user most likely wants to re-use. */
const images = useMemo(() => collectConversationImages(messages).reverse(), [messages]);
const handleSelect = (index: number) => {
const image = images[index];
if (image == null) {
return;
}
if (attachImage(image)) {
onOpenChange(false);
}
};
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogTemplate
title={localize('com_ui_previous_images')}
className="w-11/12 sm:w-[520px] md:w-[560px]"
main={
images.length === 0 ? (
<div className="py-8 text-center text-sm text-text-secondary">
{localize('com_ui_no_previous_images')}
</div>
) : (
<div className="grid max-h-[60vh] grid-cols-2 gap-3 overflow-y-auto p-1 sm:grid-cols-3">
{images.map((image, index) => (
<button
key={image.file_id ?? image.filepath}
type="button"
onClick={() => handleSelect(index)}
aria-label={image.filename ?? localize('com_ui_previous_images')}
className="group relative aspect-square overflow-hidden rounded-lg border border-border-light bg-surface-secondary transition-colors hover:border-border-heavy focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<img
src={resolveImageUrl(image.filepath)}
alt={image.filename ?? ''}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-150 group-hover:scale-105"
/>
</button>
))}
</div>
)
}
/>
</OGDialog>
);
}
@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Button, TooltipAnchor } from '@hanzochat/client';
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
import type { ConversationImage } from '~/utils';
import FixImageButton from './FixImageButton';
import { useLocalize } from '~/hooks';
const getQualityStyles = (quality: string): string => {
@@ -21,6 +23,7 @@ export default function DialogImage({
downloadImage,
args,
triggerRef,
fixImage,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
@@ -33,6 +36,8 @@ export default function DialogImage({
[key: string]: unknown;
};
triggerRef?: React.RefObject<HTMLButtonElement>;
/** When set, show a "Fix" action that attaches this image to the composer. */
fixImage?: ConversationImage;
}) {
const localize = useLocalize();
const [isPromptOpen, setIsPromptOpen] = useState(false);
@@ -296,6 +301,13 @@ export default function DialogImage({
}
/>
)}
{fixImage != null && (
<FixImageButton
image={fixImage}
variant="toolbar"
onDone={() => onOpenChange(false)}
/>
)}
<TooltipAnchor
description={localize('com_ui_download')}
render={
@@ -0,0 +1,76 @@
import { memo, useCallback } from 'react';
import { Wand2 } from 'lucide-react';
import { Button, TooltipAnchor } from '@hanzochat/client';
import type { ConversationImage } from '~/utils';
import useAttachImage from '~/hooks/Files/useAttachImage';
import useLocalize from '~/hooks/useLocalize';
import { cn } from '~/utils';
interface FixImageButtonProps {
image: ConversationImage;
/** `overlay` = pill over the message thumbnail; `toolbar` = action-bar icon in the fullscreen dialog. */
variant?: 'overlay' | 'toolbar';
onDone?: () => void;
className?: string;
}
/**
* "Fix" affordance for an AI-generated image: attaches the image to the composer
* as a reference and seeds a "Fix this image: " prompt. Renders nothing outside a
* live chat (e.g. shared/search views) where there is no composer to attach to.
*/
function FixImageButton({ image, variant = 'overlay', onDone, className }: FixImageButtonProps) {
const localize = useLocalize();
const { fixImage, canAttach } = useAttachImage();
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
fixImage(image);
onDone?.();
},
[fixImage, image, onDone],
);
if (!canAttach) {
return null;
}
if (variant === 'toolbar') {
return (
<TooltipAnchor
description={localize('com_ui_fix_image')}
render={
<Button
onClick={handleClick}
variant="ghost"
className={cn('h-10 w-10 p-0 text-white hover:bg-white/10', className)}
aria-label={localize('com_ui_fix_image')}
>
<Wand2 className="size-5" aria-hidden="true" />
</Button>
}
/>
);
}
return (
<button
type="button"
onClick={handleClick}
aria-label={localize('com_ui_fix_image')}
title={localize('com_ui_fix_image')}
className={cn(
'absolute right-2 top-2 z-10 flex items-center gap-1 rounded-lg bg-black/60 px-2 py-1 text-xs font-medium text-white opacity-0 backdrop-blur-sm transition-opacity duration-150',
'hover:bg-black/80 focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70',
'group-hover/image:opacity-100',
className,
)}
>
<Wand2 className="size-3.5" aria-hidden="true" />
{localize('com_ui_fix')}
</button>
);
}
export default memo(FixImageButton);
@@ -1,8 +1,9 @@
import React, { useState, useRef, useMemo } from 'react';
import { Skeleton } from '@hanzochat/client';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import { apiBaseUrl } from '@hanzochat/data-provider';
import { cn, scaleImage } from '~/utils';
import type { ConversationImage } from '~/utils';
import { cn, scaleImage, resolveImageUrl } from '~/utils';
import FixImageButton from './FixImageButton';
import DialogImage from './DialogImage';
const Image = ({
@@ -13,6 +14,9 @@ const Image = ({
placeholderDimensions,
className,
args,
enableFix,
fileId,
fileType,
}: {
imagePath: string;
altText: string;
@@ -30,6 +34,12 @@ const Image = ({
style?: string;
[key: string]: unknown;
};
/** Render the "Fix" affordance (AI-generated images only). */
enableFix?: boolean;
/** Server file id, so the image can be re-attached by reference (no re-upload). */
fileId?: string;
/** MIME type of the image, when known. */
fileType?: string;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
@@ -38,23 +48,20 @@ const Image = ({
const handleImageLoad = () => setIsLoaded(true);
// Fix image path to include base path for subdirectory deployments
const absoluteImageUrl = useMemo(() => {
if (!imagePath) return imagePath;
// Resolve image path to an absolute URL (base path for subdirectory deployments)
const absoluteImageUrl = useMemo(() => resolveImageUrl(imagePath), [imagePath]);
// If it's already an absolute URL or doesn't start with /images/, return as is
if (
imagePath.startsWith('http') ||
imagePath.startsWith('data:') ||
!imagePath.startsWith('/images/')
) {
return imagePath;
}
// Get the base URL and prepend it to the image path
const baseURL = apiBaseUrl();
return `${baseURL}${imagePath}`;
}, [imagePath]);
const fixImageRef = useMemo<ConversationImage>(
() => ({
file_id: fileId,
filepath: imagePath,
filename: altText,
type: fileType,
height,
width,
}),
[fileId, imagePath, altText, fileType, height, width],
);
const { width: scaledWidth, height: scaledHeight } = useMemo(
() =>
@@ -96,7 +103,7 @@ const Image = ({
};
return (
<div ref={containerRef}>
<div ref={containerRef} className="group/image relative">
<button
ref={triggerRef}
type="button"
@@ -133,6 +140,7 @@ const Image = ({
}
/>
</button>
{enableFix === true && isLoaded && <FixImageButton image={fixImageRef} variant="overlay" />}
{isLoaded && (
<DialogImage
isOpen={isOpen}
@@ -141,6 +149,7 @@ const Image = ({
downloadImage={downloadImage}
args={args}
triggerRef={triggerRef}
fixImage={enableFix === true ? fixImageRef : undefined}
/>
)}
</div>
@@ -52,7 +52,7 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
const [isLoaded, setIsLoaded] = useState(false);
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
const { width, height, filepath = null, file_id } = attachment as TFile & TAttachmentMetadata;
useEffect(() => {
setIsLoaded(false);
@@ -79,6 +79,8 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
height={height ?? 0}
width={width ?? 0}
className="mb-4"
enableFix
fileId={file_id}
/>
</div>
);
@@ -195,6 +195,8 @@ export default function OpenAIImageGen({
height={Number(dimensions.height?.split('px')[0])}
placeholderDimensions={{ width: dimensions.width, height: dimensions.height }}
args={parsedArgs}
enableFix
fileId={(attachment as TFile | undefined)?.file_id}
/>
</div>
</div>
@@ -26,6 +26,12 @@ jest.mock('../DialogImage', () => ({
isOpen ? <div data-testid="dialog-image" data-src={src} /> : null,
}));
/** Fix affordance pulls the localization/store graph; stub it out of this leaf-render test. */
jest.mock('../FixImageButton', () => ({
__esModule: true,
default: () => null,
}));
describe('Image', () => {
const defaultProps = {
imagePath: '/images/test.png',
+1
View File
@@ -1,6 +1,7 @@
export { default as useDeleteFilesFromTable } from './useDeleteFilesFromTable';
export { default as useSetFilesToDelete } from './useSetFilesToDelete';
export { default as useFileHandling } from './useFileHandling';
export { default as useAttachImage } from './useAttachImage';
export { default as useFileDeletion } from './useFileDeletion';
export { default as useUpdateFiles } from './useUpdateFiles';
export { default as useDragHelpers } from './useDragHelpers';
+83
View File
@@ -0,0 +1,83 @@
import { useCallback } from 'react';
import type { ExtendedFile } from '~/common';
import type { ConversationImage } from '~/utils';
import { resolveImageUrl, insertTextAtCursor, imageMimeFromName } from '~/utils';
import { useChatContext } from '~/Providers/ChatContext';
import { mainTextareaId } from '~/common';
import useUpdateFiles from './useUpdateFiles';
import useLocalize from '../useLocalize';
/**
* Attach an existing image (a user upload or an AI generation) to the composer.
*
* The image already exists on the server, so it is attached BY REFERENCE: the
* completed `ExtendedFile` is injected straight into the composer's file map
* (`attached: true`, so removing it never deletes the original) and the send path
* forwards `{file_id, filepath, type, width, height}` verbatim. No re-upload.
*
* `fixImage` additionally seeds the composer with a "Fix this image: " prompt and
* focuses the input, so the user only has to describe the fix.
*/
export default function useAttachImage() {
const localize = useLocalize();
const { files, setFiles } = useChatContext();
const { addFile } = useUpdateFiles(setFiles);
/** True only inside a live chat where a composer exists to attach to. */
const canAttach = typeof setFiles === 'function';
const attachImage = useCallback(
(image: ConversationImage): boolean => {
if (!canAttach || image.file_id == null || image.file_id === '') {
return false;
}
if (files?.has(image.file_id) === true) {
return true;
}
const url = resolveImageUrl(image.filepath);
if (!url) {
return false;
}
const inferred = image.type ?? imageMimeFromName(image.filename);
const attached: ExtendedFile = {
file_id: image.file_id,
filepath: image.filepath,
filename: image.filename ?? 'image.png',
type: inferred?.startsWith('image/') === true ? inferred : 'image/png',
height: image.height,
width: image.width,
source: image.source,
preview: url,
size: 0,
progress: 1,
attached: true,
};
addFile(attached);
return true;
},
[canAttach, files, addFile],
);
const fixImage = useCallback(
(image: ConversationImage): boolean => {
if (!attachImage(image)) {
return false;
}
/** Seed the prompt only when empty so a user's in-progress text is never clobbered. */
const el = document.getElementById(mainTextareaId) as HTMLTextAreaElement | null;
if (el != null) {
el.focus();
if (el.value.trim() === '') {
insertTextAtCursor(el, localize('com_ui_fix_image_prompt'));
}
}
return true;
},
[attachImage, localize],
);
return { attachImage, fixImage, canAttach };
}
+8 -10
View File
@@ -8,7 +8,6 @@ import {
Tools,
QueryKeys,
Constants,
inferMimeType,
EToolResources,
EModelEndpoint,
mergeFileConfig,
@@ -118,19 +117,18 @@ export default function useDragHelpers() {
}
}
/** Determine if dragged files are all images (enables the base image option) */
const allImages = item.files.every((f) =>
inferMimeType(f.name, f.type)?.startsWith('image/'),
);
const shouldShowModal =
allImages ||
/**
* The modal only earns its place when there's a tool-resource CHOICE to make
* (file search / code / OCR). With none available — the common case of dropping
* an image or file into a plain chat — attach straight into the current
* conversation instead of surfacing a single-option dead-end modal.
*/
const hasToolOptions =
(fileSearchEnabled && fileSearchAllowedByAgent) ||
(codeEnabled && codeAllowedByAgent) ||
contextEnabled;
if (!shouldShowModal) {
// Fallback: directly handle files without showing modal
if (!hasToolOptions) {
handleFilesRef.current(item.files);
return;
}
+6
View File
@@ -766,6 +766,7 @@
"com_ui_attach_error_openai": "Cannot attach Assistant files to other endpoints",
"com_ui_attach_error_size": "File size limit exceeded for endpoint:",
"com_ui_attach_error_type": "Unsupported file type for endpoint:",
"com_ui_attach_previous_image": "Attach a previous image",
"com_ui_attach_remove": "Remove file",
"com_ui_attach_warn_endpoint": "Non-Assistant files may be ignored without a compatible tool",
"com_ui_attachment": "Attachment",
@@ -1030,6 +1031,9 @@
"com_ui_filter_prompts_name": "Filter prompts by name",
"com_ui_final_touch": "Final touch",
"com_ui_finance": "Finance",
"com_ui_fix": "Fix",
"com_ui_fix_image": "Fix this image",
"com_ui_fix_image_prompt": "Fix this image: ",
"com_ui_fork": "Fork",
"com_ui_fork_all_target": "Include all to/from here",
"com_ui_fork_branches": "Include related branches",
@@ -1221,6 +1225,7 @@
"com_ui_no_changes": "No changes were made",
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
"com_ui_no_mcp_servers": "No MCP servers yet",
"com_ui_no_previous_images": "No images in this conversation yet",
"com_ui_no_mcp_servers_match": "No MCP servers match your filter",
"com_ui_no_memories": "No memories. Create them manually or prompt the AI to remember something",
"com_ui_no_memories_match": "No memories match your search",
@@ -1266,6 +1271,7 @@
"com_ui_preferences_updated": "Preferences updated successfully",
"com_ui_prev": "Prev",
"com_ui_preview": "Preview",
"com_ui_previous_images": "Previous images",
"com_ui_privacy_policy": "Privacy policy",
"com_ui_privacy_policy_url": "Privacy Policy URL",
"com_ui_prompt": "Prompt",
+143
View File
@@ -0,0 +1,143 @@
import { ContentTypes } from '@hanzochat/data-provider';
import type { TMessage } from '@hanzochat/data-provider';
import { collectConversationImages, resolveImageUrl } from './conversationImages';
const msg = (partial: Partial<TMessage>): TMessage => partial as TMessage;
describe('collectConversationImages', () => {
it('returns [] for empty/undefined input', () => {
expect(collectConversationImages(undefined)).toEqual([]);
expect(collectConversationImages(null)).toEqual([]);
expect(collectConversationImages([])).toEqual([]);
});
it('collects user-uploaded images from message.files', () => {
const messages = [
msg({
files: [
{ file_id: 'f1', filepath: '/images/a.png', filename: 'a.png', type: 'image/png' },
{
file_id: 'd1',
filepath: '/uploads/doc.pdf',
filename: 'doc.pdf',
type: 'application/pdf',
},
],
}),
];
expect(collectConversationImages(messages)).toEqual([
{
file_id: 'f1',
filepath: '/images/a.png',
filename: 'a.png',
type: 'image/png',
height: undefined,
width: undefined,
source: undefined,
},
]);
});
it('collects AI-generated images from message.attachments (image predicate)', () => {
const messages = [
msg({
attachments: [
{
file_id: 'g1',
filepath: '/images/gen.png',
filename: 'gen.png',
width: 1024,
height: 1024,
type: 'image_gen_oai',
},
/* Missing dimensions → not treated as a renderable image. */
{ file_id: 'g2', filepath: '/images/nodim.png', filename: 'nodim.png' },
] as TMessage['attachments'],
}),
];
const images = collectConversationImages(messages);
expect(images).toHaveLength(1);
expect(images[0]).toMatchObject({
file_id: 'g1',
filepath: '/images/gen.png',
filename: 'gen.png',
type: 'image/png',
width: 1024,
height: 1024,
});
});
it('collects inline image_file content parts', () => {
const messages = [
msg({
content: [
{ type: ContentTypes.TEXT, text: 'hi' },
{
type: ContentTypes.IMAGE_FILE,
[ContentTypes.IMAGE_FILE]: {
file_id: 'p1',
filepath: '/images/part.webp',
filename: 'part.webp',
height: 512,
width: 512,
},
},
] as TMessage['content'],
}),
];
const images = collectConversationImages(messages);
expect(images).toHaveLength(1);
expect(images[0]).toMatchObject({
file_id: 'p1',
filepath: '/images/part.webp',
type: 'image/webp',
});
});
it('dedupes by file_id (then filepath) preserving first occurrence order', () => {
const messages = [
msg({
files: [{ file_id: 'f1', filepath: '/images/a.png', filename: 'a.png', type: 'image/png' }],
}),
msg({
attachments: [
{ file_id: 'f1', filepath: '/images/a.png', filename: 'a.png', width: 1, height: 1 },
{ file_id: 'f2', filepath: '/images/b.png', filename: 'b.png', width: 1, height: 1 },
] as TMessage['attachments'],
}),
];
const images = collectConversationImages(messages);
expect(images.map((i) => i.file_id)).toEqual(['f1', 'f2']);
});
it('skips images without a filepath', () => {
const messages = [
msg({
files: [{ file_id: 'f1', filename: 'a.png', type: 'image/png' }] as TMessage['files'],
}),
];
expect(collectConversationImages(messages)).toEqual([]);
});
});
describe('resolveImageUrl', () => {
it('returns empty string for empty input', () => {
expect(resolveImageUrl('')).toBe('');
expect(resolveImageUrl(null)).toBe('');
expect(resolveImageUrl(undefined)).toBe('');
});
it('passes through absolute URLs and data URIs untouched', () => {
expect(resolveImageUrl('https://cdn.example/x.png')).toBe('https://cdn.example/x.png');
expect(resolveImageUrl('data:image/png;base64,AAAA')).toBe('data:image/png;base64,AAAA');
});
it('passes through non-/images/ paths untouched', () => {
expect(resolveImageUrl('/uploads/x.png')).toBe('/uploads/x.png');
});
it('prefixes /images/ paths with the api base url', () => {
/* In jsdom with no <base>, apiBaseUrl() resolves to '' so the path is preserved. */
expect(resolveImageUrl('/images/x.png')).toBe('/images/x.png');
});
});
+139
View File
@@ -0,0 +1,139 @@
import { apiBaseUrl, ContentTypes, imageExtRegex } from '@hanzochat/data-provider';
import type { TMessage, TFile, TAttachment } from '@hanzochat/data-provider';
const IMAGE_MIME_BY_EXT: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
heic: 'image/heic',
heif: 'image/heif',
};
/** Best-effort `image/*` MIME from a filename's extension (composer preview only). */
export function imageMimeFromName(filename?: string | null): string | undefined {
const ext = filename?.split('.').pop()?.toLowerCase() ?? '';
return IMAGE_MIME_BY_EXT[ext];
}
/**
* A minimal, re-attachable reference to an image that already exists on the
* server (a user upload or an AI generation). Carries everything the composer
* needs to attach it WITHOUT re-uploading (see `useAttachImage`).
*/
export interface ConversationImage {
file_id?: string;
filepath: string;
filename?: string;
/** MIME type, always `image/*`. */
type?: string;
height?: number;
width?: number;
source?: TFile['source'];
}
/**
* Resolve an image `filepath` to an absolute URL. Server images are served from
* `/images/...`; for subdirectory deployments these must be prefixed with the
* API base path. Absolute URLs and data URIs are returned untouched. This is the
* single source of truth shared by the message `Image` renderer and the
* attach-by-reference path.
*/
export function resolveImageUrl(imagePath?: string | null): string {
if (!imagePath) {
return '';
}
if (
imagePath.startsWith('http') ||
imagePath.startsWith('data:') ||
!imagePath.startsWith('/images/')
) {
return imagePath;
}
return `${apiBaseUrl()}${imagePath}`;
}
const isImageType = (type?: string): boolean => type?.startsWith('image/') === true;
const isImageName = (filename?: string): boolean =>
filename != null && imageExtRegex.test(filename);
const toImage = (file: Partial<TFile>): ConversationImage | null => {
const filepath = file.filepath ?? '';
if (!filepath) {
return null;
}
const filename = file.filename ?? undefined;
const type = isImageType(file.type) ? file.type : imageMimeFromName(filename ?? filepath);
return {
file_id: file.file_id,
filepath,
filename,
type,
height: file.height,
width: file.width,
source: file.source,
};
};
/**
* Walk a conversation's messages (chronological) and collect every image that
* can be re-attached to the composer: user uploads (`message.files`), AI/tool
* generations (`message.attachments`), and inline image content parts
* (`content[].image_file`). Deduped by `file_id` (falling back to `filepath`),
* keeping first occurrence.
*
* Pure and framework-free so it can be unit-tested and reused by any picker.
*/
export function collectConversationImages(messages?: TMessage[] | null): ConversationImage[] {
if (!messages?.length) {
return [];
}
const seen = new Set<string>();
const images: ConversationImage[] = [];
const push = (image: ConversationImage | null) => {
if (!image) {
return;
}
const key = image.file_id ?? image.filepath;
if (!key || seen.has(key)) {
return;
}
seen.add(key);
images.push(image);
};
for (const message of messages) {
/* User-uploaded files (images carry an `image/*` type). */
for (const file of message.files ?? []) {
if (isImageType(file.type) || isImageName(file.filename)) {
push(toImage(file));
}
}
/* Tool/AI-generated outputs. Mirror the image predicate in `Attachment.tsx`. */
for (const attachment of (message.attachments ?? []) as TAttachment[]) {
const file = attachment as Partial<TFile>;
if (
isImageName(file.filename) &&
file.width != null &&
file.height != null &&
file.filepath != null
) {
push(toImage(file));
}
}
/* Inline image content parts. */
for (const part of message.content ?? []) {
if (part?.type === ContentTypes.IMAGE_FILE) {
push(toImage(part[ContentTypes.IMAGE_FILE] as Partial<TFile>));
}
}
}
return images;
}
+1
View File
@@ -14,6 +14,7 @@ export * from './agentCommand';
export * from './buildApp';
export * from './drafts';
export * from './convos';
export * from './conversationImages';
export * from './routes';
export * from './presets';
export * from './prompts';