mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🖼️ feat: Tool Call and Loading UI Refresh, Image Resize Config (#7086)
* ✨ 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
This commit is contained in:
parent
739b0d3012
commit
c79ee32006
44 changed files with 1452 additions and 527 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -206,8 +206,8 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
<form
|
||||
onSubmit={methods.handleSubmit(submitMessage)}
|
||||
className={cn(
|
||||
'mx-auto flex flex-row gap-3 sm:px-2',
|
||||
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2',
|
||||
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
centerFormOnLanding &&
|
||||
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
|
||||
!isSubmitting &&
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import FileIcon from '~/components/svg/Files/FileIcon';
|
||||
import ProgressCircle from './ProgressCircle';
|
||||
import { Spinner } from '~/components';
|
||||
import SourceIcon from './SourceIcon';
|
||||
import { useProgress } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const FilePreview = ({
|
||||
|
|
@ -19,28 +18,15 @@ const FilePreview = ({
|
|||
};
|
||||
className?: string;
|
||||
}) => {
|
||||
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 (
|
||||
<div className={cn('relative size-10 shrink-0 overflow-hidden rounded-xl', className)}>
|
||||
<FileIcon file={file} fileType={fileType} />
|
||||
<SourceIcon source={file?.source} isCodeFile={!!file?.['metadata']?.fileIdentifier} />
|
||||
{progress < 1 && (
|
||||
<ProgressCircle
|
||||
circumference={circumference}
|
||||
offset={offset}
|
||||
circleCSSProperties={circleCSSProperties}
|
||||
{typeof file?.['progress'] === 'number' && file?.['progress'] < 1 && (
|
||||
<Spinner
|
||||
bgOpacity={0.2}
|
||||
color="white"
|
||||
className="absolute inset-0 m-2.5 flex items-center justify-center"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ const ImagePreview = ({
|
|||
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className={cn('w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto')}
|
||||
className="w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto"
|
||||
disableScroll={false}
|
||||
>
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -1,17 +1,9 @@
|
|||
import { X } from 'lucide-react';
|
||||
|
||||
export default function CancelledIcon() {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-gray-300 text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.32256 1.48447C7.59011 1.16827 7.55068 0.695034 7.23447 0.427476C6.91827 0.159918 6.44503 0.199354 6.17748 0.515559L4.00002 3.08892L1.82256 0.515559C1.555 0.199354 1.08176 0.159918 0.765559 0.427476C0.449355 0.695034 0.409918 1.16827 0.677476 1.48447L3.01755 4.25002L0.677476 7.01556C0.409918 7.33176 0.449354 7.805 0.765559 8.07256C1.08176 8.34011 1.555 8.30068 1.82256 7.98447L4.00002 5.41111L6.17748 7.98447C6.44503 8.30068 6.91827 8.34011 7.23447 8.07256C7.55068 7.805 7.59011 7.33176 7.32256 7.01556L4.98248 4.25002L7.32256 1.48447Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-transparent text-text-secondary">
|
||||
<X className="size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>[];
|
||||
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 (
|
||||
<>
|
||||
<div className="my-2.5 flex items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">
|
||||
{progress < 1 ? (
|
||||
<CodeInProgress
|
||||
offset={offset}
|
||||
radius={radius}
|
||||
progress={progress}
|
||||
isSubmitting={isSubmitting}
|
||||
circumference={circumference}
|
||||
/>
|
||||
) : (
|
||||
<FinishedIcon />
|
||||
)}
|
||||
</div>
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => setShowCode((prev) => !prev)}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className="radix-state-open:animate-show fixed inset-0 z-[100] flex items-center justify-center overflow-hidden bg-black/90 dark:bg-black/80"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="h-full w-full rounded-none bg-transparent"
|
||||
disableScroll={false}
|
||||
overlayClassName="bg-surface-secondary"
|
||||
>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="absolute right-4 top-4 text-gray-50 transition hover:text-gray-200"
|
||||
type="button"
|
||||
<div className="absolute left-0 right-0 top-0 flex items-center justify-between p-4">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0 hover:bg-surface-hover"
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-5 w-5"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<X className="size-6" />
|
||||
</Button>
|
||||
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0">
|
||||
<ArrowDownToLine className="size-6" />
|
||||
</Button>
|
||||
</div>
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="w-11/12 overflow-x-auto rounded-none bg-transparent p-4 shadow-none sm:w-auto"
|
||||
disableScroll={false}
|
||||
overlayClassName="bg-transparent"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Content
|
||||
className="radix-state-open:animate-contentShow relative max-h-[85vh] max-w-[90vw] shadow-xl focus:outline-none"
|
||||
tabIndex={-1}
|
||||
style={{ pointerEvents: 'auto', aspectRatio: height > width ? 1 / 1.75 : 1.75 / 1 }}
|
||||
>
|
||||
<img src={src} alt="Uploaded image" className="h-full w-full object-contain" />
|
||||
</Dialog.Content>
|
||||
</Dialog.Overlay>
|
||||
</Dialog.Portal>
|
||||
<img
|
||||
src={src}
|
||||
alt="Uploaded image"
|
||||
className="max-w-screen h-full max-h-screen w-full object-contain"
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
export default function FinishedIcon() {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-brand-purple text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
className="flex size-4 items-center justify-center rounded-full bg-brand-purple text-white"
|
||||
data-projection-id="162"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="none" width="8" height="8">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
|
|
|
|||
|
|
@ -1,27 +1,8 @@
|
|||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { cn, scaleImage } from '~/utils';
|
||||
import DialogImage from './DialogImage';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const scaleImage = ({
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
containerRef,
|
||||
}: {
|
||||
originalWidth?: number;
|
||||
originalHeight?: number;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}) => {
|
||||
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<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -56,17 +38,29 @@ 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 (
|
||||
<Dialog.Root>
|
||||
<div ref={containerRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden bg-surface-active-alt text-text-secondary-alt',
|
||||
'relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`View ${altText} in dialog`}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<LazyLoadImage
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
|
|
@ -77,18 +71,30 @@ const Image = ({
|
|||
)}
|
||||
src={imagePath}
|
||||
style={{
|
||||
width: scaledWidth,
|
||||
width: `${scaledWidth}`,
|
||||
height: 'auto',
|
||||
color: 'transparent',
|
||||
display: 'block',
|
||||
}}
|
||||
placeholder={<div style={{ width: scaledWidth, height: scaledHeight }} />}
|
||||
placeholder={
|
||||
<Skeleton
|
||||
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
|
||||
aria-label="Loading image"
|
||||
aria-busy="true"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
{isLoaded && (
|
||||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={imagePath}
|
||||
downloadImage={downloadImage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isLoaded && <DialogImage src={imagePath} height={height} width={width} />}
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
<ExecuteCode
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
output={toolCall.output ?? ''}
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
attachments={attachments}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
isToolCall &&
|
||||
(toolCall.name === 'image_gen_oai' || toolCall.name === 'image_edit_oai')
|
||||
) {
|
||||
return (
|
||||
<OpenAIImageGen
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
toolName={toolCall.name}
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
output={toolCall.output ?? ''}
|
||||
attachments={attachments}
|
||||
/>
|
||||
);
|
||||
|
|
@ -118,7 +127,6 @@ const Part = memo(
|
|||
initialProgress={toolCall.progress ?? 0.1}
|
||||
code={code_interpreter.input}
|
||||
outputs={code_interpreter.outputs ?? []}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'file-attachment-container',
|
||||
'transition-all duration-300 ease-out',
|
||||
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
|
||||
)}
|
||||
style={{
|
||||
transformOrigin: 'center top',
|
||||
willChange: 'opacity, transform',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<FileContainer
|
||||
file={attachment}
|
||||
onClick={handleDownload}
|
||||
overrideType={extension}
|
||||
containerClassName="max-w-fit"
|
||||
buttonClassName="hover:cursor-pointer hover:bg-surface-secondary active:bg-surface-secondary focus:bg-surface-secondary hover:border-border-heavy active:border-border-heavy"
|
||||
buttonClassName="bg-surface-secondary hover:cursor-pointer hover:bg-surface-hover active:bg-surface-secondary focus:bg-surface-hover hover:border-border-heavy active:border-border-heavy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'image-attachment-container',
|
||||
'transition-all duration-500 ease-out',
|
||||
isLoaded ? 'scale-100 opacity-100' : 'scale-[0.98] opacity-0',
|
||||
)}
|
||||
style={{
|
||||
transformOrigin: 'center top',
|
||||
willChange: 'opacity, transform',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
altText={attachment.filename}
|
||||
imagePath={filepath ?? ''}
|
||||
height={height ?? 0}
|
||||
width={width ?? 0}
|
||||
className="mb-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -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 (
|
||||
<Image
|
||||
altText={attachment.filename}
|
||||
imagePath={filepath}
|
||||
height={height}
|
||||
width={width}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
return <ImageAttachment attachment={attachment} />;
|
||||
}
|
||||
return <FileAttachment attachment={attachment} />;
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div className="my-2 flex flex-wrap items-center gap-2.5">
|
||||
{fileAttachments.map((attachment, index) => (
|
||||
<FileAttachment attachment={attachment} key={`file-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap items-center">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
<ImageAttachment attachment={attachment} key={`image-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <CancelledIcon />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="77"
|
||||
>
|
||||
<div className="absolute bottom-[1.5px] right-[1.5px]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
height="20"
|
||||
style={{ transform: 'translate3d(0px, 0px, 0px)' }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="__lottie_element_11">
|
||||
<rect width="20" height="20" x="0" y="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#__lottie_element_11)">
|
||||
<g
|
||||
style={{ display: 'block', transform: 'matrix(1,0,0,1,-2,-2)', opacity: 1 }}
|
||||
className="slide-from-left"
|
||||
>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,7.026679992675781,8.834091186523438)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M1.2870399951934814,0.2207774966955185 C0.992609977722168,-0.07359249889850616 0.5152599811553955,-0.07359249889850616 0.22082999348640442,0.2207774966955185 C-0.07361000031232834,0.5151575207710266 -0.07361000031232834,0.992437481880188 0.22082999348640442,1.2868175506591797 C0.8473266959190369,1.9131841659545898 1.4738233089447021,2.53955078125 2.1003201007843018,3.16591739654541 C1.4738233089447021,3.7922842502593994 0.8473266959190369,4.4186506271362305 0.22082999348640442,5.045017719268799 C-0.07361000031232834,5.339417457580566 -0.07361000031232834,5.816617488861084 0.22082999348640442,6.11101770401001 C0.5152599811553955,6.405417442321777 0.992609977722168,6.405417442321777 1.2870399951934814,6.11101770401001 C2.091266632080078,5.306983947753906 2.895493268966675,4.502950668334961 3.6997199058532715,3.6989173889160156 C3.994119882583618,3.404517412185669 3.994119882583618,2.927217483520508 3.6997199058532715,2.6329174041748047 C2.895493268966675,1.8288708925247192 2.091266632080078,1.0248241424560547 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(177,98,253)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="0.201031"
|
||||
d=" M1.2870399951934814,0.2207774966955185 C0.992609977722168,-0.07359249889850616 0.5152599811553955,-0.07359249889850616 0.22082999348640442,0.2207774966955185 C-0.07361000031232834,0.5151575207710266 -0.07361000031232834,0.992437481880188 0.22082999348640442,1.2868175506591797 C0.8473266959190369,1.9131841659545898 1.4738233089447021,2.53955078125 2.1003201007843018,3.16591739654541 C1.4738233089447021,3.7922842502593994 0.8473266959190369,4.4186506271362305 0.22082999348640442,5.045017719268799 C-0.07361000031232834,5.339417457580566 -0.07361000031232834,5.816617488861084 0.22082999348640442,6.11101770401001 C0.5152599811553955,6.405417442321777 0.992609977722168,6.405417442321777 1.2870399951934814,6.11101770401001 C2.091266632080078,5.306983947753906 2.895493268966675,4.502950668334961 3.6997199058532715,3.6989173889160156 C3.994119882583618,3.404517412185669 3.994119882583618,2.927217483520508 3.6997199058532715,2.6329174041748047 C2.895493268966675,1.8288708925247192 2.091266632080078,1.0248241424560547 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
style={{ display: 'block', transform: 'matrix(1,0,0,1,-2,-2)', opacity: 1 }}
|
||||
className="slide-to-down"
|
||||
>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,11.79640007019043,13.512199401855469)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M4.3225998878479,0 C3.1498000621795654,0 1.9769999980926514,0 0.8041999936103821,0 C0.36010000109672546,0 0,0.36000001430511475 0,0.804099977016449 C0,1.2482000589370728 0.36010000109672546,1.6081000566482544 0.8041999936103821,1.6081000566482544 C1.9769999980926514,1.6081000566482544 3.1498000621795654,1.6081000566482544 4.3225998878479,1.6081000566482544 C4.7667999267578125,1.6081000566482544 5.126800060272217,1.2482000589370728 5.126800060272217,0.804099977016449 C5.126800060272217,0.36000001430511475 4.7667999267578125,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(177,98,253)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="0.100515"
|
||||
d=" M4.3225998878479,0 C3.1498000621795654,0 1.9769999980926514,0 0.8041999936103821,0 C0.36010000109672546,0 0,0.36000001430511475 0,0.804099977016449 C0,1.2482000589370728 0.36010000109672546,1.6081000566482544 0.8041999936103821,1.6081000566482544 C1.9769999980926514,1.6081000566482544 3.1498000621795654,1.6081000566482544 4.3225998878479,1.6081000566482544 C4.7667999267578125,1.6081000566482544 5.126800060272217,1.2482000589370728 5.126800060272217,0.804099977016449 C5.126800060272217,0.36000001430511475 4.7667999267578125,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const hasOutput = output.length > 0;
|
||||
const outputRef = useRef<string>(output);
|
||||
const prevShowCodeRef = useRef<boolean>(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 (
|
||||
<>
|
||||
<div className="my-2.5 flex items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">
|
||||
{progress < 1 ? (
|
||||
<CodeInProgress
|
||||
offset={offset}
|
||||
radius={radius}
|
||||
progress={progress}
|
||||
isSubmitting={isSubmitting}
|
||||
circumference={circumference}
|
||||
/>
|
||||
) : (
|
||||
<FinishedIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => setShowCode((prev) => !prev)}
|
||||
|
|
@ -94,31 +148,71 @@ export default function ExecuteCode({
|
|||
isExpanded={showCode}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="relative mb-2"
|
||||
style={{
|
||||
height: showCode ? contentHeight : 0,
|
||||
overflow: 'hidden',
|
||||
transition:
|
||||
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transformOrigin: 'top',
|
||||
willChange: 'height, opacity',
|
||||
perspective: '1000px',
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'code-analyze-block mt-0.5 overflow-hidden rounded-xl bg-surface-primary',
|
||||
showCode && 'shadow-lg',
|
||||
)}
|
||||
ref={codeContentRef}
|
||||
style={{
|
||||
transform: showCode ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
{showCode && (
|
||||
<div className="code-analyze-block mb-3 mt-0.5 overflow-hidden rounded-xl bg-black">
|
||||
<div
|
||||
style={{
|
||||
transform: showCode ? 'translateY(0)' : 'translateY(-4px)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
<MarkdownLite
|
||||
content={code ? `\`\`\`${lang}\n${code}\n\`\`\`` : ''}
|
||||
codeExecution={false}
|
||||
/>
|
||||
{output.length > 0 && (
|
||||
<div className="bg-gray-700 p-4 text-xs">
|
||||
</div>
|
||||
)}
|
||||
{hasOutput && (
|
||||
<div
|
||||
className="prose flex flex-col-reverse text-white"
|
||||
className={cn(
|
||||
'bg-surface-tertiary p-4 text-xs',
|
||||
showCode ? 'border-t border-surface-primary-contrast' : '',
|
||||
)}
|
||||
style={{
|
||||
color: 'white',
|
||||
transform: showCode ? 'translateY(0)' : 'translateY(-6px)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.05s, opacity 0.45s cubic-bezier(0.19, 1, 0.22, 1) 0.05s',
|
||||
boxShadow: showCode ? '0 -1px 0 rgba(0,0,0,0.05)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="prose flex flex-col-reverse">
|
||||
<Stdout output={output} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2.5">
|
||||
{attachments?.map((attachment, index) => (
|
||||
<Attachment attachment={attachment} key={index} />
|
||||
))}
|
||||
</div>
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
output?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
}) {
|
||||
const [progress, setProgress] = useState(initialProgress);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(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<HTMLDivElement>(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 (
|
||||
<>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
|
||||
</div>
|
||||
|
||||
{/* {showInfo && hasInfo && (
|
||||
<ToolCallInfo
|
||||
key="tool-call-info"
|
||||
input={args ?? ''}
|
||||
output={output}
|
||||
function_name={function_name}
|
||||
pendingAuth={authDomain.length > 0 && !cancelled && initialProgress < 1}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
altText={filename}
|
||||
imagePath={filepath ?? ''}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
placeholderDimensions={{ width: dimensions.width, height: dimensions.height }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'progress-text-content pointer-events-none absolute left-0 top-0 inline-flex w-full items-center gap-2 overflow-visible whitespace-nowrap',
|
||||
)}
|
||||
>
|
||||
<span className={`font-medium ${progress < 1 ? 'shimmer' : ''}`}>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as OpenAIImageGen } from './OpenAIImageGen';
|
||||
|
|
@ -17,7 +17,7 @@ const Stdout: React.FC<StdoutProps> = ({ output = '' }) => {
|
|||
return (
|
||||
processedContent && (
|
||||
<pre className="shrink-0">
|
||||
<div>{processedContent}</div>
|
||||
<div className="text-text-primary">{processedContent}</div>
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
|
|
|
|||
10
client/src/components/Chat/Messages/Content/Parts/index.ts
Normal file
10
client/src/components/Chat/Messages/Content/Parts/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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
|
|||
<div className={wrapperClass}>
|
||||
<Popover.Trigger asChild>
|
||||
<div
|
||||
className="progress-text-content absolute left-0 top-0 line-clamp-1 overflow-visible"
|
||||
className="progress-text-content absolute left-0 top-0 overflow-visible whitespace-nowrap"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="78"
|
||||
>
|
||||
|
|
@ -24,7 +28,7 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
|
|||
return (
|
||||
<div className={wrapperClass}>
|
||||
<div
|
||||
className="progress-text-content absolute left-0 top-0 line-clamp-1 overflow-visible"
|
||||
className="progress-text-content absolute left-0 top-0 overflow-visible whitespace-nowrap"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="78"
|
||||
>
|
||||
|
|
@ -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 (
|
||||
<Wrapper popover={popover}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex items-center gap-1', hasInput ? '' : 'pointer-events-none')}
|
||||
className={cn(
|
||||
'inline-flex w-full items-center gap-2',
|
||||
hasInput ? '' : 'pointer-events-none',
|
||||
)}
|
||||
disabled={!hasInput}
|
||||
onClick={onClick}
|
||||
onClick={hasInput ? onClick : undefined}
|
||||
>
|
||||
{text}
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
className={isExpanded ? 'rotate-180' : 'rotate-0'}
|
||||
>
|
||||
<path
|
||||
className={hasInput ? '' : 'stroke-transparent'}
|
||||
d="M11.3346 7.83203L8.00131 11.1654L4.66797 7.83203"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{progress < 1 ? <Spinner /> : error ? <CancelledIcon /> : <FinishedIcon />}
|
||||
<span className={`${progress < 1 ? 'shimmer' : ''}`}>{text}</span>
|
||||
{hasInput &&
|
||||
(isExpanded ? (
|
||||
<ChevronUp className="size-4 translate-y-[1px]" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 translate-y-[1px]" />
|
||||
))}
|
||||
</button>
|
||||
</Wrapper>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const prevShowInfoRef = useRef<boolean>(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 (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-text-secondary"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="849"
|
||||
>
|
||||
<div>
|
||||
<ShieldCheck />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (progress < 1) {
|
||||
return (
|
||||
<InProgressCall progress={progress} isSubmitting={isSubmitting} error={error}>
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="849"
|
||||
>
|
||||
<div>
|
||||
<WrenchIcon />
|
||||
</div>
|
||||
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
|
||||
</div>
|
||||
</InProgressCall>
|
||||
);
|
||||
}
|
||||
|
||||
return cancelled ? <CancelledIcon /> : <FinishedIcon />;
|
||||
};
|
||||
|
||||
const getFinishedText = () => {
|
||||
if (cancelled) {
|
||||
|
|
@ -148,23 +108,96 @@ 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 (
|
||||
<Popover.Root>
|
||||
<div className="my-2.5 flex flex-wrap items-center gap-2.5">
|
||||
<div className="flex w-full items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">{renderIcon()}</div>
|
||||
<>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
<ProgressText
|
||||
progress={cancelled ? 1 : progress}
|
||||
progress={progress}
|
||||
onClick={() => setShowInfo((prev) => !prev)}
|
||||
inProgressText={localize('com_assistants_running_action')}
|
||||
authText={
|
||||
!cancelled && authDomain.length > 0 ? localize('com_ui_requires_auth') : undefined
|
||||
}
|
||||
finishedText={getFinishedText()}
|
||||
hasInput={hasInfo}
|
||||
popover={true}
|
||||
isExpanded={showInfo}
|
||||
error={cancelled}
|
||||
/>
|
||||
{hasInfo && (
|
||||
<ToolPopover
|
||||
</div>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: showInfo ? contentHeight : 0,
|
||||
overflow: 'hidden',
|
||||
transition:
|
||||
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
opacity: showInfo ? 1 : 0,
|
||||
transformOrigin: 'top',
|
||||
willChange: 'height, opacity',
|
||||
perspective: '1000px',
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-md',
|
||||
showInfo && 'shadow-lg',
|
||||
)}
|
||||
style={{
|
||||
transform: showInfo ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
|
||||
opacity: showInfo ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{showInfo && hasInfo && (
|
||||
<ToolCallInfo
|
||||
key="tool-call-info"
|
||||
input={args ?? ''}
|
||||
output={output}
|
||||
domain={authDomain || (domain ?? '')}
|
||||
|
|
@ -173,26 +206,27 @@ export default function ToolCall({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{auth != null && auth && progress < 1 && !cancelled && (
|
||||
<div className="flex w-full flex-col gap-2.5">
|
||||
<div className="mb-1 mt-2">
|
||||
<a
|
||||
className="inline-flex items-center justify-center gap-2 rounded-3xl bg-surface-tertiary px-4 py-2 text-sm font-medium hover:bg-surface-hover"
|
||||
href={auth}
|
||||
target="_blank"
|
||||
<Button
|
||||
className="font-mediu inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm"
|
||||
variant="default"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => window.open(auth, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{localize('com_ui_sign_in_to_domain', { 0: authDomain })}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="flex items-center text-xs text-text-secondary">
|
||||
<p className="flex items-center text-xs text-text-warning">
|
||||
<TriangleAlert className="mr-1.5 inline-block h-4 w-4" />
|
||||
{localize('com_assistants_allow_sites_you_trust')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{attachments?.map((attachment, index) => <Attachment attachment={attachment} key={index} />)}
|
||||
</Popover.Root>
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
74
client/src/components/Chat/Messages/Content/ToolCallInfo.tsx
Normal file
74
client/src/components/Chat/Messages/Content/ToolCallInfo.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import React from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg bg-surface-tertiary p-2 text-xs text-text-primary"
|
||||
style={{
|
||||
position: 'relative',
|
||||
maxHeight,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<pre className="m-0 whitespace-pre-wrap break-words" style={{ overflowWrap: 'break-word' }}>
|
||||
<code>{text}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="w-full p-2">
|
||||
<div style={{ opacity: 1 }}>
|
||||
<div className="mb-2 text-sm font-medium text-text-primary">{title}</div>
|
||||
<div>
|
||||
<OptimizedCodeBlock text={formatText(input)} maxHeight={250} />
|
||||
</div>
|
||||
{output && (
|
||||
<>
|
||||
<div className="my-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_result')}
|
||||
</div>
|
||||
<div>
|
||||
<OptimizedCodeBlock text={formatText(output)} maxHeight={250} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
alignOffset={-5}
|
||||
className="w-18 min-w-[180px] max-w-sm rounded-lg bg-surface-primary px-1"
|
||||
>
|
||||
<div tabIndex={-1}>
|
||||
<div className="bg-token-surface-primary max-w-sm rounded-md p-2 shadow-[0_0_24px_0_rgba(0,0,0,0.05),inset_0_0.5px_0_0_rgba(0,0,0,0.05),0_2px_8px_0_rgba(0,0,0,0.05)]">
|
||||
<div className="mb-2 text-sm font-medium text-text-primary">{title}</div>
|
||||
<div className="bg-token-surface-secondary text-token-text-primary dark rounded-md text-xs">
|
||||
<div className="max-h-32 overflow-y-auto rounded-md bg-surface-tertiary p-2">
|
||||
<code className="!whitespace-pre-wrap ">{formatText(input)}</code>
|
||||
</div>
|
||||
</div>
|
||||
{output != null && output && (
|
||||
<>
|
||||
<div className="mb-2 mt-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_result')}
|
||||
</div>
|
||||
<div className="bg-token-surface-secondary text-token-text-primary dark rounded-md text-xs">
|
||||
<div className="max-h-32 overflow-y-auto rounded-md bg-surface-tertiary p-2">
|
||||
<code className="!whitespace-pre-wrap ">{formatText(output)}</code>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ const LoadingSpinner = memo(() => {
|
|||
|
||||
return (
|
||||
<div className="mx-auto mt-2 flex items-center justify-center gap-2">
|
||||
<Spinner className="h-4 w-4 text-text-primary" />
|
||||
<Spinner className="text-text-primary" />
|
||||
<span className="animate-pulse text-text-primary">{localize('com_ui_loading')}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex }) => {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -91,7 +92,7 @@ const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex
|
|||
disabled={execute.isLoading}
|
||||
>
|
||||
{execute.isLoading ? (
|
||||
<Loader className="animate-spin" size={18} />
|
||||
<Spinner className="animate-spin" size={18} />
|
||||
) : (
|
||||
<TerminalSquareIcon size={18} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export default function AgentFooter({
|
|||
const showButtons = activePanel === Panel.builder;
|
||||
|
||||
return (
|
||||
<div className="mx-1 mb-1 flex w-full flex-col gap-2">
|
||||
<div className="mb-1 flex w-full flex-col gap-2">
|
||||
{showButtons && <AdvancedButton setActivePanel={setActivePanel} />}
|
||||
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
||||
{/* Context Button */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
|
||||
<div className="mt-2 flex w-full flex-wrap gap-2">
|
||||
<div className="w-full">
|
||||
<AgentSelect
|
||||
createMutation={create}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export function AvatarMenu({
|
|||
>
|
||||
<div
|
||||
role="menuitem"
|
||||
className="group m-1.5 flex cursor-pointer gap-2 rounded p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5"
|
||||
className="group m-1.5 flex cursor-pointer gap-2 rounded-lg p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5"
|
||||
tabIndex={-1}
|
||||
data-orientation="vertical"
|
||||
onClick={onItemClick}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { useGetEndpointsQuery } from '~/data-provider';
|
|||
import NavToggle from '~/components/Nav/NavToggle';
|
||||
import { cn, getEndpointField } from '~/utils';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
||||
import Nav from './Nav';
|
||||
|
||||
const defaultMinSize = 20;
|
||||
|
|
|
|||
|
|
@ -1,20 +1,67 @@
|
|||
import { cn } from '~/utils/';
|
||||
|
||||
export default function Spinner({ className = 'm-auto', size = '1em' }) {
|
||||
interface SpinnerProps {
|
||||
className?: string;
|
||||
size?: string | number;
|
||||
color?: string;
|
||||
bgOpacity?: number;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export default function Spinner({
|
||||
className = 'm-auto',
|
||||
size = 20,
|
||||
color = 'currentColor',
|
||||
bgOpacity = 0.1,
|
||||
speed = 0.75,
|
||||
}: SpinnerProps) {
|
||||
const cssVars = {
|
||||
'--spinner-speed': `${speed}s`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(className, 'spinner')}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn('animate-spin', className)}
|
||||
viewBox="0 0 40 40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={cssVars}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
<defs>
|
||||
<style type="text/css">{`
|
||||
.spinner {
|
||||
transform-origin: center;
|
||||
overflow: visible;
|
||||
animation: spinner-rotate var(--spinner-speed) linear infinite;
|
||||
}
|
||||
@keyframes spinner-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</defs>
|
||||
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="14.5"
|
||||
pathLength="100"
|
||||
strokeWidth="5"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeOpacity={bgOpacity}
|
||||
/>
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="14.5"
|
||||
pathLength="100"
|
||||
strokeWidth="5"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeDasharray="25 75"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const DialogPortal = DialogPrimitive.Portal;
|
|||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
export const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
|
|
|
|||
375
client/src/components/ui/PixelCard.tsx
Normal file
375
client/src/components/ui/PixelCard.tsx
Normal file
|
|
@ -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<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pixelsRef = useRef<Pixel[]>([]);
|
||||
const animationRef = useRef<number>();
|
||||
const timePrevRef = useRef(performance.now());
|
||||
const progressRef = useRef<number | undefined>(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<HTMLDivElement> = (e) => {
|
||||
if (
|
||||
!disableFocus &&
|
||||
!e.currentTarget.contains(e.relatedTarget) &&
|
||||
progressRef.current === undefined
|
||||
) {
|
||||
startAnim('appear');
|
||||
}
|
||||
};
|
||||
const focusOut: React.FocusEventHandler<HTMLDivElement> = (e) => {
|
||||
if (
|
||||
!disableFocus &&
|
||||
!e.currentTarget.contains(e.relatedTarget) &&
|
||||
progressRef.current === undefined
|
||||
) {
|
||||
startAnim('disappear');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: width || '100%',
|
||||
height: height || '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'ease-[cubic-bezier(0.5,1,0.89,1)] relative isolate grid select-none place-items-center overflow-hidden rounded-lg border border-border-light shadow-md transition-colors duration-200',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
onMouseEnter={hoverIn}
|
||||
onMouseLeave={hoverOut}
|
||||
onFocus={disableFocus ? undefined : focusIn}
|
||||
onBlur={disableFocus ? undefined : focusOut}
|
||||
tabIndex={disableFocus ? -1 : 0}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none absolute inset-0 block"
|
||||
width={width && width !== 'auto' ? parseInt(String(width)) : undefined}
|
||||
height={height && height !== 'auto' ? parseInt(String(height)) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
21
client/src/utils/scaleImage.ts
Normal file
21
client/src/utils/scaleImage.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export default function scaleImage({
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
containerRef,
|
||||
}: {
|
||||
originalWidth?: number;
|
||||
originalHeight?: number;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
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` };
|
||||
}
|
||||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue