Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eea55929e |
@@ -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,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';
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user