🎨 feat: UI Refresh for Enhanced UX (#6346)

*  feat: Add Expand Chat functionality and improve UI components

*  feat: Introduce Chat Badges feature with editing capabilities and UI enhancements

*  feat: re-implement file attachment functionality with new components and improved UI

*  feat: Enhance BadgeRow component with drag-and-drop functionality and add animations for better user experience

*  feat: Add useChatBadges hook and enhance Badge component with animations and toggle functionality

* feat: Improve Add/Delete Badges + style and bug fixes

*  feat: Refactor EditBadges component and optimize useChatBadges hook for improved performance and readability

*  feat: Add type definition for LucideIcon in EditBadges component

* refactor: Clean up BadgeRow component by removing outdated comment and improving code readability

* refactor: Rename app-icon class to badge-icon for consistency and improve badge styling

* feat: Add Center Chat Input toggle and update related components for improved UI/UX

* refactor: Simplify ChatView and MessagesView components for improved readability and performance

* refactor: Improve layout and positioning of scroll button in MessagesView component

* refactor: Adjust scroll button position in MessagesView component for better visibility

* refactor: Remove redundant background class from Badge component for cleaner styling

* feat: disable chat badges

* refactor: adjust positioning of scroll button and popover for improved layout

* refactor: simplify class names in ChatForm and RemoveFile components for cleaner code

* refactor: move Switcher to HeaderOptions from SidePanel

* fix(Landing): duplicate description

* feat: add SplitText component for animated text display and update Landing component to use it

* feat(Chat): add ConversationStarters component and integrate it into ChatView; remove ConvoStarter component

* feat(Chat): enhance Message component layout and styling for improved readability

* feat(ControlCombobox, Select): enhance styling and add animation for improved UI experience

* feat(Chat): update Header and HeaderNewChat components for improved layout and styling

* feat(Chat): add ModelDropdown (now includes both endpoint and model) and refactor Menu components for improved UI

* feat(ModelDropdown): add Agent Select; removed old AgentSwitcher components

* feat(ModelDropdown): add settings button for user key configuration

* fix(ModelDropdown): the model dropdown wasn't opening automatically when opening the endpoint one

* refactor(Chat): remove unused EndpointsMenu and related components to streamline codebase

* feat: enhance greeting message and improve accessibility fro ModelDropdown

* refactor(Endpoints): add new hooks and components for endpoint management

* feat(Endpoint): add support for modelSpecs

* feat(Endpoints): add mobile support

* fix: type issues

* fix(modelSpec): type issue

* fix(EndpointMenuDropdown): double overflow scroller in mobile model list

* fix: search model on mobile

* refactor: Endpoint/Model/modelSpec dropdown

* refactor: reorganize imports in Endpoint components

* refactor: remove unused translation keys from English locale

* BREAKING: moving to ariakit with new CustomMenu

* refactor: remove unnecessary comments

* refactor: remove EndpointItem, ModelDropdownButton, SpecIcon, and SpecItem components

* 🔧 fix: AI Icon bump when regenerating message

* wip: chat UI refactoring, fix issues

* chore: add recent update to useAutoSave

* feat: add access control for agent permissions in useMentions hook

* refactor: streamline ModelSelector by removing unused endpoints logic

* refactor: enhance ModelSelector and context by integrating endpointsConfig and improving type usage

* feat: update ModelSelectorContext to utilize conversation data for initial state

* feat: add selector effects for synced endpoint handling

* feat: add guard clause for conversation endpoint in useSelectorEffects hook

* fix: safely call onSelectMention and add autofocus to mention input

* chore: typing

* refactor: ModelSelector to streamline key dialog handling and improve endpoint rendering

* refactor: extract SettingsButton component for cleaner endpoint item rendering

* wip: first pass, expand set api key

* wip: first pass, expanding set key

* refactor: update EndpointItem styles for improved layout and hover effects

* refactor: adjust padding in EndpointItem for improved layout consistency

* refactor: update preset structure in useSelectMention to include spec as null

* refactor: rename setKeyDialogOpen to onOpenChange for clarity and consistency, bring focus back to button that opened dialog

* feat: add SpecIcon component for dynamic model spec icons in menu, adjust icon styling

* refactor: update getSelectedIcon to accept additional parameters and improve icon rendering logic

* fix: adjust padding in MessageRender for improved layout

* refactor: remove inline style for menu width in CustomMenu component

* refactor: enhance layout and styling in ModelSpecItem component for better responsiveness

* refactor: update getDefaultModelSpec to accept startupConfig and improve model spec retrieval logic

* refactor: improve key management and default values in ModelSelector and related components

* refactor: adjust menu width and improve responsiveness in CustomMenu and EndpointItem components

* refactor: enhance focus styles and responsiveness in EndpointItem component

* refactor: improve layout and spacing in Header and ModelSelector components for better responsiveness

* refactor: adjust button styles for consistency and improved layout in AddMultiConvo and PresetsMenu components

* fix: initial fix of assistant names

* fix: assistants handling

* chore: update version of librechat-data-provider to 0.7.75 and add 'spec' to excludedKeys

* fix: improve endpoint filtering logic based on interface configuration and access rights

* fix: remove unused HeaderOptions import and set spec to null in presets and mentions

* fix: ensure currentExample is always an object when updating examples

* fix: update interfaceConfig checks to ensure modelSelect is considered for rendering components

* fix: update model selection logic to consider interface configuration when prioritizing model specs

* fix: add missing localizations

* fix: remove unused agent and assistant selection translations

* fix: implement debounced state updates for selected values in useSelectorEffects

* style: minor style changes related to the ModelSelector

* fix: adjust maximum height for popover and set fixed height for model item

* fix: update placeholders for model and endpoint search inputs

* fix: refactor MessageRender and ContentRender components to better match each other

* fix: remove convo fallback for iconURL in MessageRender and ContentRender components

* fix: update handling of spec, iconURL, and modelLabel in conversation presets, to allow better interchangeability

* fix: replace chatGptLabel with modelLabel in OpenAI settings configuration (fully deprecate chatGptLabel)

* fix: remove console log for assistantNames in useEndpoints hook

* refactor: add cleanInput and cleanOutput options to default conversation handling

* chore: update bun.lockb

* fix: set default value for showIconInHeader in getSelectedIcon function

* refactor: enhance error handling in message processing when latest message has existing content blocks

* chore: allow import/no-cycle for messages

* fix: adjust flex properties in BookmarkMenu for better layout

* feat: support both 'prompt' and 'q' as query parameters in useQueryParams hook

* feat: re-enable Badges components

* refactor: disable edit badge component

* chore: rename assistantMap to assistantsMap for consistency

* chore: rename assistantMap to assistantsMap for consistency in Mention component

* feat: set staleTime for various queries to improve data freshness

* feat: add spec field to tQueryParamsSchema for model specification

* feat: enhance useQueryParams to handle model specs

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-03-25 23:50:58 +01:00 committed by GitHub
parent c4fea9cd79
commit 7f29f2f676
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 4507 additions and 2163 deletions

View file

@ -7,14 +7,12 @@ import { globalAudioId } from '~/common';
import { cn } from '~/utils';
export default function AudioRecorder({
isRTL,
disabled,
ask,
methods,
textAreaRef,
isSubmitting,
}: {
isRTL: boolean;
disabled: boolean;
ask: (data: { text: string }) => void;
methods: ReturnType<typeof useChatFormContext>;
@ -90,9 +88,7 @@ export default function AudioRecorder({
onClick={isListening === true ? handleStopRecording : handleStartRecording}
disabled={disabled}
className={cn(
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
isRTL ? 'bottom-2 left-2' : 'bottom-2 right-2',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
)}
title={localize('com_ui_use_micrphone')}
aria-pressed={isListening}

View file

@ -0,0 +1,357 @@
import React, {
useState,
useRef,
useEffect,
useCallback,
useMemo,
forwardRef,
useReducer,
} from 'react';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import type { LucideIcon } from 'lucide-react';
import { useChatBadges } from '~/hooks';
import { Badge } from '~/components/ui';
import { BadgeItem } from '~/common';
import store from '~/store';
interface BadgeRowProps {
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
onToggle?: (badgeId: string, currentActive: boolean) => void;
}
interface BadgeWrapperProps {
badge: BadgeItem;
isEditing: boolean;
onToggle: (badge: BadgeItem) => void;
onDelete: (id: string) => void;
onMouseDown: (e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => void;
badgeRefs: React.MutableRefObject<Record<string, HTMLDivElement>>;
}
const BadgeWrapper = React.memo(
forwardRef<HTMLDivElement, BadgeWrapperProps>(
({ badge, isEditing, onToggle, onDelete, onMouseDown, badgeRefs }, ref) => {
const isActive = badge.atom ? useRecoilValue(badge.atom) : false;
return (
<div
ref={(el) => {
if (el) {
badgeRefs.current[badge.id] = el;
}
if (typeof ref === 'function') {
ref(el);
} else if (ref) {
ref.current = el;
}
}}
onMouseDown={(e) => onMouseDown(e, badge, isActive)}
className={isEditing ? 'ios-wiggle badge-icon h-full' : 'badge-icon h-full'}
>
<Badge
icon={badge.icon as LucideIcon}
label={badge.label}
isActive={isActive}
isEditing={isEditing}
isAvailable={badge.isAvailable}
onToggle={() => onToggle(badge)}
onBadgeAction={() => onDelete(badge.id)}
/>
</div>
);
},
),
(prevProps, nextProps) =>
prevProps.badge.id === nextProps.badge.id &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.onToggle === nextProps.onToggle &&
prevProps.onDelete === nextProps.onDelete &&
prevProps.onMouseDown === nextProps.onMouseDown &&
prevProps.badgeRefs === nextProps.badgeRefs,
);
BadgeWrapper.displayName = 'BadgeWrapper';
interface DragState {
draggedBadge: BadgeItem | null;
mouseX: number;
offsetX: number;
insertIndex: number | null;
draggedBadgeActive: boolean;
}
type DragAction =
| {
type: 'START_DRAG';
badge: BadgeItem;
mouseX: number;
offsetX: number;
insertIndex: number;
isActive: boolean;
}
| { type: 'UPDATE_POSITION'; mouseX: number; insertIndex: number }
| { type: 'END_DRAG' };
const dragReducer = (state: DragState, action: DragAction): DragState => {
switch (action.type) {
case 'START_DRAG':
return {
draggedBadge: action.badge,
mouseX: action.mouseX,
offsetX: action.offsetX,
insertIndex: action.insertIndex,
draggedBadgeActive: action.isActive,
};
case 'UPDATE_POSITION':
return {
...state,
mouseX: action.mouseX,
insertIndex: action.insertIndex,
};
case 'END_DRAG':
return {
draggedBadge: null,
mouseX: 0,
offsetX: 0,
insertIndex: null,
draggedBadgeActive: false,
};
default:
return state;
}
};
export function BadgeRow({ onChange, onToggle }: BadgeRowProps) {
const [orderedBadges, setOrderedBadges] = useState<BadgeItem[]>([]);
const [dragState, dispatch] = useReducer(dragReducer, {
draggedBadge: null,
mouseX: 0,
offsetX: 0,
insertIndex: null,
draggedBadgeActive: false,
});
const badgeRefs = useRef<Record<string, HTMLDivElement>>({});
const containerRef = useRef<HTMLDivElement>(null);
const animationFrame = useRef<number | null>(null);
const containerRectRef = useRef<DOMRect | null>(null);
const allBadges = useChatBadges() || [];
const isEditing = useRecoilValue(store.isEditingBadges);
const badges = useMemo(
() => allBadges.filter((badge) => badge.isAvailable !== false),
[allBadges],
);
const toggleBadge = useRecoilCallback(
({ snapshot, set }) =>
async (badgeAtom: any) => {
const current = await snapshot.getPromise(badgeAtom);
set(badgeAtom, !current);
},
[],
);
useEffect(() => {
setOrderedBadges((prev) => {
const currentIds = new Set(prev.map((b) => b.id));
const newBadges = badges.filter((b) => !currentIds.has(b.id));
return newBadges.length > 0 ? [...prev, ...newBadges] : prev;
});
}, [badges]);
const tempBadges = dragState.draggedBadge
? orderedBadges.filter((b) => b.id !== dragState.draggedBadge?.id)
: orderedBadges;
const ghostBadge = dragState.draggedBadge || null;
const calculateInsertIndex = useCallback(
(currentMouseX: number): number => {
if (!dragState.draggedBadge || !containerRef.current || !containerRectRef.current) {
return 0;
}
const relativeMouseX = currentMouseX - containerRectRef.current.left;
const refs = tempBadges.map((b) => badgeRefs.current[b.id]).filter(Boolean);
if (refs.length === 0) {
return 0;
}
let idx = 0;
for (let i = 0; i < refs.length; i++) {
const rect = refs[i].getBoundingClientRect();
const relativeLeft = rect.left - containerRectRef.current.left;
const relativeCenter = relativeLeft + rect.width / 2;
if (relativeMouseX < relativeCenter) {
break;
}
idx = i + 1;
}
return idx;
},
[dragState.draggedBadge, tempBadges],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => {
if (!isEditing || !containerRef.current) {
return;
}
const el = badgeRefs.current[badge.id];
if (!el) {
return;
}
const rect = el.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const mouseX = e.clientX;
const initialIndex = orderedBadges.findIndex((b) => b.id === badge.id);
containerRectRef.current = containerRef.current.getBoundingClientRect();
dispatch({
type: 'START_DRAG',
badge,
mouseX,
offsetX,
insertIndex: initialIndex,
isActive,
});
},
[isEditing, orderedBadges],
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!dragState.draggedBadge) {
return;
}
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
}
animationFrame.current = requestAnimationFrame(() => {
const newMouseX = e.clientX;
const newInsertIndex = calculateInsertIndex(newMouseX);
if (newInsertIndex !== dragState.insertIndex) {
dispatch({ type: 'UPDATE_POSITION', mouseX: newMouseX, insertIndex: newInsertIndex });
} else {
dispatch({
type: 'UPDATE_POSITION',
mouseX: newMouseX,
insertIndex: dragState.insertIndex,
});
}
});
},
[dragState.draggedBadge, dragState.insertIndex, calculateInsertIndex],
);
const handleMouseUp = useCallback(() => {
if (dragState.draggedBadge && dragState.insertIndex !== null) {
const otherBadges = orderedBadges.filter((b) => b.id !== dragState.draggedBadge?.id);
const newBadges = [
...otherBadges.slice(0, dragState.insertIndex),
dragState.draggedBadge,
...otherBadges.slice(dragState.insertIndex),
];
setOrderedBadges(newBadges);
onChange(newBadges.map((badge) => ({ id: badge.id })));
}
dispatch({ type: 'END_DRAG' });
containerRectRef.current = null;
}, [dragState.draggedBadge, dragState.insertIndex, orderedBadges, onChange]);
const handleDelete = useCallback(
(badgeId: string) => {
const newBadges = orderedBadges.filter((b) => b.id !== badgeId);
setOrderedBadges(newBadges);
onChange(newBadges.map((badge) => ({ id: badge.id })));
},
[orderedBadges, onChange],
);
const handleBadgeToggle = useCallback(
(badge: BadgeItem) => {
if (badge.atom) {
toggleBadge(badge.atom);
}
if (onToggle) {
onToggle(badge.id, !!badge.atom);
}
},
[toggleBadge, onToggle],
);
useEffect(() => {
if (!dragState.draggedBadge) {
return;
}
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
animationFrame.current = null;
}
};
}, [dragState.draggedBadge, handleMouseMove, handleMouseUp]);
return (
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
{tempBadges.map((badge, index) => (
<React.Fragment key={badge.id}>
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
<div className="badge-icon h-full">
<Badge
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
/>
</div>
)}
<BadgeWrapper
badge={badge}
isEditing={isEditing}
onToggle={handleBadgeToggle}
onDelete={handleDelete}
onMouseDown={handleMouseDown}
badgeRefs={badgeRefs}
/>
</React.Fragment>
))}
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
<div className="badge-icon h-full">
<Badge
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
/>
</div>
)}
{ghostBadge && (
<div
className="ghost-badge h-full"
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateX(${dragState.mouseX - dragState.offsetX - (containerRectRef.current?.left || 0)}px)`,
zIndex: 10,
pointerEvents: 'none',
}}
>
<Badge
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isAvailable={ghostBadge.isAvailable}
isEditing
isDragging
/>
</div>
)}
</div>
);
}

View file

@ -1,11 +1,7 @@
import { memo, useRef, useMemo, useEffect, useState } from 'react';
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
import { useWatch } from 'react-hook-form';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
supportsFiles,
mergeFileConfig,
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { isAssistantsEndpoint } from 'librechat-data-provider';
import {
useChatContext,
useChatFormContext,
@ -20,47 +16,105 @@ import {
useQueryParams,
useSubmitMessage,
} from '~/hooks';
import { cn, removeFocusRings, checkIfScrollable } from '~/utils';
import FileFormWrapper from './Files/FileFormWrapper';
import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider';
import { TemporaryChat } from './TemporaryChat';
import { mainTextareaId, BadgeItem } from '~/common';
import AttachFileChat from './Files/AttachFileChat';
import FileFormChat from './Files/FileFormChat';
import { TextareaAutosize } from '~/components';
import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import AudioRecorder from './AudioRecorder';
import { mainTextareaId } from '~/common';
import CollapseChat from './CollapseChat';
import StreamAudio from './StreamAudio';
import StopButton from './StopButton';
import SendButton from './SendButton';
import { BadgeRow } from './BadgeRow';
import EditBadges from './EditBadges';
import Mention from './Mention';
import store from '~/store';
const ChatForm = ({ index = 0 }) => {
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
useQueryParams({ textAreaRef });
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [isCollapsed, setIsCollapsed] = useState(false);
const [isScrollable, setIsScrollable] = useState(false);
const [, setIsScrollable] = useState(false);
const [visualRowCount, setVisualRowCount] = useState(1);
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const [isTemporaryChat, setIsTemporaryChat] = useRecoilState<boolean>(store.isTemporary);
const chatDirection = useRecoilValue(store.chatDirection);
const isSearching = useRecoilValue(store.isSearching);
const [badges, setBadges] = useRecoilState(store.chatBadges);
const [isEditingBadges, setIsEditingBadges] = useRecoilState(store.isEditingBadges);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index));
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
store.showMentionPopoverFamily(index),
);
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const isRTL = chatDirection === 'rtl';
const { requiresKey } = useRequiresKey();
const methods = useChatFormContext();
const {
files,
setFiles,
conversation,
isSubmitting,
filesLoading,
newConversation,
handleStopGenerating,
} = useChatContext();
const {
addedIndex,
generateConversation,
conversation: addedConvo,
setConversation: setAddedConvo,
isSubmitting: isSubmittingAdded,
} = useAddedChatContext();
const assistantMap = useAssistantsMapContext();
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
const endpoint = useMemo(
() => conversation?.endpointType ?? conversation?.endpoint,
[conversation?.endpointType, conversation?.endpoint],
);
const isRTL = useMemo(() => chatDirection === 'rtl', [chatDirection.toLowerCase()]);
const invalidAssistant = useMemo(
() =>
isAssistantsEndpoint(endpoint) &&
(!(conversation?.assistant_id ?? '') ||
!assistantMap?.[endpoint ?? '']?.[conversation?.assistant_id ?? '']),
[conversation?.assistant_id, endpoint, assistantMap],
);
const disableInputs = useMemo(
() => requiresKey || invalidAssistant,
[requiresKey, invalidAssistant],
);
const handleContainerClick = useCallback(() => {
textAreaRef.current?.focus();
}, []);
const handleFocusOrClick = useCallback(() => {
if (isCollapsed) {
setIsCollapsed(false);
}
}, [isCollapsed]);
useAutoSave({
conversationId: conversation?.conversationId,
textAreaRef,
files,
setFiles,
});
const { submitMessage, submitPrompt } = useSubmitMessage();
const handleKeyUp = useHandleKeyUp({
index,
textAreaRef,
@ -71,65 +125,22 @@ const ChatForm = ({ index = 0 }) => {
textAreaRef,
submitButtonRef,
setIsScrollable,
disabled: !!(requiresKey ?? false),
disabled: disableInputs,
});
const {
files,
setFiles,
conversation,
isSubmitting,
filesLoading,
newConversation,
handleStopGenerating,
} = useChatContext();
const methods = useChatFormContext();
const {
addedIndex,
generateConversation,
conversation: addedConvo,
setConversation: setAddedConvo,
isSubmitting: isSubmittingAdded,
} = useAddedChatContext();
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
useAutoSave({
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
textAreaRef,
files,
setFiles,
});
const assistantMap = useAssistantsMapContext();
const { submitMessage, submitPrompt } = useSubmitMessage();
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpoint = endpointType ?? _endpoint;
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
const invalidAssistant = useMemo(
() =>
isAssistantsEndpoint(conversation?.endpoint) &&
(!(conversation?.assistant_id ?? '') ||
!assistantMap?.[conversation?.endpoint ?? ''][conversation?.assistant_id ?? '']),
[conversation?.assistant_id, conversation?.endpoint, assistantMap],
);
const disableInputs = useMemo(
() => !!((requiresKey ?? false) || invalidAssistant),
[requiresKey, invalidAssistant],
);
useQueryParams({ textAreaRef });
const { ref, ...registerProps } = methods.register('text', {
required: true,
onChange: (e) => {
methods.setValue('text', e.target.value, { shouldValidate: true });
},
onChange: useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
methods.setValue('text', e.target.value, { shouldValidate: true }),
[methods],
),
});
const textValue = useWatch({ control: methods.control, name: 'text' });
useEffect(() => {
if (!isSearching && textAreaRef.current && !disableInputs) {
textAreaRef.current.focus();
@ -138,33 +149,53 @@ const ChatForm = ({ index = 0 }) => {
useEffect(() => {
if (textAreaRef.current) {
checkIfScrollable(textAreaRef.current);
const style = window.getComputedStyle(textAreaRef.current);
const lineHeight = parseFloat(style.lineHeight);
setVisualRowCount(Math.floor(textAreaRef.current.scrollHeight / lineHeight));
}
}, [textValue]);
useEffect(() => {
if (isEditingBadges && backupBadges.length === 0) {
setBackupBadges([...badges]);
}
}, [isEditingBadges, badges, backupBadges.length]);
const handleSaveBadges = useCallback(() => {
setIsEditingBadges(false);
setBackupBadges([]);
}, []);
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
const handleCancelBadges = useCallback(() => {
if (backupBadges.length > 0) {
setBadges([...backupBadges]);
}
setIsEditingBadges(false);
setBackupBadges([]);
}, [backupBadges, setBadges]);
const baseClasses = cn(
'md:py-3.5 m-0 w-full resize-none py-[13px] bg-surface-tertiary placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
isCollapsed ? 'max-h-[52px]' : 'max-h-[65vh] md:max-h-[75vh]',
const isMoreThanThreeRows = visualRowCount > 3;
const baseClasses = useMemo(
() =>
cn(
'md:py-3.5 m-0 w-full resize-none py-[13px] bg-surface-chat placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
isCollapsed ? 'max-h-[52px]' : 'max-h-[45vh] md:max-h-[55vh]',
isMoreThanThreeRows ? 'pl-5' : 'px-5',
),
[isCollapsed, isMoreThanThreeRows],
);
const uploadActive = endpointSupportsFiles && !isUploadDisabled;
const speechClass = isRTL
? `pr-${uploadActive ? '12' : '4'} pl-12`
: `pl-${uploadActive ? '12' : '4'} pr-12`;
return (
<form
onSubmit={methods.handleSubmit((data) => submitMessage(data))}
onSubmit={methods.handleSubmit(submitMessage)}
className={cn(
'mx-auto flex flex-row gap-3 pl-2 transition-all duration-200 last:mb-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-2xl xl:max-w-3xl',
'mx-auto flex flex-row gap-3 transition-all duration-200 sm:mb-2 sm:px-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
)}
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="flex w-full items-center">
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
setShowMentionPopover={setShowPlusPopover}
@ -183,90 +214,101 @@ const ChatForm = ({ index = 0 }) => {
/>
)}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl bg-surface-tertiary text-text-primary duration-200">
<TemporaryChat
isTemporaryChat={isTemporaryChat}
setIsTemporaryChat={setIsTemporaryChat}
/>
<div
onClick={handleContainerClick}
className={cn(
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border border-border-light bg-surface-chat text-text-primary transition-all duration-200 sm:rounded-3xl',
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
)}
>
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileFormWrapper disableInputs={disableInputs}>
{endpoint && (
<>
<EditBadges
isEditingChatBadges={isEditingBadges}
handleCancelBadges={handleCancelBadges}
handleSaveBadges={handleSaveBadges}
setBadges={setBadges}
/>
<FileFormChat disableInputs={disableInputs} />
{endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
removeFocusRings,
'transition-[max-height] duration-200',
)}
/>
<div className="flex flex-col items-start justify-start pt-1.5">
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isScrollable}
isScrollable={isMoreThanThreeRows}
setIsCollapsed={setIsCollapsed}
/>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
textAreaRef.current = e;
}}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onHeightChange={() => {
if (textAreaRef.current) {
const scrollable = checkIfScrollable(textAreaRef.current);
setIsScrollable(scrollable);
}
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => isCollapsed && setIsCollapsed(false)}
onClick={() => isCollapsed && setIsCollapsed(false)}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
speechClass,
removeFocusRings,
'transition-[max-height] duration-200',
)}
/>
</>
</div>
</div>
)}
<div
className={cn(
'items-between flex gap-2 pb-2',
isRTL ? 'flex-row-reverse' : 'flex-row',
)}
</FileFormWrapper>
{SpeechToText && (
<AudioRecorder
isRTL={isRTL}
methods={methods}
ask={submitMessage}
textAreaRef={textAreaRef}
disabled={!!disableInputs}
isSubmitting={isSubmitting}
/>
)}
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div>
<div
className={cn(
'mb-[5px] ml-[8px] flex flex-col items-end justify-end',
isRTL && 'order-first mr-[8px]',
)}
style={{ alignSelf: 'flex-end' }}
>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={!!(filesLoading || isSubmitting || disableInputs)}
>
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
<AttachFileChat disableInputs={disableInputs} />
</div>
<BadgeRow onChange={(newBadges) => setBadges(newBadges)} />
<div className="mx-auto flex" />
{SpeechToText && (
<AudioRecorder
methods={methods}
ask={submitMessage}
textAreaRef={textAreaRef}
disabled={disableInputs}
isSubmitting={isSubmitting}
/>
)
)}
)}
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={filesLoading || isSubmitting || disableInputs}
/>
)
)}
</div>
</div>
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div>
</div>
</div>
</form>
);
};
});
export default memo(ChatForm);
export default ChatForm;

View file

@ -1,5 +1,5 @@
import React from 'react';
import { Minimize2 } from 'lucide-react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { TooltipAnchor } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -18,23 +18,37 @@ const CollapseChat = ({
return null;
}
if (isCollapsed) {
return null;
}
const description = isCollapsed
? localize('com_ui_expand_chat')
: localize('com_ui_collapse_chat');
return (
<TooltipAnchor
role="button"
description={localize('com_ui_collapse_chat')}
aria-label={localize('com_ui_collapse_chat')}
onClick={() => setIsCollapsed(true)}
className={cn(
'absolute right-2 top-2 z-10 size-[35px] rounded-full p-2 transition-colors',
'hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
)}
>
<Minimize2 className="h-full w-full" />
</TooltipAnchor>
<div className="relative ml-auto items-end justify-end">
<TooltipAnchor
description={description}
render={
<button
aria-label={description}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsCollapsed((prev) => !prev);
}}
className={cn(
// 'absolute right-1.5 top-1.5',
'z-10 size-5 rounded-full transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
)}
>
{isCollapsed ? (
<ChevronDown className="h-full w-full" />
) : (
<ChevronUp className="h-full w-full" />
)}
</button>
}
/>
</div>
);
};

View file

@ -0,0 +1,85 @@
import { useMemo, useCallback } from 'react';
import { EModelEndpoint, Constants } from 'librechat-data-provider';
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import { useGetAssistantDocsQuery, useGetEndpointsQuery } from '~/data-provider';
import { getIconEndpoint, getEntity } from '~/utils';
import { useSubmitMessage } from '~/hooks';
const ConversationStarters = () => {
const { conversation } = useChatContext();
const agentsMap = useAgentsMapContext();
const assistantMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
const endpointType = useMemo(() => {
let ep = conversation?.endpoint ?? '';
if (
[
EModelEndpoint.chatGPTBrowser,
EModelEndpoint.azureOpenAI,
EModelEndpoint.gptPlugins,
].includes(ep as EModelEndpoint)
) {
ep = EModelEndpoint.openAI;
}
return getIconEndpoint({
endpointsConfig,
iconURL: conversation?.iconURL,
endpoint: ep,
});
}, [conversation?.endpoint, conversation?.iconURL, endpointsConfig]);
const { data: documentsMap = new Map() } = useGetAssistantDocsQuery(endpointType, {
select: (data) => new Map(data.map((dbA) => [dbA.assistant_id, dbA])),
});
const { entity, isAgent } = getEntity({
endpoint: endpointType,
agentsMap,
assistantMap,
agent_id: conversation?.agent_id,
assistant_id: conversation?.assistant_id,
});
const conversation_starters = useMemo(() => {
if (entity?.conversation_starters?.length) {
return entity.conversation_starters;
}
if (isAgent) {
return [];
}
return documentsMap.get(entity?.id ?? '')?.conversation_starters ?? [];
}, [documentsMap, isAgent, entity]);
const { submitMessage } = useSubmitMessage();
const sendConversationStarter = useCallback(
(text: string) => submitMessage({ text }),
[submitMessage],
);
if (!conversation_starters.length) {
return null;
}
return (
<div className="mt-8 flex flex-wrap justify-center gap-3 px-4">
{conversation_starters
.slice(0, Constants.MAX_CONVO_STARTERS)
.map((text: string, index: number) => (
<button
key={index}
onClick={() => sendConversationStarter(text)}
className="relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border border-border-medium px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-surface-tertiary"
>
<p className="break-word line-clamp-3 overflow-hidden text-balance break-all text-text-secondary">
{text}
</p>
</button>
))}
</div>
);
};
export default ConversationStarters;

View file

@ -0,0 +1,87 @@
import React, { useCallback } from 'react';
import { Edit3, Check, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { BadgeItem } from '~/common';
import { useChatBadges, useLocalize } from '~/hooks';
import { Button, Badge } from '~/components/ui';
interface EditBadgesProps {
isEditingChatBadges: boolean;
handleCancelBadges: () => void;
handleSaveBadges: () => void;
setBadges: React.Dispatch<React.SetStateAction<Pick<BadgeItem, 'id'>[]>>;
}
const EditBadgesComponent = ({
isEditingChatBadges,
handleCancelBadges,
handleSaveBadges,
setBadges,
}: EditBadgesProps) => {
const localize = useLocalize();
const allBadges = useChatBadges() || [];
const unavailableBadges = allBadges.filter((badge) => !badge.isAvailable);
const handleRestoreBadge = useCallback(
(badgeId: string) => {
setBadges((prev: Pick<BadgeItem, 'id'>[]) => [...prev, { id: badgeId }]);
},
[setBadges],
);
if (!isEditingChatBadges) {
return null;
}
return (
<div className="m-1.5 flex flex-col overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
<div className="flex items-center gap-4 py-2 pl-3 pr-1.5 text-sm">
<span className="mt-0 flex size-6 flex-shrink-0 items-center justify-center">
<div className="icon-md">
<Edit3 className="icon-md" aria-hidden="true" />
</div>
</span>
<span className="text-token-text-secondary line-clamp-3 flex-1 py-0.5 font-semibold">
{localize('com_ui_save_badge_changes')}
</span>
<div className="flex h-8 gap-2">
<Button
size="sm"
variant="destructive"
aria-label="Cancel"
onClick={handleCancelBadges}
className="h-8"
>
<X className="icon-md" aria-hidden="true" />
</Button>
<Button
size="sm"
variant="submit"
aria-label="Save changes"
onClick={handleSaveBadges}
className="h-8 rounded-b-lg rounded-tr-xl"
>
<Check className="icon-md" aria-hidden="true" />
</Button>
</div>
</div>
{unavailableBadges && unavailableBadges.length > 0 && (
<div className="flex flex-wrap items-center gap-2 p-2">
{unavailableBadges.map((badge) => (
<div key={badge.id} className="badge-icon">
<Badge
icon={badge.icon as unknown as LucideIcon}
label={badge.label}
isAvailable={false}
isEditing={true}
onBadgeAction={() => handleRestoreBadge(badge.id)}
/>
</div>
))}
</div>
)}
</div>
);
};
export default React.memo(EditBadgesComponent);

View file

@ -1,55 +1,51 @@
import React, { useRef } from 'react';
import { FileUpload, TooltipAnchor } from '~/components/ui';
import { AttachmentIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { FileUpload, TooltipAnchor, AttachmentIcon } from '~/components';
import { useLocalize, useFileHandling } from '~/hooks';
import { cn } from '~/utils';
const AttachFile = ({
isRTL,
disabled,
handleFileChange,
}: {
isRTL: boolean;
disabled?: boolean | null;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}) => {
const AttachFile = ({ disabled }: { disabled?: boolean | null }) => {
const localize = useLocalize();
const inputRef = useRef<HTMLInputElement>(null);
const isUploadDisabled = disabled ?? false;
const { handleFileChange } = useFileHandling();
return (
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
<TooltipAnchor
role="button"
id="attach-file"
aria-label={localize('com_sidepanel_attach_files')}
disabled={isUploadDisabled}
className={cn(
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
isRTL ? 'bottom-2 right-2' : 'bottom-2 left-2',
)}
description={localize('com_sidepanel_attach_files')}
onKeyDownCapture={(e) => {
if (!inputRef.current) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
inputRef.current.value = '';
inputRef.current.click();
}
}}
onClick={() => {
if (!inputRef.current) {
return;
}
inputRef.current.value = '';
inputRef.current.click();
}}
>
<div className="flex w-full items-center justify-center gap-2">
<AttachmentIcon />
</div>
</TooltipAnchor>
id="attach-file"
disabled={isUploadDisabled}
render={
<button
aria-label={localize('com_sidepanel_attach_files')}
disabled={isUploadDisabled}
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
)}
onKeyDownCapture={(e) => {
if (!inputRef.current) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
inputRef.current.value = '';
inputRef.current.click();
}
}}
onClick={() => {
if (!inputRef.current) {
return;
}
inputRef.current.value = '';
inputRef.current.click();
}}
>
<div className="flex w-full items-center justify-center gap-2">
<AttachmentIcon />
</div>
</button>
}
/>
</FileUpload>
);
};

View file

@ -0,0 +1,44 @@
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useChatContext } from '~/Providers';
import { useGetFileConfig } from '~/data-provider';
import AttachFileMenu from './AttachFileMenu';
import AttachFile from './AttachFile';
import store from '~/store';
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const { conversation } = useChatContext();
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
| EndpointFileConfig
| undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
if (isAgents) {
return <AttachFileMenu disabled={disableInputs} />;
}
if (endpointSupportsFiles && !isUploadDisabled) {
return <AttachFile disabled={disableInputs} />;
}
return null;
}
export default memo(AttachFileChat);

View file

@ -2,25 +2,23 @@ import * as Ariakit from '@ariakit/react';
import React, { useRef, useState, useMemo } from 'react';
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { FileUpload, TooltipAnchor, DropdownPopup } from '~/components/ui';
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
import { useGetEndpointsQuery } from '~/data-provider';
import { AttachmentIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { useLocalize, useFileHandling } from '~/hooks';
import { cn } from '~/utils';
interface AttachFileProps {
isRTL: boolean;
disabled?: boolean | null;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>, toolResource?: string) => void;
}
const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
const AttachFile = ({ disabled }: AttachFileProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { handleFileChange } = useFileHandling();
const capabilities = useMemo(
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
@ -93,8 +91,7 @@ const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
id="attach-file-menu-button"
aria-label="Attach File Options"
className={cn(
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
isRTL ? 'bottom-2 right-2' : 'bottom-2 left-1 md:left-2',
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
)}
>
<div className="flex w-full items-center justify-center gap-2">
@ -115,17 +112,15 @@ const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
handleFileChange(e, toolResource);
}}
>
<div className="relative select-none">
<DropdownPopup
menuId="attach-file-menu"
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
modal={true}
trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"
/>
</div>
<DropdownPopup
menuId="attach-file-menu"
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
modal={true}
trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"
/>
</FileUpload>
);
};

View file

@ -0,0 +1,30 @@
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import { useChatContext } from '~/Providers';
import { useFileHandling } from '~/hooks';
import FileRow from './FileRow';
import store from '~/store';
function FileFormChat({ disableInputs }: { disableInputs: boolean }) {
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
const { endpoint: _endpoint } = conversation ?? { endpoint: null };
const { abortUpload } = useFileHandling();
const isRTL = chatDirection === 'rtl';
return (
<>
<FileRow
files={files}
setFiles={setFiles}
abortUpload={abortUpload}
setFilesLoading={setFilesLoading}
isRTL={isRTL}
Wrapper={({ children }) => <div className="mx-2 mt-2 flex flex-wrap gap-2">{children}</div>}
/>
</>
);
}
export default memo(FileFormChat);

View file

@ -1,80 +0,0 @@
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useGetFileConfig } from '~/data-provider';
import AttachFileMenu from './AttachFileMenu';
import { useChatContext } from '~/Providers';
import { useFileHandling } from '~/hooks';
import AttachFile from './AttachFile';
import FileRow from './FileRow';
import store from '~/store';
function FileFormWrapper({
children,
disableInputs,
}: {
disableInputs: boolean;
children?: React.ReactNode;
}) {
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
const { handleFileChange, abortUpload } = useFileHandling();
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const isRTL = chatDirection === 'rtl';
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
| EndpointFileConfig
| undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
const renderAttachFile = () => {
if (isAgents) {
return (
<AttachFileMenu
isRTL={isRTL}
disabled={disableInputs}
handleFileChange={handleFileChange}
/>
);
}
if (endpointSupportsFiles && !isUploadDisabled) {
return (
<AttachFile isRTL={isRTL} disabled={disableInputs} handleFileChange={handleFileChange} />
);
}
return null;
};
return (
<>
<FileRow
files={files}
setFiles={setFiles}
abortUpload={abortUpload}
setFilesLoading={setFilesLoading}
isRTL={isRTL}
Wrapper={({ children }) => <div className="mx-2 mt-2 flex flex-wrap gap-2">{children}</div>}
/>
{children}
{renderAttachFile()}
</>
);
}
export default memo(FileFormWrapper);

View file

@ -2,7 +2,7 @@ export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
return (
<button
type="button"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full bg-surface-secondary p-0.5 transition-colors duration-200 hover:bg-surface-primary z-50"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full bg-surface-secondary p-0.5 transition-colors duration-200 hover:bg-surface-primary"
onClick={onRemove}
>
<span>

View file

@ -2,17 +2,12 @@ import { useRecoilState } from 'recoil';
import { Settings2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { Root, Anchor } from '@radix-ui/react-popover';
import {
EModelEndpoint,
isParamEndpoint,
isAgentsEndpoint,
tConvoUpdateSchema,
} from 'librechat-data-provider';
import { EModelEndpoint, isParamEndpoint, tConvoUpdateSchema } from 'librechat-data-provider';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
import { useSetIndexOptions, useMediaQuery, useLocalize } from '~/hooks';
import { PluginStoreDialog, TooltipAnchor } from '~/components';
import { ModelSelect } from '~/components/Input/ModelSelect';
import { useSetIndexOptions, useLocalize } from '~/hooks';
import { useGetEndpointsQuery } from '~/data-provider';
import OptionsPopover from './OptionsPopover';
import PopoverButtons from './PopoverButtons';
@ -26,6 +21,7 @@ export default function HeaderOptions({
interfaceConfig?: Partial<TInterfaceConfig>;
}) {
const { data: endpointsConfig } = useGetEndpointsQuery();
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
store.showPluginStoreDialog,
@ -35,6 +31,15 @@ export default function HeaderOptions({
const { showPopover, conversation, setShowPopover } = useChatContext();
const { setOption } = useSetIndexOptions();
const { endpoint, conversationId } = conversation ?? {};
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? '');
const userProvidesKey = useMemo(
() => !!(endpointsConfig?.[endpoint ?? '']?.userProvide ?? false),
[endpointsConfig, endpoint],
);
const keyProvided = useMemo(
() => (userProvidesKey ? !!(keyExpiry.expiresAt ?? '') : true),
[keyExpiry.expiresAt, userProvidesKey],
);
const noSettings = useMemo<{ [key: string]: boolean }>(
() => ({
@ -71,14 +76,6 @@ export default function HeaderOptions({
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
<div className="z-[61] flex w-full items-center justify-center gap-2">
{interfaceConfig?.modelSelect === true && !isAgentsEndpoint(endpoint) && (
<ModelSelect
conversation={conversation}
setOption={setOption}
showAbove={false}
popover={true}
/>
)}
{!noSettings[endpoint] &&
interfaceConfig?.parameters === true &&
paramEndpoint === false && (

View file

@ -28,7 +28,7 @@ export default function Mention({
includeAssistants?: boolean;
}) {
const localize = useLocalize();
const assistantMap = useAssistantsMapContext();
const assistantsMap = useAssistantsMapContext();
const {
options,
presets,
@ -37,11 +37,11 @@ export default function Mention({
modelsConfig,
endpointsConfig,
assistantListMap,
} = useMentions({ assistantMap: assistantMap || {}, includeAssistants });
} = useMentions({ assistantMap: assistantsMap || {}, includeAssistants });
const { onSelectMention } = useSelectMention({
presets,
modelSpecs,
assistantMap,
assistantsMap,
endpointsConfig,
newConversation,
});
@ -65,7 +65,7 @@ export default function Mention({
setSearchValue('');
setOpen(false);
setShowMentionPopover(false);
onSelectMention(mention);
onSelectMention?.(mention);
if (textAreaRef.current) {
removeCharIfLast(textAreaRef.current, commandChar);
@ -158,11 +158,11 @@ export default function Mention({
};
return (
<div className="absolute bottom-14 z-10 w-full space-y-2">
<div className="absolute bottom-28 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700">
<input
// The user expects focus to transition to the input field when the popover is opened
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
placeholder={localize(placeholder)}

View file

@ -69,7 +69,9 @@ function PromptsCommand({
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
group.name
}: ${
(group.oneliner?.length ?? 0) > 0 ? group.oneliner : group.productionPrompt?.prompt ?? ''
(group.oneliner?.length ?? 0) > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '')
}`,
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
}));
@ -195,11 +197,11 @@ function PromptsCommand({
variableGroup={variableGroup}
setVariableDialogOpen={setVariableDialogOpen}
>
<div className="absolute bottom-14 z-10 w-full space-y-2">
<div className="absolute bottom-28 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
// The user expects focus to transition to the input field when the popover is opened
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
placeholder={localize('com_ui_command_usage_placeholder')}

View file

@ -24,7 +24,7 @@ const SubmitButton = React.memo(
id="send-button"
disabled={props.disabled}
className={cn(
'rounded-full bg-text-primary p-2 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
'rounded-full bg-text-primary p-1.5 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}
data-testid="send-button"
type="submit"
@ -34,7 +34,7 @@ const SubmitButton = React.memo(
</span>
</button>
}
></TooltipAnchor>
/>
);
}),
);

View file

@ -12,7 +12,7 @@ export default function StopButton({ stop, setShowStopButton }) {
<button
type="button"
className={cn(
'rounded-full bg-text-primary p-2 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
'rounded-full bg-text-primary p-1.5 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}
aria-label={localize('com_nav_stop_generating')}
onClick={(e) => {

View file

@ -1,38 +0,0 @@
import { MessageCircleDashed, X } from 'lucide-react';
import { useLocalize } from '~/hooks';
interface TemporaryChatProps {
isTemporaryChat: boolean;
setIsTemporaryChat: (value: boolean) => void;
}
export const TemporaryChat = ({ isTemporaryChat, setIsTemporaryChat }: TemporaryChatProps) => {
const localize = useLocalize();
if (!isTemporaryChat) {
return null;
}
return (
<div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
<div className="flex items-start gap-4 py-2.5 pl-3 pr-1.5 text-sm">
<span className="mt-0 flex h-6 w-6 flex-shrink-0 items-center justify-center">
<div className="icon-md">
<MessageCircleDashed className="icon-md" aria-hidden="true" />
</div>
</span>
<span className="text-token-text-secondary line-clamp-3 flex-1 py-0.5 font-semibold">
{localize('com_ui_temporary_chat')}
</span>
<button
className="text-token-text-secondary flex-shrink-0"
type="button"
aria-label="Close temporary chat"
onClick={() => setIsTemporaryChat(false)}
>
<X className="pr-1" />
</button>
</div>
</div>
);
};

View file

@ -13,7 +13,7 @@ export default function TextareaHeader({
return null;
}
return (
<div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
<div className="m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
<AddedConvo addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
</div>
);