From c79ee32006db3240ce8cfcb533278d189fa4ed67 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 16 May 2025 17:50:18 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20feat:=20Tool=20Call=20a?= =?UTF-8?q?nd=20Loading=20UI=20Refresh,=20Image=20Resize=20Config=20(#7086?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Enhance Spinner component with customizable properties and improved animation * 🔧 fix: Replace Loader with Spinner in RunCode component and update FilePreview to use Spinner for progress indication * ✨ feat: Refactor icons in CodeProgress and CancelledIcon components; enhance animation and styling in ExecuteCode and ProgressText components * ✨ feat: Refactor attachment handling in ExecuteCode component; replace individual attachment rendering with AttachmentGroup for improved structure * ✨ feat: Refactor dialog components for improved accessibility and styling; integrate Skeleton loading state in Image component * ✨ feat: Refactor ToolCall component to use ToolCallInfo for better structure; replace ToolPopover with AttachmentGroup; enhance ProgressText with error handling and improved UI elements * 🔧 fix: Remove unnecessary whitespace in ProgressText * 🔧 fix: Remove unnecessary margin from AgentFooter and AgentPanel components; clean up SidePanel imports * ✨ feat: Enhance ToolCall and ToolCallInfo components with improved styling; update translations and add warning text color to Tailwind config * 🔧 fix: Update import statement for useLocalize in ToolCallInfo component; fix: chatform transition * ✨ feat: Refactor ToolCall and ToolCallInfo components for improved structure and styling; add optimized code block for better output display * ✨ feat: Implement OpenAI image generation component; add progress tracking and localization for user feedback * 🔧 fix: Adjust base duration values for image generation; optimize timing for quality settings * chore: remove unnecessary space * ✨ feat: Enhance OpenAI image generation with editing capabilities; update localization for progress feedback * ✨ feat: Add download functionality to images; enhance DialogImage component with download button * ✨ feat: Enhance image resizing functionality; support custom percentage and pixel dimensions in resizeImageBuffer --- .../tools/structured/OpenAIImageTools.js | 2 +- api/server/services/Files/images/resize.js | 29 +- api/server/services/Files/process.js | 7 +- client/src/components/Chat/Input/ChatForm.tsx | 4 +- .../Chat/Input/Files/FilePreview.tsx | 26 +- .../Chat/Input/Files/ImagePreview.tsx | 2 +- .../Chat/Messages/Content/CancelledIcon.tsx | 16 +- .../Chat/Messages/Content/CodeAnalyze.tsx | 21 - .../Chat/Messages/Content/ContentParts.tsx | 2 +- .../Chat/Messages/Content/DialogImage.tsx | 72 ++-- .../Chat/Messages/Content/FinishedIcon.tsx | 5 +- .../Chat/Messages/Content/Image.tsx | 106 ++--- .../components/Chat/Messages/Content/Part.tsx | 20 +- .../Messages/Content/Parts/Attachment.tsx | 124 +++++- .../Messages/Content/Parts/CodeProgress.tsx | 90 ----- .../Messages/Content/Parts/ExecuteCode.tsx | 180 +++++++-- .../Parts/OpenAIImageGen/OpenAIImageGen.tsx | 205 ++++++++++ .../Parts/OpenAIImageGen/ProgressText.tsx | 62 +++ .../Content/Parts/OpenAIImageGen/index.ts | 1 + .../Chat/Messages/Content/Parts/Stdout.tsx | 2 +- .../Chat/Messages/Content/Parts/index.ts | 10 + .../Chat/Messages/Content/ProgressText.tsx | 42 +- .../Chat/Messages/Content/ToolCall.tsx | 230 ++++++----- .../Chat/Messages/Content/ToolCallInfo.tsx | 74 ++++ .../Chat/Messages/Content/ToolPopover.tsx | 71 ---- .../Conversations/Conversations.tsx | 2 +- .../components/Messages/Content/CodeBlock.tsx | 2 +- .../components/Messages/Content/RunCode.tsx | 5 +- .../SidePanel/Agents/AgentFooter.tsx | 2 +- .../SidePanel/Agents/AgentPanel.tsx | 2 +- .../components/SidePanel/Agents/Images.tsx | 2 +- client/src/components/SidePanel/SidePanel.tsx | 1 - client/src/components/svg/Spinner.tsx | 67 +++- client/src/components/ui/Button.tsx | 2 +- client/src/components/ui/OriginalDialog.tsx | 2 +- client/src/components/ui/PixelCard.tsx | 375 ++++++++++++++++++ client/src/components/ui/index.ts | 3 +- client/src/locales/en/translation.json | 11 +- client/src/style.css | 68 +++- client/src/utils/index.ts | 1 + client/src/utils/scaleImage.ts | 21 + client/tailwind.config.cjs | 1 + librechat.example.yaml | 3 + packages/data-provider/src/file-config.ts | 6 + 44 files changed, 1452 insertions(+), 527 deletions(-) delete mode 100644 client/src/components/Chat/Messages/Content/Parts/CodeProgress.tsx create mode 100644 client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx create mode 100644 client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/ProgressText.tsx create mode 100644 client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/index.ts create mode 100644 client/src/components/Chat/Messages/Content/Parts/index.ts create mode 100644 client/src/components/Chat/Messages/Content/ToolCallInfo.tsx delete mode 100644 client/src/components/Chat/Messages/Content/ToolPopover.tsx create mode 100644 client/src/components/ui/PixelCard.tsx create mode 100644 client/src/utils/scaleImage.ts diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js index 85941a779a..afea9dfd55 100644 --- a/api/app/clients/tools/structured/OpenAIImageTools.js +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -30,7 +30,7 @@ const DEFAULT_IMAGE_EDIT_DESCRIPTION = When to use \`image_edit_oai\`: - The user wants to modify, extend, or remix one **or more** uploaded images, either: - - Previously generated, or in the current request (both to be included in the \`image_ids\` array). +- Previously generated, or in the current request (both to be included in the \`image_ids\` array). - Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements. - Any current or existing images are to be used as visual guides. - If there are any files in the current request, they are more likely than not expected as references for image edit requests. diff --git a/api/server/services/Files/images/resize.js b/api/server/services/Files/images/resize.js index 50bec1ef3b..c2cdaacb63 100644 --- a/api/server/services/Files/images/resize.js +++ b/api/server/services/Files/images/resize.js @@ -5,9 +5,10 @@ const { EModelEndpoint } = require('librechat-data-provider'); * Resizes an image from a given buffer based on the specified resolution. * * @param {Buffer} inputBuffer - The buffer of the image to be resized. - * @param {'low' | 'high'} resolution - The resolution to resize the image to. + * @param {'low' | 'high' | {percentage?: number, px?: number}} resolution - The resolution to resize the image to. * 'low' for a maximum of 512x512 resolution, - * 'high' for a maximum of 768x2000 resolution. + * 'high' for a maximum of 768x2000 resolution, + * or a custom object with percentage or px values. * @param {EModelEndpoint} endpoint - Identifier for specific endpoint handling * @returns {Promise<{buffer: Buffer, width: number, height: number}>} An object containing the resized image buffer and its dimensions. * @throws Will throw an error if the resolution parameter is invalid. @@ -17,10 +18,32 @@ async function resizeImageBuffer(inputBuffer, resolution, endpoint) { const maxShortSideHighRes = 768; const maxLongSideHighRes = endpoint === EModelEndpoint.anthropic ? 1568 : 2000; + let customPercent, customPx; + if (resolution && typeof resolution === 'object') { + if (typeof resolution.percentage === 'number') { + customPercent = resolution.percentage; + } else if (typeof resolution.px === 'number') { + customPx = resolution.px; + } + } + let newWidth, newHeight; let resizeOptions = { fit: 'inside', withoutEnlargement: true }; - if (resolution === 'low') { + if (customPercent != null || customPx != null) { + // percentage-based resize + const metadata = await sharp(inputBuffer).metadata(); + if (customPercent != null) { + newWidth = Math.round(metadata.width * (customPercent / 100)); + newHeight = Math.round(metadata.height * (customPercent / 100)); + } else { + // pixel max on both sides + newWidth = Math.min(metadata.width, customPx); + newHeight = Math.min(metadata.height, customPx); + } + resizeOptions.width = newWidth; + resizeOptions.height = newHeight; + } else if (resolution === 'low') { resizeOptions.width = maxLowRes; resizeOptions.height = maxLowRes; } else if (resolution === 'high') { diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 81a4f52855..e8aef7da85 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -693,7 +693,7 @@ const processOpenAIFile = async ({ const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileExt }) => { const currentDate = new Date(); const formattedDate = currentDate.toISOString(); - const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`); + const _file = await convertImage(req, buffer, undefined, `${file_id}${fileExt}`); const file = { ..._file, usage: 1, @@ -838,8 +838,9 @@ function base64ToBuffer(base64String) { async function saveBase64Image( url, - { req, file_id: _file_id, filename: _filename, endpoint, context, resolution = 'high' }, + { req, file_id: _file_id, filename: _filename, endpoint, context, resolution }, ) { + const effectiveResolution = resolution ?? req.app.locals.fileConfig?.imageGeneration ?? 'high'; const file_id = _file_id ?? v4(); let filename = `${file_id}-${_filename}`; const { buffer: inputBuffer, type } = base64ToBuffer(url); @@ -852,7 +853,7 @@ async function saveBase64Image( } } - const image = await resizeImageBuffer(inputBuffer, resolution, endpoint); + const image = await resizeImageBuffer(inputBuffer, effectiveResolution, endpoint); const source = req.app.locals.fileStrategy; const { saveBuffer } = getStrategyFunctions(source); const filepath = await saveBuffer({ diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 7067280001..23bece3626 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -206,8 +206,8 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
{ - const radius = 55; - const circumference = 2 * Math.PI * radius; - const progress = useProgress( - file?.['progress'] ?? 1, - 0.001, - (file as ExtendedFile | undefined)?.size ?? 1, - ); - - const offset = circumference - progress * circumference; - const circleCSSProperties = { - transition: 'stroke-dashoffset 0.5s linear', - }; - return (
- {progress < 1 && ( - )}
diff --git a/client/src/components/Chat/Input/Files/ImagePreview.tsx b/client/src/components/Chat/Input/Files/ImagePreview.tsx index 5d66d5ddb4..fe761da862 100644 --- a/client/src/components/Chat/Input/Files/ImagePreview.tsx +++ b/client/src/components/Chat/Input/Files/ImagePreview.tsx @@ -161,7 +161,7 @@ const ImagePreview = ({ - - - +
+
); } diff --git a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx index f0918240ba..139496c621 100644 --- a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx +++ b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx @@ -1,31 +1,23 @@ import { useState } from 'react'; import { useRecoilValue } from 'recoil'; -import { CodeInProgress } from './Parts/CodeProgress'; import { useProgress, useLocalize } from '~/hooks'; import ProgressText from './ProgressText'; -import FinishedIcon from './FinishedIcon'; import MarkdownLite from './MarkdownLite'; import store from '~/store'; -const radius = 56.08695652173913; -const circumference = 2 * Math.PI * radius; - export default function CodeAnalyze({ initialProgress = 0.1, code, outputs = [], - isSubmitting, }: { initialProgress: number; code: string; outputs: Record[]; - isSubmitting: boolean; }) { const localize = useLocalize(); const progress = useProgress(initialProgress); const showAnalysisCode = useRecoilValue(store.showCode); const [showCode, setShowCode] = useState(showAnalysisCode); - const offset = circumference - progress * circumference; const logs = outputs.reduce((acc, output) => { if (output['logs']) { @@ -37,19 +29,6 @@ export default function CodeAnalyze({ return ( <>
-
- {progress < 1 ? ( - - ) : ( - - )} -
setShowCode((prev) => !prev)} diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 3805e0bb41..b40014318d 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -3,10 +3,10 @@ import { useRecoilValue, useRecoilState } from 'recoil'; import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts, TAttachment, Agents } from 'librechat-data-provider'; import { ThinkingButton } from '~/components/Artifacts/Thinking'; -import EditTextPart from './Parts/EditTextPart'; import useLocalize from '~/hooks/useLocalize'; import { mapAttachments } from '~/utils/map'; import { MessageContext } from '~/Providers'; +import { EditTextPart } from './Parts'; import store from '~/store'; import Part from './Part'; diff --git a/client/src/components/Chat/Messages/Content/DialogImage.tsx b/client/src/components/Chat/Messages/Content/DialogImage.tsx index c7cf734a7f..3418f5c9dc 100644 --- a/client/src/components/Chat/Messages/Content/DialogImage.tsx +++ b/client/src/components/Chat/Messages/Content/DialogImage.tsx @@ -1,42 +1,42 @@ -import * as Dialog from '@radix-ui/react-dialog'; +import { X, ArrowDownToLine } from 'lucide-react'; +import { Button, OGDialog, OGDialogContent } from '~/components'; -export default function DialogImage({ src = '', width = 1920, height = 1080 }) { +export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage }) { return ( - - + - - - - width ? 1 / 1.75 : 1.75 / 1 }} - > - Uploaded image - - - + + + +
+ + + Uploaded image + + +
+
); } diff --git a/client/src/components/Chat/Messages/Content/FinishedIcon.tsx b/client/src/components/Chat/Messages/Content/FinishedIcon.tsx index 2d47833156..48660f8757 100644 --- a/client/src/components/Chat/Messages/Content/FinishedIcon.tsx +++ b/client/src/components/Chat/Messages/Content/FinishedIcon.tsx @@ -1,11 +1,10 @@ export default function FinishedIcon() { return (
- + ; -}) => { - const containerWidth = containerRef.current?.offsetWidth ?? 0; - if (containerWidth === 0 || originalWidth == null || originalHeight == null) { - return { width: 'auto', height: 'auto' }; - } - const aspectRatio = originalWidth / originalHeight; - const scaledWidth = Math.min(containerWidth, originalWidth); - const scaledHeight = scaledWidth / aspectRatio; - return { width: `${scaledWidth}px`, height: `${scaledHeight}px` }; -}; +import { Skeleton } from '~/components'; const Image = ({ imagePath, @@ -41,6 +22,7 @@ const Image = ({ }; className?: string; }) => { + const [isOpen, setIsOpen] = useState(false); const [isLoaded, setIsLoaded] = useState(false); const containerRef = useRef(null); @@ -56,39 +38,63 @@ const Image = ({ [placeholderDimensions, height, width], ); + const downloadImage = () => { + const link = document.createElement('a'); + link.href = imagePath; + link.download = altText; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + return ( - -
-
+
+ - -
+ } + /> + + {isLoaded && ( + + )}
- {isLoaded && } - +
); }; diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 8e6e5e09fa..e8d08040f7 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -7,17 +7,13 @@ import { } from 'librechat-data-provider'; import { memo } from 'react'; import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'; +import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts'; import { ErrorMessage } from './MessageContent'; -import AgentUpdate from './Parts/AgentUpdate'; -import ExecuteCode from './Parts/ExecuteCode'; import RetrievalCall from './RetrievalCall'; -import Reasoning from './Parts/Reasoning'; -import EmptyText from './Parts/EmptyText'; import CodeAnalyze from './CodeAnalyze'; import Container from './Container'; import ToolCall from './ToolCall'; import ImageGen from './ImageGen'; -import Text from './Parts/Text'; import Image from './Image'; type PartProps = { @@ -93,8 +89,21 @@ const Part = memo( + ); + } else if ( + isToolCall && + (toolCall.name === 'image_gen_oai' || toolCall.name === 'image_edit_oai') + ) { + return ( + ); @@ -118,7 +127,6 @@ const Part = memo( initialProgress={toolCall.progress ?? 0.1} code={code_interpreter.input} outputs={code_interpreter.outputs ?? []} - isSubmitting={isSubmitting} /> ); } else if ( diff --git a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx index e1aeb86a5e..d6b9b00b2c 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx @@ -1,9 +1,10 @@ -import { memo } from 'react'; +import { memo, useState, useEffect } from 'react'; import { imageExtRegex } from 'librechat-data-provider'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; import FileContainer from '~/components/Chat/Input/Files/FileContainer'; import Image from '~/components/Chat/Messages/Content/Image'; import { useAttachmentLink } from './LogLink'; +import { cn } from '~/utils'; const FileAttachment = memo(({ attachment }: { attachment: TAttachment }) => { const { handleDownload } = useAttachmentLink({ @@ -11,15 +12,68 @@ const FileAttachment = memo(({ attachment }: { attachment: TAttachment }) => { filename: attachment.filename, }); const extension = attachment.filename.split('.').pop(); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), 50); + return () => clearTimeout(timer); + }, []); return ( - +
+ +
+ ); +}); + +const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => { + const [isLoaded, setIsLoaded] = useState(false); + const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; + + useEffect(() => { + setIsLoaded(false); + const timer = setTimeout(() => setIsLoaded(true), 100); + return () => clearTimeout(timer); + }, [attachment]); + + return ( +
+ +
); }); @@ -27,20 +81,56 @@ export default function Attachment({ attachment }: { attachment?: TAttachment }) if (!attachment) { return null; } + const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; const isImage = imageExtRegex.test(attachment.filename) && width != null && height != null && filepath != null; if (isImage) { - return ( - - ); + return ; } return ; } + +export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] }) { + if (!attachments || attachments.length === 0) { + return null; + } + + const fileAttachments: TAttachment[] = []; + const imageAttachments: TAttachment[] = []; + + attachments.forEach((attachment) => { + const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; + const isImage = + imageExtRegex.test(attachment.filename) && + width != null && + height != null && + filepath != null; + + if (isImage) { + imageAttachments.push(attachment); + } else { + fileAttachments.push(attachment); + } + }); + + return ( + <> + {fileAttachments.length > 0 && ( +
+ {fileAttachments.map((attachment, index) => ( + + ))} +
+ )} + {imageAttachments.length > 0 && ( +
+ {imageAttachments.map((attachment, index) => ( + + ))} +
+ )} + + ); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/CodeProgress.tsx b/client/src/components/Chat/Messages/Content/Parts/CodeProgress.tsx deleted file mode 100644 index 6e70688cd6..0000000000 --- a/client/src/components/Chat/Messages/Content/Parts/CodeProgress.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import ProgressCircle from '~/components/Chat/Messages/Content/ProgressCircle'; -import CancelledIcon from '~/components/Chat/Messages/Content/CancelledIcon'; - -export const CodeInProgress = ({ - offset, - circumference, - radius, - isSubmitting, - progress, -}: { - progress: number; - offset: number; - circumference: number; - radius: number; - isSubmitting: boolean; -}) => { - if (progress < 1 && !isSubmitting) { - return ; - } - return ( -
-
- - - - - - - - - - - - - - - - - - - - - -
- -
- ); -}; diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index 40ad8c6a66..ffd392f01c 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -1,13 +1,12 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useRef, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import type { TAttachment } from 'librechat-data-provider'; import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; -import FinishedIcon from '~/components/Chat/Messages/Content/FinishedIcon'; import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; import { useProgress, useLocalize } from '~/hooks'; -import { CodeInProgress } from './CodeProgress'; -import Attachment from './Attachment'; +import { AttachmentGroup } from './Attachment'; import Stdout from './Stdout'; +import { cn } from '~/utils'; import store from '~/store'; interface ParsedArgs { @@ -45,46 +44,101 @@ export function useParseArgs(args: string): ParsedArgs { }, [args]); } -const radius = 56.08695652173913; -const circumference = 2 * Math.PI * radius; - export default function ExecuteCode({ initialProgress = 0.1, args, output = '', - isSubmitting, attachments, }: { initialProgress: number; args: string; output?: string; - isSubmitting: boolean; attachments?: TAttachment[]; }) { const localize = useLocalize(); const showAnalysisCode = useRecoilValue(store.showCode); const [showCode, setShowCode] = useState(showAnalysisCode); + const codeContentRef = useRef(null); + const [contentHeight, setContentHeight] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + const hasOutput = output.length > 0; + const outputRef = useRef(output); + const prevShowCodeRef = useRef(showCode); const { lang, code } = useParseArgs(args); const progress = useProgress(initialProgress); - const offset = circumference - progress * circumference; + + useEffect(() => { + if (output !== outputRef.current) { + outputRef.current = output; + + if (showCode && codeContentRef.current) { + setTimeout(() => { + if (codeContentRef.current) { + const newHeight = codeContentRef.current.scrollHeight; + setContentHeight(newHeight); + } + }, 10); + } + } + }, [output, showCode]); + + useEffect(() => { + if (showCode !== prevShowCodeRef.current) { + prevShowCodeRef.current = showCode; + + if (showCode && codeContentRef.current) { + setIsAnimating(true); + requestAnimationFrame(() => { + if (codeContentRef.current) { + const height = codeContentRef.current.scrollHeight; + setContentHeight(height); + } + + const timer = setTimeout(() => { + setIsAnimating(false); + }, 500); + + return () => clearTimeout(timer); + }); + } else if (!showCode) { + setIsAnimating(true); + setContentHeight(0); + + const timer = setTimeout(() => { + setIsAnimating(false); + }, 500); + + return () => clearTimeout(timer); + } + } + }, [showCode]); + + useEffect(() => { + if (!codeContentRef.current) { + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + if (showCode && !isAnimating) { + for (const entry of entries) { + if (entry.target === codeContentRef.current) { + setContentHeight(entry.contentRect.height); + } + } + } + }); + + resizeObserver.observe(codeContentRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [showCode, isAnimating]); return ( <> -
-
- {progress < 1 ? ( - - ) : ( - - )} -
+
setShowCode((prev) => !prev)} @@ -94,31 +148,71 @@ export default function ExecuteCode({ isExpanded={showCode} />
- {showCode && ( -
- - {output.length > 0 && ( -
-
+
+
+ {showCode && ( +
+ +
+ )} + {hasOutput && ( +
+
)}
- )} -
- {attachments?.map((attachment, index) => ( - - ))}
+ {attachments && attachments.length > 0 && } ); } diff --git a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx new file mode 100644 index 0000000000..3237bc37f2 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx @@ -0,0 +1,205 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; +import Image from '~/components/Chat/Messages/Content/Image'; +import ProgressText from './ProgressText'; +import { PixelCard } from '~/components'; +import { scaleImage } from '~/utils'; + +export default function OpenAIImageGen({ + initialProgress = 0.1, + isSubmitting, + toolName, + args: _args = '', + output, + attachments, +}: { + initialProgress: number; + isSubmitting: boolean; + toolName: string; + args: string | Record; + output?: string | null; + attachments?: TAttachment[]; +}) { + const [progress, setProgress] = useState(initialProgress); + const intervalRef = useRef(null); + + const error = + typeof output === 'string' && output.toLowerCase().includes('error processing tool'); + + const cancelled = (!isSubmitting && initialProgress < 1) || error === true; + + let width: number | undefined; + let height: number | undefined; + let quality: 'low' | 'medium' | 'high' = 'high'; + + try { + const argsObj = typeof _args === 'string' ? JSON.parse(_args) : _args; + + 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 (e) { + width = undefined; + height = undefined; + } + + // Default to 1024x1024 if width and height are still undefined after parsing args and attachment metadata + 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(null); + + const updateDimensions = useCallback(() => { + if (origWidth && origHeight && containerRef.current) { + const scaled = scaleImage({ + originalWidth: origWidth, + originalHeight: origHeight, + containerRef, + }); + setDimensions(scaled); + } + }, [origWidth, origHeight]); + + useEffect(() => { + if (isSubmitting) { + setProgress(initialProgress); + + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + let baseDuration = 20000; + if (quality === 'low') { + baseDuration = 10000; + } else if (quality === 'high') { + baseDuration = 50000; + } + // adding some jitter (±30% of base) + 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); + setProgress(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; + + setProgress(scaledProgress); + } + }, updateInterval); + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialProgress, quality]); + + useEffect(() => { + if (initialProgress >= 1 || cancelled) { + setProgress(initialProgress); + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + } + }, [initialProgress, cancelled]); + + useEffect(() => { + updateDimensions(); + + const resizeObserver = new ResizeObserver(() => { + updateDimensions(); + }); + + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [updateDimensions]); + + return ( + <> +
+ +
+ + {/* {showInfo && hasInfo && ( + 0 && !cancelled && initialProgress < 1} + /> + )} */} + +
+
+ {dimensions.width !== 'auto' && progress < 1 && ( + + )} + +
+
+ + ); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/ProgressText.tsx b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/ProgressText.tsx new file mode 100644 index 0000000000..6752352da6 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/ProgressText.tsx @@ -0,0 +1,62 @@ +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +export default function ProgressText({ + progress, + error, + toolName = 'image_gen_oai', +}: { + progress: number; + error?: boolean; + toolName: string; +}) { + const localize = useLocalize(); + + const getText = () => { + if (error) { + return localize('com_ui_error'); + } + + if (toolName === 'image_edit_oai') { + if (progress >= 1) { + return localize('com_ui_image_edited'); + } + if (progress >= 0.7) { + return localize('com_ui_final_touch'); + } + if (progress >= 0.5) { + return localize('com_ui_adding_details'); + } + if (progress >= 0.3) { + return localize('com_ui_edit_editing_image'); + } + return localize('com_ui_getting_started'); + } + + if (progress >= 1) { + return localize('com_ui_image_created'); + } + if (progress >= 0.7) { + return localize('com_ui_final_touch'); + } + if (progress >= 0.5) { + return localize('com_ui_adding_details'); + } + if (progress >= 0.3) { + return localize('com_ui_creating_image'); + } + return localize('com_ui_getting_started'); + }; + + const text = getText(); + + return ( +
+ {text} +
+ ); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/index.ts b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/index.ts new file mode 100644 index 0000000000..c5f60526f5 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/index.ts @@ -0,0 +1 @@ +export { default as OpenAIImageGen } from './OpenAIImageGen'; diff --git a/client/src/components/Chat/Messages/Content/Parts/Stdout.tsx b/client/src/components/Chat/Messages/Content/Parts/Stdout.tsx index b79c333f72..562d85489a 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Stdout.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Stdout.tsx @@ -17,7 +17,7 @@ const Stdout: React.FC = ({ output = '' }) => { return ( processedContent && (
-        
{processedContent}
+
{processedContent}
) ); diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts new file mode 100644 index 0000000000..8788201e65 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/index.ts @@ -0,0 +1,10 @@ +export * from './Attachment'; +export * from './OpenAIImageGen'; + +export { default as Text } from './Text'; +export { default as Reasoning } from './Reasoning'; +export { default as EmptyText } from './EmptyText'; +export { default as LogContent } from './LogContent'; +export { default as ExecuteCode } from './ExecuteCode'; +export { default as AgentUpdate } from './AgentUpdate'; +export { default as EditTextPart } from './EditTextPart'; diff --git a/client/src/components/Chat/Messages/Content/ProgressText.tsx b/client/src/components/Chat/Messages/Content/ProgressText.tsx index 11ec4b291d..a74061e7bb 100644 --- a/client/src/components/Chat/Messages/Content/ProgressText.tsx +++ b/client/src/components/Chat/Messages/Content/ProgressText.tsx @@ -1,4 +1,8 @@ import * as Popover from '@radix-ui/react-popover'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import CancelledIcon from './CancelledIcon'; +import FinishedIcon from './FinishedIcon'; +import { Spinner } from '~/components'; import { cn } from '~/utils'; const wrapperClass = @@ -10,7 +14,7 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
@@ -24,7 +28,7 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac return (
@@ -43,6 +47,7 @@ export default function ProgressText({ hasInput = true, popover = false, isExpanded = false, + error = false, }: { progress: number; onClick?: () => void; @@ -52,33 +57,28 @@ export default function ProgressText({ hasInput?: boolean; popover?: boolean; isExpanded?: boolean; + error?: boolean; }) { const text = progress < 1 ? (authText ?? inProgressText) : finishedText; return ( ); diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index 509d420374..3d68c3fc16 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -1,22 +1,13 @@ -import { useMemo } from 'react'; -import * as Popover from '@radix-ui/react-popover'; -import { ShieldCheck, TriangleAlert } from 'lucide-react'; +import { useMemo, useState, useEffect, useRef, useLayoutEffect } from 'react'; +import { TriangleAlert } from 'lucide-react'; import { actionDelimiter, actionDomainSeparator, Constants } from 'librechat-data-provider'; import type { TAttachment } from 'librechat-data-provider'; -import useLocalize from '~/hooks/useLocalize'; -import ProgressCircle from './ProgressCircle'; -import InProgressCall from './InProgressCall'; -import Attachment from './Parts/Attachment'; -import CancelledIcon from './CancelledIcon'; +import { useLocalize, useProgress } from '~/hooks'; +import { AttachmentGroup } from './Parts'; +import ToolCallInfo from './ToolCallInfo'; import ProgressText from './ProgressText'; -import FinishedIcon from './FinishedIcon'; -import ToolPopover from './ToolPopover'; -import WrenchIcon from './WrenchIcon'; -import { useProgress } from '~/hooks'; -import { logger } from '~/utils'; - -const radius = 56.08695652173913; -const circumference = 2 * Math.PI * radius; +import { Button } from '~/components'; +import { logger, cn } from '~/utils'; export default function ToolCall({ initialProgress = 0.1, @@ -37,11 +28,16 @@ export default function ToolCall({ expires_at?: number; }) { const localize = useLocalize(); + const [showInfo, setShowInfo] = useState(false); + const contentRef = useRef(null); + const [contentHeight, setContentHeight] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + const prevShowInfoRef = useRef(showInfo); + const { function_name, domain, isMCPToolCall } = useMemo(() => { if (typeof name !== 'string') { return { function_name: '', domain: null, isMCPToolCall: false }; } - if (name.includes(Constants.mcp_delimiter)) { const [func, server] = name.split(Constants.mcp_delimiter); return { @@ -50,7 +46,6 @@ export default function ToolCall({ isMCPToolCall: true, }; } - const [func, _domain] = name.includes(actionDelimiter) ? name.split(actionDelimiter) : [name, '']; @@ -68,7 +63,6 @@ export default function ToolCall({ if (typeof _args === 'string') { return _args; } - try { return JSON.stringify(_args, null, 2); } catch (e) { @@ -98,42 +92,8 @@ export default function ToolCall({ } }, [auth]); - const progress = useProgress(error === true ? 1 : initialProgress); + const progress = useProgress(initialProgress); const cancelled = (!isSubmitting && progress < 1) || error === true; - const offset = circumference - progress * circumference; - - const renderIcon = () => { - if (progress < 1 && authDomain.length > 0) { - return ( -
-
- -
-
- ); - } else if (progress < 1) { - return ( - -
-
- -
- -
-
- ); - } - - return cancelled ? : ; - }; const getFinishedText = () => { if (cancelled) { @@ -148,51 +108,125 @@ export default function ToolCall({ return localize('com_assistants_completed_function', { 0: function_name }); }; + useLayoutEffect(() => { + if (showInfo !== prevShowInfoRef.current) { + prevShowInfoRef.current = showInfo; + setIsAnimating(true); + + if (showInfo && contentRef.current) { + requestAnimationFrame(() => { + if (contentRef.current) { + const height = contentRef.current.scrollHeight; + setContentHeight(height + 4); + } + }); + } else { + setContentHeight(0); + } + + const timer = setTimeout(() => { + setIsAnimating(false); + }, 400); + + return () => clearTimeout(timer); + } + }, [showInfo]); + + useEffect(() => { + if (!contentRef.current) { + return; + } + const resizeObserver = new ResizeObserver((entries) => { + if (showInfo && !isAnimating) { + for (const entry of entries) { + if (entry.target === contentRef.current) { + setContentHeight(entry.contentRect.height + 4); + } + } + } + }); + resizeObserver.observe(contentRef.current); + return () => { + resizeObserver.disconnect(); + }; + }, [showInfo, isAnimating]); + return ( - -
-
-
{renderIcon()}
- 0 ? localize('com_ui_requires_auth') : undefined - } - finishedText={getFinishedText()} - hasInput={hasInfo} - popover={true} - /> - {hasInfo && ( - 0 && !cancelled && progress < 1} - /> - )} -
- {auth != null && auth && progress < 1 && !cancelled && ( -
- -

- - {localize('com_assistants_allow_sites_you_trust')} -

-
- )} + <> +
+ setShowInfo((prev) => !prev)} + inProgressText={localize('com_assistants_running_action')} + authText={ + !cancelled && authDomain.length > 0 ? localize('com_ui_requires_auth') : undefined + } + finishedText={getFinishedText()} + hasInput={hasInfo} + isExpanded={showInfo} + error={cancelled} + />
- {attachments?.map((attachment, index) => )} - +
+
+
+ {showInfo && hasInfo && ( + 0 && !cancelled && progress < 1} + /> + )} +
+
+
+ {auth != null && auth && progress < 1 && !cancelled && ( +
+
+ +
+

+ + {localize('com_assistants_allow_sites_you_trust')} +

+
+ )} + {attachments && attachments.length > 0 && } + ); } diff --git a/client/src/components/Chat/Messages/Content/ToolCallInfo.tsx b/client/src/components/Chat/Messages/Content/ToolCallInfo.tsx new file mode 100644 index 0000000000..fd0fe8e1db --- /dev/null +++ b/client/src/components/Chat/Messages/Content/ToolCallInfo.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { useLocalize } from '~/hooks'; + +function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) { + return ( +
+
+        {text}
+      
+
+ ); +} + +export default function ToolCallInfo({ + input, + output, + domain, + function_name, + pendingAuth, +}: { + input: string; + function_name: string; + output?: string | null; + domain?: string; + pendingAuth?: boolean; +}) { + const localize = useLocalize(); + const formatText = (text: string) => { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + }; + + let title = + domain != null && domain + ? localize('com_assistants_domain_info', { 0: domain }) + : localize('com_assistants_function_use', { 0: function_name }); + if (pendingAuth === true) { + title = + domain != null && domain + ? localize('com_assistants_action_attempt', { 0: domain }) + : localize('com_assistants_attempt_info'); + } + + return ( +
+
+
{title}
+
+ +
+ {output && ( + <> +
+ {localize('com_ui_result')} +
+
+ +
+ + )} +
+
+ ); +} diff --git a/client/src/components/Chat/Messages/Content/ToolPopover.tsx b/client/src/components/Chat/Messages/Content/ToolPopover.tsx deleted file mode 100644 index 198f64b3e0..0000000000 --- a/client/src/components/Chat/Messages/Content/ToolPopover.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as Popover from '@radix-ui/react-popover'; -import useLocalize from '~/hooks/useLocalize'; - -export default function ToolPopover({ - input, - output, - domain, - function_name, - pendingAuth, -}: { - input: string; - function_name: string; - output?: string | null; - domain?: string; - pendingAuth?: boolean; -}) { - const localize = useLocalize(); - const formatText = (text: string) => { - try { - return JSON.stringify(JSON.parse(text), null, 2); - } catch { - return text; - } - }; - - let title = - domain != null && domain - ? localize('com_assistants_domain_info', { 0: domain }) - : localize('com_assistants_function_use', { 0: function_name }); - if (pendingAuth === true) { - title = - domain != null && domain - ? localize('com_assistants_action_attempt', { 0: domain }) - : localize('com_assistants_attempt_info'); - } - - return ( - - -
-
-
{title}
-
-
- {formatText(input)} -
-
- {output != null && output && ( - <> -
- {localize('com_ui_result')} -
-
-
- {formatText(output)} -
-
- - )} -
-
-
-
- ); -} diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index a0bf03e49d..46f217a83c 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -23,7 +23,7 @@ const LoadingSpinner = memo(() => { return (
- + {localize('com_ui_loading')}
); diff --git a/client/src/components/Messages/Content/CodeBlock.tsx b/client/src/components/Messages/Content/CodeBlock.tsx index df80c90ecb..d48c8042b7 100644 --- a/client/src/components/Messages/Content/CodeBlock.tsx +++ b/client/src/components/Messages/Content/CodeBlock.tsx @@ -3,9 +3,9 @@ import { InfoIcon } from 'lucide-react'; import { Tools } from 'librechat-data-provider'; import React, { useRef, useState, useMemo, useEffect } from 'react'; import type { CodeBarProps } from '~/common'; -import LogContent from '~/components/Chat/Messages/Content/Parts/LogContent'; import ResultSwitcher from '~/components/Messages/Content/ResultSwitcher'; import { useToolCallsMapContext, useMessageContext } from '~/Providers'; +import { LogContent } from '~/components/Chat/Messages/Content/Parts'; import RunCode from '~/components/Messages/Content/RunCode'; import Clipboard from '~/components/svg/Clipboard'; import CheckMark from '~/components/svg/CheckMark'; diff --git a/client/src/components/Messages/Content/RunCode.tsx b/client/src/components/Messages/Content/RunCode.tsx index e80c589bd1..10adb7df3f 100644 --- a/client/src/components/Messages/Content/RunCode.tsx +++ b/client/src/components/Messages/Content/RunCode.tsx @@ -1,6 +1,6 @@ import debounce from 'lodash/debounce'; import { Tools, AuthType } from 'librechat-data-provider'; -import { TerminalSquareIcon, Loader } from 'lucide-react'; +import { TerminalSquareIcon } from 'lucide-react'; import React, { useMemo, useCallback, useEffect } from 'react'; import type { CodeBarProps } from '~/common'; import { useVerifyAgentToolAuth, useToolCallMutation } from '~/data-provider'; @@ -9,6 +9,7 @@ import { useLocalize, useCodeApiKeyForm } from '~/hooks'; import { useMessageContext } from '~/Providers'; import { cn, normalizeLanguage } from '~/utils'; import { useToastContext } from '~/Providers'; +import { Spinner } from '~/components'; const RunCode: React.FC = React.memo(({ lang, codeRef, blockIndex }) => { const localize = useLocalize(); @@ -91,7 +92,7 @@ const RunCode: React.FC = React.memo(({ lang, codeRef, blockIndex disabled={execute.isLoading} > {execute.isLoading ? ( - + ) : ( )} diff --git a/client/src/components/SidePanel/Agents/AgentFooter.tsx b/client/src/components/SidePanel/Agents/AgentFooter.tsx index 75f10a3851..ce99e1189f 100644 --- a/client/src/components/SidePanel/Agents/AgentFooter.tsx +++ b/client/src/components/SidePanel/Agents/AgentFooter.tsx @@ -53,7 +53,7 @@ export default function AgentFooter({ const showButtons = activePanel === Panel.builder; return ( -
+
{showButtons && } {user?.role === SystemRoles.ADMIN && showButtons && } {/* Context Button */} diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 23ede096a1..1cddf15180 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -220,7 +220,7 @@ export default function AgentPanel({ className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden" aria-label="Agent configuration form" > -
+
- + + + + + + ); } diff --git a/client/src/components/ui/Button.tsx b/client/src/components/ui/Button.tsx index 35b2d103b7..1b2284507f 100644 --- a/client/src/components/ui/Button.tsx +++ b/client/src/components/ui/Button.tsx @@ -14,7 +14,7 @@ const buttonVariants = cva( outline: 'text-text-primary border border-border-light bg-background hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', + ghost: 'hover:bg-surface-hover hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', // hardcoded text color because of WCAG contrast issues (text-white) submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover', diff --git a/client/src/components/ui/OriginalDialog.tsx b/client/src/components/ui/OriginalDialog.tsx index c7b4c1f4c1..83405b1a60 100644 --- a/client/src/components/ui/OriginalDialog.tsx +++ b/client/src/components/ui/OriginalDialog.tsx @@ -32,7 +32,7 @@ const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; -const DialogOverlay = React.forwardRef< +export const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( diff --git a/client/src/components/ui/PixelCard.tsx b/client/src/components/ui/PixelCard.tsx new file mode 100644 index 0000000000..aabed69fb4 --- /dev/null +++ b/client/src/components/ui/PixelCard.tsx @@ -0,0 +1,375 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { cn } from '~/utils'; + +class Pixel { + width: number; + height: number; + ctx: CanvasRenderingContext2D; + x: number; + y: number; + color: string; + speed: number; + size: number; + sizeStep: number; + minSize: number; + maxSizeInteger: number; + maxSize: number; + delay: number; + counter: number; + counterStep: number; + isIdle: boolean; + isReverse: boolean; + isShimmer: boolean; + activationThreshold: number; + + constructor( + canvas: HTMLCanvasElement, + context: CanvasRenderingContext2D, + x: number, + y: number, + color: string, + speed: number, + delay: number, + activationThreshold: number, + ) { + this.width = canvas.width; + this.height = canvas.height; + this.ctx = context; + this.x = x; + this.y = y; + this.color = color; + this.speed = this.random(0.1, 0.9) * speed; + this.size = 0; + this.sizeStep = Math.random() * 0.4; + this.minSize = 0.5; + this.maxSizeInteger = 2; + this.maxSize = this.random(this.minSize, this.maxSizeInteger); + this.delay = delay; + this.counter = 0; + this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01; + this.isIdle = false; + this.isReverse = false; + this.isShimmer = false; + this.activationThreshold = activationThreshold; + } + + private random(min: number, max: number) { + return Math.random() * (max - min) + min; + } + + private draw() { + const offset = this.maxSizeInteger * 0.5 - this.size * 0.5; + this.ctx.fillStyle = this.color; + this.ctx.fillRect(this.x + offset, this.y + offset, this.size, this.size); + } + + appear() { + this.isIdle = false; + if (this.counter <= this.delay) { + this.counter += this.counterStep; + return; + } + if (this.size >= this.maxSize) { + this.isShimmer = true; + } + if (this.isShimmer) { + this.shimmer(); + } else { + this.size += this.sizeStep; + } + this.draw(); + } + + appearWithProgress(progress: number) { + const diff = progress - this.activationThreshold; + if (diff <= 0) { + this.isIdle = true; + return; + } + if (this.counter <= this.delay) { + this.counter += this.counterStep; + this.isIdle = false; + return; + } + if (this.size >= this.maxSize) { + this.isShimmer = true; + } + if (this.isShimmer) { + this.shimmer(); + } else { + this.size += this.sizeStep; + } + this.isIdle = false; + this.draw(); + } + + disappear() { + this.isShimmer = false; + this.counter = 0; + if (this.size <= 0) { + this.isIdle = true; + return; + } + this.size -= 0.1; + this.draw(); + } + + private shimmer() { + if (this.size >= this.maxSize) { + this.isReverse = true; + } else if (this.size <= this.minSize) { + this.isReverse = false; + } + this.size += this.isReverse ? -this.speed : this.speed; + } +} + +const getEffectiveSpeed = (value: number, reducedMotion: boolean) => { + const parsed = parseInt(String(value), 10); + const throttle = 0.001; + if (parsed <= 0 || reducedMotion) { + return 0; + } + if (parsed >= 100) { + return 100 * throttle; + } + return parsed * throttle; +}; + +const clamp = (n: number, min = 0, max = 1) => Math.min(Math.max(n, min), max); + +const VARIANTS = { + default: { gap: 5, speed: 35, colors: '#f8fafc,#f1f5f9,#cbd5e1', noFocus: false }, + blue: { gap: 10, speed: 25, colors: '#e0f2fe,#7dd3fc,#0ea5e9', noFocus: false }, + yellow: { gap: 3, speed: 20, colors: '#fef08a,#fde047,#eab308', noFocus: false }, + pink: { gap: 6, speed: 80, colors: '#fecdd3,#fda4af,#e11d48', noFocus: true }, +} as const; + +interface PixelCardProps { + variant?: keyof typeof VARIANTS; + gap?: number; + speed?: number; + colors?: string; + noFocus?: boolean; + className?: string; + progress?: number; + randomness?: number; + width?: string; + height?: string; +} + +export default function PixelCard({ + variant = 'default', + gap, + speed, + colors, + noFocus, + className = '', + progress, + randomness = 0.3, + width, + height, +}: PixelCardProps) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const pixelsRef = useRef([]); + const animationRef = useRef(); + const timePrevRef = useRef(performance.now()); + const progressRef = useRef(progress); + const reducedMotion = useRef( + window.matchMedia('(prefers-reduced-motion: reduce)').matches, + ).current; + + const cfg = VARIANTS[variant]; + const g = gap ?? cfg.gap; + const s = speed ?? cfg.speed; + const palette = colors ?? cfg.colors; + const disableFocus = noFocus ?? cfg.noFocus; + + const updateCanvasOpacity = useCallback(() => { + if (!canvasRef.current) { + return; + } + if (progressRef.current === undefined) { + canvasRef.current.style.opacity = '1'; + return; + } + const fadeStart = 0.9; + const alpha = + progressRef.current >= fadeStart ? 1 - (progressRef.current - fadeStart) / 0.1 : 1; + canvasRef.current.style.opacity = String(clamp(alpha)); + }, []); + + const animate = useCallback( + (method: keyof Pixel) => { + animationRef.current = requestAnimationFrame(() => animate(method)); + + const now = performance.now(); + const elapsed = now - timePrevRef.current; + if (elapsed < 1000 / 60) { + return; + } + timePrevRef.current = now - (elapsed % (1000 / 60)); + + const ctx = canvasRef.current?.getContext('2d'); + if (!ctx || !canvasRef.current) { + return; + } + + ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); + + let idle = true; + for (const p of pixelsRef.current) { + if (method === 'appearWithProgress') { + progressRef.current !== undefined + ? p.appearWithProgress(progressRef.current) + : (p.isIdle = true); + } else { + // @ts-ignore dynamic dispatch + p[method](); + } + if (!p.isIdle) { + idle = false; + } + } + + updateCanvasOpacity(); + if (idle) { + cancelAnimationFrame(animationRef.current!); + } + }, + [updateCanvasOpacity], + ); + + const startAnim = useCallback( + (m: keyof Pixel) => { + cancelAnimationFrame(animationRef.current!); + animationRef.current = requestAnimationFrame(() => animate(m)); + }, + [animate], + ); + + const initPixels = useCallback(() => { + if (!containerRef.current || !canvasRef.current) { + return; + } + + const { width: cw, height: ch } = containerRef.current.getBoundingClientRect(); + const ctx = canvasRef.current.getContext('2d'); + canvasRef.current.width = Math.floor(cw); + canvasRef.current.height = Math.floor(ch); + + const cols = palette.split(','); + const px: Pixel[] = []; + + const cx = cw / 2; + const cy = ch / 2; + const maxDist = Math.hypot(cx, cy); + + for (let x = 0; x < cw; x += g) { + for (let y = 0; y < ch; y += g) { + const color = cols[Math.floor(Math.random() * cols.length)]; + const distNorm = Math.hypot(x - cx, y - cy) / maxDist; + const threshold = clamp(distNorm * (1 - randomness) + Math.random() * randomness); + const delay = reducedMotion ? 0 : distNorm * maxDist; + if (!ctx) { + continue; + } + px.push( + new Pixel( + canvasRef.current, + ctx, + x, + y, + color, + getEffectiveSpeed(s, reducedMotion), + delay, + threshold, + ), + ); + } + } + pixelsRef.current = px; + + if (progressRef.current !== undefined) { + startAnim('appearWithProgress'); + } + }, [g, palette, s, randomness, reducedMotion, startAnim]); + + useEffect(() => { + progressRef.current = progress; + if (progress !== undefined) { + startAnim('appearWithProgress'); + } + }, [progress, startAnim]); + + useEffect(() => { + if (progress === undefined) { + cancelAnimationFrame(animationRef.current!); + } + }, [progress]); + + useEffect(() => { + initPixels(); + const obs = new ResizeObserver(initPixels); + containerRef.current && obs.observe(containerRef.current); + return () => { + obs.disconnect(); + cancelAnimationFrame(animationRef.current!); + }; + }, [initPixels]); + + const hoverIn = () => progressRef.current === undefined && startAnim('appear'); + const hoverOut = () => progressRef.current === undefined && startAnim('disappear'); + const focusIn: React.FocusEventHandler = (e) => { + if ( + !disableFocus && + !e.currentTarget.contains(e.relatedTarget) && + progressRef.current === undefined + ) { + startAnim('appear'); + } + }; + const focusOut: React.FocusEventHandler = (e) => { + if ( + !disableFocus && + !e.currentTarget.contains(e.relatedTarget) && + progressRef.current === undefined + ) { + startAnim('disappear'); + } + }; + + return ( +
+
+ +
+
+ ); +} diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts index 8d9eed0c85..2708379b6e 100644 --- a/client/src/components/ui/index.ts +++ b/client/src/components/ui/index.ts @@ -31,8 +31,9 @@ export { default as MCPIcon } from './MCPIcon'; export { default as Combobox } from './Combobox'; export { default as Dropdown } from './Dropdown'; export { default as SplitText } from './SplitText'; -export { default as FileUpload } from './FileUpload'; export { default as FormInput } from './FormInput'; +export { default as PixelCard } from './PixelCard'; +export { default as FileUpload } from './FileUpload'; export { default as DropdownPopup } from './DropdownPopup'; export { default as DelayedRender } from './DelayedRender'; export { default as ThemeSelector } from './ThemeSelector'; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 1d15ed42e7..827c5a26ce 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -29,7 +29,7 @@ "com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's", "com_assistants_add_actions": "Add Actions", "com_assistants_add_tools": "Add Tools", - "com_assistants_allow_sites_you_trust": "Only allow sites you trust.", + "com_assistants_allow_sites_you_trust": "Only allow sites you trust", "com_assistants_append_date": "Append Current Date & Time", "com_assistants_append_date_tooltip": "When enabled, the current client date and time will be appended to the assistant system instructions.", "com_assistants_attempt_info": "Assistant wants to send the following:", @@ -871,6 +871,13 @@ "com_ui_x_selected": "{{0}} selected", "com_ui_yes": "Yes", "com_ui_zoom": "Zoom", + "com_ui_getting_started": "Getting Started", + "com_ui_creating_image": "Creating image. May take a moment", + "com_ui_adding_details": "Adding details", + "com_ui_final_touch": "Final touch", + "com_ui_image_created": "Image created", + "com_ui_edit_editing_image": "Editing image", + "com_ui_image_edited": "Image edited", "com_user_message": "You", "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint." -} \ No newline at end of file +} diff --git a/client/src/style.css b/client/src/style.css index 1c29072797..309a27f01b 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -40,6 +40,17 @@ --red-800: #991b1b; --red-900: #7f1d1d; --red-950: #450a0a; + --amber-50: #fffbeb; + --amber-100: #fef3c7; + --amber-200: #fde68a; + --amber-300: #fcd34d; + --amber-400: #fbbf24; + --amber-500: #f59e0b; + --amber-600: #d97706; + --amber-700: #b45309; + --amber-800: #92400e; + --amber-900: #78350f; + --amber-950: #451a03; --gizmo-gray-500: #999; --gizmo-gray-600: #666; --gizmo-gray-950: #0f0f0f; @@ -55,6 +66,7 @@ html { --text-secondary: var(--gray-600); --text-secondary-alt: var(--gray-500); --text-tertiary: var(--gray-500); + --text-warning: var(--amber-500); --ring-primary: var(--gray-500); --header-primary: var(--white); --header-hover: var(--gray-50); @@ -114,6 +126,7 @@ html { --text-secondary: var(--gray-300); --text-secondary-alt: var(--gray-400); --text-tertiary: var(--gray-500); + --text-warning: var(--amber-500); --header-primary: var(--gray-700); --header-hover: var(--gray-600); --header-button-hover: var(--gray-700); @@ -715,8 +728,8 @@ pre { .premium-scroll-button:hover:not(:active) { transform: translateY(-1.5px) scale(1.02); - box-shadow: - 0 5px 10px rgba(0, 0, 0, 0.07), + box-shadow: + 0 5px 10px rgba(0, 0, 0, 0.07), 0 7px 14px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(255, 255, 255, 0.1); } @@ -2578,7 +2591,9 @@ html { .animate-popover { transform-origin: top; opacity: 0; - transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + transition: + opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), + transform 150ms cubic-bezier(0.4, 0, 0.2, 1); transform: scale(0.95) translateY(-0.5rem); } @@ -2590,7 +2605,9 @@ html { .animate-popover-left { transform-origin: left; opacity: 0; - transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + transition: + opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), + transform 150ms cubic-bezier(0.4, 0, 0.2, 1); transform: scale(0.95) translateX(-0.5rem); } @@ -2678,3 +2695,46 @@ html { .badge-icon { transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); } + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.shimmer { + display: inline-block; + position: relative; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.8) 25%, + rgba(179, 179, 179, 0.25) 50%, + rgba(255, 255, 255, 0.8) 75% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer 4s linear infinite; +} + +:global(.dark) .shimmer { + background: linear-gradient( + 90deg, + rgba(255, 255, 255) 25%, + rgba(129, 130, 134, 0.18) 50%, + rgb(255, 255, 255) 75% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer 4s linear infinite; +} + +.custom-style-2 { + padding: 12px; +} diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 7518aeeb11..5363ff689c 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -18,6 +18,7 @@ export * from './promptGroups'; export { default as cn } from './cn'; export { default as logger } from './logger'; export { default as buildTree } from './buildTree'; +export { default as scaleImage } from './scaleImage'; export { default as getLoginError } from './getLoginError'; export { default as cleanupPreset } from './cleanupPreset'; export { default as buildDefaultConvo } from './buildDefaultConvo'; diff --git a/client/src/utils/scaleImage.ts b/client/src/utils/scaleImage.ts new file mode 100644 index 0000000000..11e051fbd9 --- /dev/null +++ b/client/src/utils/scaleImage.ts @@ -0,0 +1,21 @@ +export default function scaleImage({ + originalWidth, + originalHeight, + containerRef, +}: { + originalWidth?: number; + originalHeight?: number; + containerRef: React.RefObject; +}) { + const containerWidth = containerRef.current?.offsetWidth ?? 0; + + if (containerWidth === 0 || originalWidth == null || originalHeight == null) { + return { width: 'auto', height: 'auto' }; + } + + const aspectRatio = originalWidth / originalHeight; + const scaledWidth = Math.min(containerWidth, originalWidth); + const scaledHeight = scaledWidth / aspectRatio; + + return { width: `${scaledWidth}px`, height: `${scaledHeight}px` }; +} diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index fba7df05c2..f114a87334 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -67,6 +67,7 @@ module.exports = { 'text-secondary': 'var(--text-secondary)', 'text-secondary-alt': 'var(--text-secondary-alt)', 'text-tertiary': 'var(--text-tertiary)', + 'text-warning': 'var(--text-warning)', 'ring-primary': 'var(--ring-primary)', 'header-primary': 'var(--header-primary)', 'header-hover': 'var(--header-hover)', diff --git a/librechat.example.yaml b/librechat.example.yaml index ae14b0faae..dfa8626ecc 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -294,5 +294,8 @@ endpoints: # fileSizeLimit: 5 # serverFileSizeLimit: 100 # Global server file size limit in MB # avatarSizeLimit: 2 # Limit for user avatar image size in MB +# imageGeneration: # Image Gen settings, either percentage or px +# percentage: 100 +# px: 1024 # # See the Custom Configuration Guide for more information on Assistants Config: # # https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/assistants_endpoint diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index 3798b48d4d..09476cbe10 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -222,6 +222,12 @@ export const fileConfigSchema = z.object({ endpoints: z.record(endpointFileConfigSchema).optional(), serverFileSizeLimit: z.number().min(0).optional(), avatarSizeLimit: z.number().min(0).optional(), + imageGeneration: z + .object({ + percentage: z.number().min(0).max(100).optional(), + px: z.number().min(0).optional(), + }) + .optional(), }); /** Helper function to safely convert string patterns to RegExp objects */