mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-25 12:48:53 +01:00
💬 fix: Temporary Chat PR's broken components and improved UI (#5705)
* 💬 fix: Temporary Chat PR's broken components and improved UI * 💬 fix: bring back hover effect on AudioRecorder button * style: adjust position of Mention component popover * refactor: PromptsCommand typing and style position * refactor: virtualize mention UI --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
63afb317c6
commit
70e410f38b
11 changed files with 196 additions and 86 deletions
|
|
@ -13,7 +13,6 @@ export default function AudioRecorder({
|
|||
methods,
|
||||
textAreaRef,
|
||||
isSubmitting,
|
||||
isTemporary = false,
|
||||
}: {
|
||||
isRTL: boolean;
|
||||
disabled: boolean;
|
||||
|
|
@ -21,7 +20,6 @@ export default function AudioRecorder({
|
|||
methods: ReturnType<typeof useChatFormContext>;
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
isSubmitting: boolean;
|
||||
isTemporary?: boolean;
|
||||
}) {
|
||||
const { setValue, reset } = methods;
|
||||
const localize = useLocalize();
|
||||
|
|
@ -78,11 +76,7 @@ export default function AudioRecorder({
|
|||
if (isLoading === true) {
|
||||
return <Spinner className="stroke-gray-700 dark:stroke-gray-300" />;
|
||||
}
|
||||
return (
|
||||
<ListeningIcon
|
||||
className={cn(isTemporary ? 'stroke-white' : 'stroke-gray-700 dark:stroke-gray-300')}
|
||||
/>
|
||||
);
|
||||
return <ListeningIcon className="stroke-gray-700 dark:stroke-gray-300" />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ 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 TextareaHeader from './TextareaHeader';
|
||||
import PromptsCommand from './PromptsCommand';
|
||||
import AudioRecorder from './AudioRecorder';
|
||||
|
|
@ -47,7 +48,7 @@ const ChatForm = ({ index = 0 }) => {
|
|||
const TextToSpeech = useRecoilValue(store.textToSpeech);
|
||||
const automaticPlayback = useRecoilValue(store.automaticPlayback);
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const isTemporary = useRecoilValue(store.isTemporary);
|
||||
const [isTemporaryChat, setIsTemporaryChat] = useRecoilState<boolean>(store.isTemporary);
|
||||
|
||||
const isSearching = useRecoilValue(store.isSearching);
|
||||
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
|
||||
|
|
@ -145,11 +146,8 @@ const ChatForm = ({ index = 0 }) => {
|
|||
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
|
||||
|
||||
const baseClasses = cn(
|
||||
'md:py-3.5 m-0 w-full resize-none bg-surface-tertiary py-[13px] placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
|
||||
'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]',
|
||||
isTemporary
|
||||
? 'bg-gray-600 text-white placeholder-white/20'
|
||||
: 'bg-surface-tertiary placeholder-black/50 dark:placeholder-white/50',
|
||||
);
|
||||
|
||||
const uploadActive = endpointSupportsFiles && !isUploadDisabled;
|
||||
|
|
@ -185,12 +183,11 @@ const ChatForm = ({ index = 0 }) => {
|
|||
/>
|
||||
)}
|
||||
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
|
||||
<div
|
||||
className={cn(
|
||||
'transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl text-text-primary ',
|
||||
isTemporary ? 'text-white' : 'duration-200',
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||
<FileFormWrapper disableInputs={disableInputs}>
|
||||
{endpoint && (
|
||||
|
|
@ -243,7 +240,6 @@ const ChatForm = ({ index = 0 }) => {
|
|||
textAreaRef={textAreaRef}
|
||||
disabled={!!disableInputs}
|
||||
isSubmitting={isSubmitting}
|
||||
isTemporary={isTemporary}
|
||||
/>
|
||||
)}
|
||||
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { AutoSizer, List } from 'react-virtualized';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type { MentionOption, ConvoGenerator } from '~/common';
|
||||
|
|
@ -9,6 +10,8 @@ import { useLocalize, useCombobox } from '~/hooks';
|
|||
import { removeCharIfLast } from '~/utils';
|
||||
import MentionItem from './MentionItem';
|
||||
|
||||
const ROW_HEIGHT = 40;
|
||||
|
||||
export default function Mention({
|
||||
setShowMentionPopover,
|
||||
newConversation,
|
||||
|
|
@ -121,8 +124,41 @@ export default function Mention({
|
|||
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
|
||||
}, [type, activeIndex]);
|
||||
|
||||
const rowRenderer = ({
|
||||
index,
|
||||
key,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
key: string;
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const mention = matches[index] as MentionOption;
|
||||
return (
|
||||
<MentionItem
|
||||
type={type}
|
||||
index={index}
|
||||
key={key}
|
||||
style={style}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
handleSelect(mention);
|
||||
}}
|
||||
name={mention.label ?? ''}
|
||||
icon={mention.icon}
|
||||
description={mention.description}
|
||||
isActive={index === activeIndex}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-16 z-10 w-full space-y-2">
|
||||
<div className="absolute bottom-14 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
|
||||
|
|
@ -167,27 +203,20 @@ export default function Mention({
|
|||
}}
|
||||
/>
|
||||
{open && (
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
{(matches as MentionOption[]).map((mention, index) => (
|
||||
<MentionItem
|
||||
type={type}
|
||||
index={index}
|
||||
key={`${mention.value}-${index}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
handleSelect(mention);
|
||||
}}
|
||||
name={mention.label ?? ''}
|
||||
icon={mention.icon}
|
||||
description={mention.description}
|
||||
isActive={index === activeIndex}
|
||||
/>
|
||||
))}
|
||||
<div className="max-h-40">
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<List
|
||||
width={width}
|
||||
overscanRowCount={5}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
rowCount={matches.length}
|
||||
rowRenderer={rowRenderer}
|
||||
scrollToIndex={activeIndex}
|
||||
height={Math.min(matches.length * ROW_HEIGHT, 160)}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface MentionItemProps {
|
|||
icon?: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
description?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function MentionItem({
|
||||
|
|
@ -19,21 +20,28 @@ export default function MentionItem({
|
|||
icon,
|
||||
isActive,
|
||||
description,
|
||||
style,
|
||||
type = 'mention',
|
||||
}: MentionItemProps) {
|
||||
return (
|
||||
<button tabIndex={index} onClick={onClick} id={`${type}-item-${index}`} className="w-full">
|
||||
<button
|
||||
tabIndex={index}
|
||||
onClick={onClick}
|
||||
id={`${type}-item-${index}`}
|
||||
className="w-full"
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'text-token-text-primary bg-token-main-surface-secondary group flex h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium hover:bg-surface-secondary',
|
||||
isActive ? 'bg-surface-active' : 'bg-transparent',
|
||||
isActive === true ? 'bg-surface-active' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">{icon}</div>
|
||||
<div className="flex min-w-0 flex-grow items-center justify-between">
|
||||
<div className="truncate">
|
||||
<span className="font-medium">{name}</span>
|
||||
{description ? (
|
||||
{description != null && description ? (
|
||||
<span className="text-token-text-tertiary ml-2 text-sm font-light">
|
||||
{description}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useRef, useEffect, useMemo, memo, useCallback } from 'react';
|
||||
import { AutoSizer, List } from 'react-virtualized';
|
||||
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
|
|
@ -42,6 +43,8 @@ const PopoverContainer = memo(
|
|||
},
|
||||
);
|
||||
|
||||
const ROW_HEIGHT = 40;
|
||||
|
||||
function PromptsCommand({
|
||||
index,
|
||||
textAreaRef,
|
||||
|
|
@ -63,8 +66,10 @@ function PromptsCommand({
|
|||
const mappedArray = data.map((group) => ({
|
||||
id: group._id,
|
||||
value: group.command ?? group.name,
|
||||
label: `${group.command ? `/${group.command} - ` : ''}${group.name}: ${
|
||||
group.oneliner?.length ? group.oneliner : group.productionPrompt?.prompt ?? ''
|
||||
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
|
||||
group.name
|
||||
}: ${
|
||||
(group.oneliner?.length ?? 0) > 0 ? group.oneliner : group.productionPrompt?.prompt ?? ''
|
||||
}`,
|
||||
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
|
||||
}));
|
||||
|
|
@ -85,12 +90,12 @@ function PromptsCommand({
|
|||
const [variableGroup, setVariableGroup] = useState<TPromptGroup | null>(null);
|
||||
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
|
||||
|
||||
const prompts = useMemo(() => data?.promptGroups ?? [], [data]);
|
||||
const promptsMap = useMemo(() => data?.promptsMap ?? {}, [data]);
|
||||
const prompts = useMemo(() => data?.promptGroups, [data]);
|
||||
const promptsMap = useMemo(() => data?.promptsMap, [data]);
|
||||
|
||||
const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({
|
||||
value: '',
|
||||
options: prompts,
|
||||
options: prompts ?? [],
|
||||
});
|
||||
|
||||
const handleSelect = useCallback(
|
||||
|
|
@ -107,22 +112,20 @@ function PromptsCommand({
|
|||
removeCharIfLast(textAreaRef.current, commandChar);
|
||||
}
|
||||
|
||||
const isValidPrompt = mention && promptsMap && promptsMap[mention.id];
|
||||
|
||||
if (!isValidPrompt) {
|
||||
const group = promptsMap?.[mention.id];
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = promptsMap[mention.id];
|
||||
const hasVariables = detectVariables(group.productionPrompt?.prompt ?? '');
|
||||
if (group && hasVariables) {
|
||||
if (hasVariables) {
|
||||
if (e && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
}
|
||||
setVariableGroup(group);
|
||||
setVariableDialogOpen(true);
|
||||
return;
|
||||
} else if (group) {
|
||||
} else {
|
||||
submitPrompt(group.productionPrompt?.prompt ?? '');
|
||||
}
|
||||
},
|
||||
|
|
@ -154,6 +157,37 @@ function PromptsCommand({
|
|||
return null;
|
||||
}
|
||||
|
||||
const rowRenderer = ({
|
||||
index,
|
||||
key,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
key: string;
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const mention = matches[index] as PromptOption;
|
||||
return (
|
||||
<MentionItem
|
||||
index={index}
|
||||
type="prompt"
|
||||
key={key}
|
||||
style={style}
|
||||
onClick={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
handleSelect(mention);
|
||||
}}
|
||||
name={mention.label ?? ''}
|
||||
icon={mention.icon}
|
||||
description={mention.description}
|
||||
isActive={index === activeIndex}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverContainer
|
||||
index={index}
|
||||
|
|
@ -161,7 +195,7 @@ function PromptsCommand({
|
|||
variableGroup={variableGroup}
|
||||
setVariableDialogOpen={setVariableDialogOpen}
|
||||
>
|
||||
<div className="absolute bottom-16 z-10 w-full space-y-2">
|
||||
<div className="absolute bottom-14 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
|
||||
|
|
@ -213,24 +247,23 @@ function PromptsCommand({
|
|||
}
|
||||
|
||||
if (!isLoading && open) {
|
||||
return (matches as PromptOption[]).map((mention, index) => (
|
||||
<MentionItem
|
||||
index={index}
|
||||
type="prompt"
|
||||
key={`${mention.value}-${index}`}
|
||||
onClick={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
handleSelect(mention);
|
||||
}}
|
||||
name={mention.label ?? ''}
|
||||
icon={mention.icon}
|
||||
description={mention.description}
|
||||
isActive={index === activeIndex}
|
||||
/>
|
||||
));
|
||||
return (
|
||||
<div className="max-h-40">
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<List
|
||||
width={width}
|
||||
overscanRowCount={5}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
rowCount={matches.length}
|
||||
rowRenderer={rowRenderer}
|
||||
scrollToIndex={activeIndex}
|
||||
height={Math.min(matches.length * ROW_HEIGHT, 160)}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
|
|
|||
38
client/src/components/Chat/Input/TemporaryChat.tsx
Normal file
38
client/src/components/Chat/Input/TemporaryChat.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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" />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue