mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 11:33:44 +00:00
* fix: Hide duplicate image placeholder during image generation * test: Update OpenAIImageGen tests for conditional Image rendering
255 lines
7.5 KiB
TypeScript
255 lines
7.5 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { PixelCard } from '@librechat/client';
|
|
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
|
|
import { ToolIcon, isError } from '~/components/Chat/Messages/Content/ToolOutput';
|
|
import Image from '~/components/Chat/Messages/Content/Image';
|
|
import { useProgress, useLocalize } from '~/hooks';
|
|
import ProgressText from './ProgressText';
|
|
import { AGENT_STYLE_TOOLS } from '.';
|
|
import { scaleImage } from '~/utils';
|
|
|
|
function computeCancelled(
|
|
isSubmitting: boolean | undefined,
|
|
initialProgress: number,
|
|
hasError: boolean,
|
|
): boolean {
|
|
if (isSubmitting !== undefined) {
|
|
return (!isSubmitting && initialProgress < 1) || hasError;
|
|
}
|
|
// Legacy path: in-progress (0 < progress < 1) is never cancelled
|
|
// because legacy image gen lacks a submitting signal.
|
|
if (initialProgress < 1 && initialProgress > 0) {
|
|
return false;
|
|
}
|
|
return hasError;
|
|
}
|
|
|
|
export default function OpenAIImageGen({
|
|
initialProgress = 0.1,
|
|
isSubmitting,
|
|
toolName,
|
|
args: _args = '',
|
|
output,
|
|
attachments,
|
|
}: {
|
|
initialProgress: number;
|
|
isSubmitting?: boolean;
|
|
toolName?: string;
|
|
args: string | Record<string, unknown>;
|
|
output?: string | null;
|
|
attachments?: TAttachment[];
|
|
}) {
|
|
const localize = useLocalize();
|
|
const isAgentStyle = toolName != null && AGENT_STYLE_TOOLS.has(toolName);
|
|
const [agentProgress, setAgentProgress] = useState(initialProgress);
|
|
const legacyProgress = useProgress(isAgentStyle ? 1 : initialProgress);
|
|
const progress = isAgentStyle ? agentProgress : legacyProgress;
|
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
const hasError = typeof output === 'string' && isError(output);
|
|
|
|
/**
|
|
* Determines if the image generation was cancelled.
|
|
* - Agent path (isSubmitting defined): cancelled if not submitting + incomplete, or on error.
|
|
* - Legacy path (isSubmitting undefined): in-progress (0 < progress < 1) is never cancelled
|
|
* because legacy image gen lacks a submitting signal — only errors cancel.
|
|
*/
|
|
const cancelled = computeCancelled(isSubmitting, initialProgress, hasError);
|
|
|
|
let width: number | undefined;
|
|
let height: number | undefined;
|
|
let quality: 'low' | 'medium' | 'high' = 'high';
|
|
|
|
let parsedArgs: Record<string, unknown> = {};
|
|
try {
|
|
parsedArgs = typeof _args === 'string' ? JSON.parse(_args) : _args;
|
|
} catch {
|
|
parsedArgs = {};
|
|
}
|
|
|
|
try {
|
|
const argsObj = parsedArgs;
|
|
|
|
if (argsObj && typeof argsObj.size === 'string') {
|
|
const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10));
|
|
if (!isNaN(w) && !isNaN(h)) {
|
|
width = w;
|
|
height = h;
|
|
}
|
|
} else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) {
|
|
width = undefined;
|
|
height = undefined;
|
|
}
|
|
|
|
if (argsObj && typeof argsObj.quality === 'string') {
|
|
const q = argsObj.quality.toLowerCase();
|
|
if (q === 'low' || q === 'medium' || q === 'high') {
|
|
quality = q;
|
|
}
|
|
}
|
|
} catch {
|
|
width = undefined;
|
|
height = undefined;
|
|
}
|
|
|
|
const attachment = attachments?.[0];
|
|
const {
|
|
width: imageWidth,
|
|
height: imageHeight,
|
|
filepath = null,
|
|
filename = '',
|
|
} = (attachment as TFile & TAttachmentMetadata) || {};
|
|
|
|
let origWidth = width ?? imageWidth;
|
|
let origHeight = height ?? imageHeight;
|
|
|
|
if (origWidth === undefined || origHeight === undefined) {
|
|
origWidth = 1024;
|
|
origHeight = 1024;
|
|
}
|
|
|
|
const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' });
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const updateDimensions = useCallback(() => {
|
|
if (origWidth && origHeight && containerRef.current) {
|
|
const scaled = scaleImage({
|
|
originalWidth: origWidth,
|
|
originalHeight: origHeight,
|
|
containerRef,
|
|
});
|
|
setDimensions(scaled);
|
|
}
|
|
}, [origWidth, origHeight]);
|
|
|
|
useEffect(() => {
|
|
if (!isAgentStyle) {
|
|
return;
|
|
}
|
|
|
|
if (isSubmitting) {
|
|
setAgentProgress(initialProgress);
|
|
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
}
|
|
|
|
let baseDuration = 20000;
|
|
if (quality === 'low') {
|
|
baseDuration = 10000;
|
|
} else if (quality === 'high') {
|
|
baseDuration = 50000;
|
|
}
|
|
const jitter = Math.floor(baseDuration * 0.3);
|
|
const totalDuration = Math.floor(Math.random() * jitter) + baseDuration;
|
|
const updateInterval = 200;
|
|
const totalSteps = totalDuration / updateInterval;
|
|
let currentStep = 0;
|
|
|
|
intervalRef.current = setInterval(() => {
|
|
currentStep++;
|
|
|
|
if (currentStep >= totalSteps) {
|
|
clearInterval(intervalRef.current as NodeJS.Timeout);
|
|
setAgentProgress(0.9);
|
|
} else {
|
|
const progressRatio = currentStep / totalSteps;
|
|
let mapRatio: number;
|
|
if (progressRatio < 0.8) {
|
|
mapRatio = Math.pow(progressRatio, 1.1);
|
|
} else {
|
|
const sub = (progressRatio - 0.8) / 0.2;
|
|
mapRatio = 0.8 + (1 - Math.pow(1 - sub, 2)) * 0.2;
|
|
}
|
|
const scaledProgress = 0.1 + mapRatio * 0.8;
|
|
|
|
setAgentProgress(scaledProgress);
|
|
}
|
|
}, updateInterval);
|
|
}
|
|
|
|
return () => {
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
}
|
|
};
|
|
}, [isSubmitting, initialProgress, quality, isAgentStyle]);
|
|
|
|
useEffect(() => {
|
|
if (!isAgentStyle) {
|
|
return;
|
|
}
|
|
|
|
if (initialProgress >= 1 || cancelled) {
|
|
setAgentProgress(initialProgress);
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
}
|
|
}
|
|
}, [initialProgress, cancelled, isAgentStyle]);
|
|
|
|
useEffect(() => {
|
|
updateDimensions();
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
updateDimensions();
|
|
});
|
|
|
|
if (containerRef.current) {
|
|
resizeObserver.observe(containerRef.current);
|
|
}
|
|
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [updateDimensions]);
|
|
|
|
const isInProgress = progress < 1 && !cancelled;
|
|
|
|
return (
|
|
<>
|
|
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
|
{(() => {
|
|
if (progress < 1 && !cancelled) {
|
|
return '';
|
|
}
|
|
if (cancelled && hasError) {
|
|
return localize('com_ui_image_gen_failed');
|
|
}
|
|
if (cancelled) {
|
|
return localize('com_ui_cancelled');
|
|
}
|
|
return localize('com_ui_image_created');
|
|
})()}
|
|
</span>
|
|
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2">
|
|
<ToolIcon type="image_gen" isAnimating={isInProgress} />
|
|
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
|
|
</div>
|
|
{isAgentStyle && (
|
|
<div className="relative mb-2 flex w-full justify-start">
|
|
<div ref={containerRef} className="w-full max-w-lg">
|
|
{dimensions.width !== 'auto' && progress < 1 && (
|
|
<PixelCard
|
|
variant="default"
|
|
progress={progress}
|
|
randomness={0.6}
|
|
width={dimensions.width}
|
|
height={dimensions.height}
|
|
/>
|
|
)}
|
|
{filepath && (
|
|
<Image
|
|
altText={filename}
|
|
imagePath={filepath}
|
|
width={Number(dimensions.width?.split('px')[0])}
|
|
height={Number(dimensions.height?.split('px')[0])}
|
|
args={parsedArgs}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|