Merge branch 'main' into feat/multi-lang-Terms-of-service

This commit is contained in:
Ruben Talstra 2025-05-14 19:52:56 +02:00 committed by GitHub
commit fcc1eb45f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
387 changed files with 17821 additions and 7594 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.7.7",
"version": "v0.7.8",
"description": "",
"type": "module",
"scripts": {
@ -29,7 +29,7 @@
"homepage": "https://librechat.ai",
"dependencies": {
"@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.15",
"@ariakit/react-core": "^0.4.17",
"@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^9.2.2",
"@dicebear/core": "^9.2.2",
@ -73,7 +73,6 @@
"lodash": "^4.17.21",
"lucide-react": "^0.394.0",
"match-sorter": "^6.3.4",
"msedge-tts": "^2.0.0",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2",
"react": "^18.2.0",
@ -87,7 +86,7 @@
"react-i18next": "^15.4.0",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.7",
"react-resizable-panels": "^2.1.8",
"react-router-dom": "^6.11.2",
"react-speech-recognition": "^3.10.0",
"react-textarea-autosize": "^8.4.0",
@ -119,6 +118,7 @@
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.5.14",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "^4.17.15",
"@types/node": "^20.3.0",
"@types/react": "^18.2.11",
"@types/react-dom": "^18.2.4",
@ -141,7 +141,7 @@
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5",
"typescript": "^5.3.3",
"vite": "^6.2.3",
"vite": "^6.3.4",
"vite-plugin-compression2": "^1.3.3",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^0.21.2"

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor" class="bg-white"><path d="m3.005 8.858 8.783 12.544h3.904L6.908 8.858zM6.905 15.825 3 21.402h3.907l1.951-2.788zM16.585 2l-6.75 9.64 1.953 2.79L20.492 2zM17.292 7.965v13.437h3.2V3.395z"></path></svg>

Before

Width:  |  Height:  |  Size: 315 B

View file

@ -1,6 +0,0 @@
import { createContext, useContext } from 'react';
import useSearch from '~/hooks/Conversations/useSearch';
type SearchContextType = ReturnType<typeof useSearch>;
export const SearchContext = createContext<SearchContextType>({} as SearchContextType);
export const useSearchContext = () => useContext(SearchContext);

View file

@ -4,7 +4,6 @@ export { default as AgentsProvider } from './AgentsContext';
export * from './ChatContext';
export * from './ShareContext';
export * from './ToastContext';
export * from './SearchContext';
export * from './FileMapContext';
export * from './AddedChatContext';
export * from './EditorContext';

View file

@ -29,7 +29,6 @@ export enum STTEndpoints {
export enum TTSEndpoints {
browser = 'browser',
edge = 'edge',
external = 'external',
}
@ -307,11 +306,14 @@ export type TAskProps = {
export type TOptions = {
editedMessageId?: string | null;
editedText?: string | null;
resubmitFiles?: boolean;
isRegenerate?: boolean;
isContinued?: boolean;
isEdited?: boolean;
overrideMessages?: t.TMessage[];
/** This value is only true when the user submits a message with "Save & Submit" for a user-created message */
isResubmission?: boolean;
/** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */
overrideFiles?: t.TMessage['files'];
};
export type TAskFunction = (props: TAskProps, options?: TOptions) => void;
@ -506,7 +508,10 @@ export interface ModelItemProps {
className?: string;
}
export type ContextType = { navVisible: boolean; setNavVisible: (visible: boolean) => void };
export type ContextType = {
navVisible: boolean;
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
};
export interface SwitcherProps {
endpoint?: t.EModelEndpoint | null;
@ -549,7 +554,8 @@ export type TResData = TBaseResData & {
responseMessage: t.TMessage;
};
export type TFinalResData = TBaseResData & {
export type TFinalResData = Omit<TBaseResData, 'conversation'> & {
conversation: Partial<t.TConversation> & Pick<t.TConversation, 'conversationId'>;
requestMessage?: t.TMessage;
responseMessage?: t.TMessage;
};

View file

@ -2,6 +2,7 @@ import React, { useEffect, useCallback, useRef, useState } from 'react';
import throttle from 'lodash/throttle';
import { visit } from 'unist-util-visit';
import { useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import type { Pluggable } from 'unified';
import type { Artifact } from '~/common';
import { useMessageContext, useArtifactContext } from '~/Providers';
@ -11,7 +12,16 @@ import ArtifactButton from './ArtifactButton';
export const artifactPlugin: Pluggable = () => {
return (tree) => {
visit(tree, ['textDirective', 'leafDirective', 'containerDirective'], (node) => {
visit(tree, ['textDirective', 'leafDirective', 'containerDirective'], (node, index, parent) => {
if (node.type === 'textDirective') {
const replacementText = `:${node.name}`;
if (parent && Array.isArray(parent.children) && typeof index === 'number') {
parent.children[index] = {
type: 'text',
value: replacementText,
};
}
}
if (node.name !== 'artifact') {
return;
}
@ -25,14 +35,18 @@ export const artifactPlugin: Pluggable = () => {
};
};
const defaultTitle = 'untitled';
const defaultType = 'unknown';
const defaultIdentifier = 'lc-no-identifier';
export function Artifact({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
node,
...props
}: Artifact & {
children: React.ReactNode | { props: { children: React.ReactNode } };
node: unknown;
}) {
const location = useLocation();
const { messageId } = useMessageContext();
const { getNextIndex, resetCounter } = useArtifactContext();
const artifactIndex = useRef(getNextIndex(false)).current;
@ -50,15 +64,18 @@ export function Artifact({
const content = extractContent(props.children);
logger.log('artifacts', 'updateArtifact: content.length', content.length);
const title = props.title ?? 'Untitled Artifact';
const type = props.type ?? 'unknown';
const identifier = props.identifier ?? 'no-identifier';
const title = props.title ?? defaultTitle;
const type = props.type ?? defaultType;
const identifier = props.identifier ?? defaultIdentifier;
const artifactKey = `${identifier}_${type}_${title}_${messageId}`
.replace(/\s+/g, '_')
.toLowerCase();
throttledUpdateRef.current(() => {
const now = Date.now();
if (artifactKey === `${defaultIdentifier}_${defaultType}_${defaultTitle}_${messageId}`) {
return;
}
const currentArtifact: Artifact = {
id: artifactKey,
@ -71,6 +88,10 @@ export function Artifact({
lastUpdateTime: now,
};
if (!location.pathname.includes('/c/')) {
return setArtifact(currentArtifact);
}
setArtifacts((prevArtifacts) => {
if (
prevArtifacts?.[artifactKey] != null &&
@ -95,6 +116,7 @@ export function Artifact({
props.identifier,
messageId,
artifactIndex,
location.pathname,
]);
useEffect(() => {

View file

@ -1,14 +1,52 @@
import { useSetRecoilState } from 'recoil';
import { useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';
import { useLocation } from 'react-router-dom';
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
import type { Artifact } from '~/common';
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { getFileType, logger } from '~/utils';
import { useLocalize } from '~/hooks';
import { getFileType } from '~/utils';
import store from '~/store';
const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
const localize = useLocalize();
const setVisible = useSetRecoilState(store.artifactsVisible);
const setArtifactId = useSetRecoilState(store.currentArtifactId);
const location = useLocation();
const setVisible = useSetRecoilState(store.artifactsVisibility);
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
const debouncedSetVisibleRef = useRef(
debounce((artifactToSet: Artifact) => {
logger.log(
'artifacts_visibility',
'Setting artifact to visible state from Artifact button',
artifactToSet,
);
setVisibleArtifacts((prev) => ({
...prev,
[artifactToSet.id]: artifactToSet,
}));
}, 750),
);
useEffect(() => {
if (artifact == null || artifact?.id == null || artifact.id === '') {
return;
}
if (!location.pathname.includes('/c/')) {
return;
}
const debouncedSetVisible = debouncedSetVisibleRef.current;
debouncedSetVisible(artifact);
return () => {
debouncedSetVisible.cancel();
};
}, [artifact, location.pathname]);
if (artifact === null || artifact === undefined) {
return null;
}
@ -19,12 +57,21 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
<button
type="button"
onClick={() => {
setArtifactId(artifact.id);
if (!location.pathname.includes('/c/')) {
return;
}
resetCurrentArtifactId();
setVisible(true);
if (artifacts?.[artifact.id] == null) {
setArtifacts(visibleArtifacts);
}
setTimeout(() => {
setCurrentArtifactId(artifact.id);
}, 15);
}}
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
>
<div className="w-fit bg-surface-tertiary p-2 ">
<div className="w-fit bg-surface-tertiary p-2">
<div className="flex flex-row items-center gap-2">
<FilePreview fileType={fileType} className="relative" />
<div className="overflow-hidden text-left">

View file

@ -5,7 +5,8 @@ import {
SandpackCodeEditor,
SandpackProvider as StyledProvider,
} from '@codesandbox/sandpack-react';
import { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
import type { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { ArtifactFiles, Artifact } from '~/common';
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
@ -65,8 +66,11 @@ const CodeEditor = ({
if (isMutating) {
return;
}
if (artifact.index == null) {
return;
}
const currentCode = sandpack.files['/' + fileKey].code;
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
if (currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim()) {
setCurrentCode(currentCode);
@ -131,9 +135,9 @@ export const ArtifactCodeEditor = memo(function ({
}
return {
...sharedOptions,
bundlerURL: config.bundlerURL,
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
};
}, [config]);
}, [config, template]);
if (Object.keys(files).length === 0) {
return null;

View file

@ -5,27 +5,27 @@ import {
SandpackProviderProps,
} from '@codesandbox/sandpack-react/unstyled';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { TStartupConfig } from 'librechat-data-provider';
import type { ArtifactFiles } from '~/common';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
import { useGetStartupConfig } from '~/data-provider';
import { useEditorContext } from '~/Providers';
export const ArtifactPreview = memo(function ({
files,
fileKey,
previewRef,
sharedProps,
template,
sharedProps,
previewRef,
currentCode,
startupConfig,
}: {
files: ArtifactFiles;
fileKey: string;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
currentCode?: string;
startupConfig?: TStartupConfig;
}) {
const { currentCode } = useEditorContext();
const { data: config } = useGetStartupConfig();
const artifactFiles = useMemo(() => {
if (Object.keys(files).length === 0) {
return files;
@ -43,14 +43,16 @@ export const ArtifactPreview = memo(function ({
}, [currentCode, files, fileKey]);
const options: typeof sharedOptions = useMemo(() => {
if (!config) {
if (!startupConfig) {
return sharedOptions;
}
return {
const _options: typeof sharedOptions = {
...sharedOptions,
bundlerURL: config.bundlerURL,
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
};
}, [config]);
return _options;
}, [startupConfig, template]);
if (Object.keys(artifactFiles).length === 0) {
return null;

View file

@ -1,11 +1,13 @@
import { useRef } from 'react';
import { useRef, useEffect } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import type { Artifact } from '~/common';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview';
import { useEditorContext } from '~/Providers';
import { cn } from '~/utils';
export default function ArtifactTabs({
@ -21,6 +23,16 @@ export default function ArtifactTabs({
editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
}) {
const { currentCode, setCurrentCode } = useEditorContext();
const { data: startupConfig } = useGetStartupConfig();
const lastIdRef = useRef<string | null>(null);
useEffect(() => {
if (artifact.id !== lastIdRef.current) {
setCurrentCode(undefined);
}
lastIdRef.current = artifact.id;
}, [setCurrentCode, artifact.id]);
const content = artifact.content ?? '';
const contentRef = useRef<HTMLDivElement>(null);
useAutoScroll({ ref: contentRef, content, isSubmitting });
@ -53,6 +65,8 @@ export default function ArtifactTabs({
template={template}
previewRef={previewRef}
sharedProps={sharedProps}
currentCode={currentCode}
startupConfig={startupConfig}
/>
</Tabs.Content>
</>

View file

@ -1,7 +1,7 @@
import { useRef, useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, X } from 'lucide-react';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
@ -18,7 +18,7 @@ export default function Artifacts() {
const previewRef = useRef<SandpackPreviewRef>();
const [isVisible, setIsVisible] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisible);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
useEffect(() => {
setIsVisible(true);
@ -48,37 +48,26 @@ export default function Artifacts() {
setTimeout(() => setIsRefreshing(false), 750);
};
const closeArtifacts = () => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), 300);
};
return (
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
{/* Main Parent */}
<div className="flex h-full w-full items-center justify-center">
{/* Main Container */}
<div
className={`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-300 ease-in-out ${
isVisible
? 'translate-x-0 scale-100 opacity-100'
: 'translate-x-full scale-95 opacity-0'
className={`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-500 ease-in-out ${
isVisible ? 'scale-100 opacity-100 blur-0' : 'scale-105 opacity-0 blur-sm'
}`}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
<div className="flex items-center">
<button
className="mr-2 text-text-secondary"
onClick={() => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), 300);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />
</svg>
<button className="mr-2 text-text-secondary" onClick={closeArtifacts}>
<ArrowLeft className="h-4 w-4" />
</button>
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
</div>
@ -118,22 +107,8 @@ export default function Artifacts() {
{localize('com_ui_code')}
</Tabs.Trigger>
</Tabs.List>
<button
className="text-text-secondary"
onClick={() => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), 300);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
</svg>
<button className="text-text-secondary" onClick={closeArtifacts}>
<X className="h-4 w-4" />
</button>
</div>
</div>
@ -149,29 +124,13 @@ export default function Artifacts() {
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
<div className="flex items-center">
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z" />
</svg>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-xs">{`${currentIndex + 1} / ${
orderedArtifactIds.length
}`}</span>
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z" />
</svg>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2">

View file

@ -35,7 +35,7 @@ export const CodeMarkdown = memo(
const [userScrolled, setUserScrolled] = useState(false);
const currentContent = content;
const rehypePlugins = [
[rehypeKatex, { output: 'mathml' }],
[rehypeKatex],
[
rehypeHighlight,
{

View file

@ -2,9 +2,8 @@
import { useEffect, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessageAudio } from '~/common';
import { useLocalize, useTTSBrowser, useTTSEdge, useTTSExternal } from '~/hooks';
import { VolumeIcon, VolumeMuteIcon, Spinner } from '~/components/svg';
import { useToastContext } from '~/Providers/ToastContext';
import { useLocalize, useTTSBrowser, useTTSExternal } from '~/hooks';
import { VolumeIcon, VolumeMuteIcon, Spinner } from '~/components';
import { logger } from '~/utils';
import store from '~/store';
@ -85,97 +84,6 @@ export function BrowserTTS({ isLast, index, messageId, content, className }: TMe
);
}
export function EdgeTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
const localize = useLocalize();
const playbackRate = useRecoilValue(store.playbackRate);
const isBrowserSupported = useMemo(
() => typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported('audio/mpeg'),
[],
);
const { showToast } = useToastContext();
const { toggleSpeech, isSpeaking, isLoading, audioRef } = useTTSEdge({
isLast,
index,
messageId,
content,
});
const renderIcon = (size: string) => {
if (isLoading === true) {
return <Spinner size={size} />;
}
if (isSpeaking === true) {
return <VolumeMuteIcon size={size} />;
}
return <VolumeIcon size={size} />;
};
useEffect(() => {
const messageAudio = document.getElementById(`audio-${messageId}`) as HTMLAudioElement | null;
if (!messageAudio) {
return;
}
if (playbackRate != null && playbackRate > 0 && messageAudio.playbackRate !== playbackRate) {
messageAudio.playbackRate = playbackRate;
}
}, [audioRef, isSpeaking, playbackRate, messageId]);
logger.log(
'MessageAudio: audioRef.current?.src, audioRef.current',
audioRef.current?.src,
audioRef.current,
);
return (
<>
<button
className={className}
onClickCapture={() => {
if (!isBrowserSupported) {
showToast({
message: localize('com_nav_tts_unsupported_error'),
status: 'error',
});
return;
}
if (audioRef.current) {
audioRef.current.muted = false;
}
toggleSpeech();
}}
type="button"
title={isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
>
{renderIcon('19')}
</button>
{isBrowserSupported ? (
<audio
ref={audioRef}
controls
preload="none"
controlsList="nodownload nofullscreen noremoteplayback"
style={{
position: 'absolute',
overflow: 'hidden',
display: 'none',
height: '0px',
width: '0px',
}}
src={audioRef.current?.src}
onError={(error) => {
logger.error('Error fetching audio:', error);
}}
id={`audio-${messageId}`}
autoPlay
/>
) : null}
</>
);
}
export function ExternalTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
const localize = useLocalize();
const playbackRate = useRecoilValue(store.playbackRate);

View file

@ -1,39 +1,11 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import type { Option } from '~/common';
import { useLocalize, useTTSBrowser, useTTSEdge, useTTSExternal } from '~/hooks';
import { useLocalize, useTTSBrowser, useTTSExternal } from '~/hooks';
import { Dropdown } from '~/components/ui';
import { logger } from '~/utils';
import store from '~/store';
export function EdgeVoiceDropdown() {
const localize = useLocalize();
const { voices = [] } = useTTSEdge();
const [voice, setVoice] = useRecoilState(store.voice);
const handleVoiceChange = (newValue?: string | Option) => {
logger.log('Edge Voice changed:', newValue);
const newVoice = typeof newValue === 'string' ? newValue : newValue?.value;
if (newVoice != null) {
return setVoice(newVoice.toString());
}
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_voice_select')}</div>
<Dropdown
key={`edge-voice-dropdown-${voices.length}`}
value={voice ?? ''}
options={voices}
onChange={handleVoiceChange}
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="EdgeVoiceDropdown"
/>
</div>
);
}
export function BrowserVoiceDropdown() {
const localize = useLocalize();
const { voices = [] } = useTTSBrowser();
@ -57,6 +29,7 @@ export function BrowserVoiceDropdown() {
onChange={handleVoiceChange}
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="BrowserVoiceDropdown"
className="z-50"
/>
</div>
);
@ -85,6 +58,7 @@ export function ExternalVoiceDropdown() {
onChange={handleVoiceChange}
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="ExternalVoiceDropdown"
className="z-50"
/>
</div>
);

View file

@ -34,19 +34,22 @@ const BookmarkItem: FC<MenuItemProps> = ({ tag, selected, handleSubmit, icon, ..
if (icon != null) {
return icon;
}
if (isLoading) {
return <Spinner className="size-4" />;
}
if (selected) {
return <BookmarkFilledIcon className="size-4" />;
}
return <BookmarkIcon className="size-4" />;
};
return (
<MenuItem
aria-label={tag as string}
className="group flex w-full gap-2 rounded-lg p-2.5 text-sm text-text-primary transition-colors duration-200 focus:outline-none data-[focus]:bg-surface-secondary data-[focus]:ring-2 data-[focus]:ring-primary"
className="group flex w-full gap-2 rounded-lg p-2.5 text-sm text-text-primary transition-colors duration-200 focus:outline-none data-[focus]:bg-surface-hover data-[focus-visible]:ring-2 data-[focus-visible]:ring-primary"
{...rest}
as="button"
onClick={clickHandler}

View file

@ -12,7 +12,6 @@ function AddMultiConvo() {
const localize = useLocalize();
const clickHandler = () => {
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
setAddedConvo({
...convo,
@ -42,7 +41,7 @@ function AddMultiConvo() {
role="button"
onClick={clickHandler}
data-testid="parameters-button"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<PlusCircle size={16} aria-label="Plus Icon" />
</TooltipAnchor>

View file

@ -2,22 +2,33 @@ import { memo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
import { Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import MessagesView from './Messages/MessagesView';
import { Spinner } from '~/components/svg';
import Presentation from './Presentation';
import { buildTree, cn } from '~/utils';
import ChatForm from './Input/ChatForm';
import { buildTree } from '~/utils';
import Landing from './Landing';
import Header from './Header';
import Footer from './Footer';
import store from '~/store';
function LoadingSpinner() {
return (
<div className="relative flex-1 overflow-hidden overflow-y-auto">
<div className="relative flex h-full items-center justify-center">
<Spinner className="text-text-primary" />
</div>
</div>
);
}
function ChatView({ index = 0 }: { index?: number }) {
const { conversationId } = useParams();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
@ -48,16 +59,15 @@ function ChatView({ index = 0 }: { index?: number }) {
});
let content: JSX.Element | null | undefined;
const isLandingPage = !messagesTree || messagesTree.length === 0;
const isLandingPage =
(!messagesTree || messagesTree.length === 0) &&
(conversationId === Constants.NEW_CONVO || !conversationId);
const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null;
if (isLoading && conversationId !== 'new') {
content = (
<div className="relative flex-1 overflow-hidden overflow-y-auto">
<div className="relative flex h-full items-center justify-center">
<Spinner className="text-text-primary" />
</div>
</div>
);
if (isLoading && conversationId !== Constants.NEW_CONVO) {
content = <LoadingSpinner />;
} else if ((isLoading || isNavigating) && !isLandingPage) {
content = <LoadingSpinner />;
} else if (!isLandingPage) {
content = <MessagesView messagesTree={messagesTree} />;
} else {
@ -71,27 +81,28 @@ function ChatView({ index = 0 }: { index?: number }) {
<Presentation>
<div className="flex h-full w-full flex-col">
{!isLoading && <Header />}
{isLandingPage ? (
<>
<div className="flex flex-1 flex-col items-center justify-end sm:justify-center">
{content}
<div className="w-full max-w-3xl transition-all duration-200 xl:max-w-4xl">
<ChatForm index={index} />
<ConversationStarters />
</div>
</div>
<Footer />
</>
) : (
<div className="flex h-full flex-col overflow-y-auto">
<>
<div
className={cn(
'flex flex-col',
isLandingPage
? 'flex-1 items-center justify-end sm:justify-center'
: 'h-full overflow-y-auto',
)}
>
{content}
<div className="w-full">
<div
className={cn(
'w-full',
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
<ChatForm index={index} />
<Footer />
{isLandingPage ? <ConversationStarters /> : <Footer />}
</div>
</div>
)}
{isLandingPage && <Footer />}
</>
</div>
</Presentation>
</AddedChatContext.Provider>

View file

@ -44,15 +44,6 @@ export default function ExportAndShareMenu({
};
const dropdownItems: t.MenuItemProps[] = [
{
label: localize('com_endpoint_export'),
onClick: exportHandler,
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
hideOnClick: false,
ref: exportButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_ui_share'),
onClick: shareHandler,
@ -63,6 +54,15 @@ export default function ExportAndShareMenu({
ref: shareButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_endpoint_export'),
onClick: exportHandler,
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
hideOnClick: false,
ref: exportButtonRef,
render: (props) => <button {...props} />,
},
];
return (
@ -70,6 +70,7 @@ export default function ExportAndShareMenu({
<DropdownPopup
menuId={menuId}
focusLoop={true}
unmountOnHide={true}
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
@ -79,9 +80,9 @@ export default function ExportAndShareMenu({
<Ariakit.MenuButton
id="export-menu-button"
aria-label="Export options"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Upload
<Share2
className="icon-md text-text-secondary"
aria-hidden="true"
focusable="false"

View file

@ -3,7 +3,7 @@ import { useOutletContext } from 'react-router-dom';
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ContextType } from '~/common';
import ModelSelector from './Menus/Endpoints/ModelSelector';
import { PresetsMenu, HeaderNewChat } from './Menus';
import { PresetsMenu, HeaderNewChat, OpenSidebar } from './Menus';
import { useGetStartupConfig } from '~/data-provider';
import ExportAndShareMenu from './ExportAndShareMenu';
import { useMediaQuery, useHasAccess } from '~/hooks';
@ -15,7 +15,7 @@ const defaultInterface = getConfigDefaults().interface;
export default function Header() {
const { data: startupConfig } = useGetStartupConfig();
const { navVisible } = useOutletContext<ContextType>();
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
@ -36,7 +36,8 @@ export default function Header() {
return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-2 flex items-center gap-2">
<div className="mx-1 flex items-center gap-2">
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
{!navVisible && <HeaderNewChat />}
{<ModelSelector startupConfig={startupConfig} />}
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}

View file

@ -1,4 +1,5 @@
import React, {
memo,
useState,
useRef,
useEffect,
@ -9,14 +10,18 @@ import React, {
} from 'react';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import type { LucideIcon } from 'lucide-react';
import CodeInterpreter from './CodeInterpreter';
import type { BadgeItem } from '~/common';
import { useChatBadges } from '~/hooks';
import { Badge } from '~/components/ui';
import MCPSelect from './MCPSelect';
import store from '~/store';
interface BadgeRowProps {
showEphemeralBadges?: boolean;
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
onToggle?: (badgeId: string, currentActive: boolean) => void;
conversationId?: string | null;
isInChat: boolean;
}
@ -33,7 +38,8 @@ interface BadgeWrapperProps {
const BadgeWrapper = React.memo(
forwardRef<HTMLDivElement, BadgeWrapperProps>(
({ badge, isEditing, isInChat, onToggle, onDelete, onMouseDown, badgeRefs }, ref) => {
const isActive = badge.atom ? useRecoilValue(badge.atom) : false;
const atomBadge = useRecoilValue(badge.atom);
const isActive = badge.atom ? atomBadge : false;
return (
<div
@ -126,7 +132,13 @@ const dragReducer = (state: DragState, action: DragAction): DragState => {
}
};
export function BadgeRow({ onChange, onToggle, isInChat }: BadgeRowProps) {
function BadgeRow({
showEphemeralBadges,
conversationId,
onChange,
onToggle,
isInChat,
}: BadgeRowProps) {
const [orderedBadges, setOrderedBadges] = useState<BadgeItem[]>([]);
const [dragState, dispatch] = useReducer(dragReducer, {
draggedBadge: null,
@ -141,7 +153,7 @@ export function BadgeRow({ onChange, onToggle, isInChat }: BadgeRowProps) {
const animationFrame = useRef<number | null>(null);
const containerRectRef = useRef<DOMRect | null>(null);
const allBadges = useChatBadges() || [];
const allBadges = useChatBadges();
const isEditing = useRecoilValue(store.isEditingBadges);
const badges = useMemo(
@ -340,6 +352,12 @@ export function BadgeRow({ onChange, onToggle, isInChat }: BadgeRowProps) {
/>
</div>
)}
{showEphemeralBadges === true && (
<>
<CodeInterpreter conversationId={conversationId} />
<MCPSelect conversationId={conversationId} />
</>
)}
{ghostBadge && (
<div
className="ghost-badge h-full"
@ -367,3 +385,5 @@ export function BadgeRow({ onChange, onToggle, isInChat }: BadgeRowProps) {
</div>
);
}
export default memo(BadgeRow);

View file

@ -1,7 +1,7 @@
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
import { useWatch } from 'react-hook-form';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Constants, isAssistantsEndpoint } from 'librechat-data-provider';
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import {
useChatContext,
useChatFormContext,
@ -15,6 +15,7 @@ import {
useHandleKeyUp,
useQueryParams,
useSubmitMessage,
useFocusChatEffect,
} from '~/hooks';
import { mainTextareaId, BadgeItem } from '~/common';
import AttachFileChat from './Files/AttachFileChat';
@ -28,14 +29,15 @@ 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 BadgeRow from './BadgeRow';
import Mention from './Mention';
import store from '~/store';
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useFocusChatEffect(textAreaRef);
const [isCollapsed, setIsCollapsed] = useState(false);
const [, setIsScrollable] = useState(false);
@ -43,7 +45,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
const isSearching = useRecoilValue(store.isSearching);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const chatDirection = useRecoilValue(store.chatDirection);
@ -85,8 +86,15 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
() => conversation?.endpointType ?? conversation?.endpoint,
[conversation?.endpointType, conversation?.endpoint],
);
const conversationId = useMemo(
() => conversation?.conversationId ?? Constants.NEW_CONVO,
[conversation?.conversationId],
);
const isRTL = useMemo(() => chatDirection === 'rtl', [chatDirection.toLowerCase()]);
const isRTL = useMemo(
() => (chatDirection != null ? chatDirection?.toLowerCase() === 'rtl' : false),
[chatDirection],
);
const invalidAssistant = useMemo(
() =>
isAssistantsEndpoint(endpoint) &&
@ -100,6 +108,10 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
);
const handleContainerClick = useCallback(() => {
/** Check if the device is a touchscreen */
if (window.matchMedia?.('(pointer: coarse)').matches) {
return;
}
textAreaRef.current?.focus();
}, []);
@ -110,20 +122,28 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
}, [isCollapsed]);
useAutoSave({
conversationId: conversation?.conversationId,
textAreaRef,
files,
setFiles,
textAreaRef,
conversationId,
isSubmitting: isSubmitting || isSubmittingAdded,
});
const { submitMessage, submitPrompt } = useSubmitMessage();
const handleKeyUp = useHandleKeyUp({
index,
textAreaRef,
setShowPlusPopover,
setShowMentionPopover,
});
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
const {
isNotAppendable,
handlePaste,
handleKeyDown,
handleCompositionStart,
handleCompositionEnd,
} = useTextarea({
textAreaRef,
submitButtonRef,
setIsScrollable,
@ -143,12 +163,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const textValue = useWatch({ control: methods.control, name: 'text' });
useEffect(() => {
if (!isSearching && textAreaRef.current && !disableInputs) {
textAreaRef.current.focus();
}
}, [isSearching, disableInputs]);
useEffect(() => {
if (textAreaRef.current) {
const style = window.getComputedStyle(textAreaRef.current);
@ -166,7 +180,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const handleSaveBadges = useCallback(() => {
setIsEditingBadges(false);
setBackupBadges([]);
}, []);
}, [setIsEditingBadges, setBackupBadges]);
const handleCancelBadges = useCallback(() => {
if (backupBadges.length > 0) {
@ -174,7 +188,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
}
setIsEditingBadges(false);
setBackupBadges([]);
}, [backupBadges, setBadges]);
}, [backupBadges, setBadges, setIsEditingBadges]);
const isMoreThanThreeRows = visualRowCount > 3;
@ -195,8 +209,9 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
'mx-auto flex flex-row gap-3 sm:px-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
centerFormOnLanding &&
(!conversation?.conversationId || conversation?.conversationId === Constants.NEW_CONVO) &&
!isSubmitting
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
!isSubmitting &&
conversation?.messages?.length === 0
? 'transition-all duration-200 sm:mb-28'
: 'sm:mb-10',
)}
@ -247,7 +262,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
disabled={disableInputs}
disabled={disableInputs || isNotAppendable}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
@ -267,7 +282,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
className={cn(
baseClasses,
removeFocusRings,
'transition-[max-height] duration-200',
'transition-[max-height] duration-200 disabled:cursor-not-allowed',
)}
/>
<div className="flex flex-col items-start justify-start pt-1.5">
@ -289,7 +304,9 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<AttachFileChat disableInputs={disableInputs} />
</div>
<BadgeRow
onChange={(newBadges) => setBadges(newBadges)}
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
conversationId={conversationId}
onChange={setBadges}
isInChat={
Array.isArray(conversation?.messages) && conversation.messages.length >= 1
}
@ -300,7 +317,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
methods={methods}
ask={submitMessage}
textAreaRef={textAreaRef}
disabled={disableInputs}
disabled={disableInputs || isNotAppendable}
isSubmitting={isSubmitting}
/>
)}
@ -312,7 +329,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={filesLoading || isSubmitting || disableInputs}
disabled={filesLoading || isSubmitting || disableInputs || isNotAppendable}
/>
)
)}

View file

@ -0,0 +1,119 @@
import debounce from 'lodash/debounce';
import React, { memo, useMemo, useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { TerminalSquareIcon } from 'lucide-react';
import {
Tools,
AuthType,
Constants,
LocalStorageKeys,
PermissionTypes,
Permissions,
} from 'librechat-data-provider';
import ApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
import { useLocalize, useHasAccess, useCodeApiKeyForm } from '~/hooks';
import CheckboxButton from '~/components/ui/CheckboxButton';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue === 'true' && value === false) {
return true;
}
} catch (e) {
console.error(e);
}
}
return value !== undefined && value !== null && value !== '' && value !== false;
};
function CodeInterpreter({ conversationId }: { conversationId?: string | null }) {
const localize = useLocalize();
const key = conversationId ?? Constants.NEW_CONVO;
const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE,
permission: Permissions.USE,
});
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const isCodeToggleEnabled = useMemo(() => {
return ephemeralAgent?.execute_code ?? false;
}, [ephemeralAgent?.execute_code]);
const { data } = useVerifyAgentToolAuth(
{ toolId: Tools.execute_code },
{
retry: 1,
},
);
const authType = useMemo(() => data?.message ?? false, [data?.message]);
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
useCodeApiKeyForm({});
const setValue = useCallback(
(isChecked: boolean) => {
setEphemeralAgent((prev) => ({
...prev,
execute_code: isChecked,
}));
},
[setEphemeralAgent],
);
const [runCode, setRunCode] = useLocalStorage<boolean>(
`${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`,
isCodeToggleEnabled,
setValue,
storageCondition,
);
const handleChange = useCallback(
(isChecked: boolean) => {
if (!isAuthenticated) {
setIsDialogOpen(true);
return;
}
setRunCode(isChecked);
},
[setRunCode, setIsDialogOpen, isAuthenticated],
);
const debouncedChange = useMemo(
() => debounce(handleChange, 50, { leading: true }),
[handleChange],
);
if (!canRunCode) {
return null;
}
return (
<>
<CheckboxButton
className="max-w-fit"
defaultChecked={runCode}
setValue={debouncedChange}
label={localize('com_assistants_code_interpreter')}
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"
icon={<TerminalSquareIcon className="icon-md" />}
/>
<ApiKeyDialog
onSubmit={onSubmit}
isOpen={isDialogOpen}
register={methods.register}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
handleSubmit={methods.handleSubmit}
isToolAuthenticated={isAuthenticated}
isUserProvided={authType === AuthType.USER_PROVIDED}
/>
</>
);
}
export default memo(CodeInterpreter);

View file

@ -41,9 +41,9 @@ const CollapseChat = ({
)}
>
{isCollapsed ? (
<ChevronDown className="h-full w-full" />
) : (
<ChevronUp className="h-full w-full" />
) : (
<ChevronDown className="h-full w-full" />
)}
</button>
}

View file

@ -1,24 +1,31 @@
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
Constants,
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
isEphemeralAgent,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useChatContext } from '~/Providers';
import { useGetFileConfig } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
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 key = conversation?.conversationId ?? Constants.NEW_CONVO;
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(key));
const isAgents = useMemo(
() => isAgentsEndpoint(_endpoint) || isEphemeralAgent(_endpoint, ephemeralAgent),
[_endpoint, ephemeralAgent],
);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),

View file

@ -18,7 +18,9 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { handleFileChange } = useFileHandling();
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
});
const capabilities = useMemo(
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
@ -117,6 +119,7 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
modal={true}
unmountOnHide={true}
trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"

View file

@ -34,7 +34,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
label: localize('com_ui_upload_image_input'),
value: undefined,
icon: <ImageUpIcon className="icon-md" />,
condition: files.every((file) => file.type.startsWith('image/')),
condition: files.every((file) => file.type?.startsWith('image/')),
},
];
for (const capability of capabilities) {

View file

@ -1,22 +1,40 @@
import type { TFile } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { getFileType, cn } from '~/utils';
import FilePreview from './FilePreview';
import RemoveFile from './RemoveFile';
import { getFileType } from '~/utils';
const FileContainer = ({
file,
overrideType,
buttonClassName,
containerClassName,
onDelete,
onClick,
}: {
file: ExtendedFile | TFile;
file: Partial<ExtendedFile | TFile>;
overrideType?: string;
buttonClassName?: string;
containerClassName?: string;
onDelete?: () => void;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}) => {
const fileType = getFileType(file.type);
const fileType = getFileType(overrideType ?? file.type);
return (
<div className="group relative inline-block text-sm text-text-primary">
<div className="relative overflow-hidden rounded-2xl border border-border-light">
<div className="w-56 bg-surface-hover-alt p-1.5">
<div
className={cn('group relative inline-block text-sm text-text-primary', containerClassName)}
>
<button
type="button"
onClick={onClick}
aria-label={file.filename}
className={cn(
'relative overflow-hidden rounded-2xl border border-border-light bg-surface-hover-alt',
buttonClassName,
)}
>
<div className="w-56 p-1.5">
<div className="flex flex-row items-center gap-2">
<FilePreview file={file} fileType={fileType} className="relative" />
<div className="overflow-hidden">
@ -29,7 +47,7 @@ const FileContainer = ({
</div>
</div>
</div>
</div>
</button>
{onDelete && <RemoveFile onRemove={onDelete} />}
</div>
);

View file

@ -11,7 +11,7 @@ const FilePreview = ({
fileType,
className = '',
}: {
file?: ExtendedFile | TFile;
file?: Partial<ExtendedFile | TFile>;
fileType: {
paths: React.FC;
fill: string;

View file

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { ArrowUpDown, Database } from 'lucide-react';
import { FileSources, FileContext } from 'librechat-data-provider';
@ -68,7 +69,7 @@ export const columns: ColumnDef<TFile>[] = [
},
cell: ({ row }) => {
const file = row.original;
if (file.type.startsWith('image')) {
if (file.type?.startsWith('image')) {
return (
<div className="flex gap-2">
<ImagePreview
@ -76,7 +77,7 @@ export const columns: ColumnDef<TFile>[] = [
className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md"
source={file.source}
/>
<span className="self-center truncate ">{file.filename}</span>
<span className="self-center truncate">{file.filename}</span>
</div>
);
}
@ -212,4 +213,4 @@ export const columns: ColumnDef<TFile>[] = [
return `${value}${suffix}`;
},
},
];
];

View file

@ -0,0 +1,124 @@
import React, { memo, useRef, useMemo, useEffect, useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import { useAvailableToolsQuery } from '~/data-provider';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import MultiSelect from '~/components/ui/MultiSelect';
import { ephemeralAgentByConvoId } from '~/store';
import MCPIcon from '~/components/ui/MCPIcon';
import { useLocalize } from '~/hooks';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue.length > 2) {
return true;
}
} catch (e) {
console.error(e);
}
}
return Array.isArray(value) && value.length > 0;
};
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
const localize = useLocalize();
const key = conversationId ?? Constants.NEW_CONVO;
const hasSetFetched = useRef<string | null>(null);
const { data: mcpServerSet, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
select: (data) => {
const serverNames = new Set<string>();
data.forEach((tool) => {
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
if (isMCP && tool.chatMenu !== false) {
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
serverNames.add(parts[parts.length - 1]);
}
});
return serverNames;
},
});
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const mcpState = useMemo(() => {
return ephemeralAgent?.mcp ?? [];
}, [ephemeralAgent?.mcp]);
const setSelectedValues = useCallback(
(values: string[] | null | undefined) => {
if (!values) {
return;
}
if (!Array.isArray(values)) {
return;
}
setEphemeralAgent((prev) => ({
...prev,
mcp: values,
}));
},
[setEphemeralAgent],
);
const [mcpValues, setMCPValues] = useLocalStorage<string[]>(
`${LocalStorageKeys.LAST_MCP_}${key}`,
mcpState,
setSelectedValues,
storageCondition,
);
useEffect(() => {
if (hasSetFetched.current === key) {
return;
}
if (!isFetched) {
return;
}
hasSetFetched.current = key;
if ((mcpServerSet?.size ?? 0) > 0) {
setMCPValues(mcpValues.filter((mcp) => mcpServerSet?.has(mcp)));
return;
}
setMCPValues([]);
}, [isFetched, setMCPValues, mcpServerSet, key, mcpValues]);
const renderSelectedValues = useCallback(
(values: string[], placeholder?: string) => {
if (values.length === 0) {
return placeholder || localize('com_ui_select') + '...';
}
if (values.length === 1) {
return values[0];
}
return localize('com_ui_x_selected', { 0: values.length });
},
[localize],
);
const mcpServers = useMemo(() => {
return Array.from(mcpServerSet ?? []);
}, [mcpServerSet]);
if (!mcpServerSet || mcpServerSet.size === 0) {
return null;
}
return (
<MultiSelect
items={mcpServers ?? []}
selectedValues={mcpValues ?? []}
setSelectedValues={setMCPValues}
defaultSelectedValues={mcpValues ?? []}
renderSelectedValues={renderSelectedValues}
placeholder={localize('com_ui_mcp_servers')}
popoverClassName="min-w-fit"
className="badge-icon min-w-fit"
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
/>
);
}
export default memo(MCPSelect);

View file

@ -44,7 +44,7 @@ export default function PopoverButtons({
const endpoint = overrideEndpoint ?? endpointType ?? _endpoint ?? '';
const model = overrideModel ?? _model;
const isGenerativeModel = model?.toLowerCase().includes('gemini') ?? false;
const isGenerativeModel = /gemini|learnlm|gemma/.test(model ?? '') ?? false;
const isChatModel = (!isGenerativeModel && model?.toLowerCase().includes('chat')) ?? false;
const isTextModel = !isGenerativeModel && !isChatModel && /code|text/.test(model ?? '');
@ -133,7 +133,6 @@ export default function PopoverButtons({
</Button>
))}
</div>
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
{disabled ? null : (
<div className="flex w-[150px] items-center justify-end">
{additionalButtons[settingsView].map((button, index) => (

View file

@ -201,7 +201,7 @@ function PromptsCommand({
<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

@ -9,7 +9,23 @@ import { useLocalize, useAuthContext } from '~/hooks';
import { getIconEndpoint, getEntity } from '~/utils';
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white dark:bg-presentation dark:text-white text-black dark:after:shadow-none ';
function getTextSizeClass(text: string | undefined | null) {
if (!text) {
return 'text-xl sm:text-2xl';
}
if (text.length < 40) {
return 'text-2xl sm:text-4xl';
}
if (text.length < 70) {
return 'text-xl sm:text-2xl';
}
return 'text-lg sm:text-md';
}
export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: boolean }) {
const { conversation } = useChatContext();
@ -52,7 +68,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
});
const name = entity?.name ?? '';
const description = entity?.description ?? '';
const description = (entity?.description || conversation?.greeting) ?? '';
const getGreeting = useCallback(() => {
if (typeof startupConfig?.interface?.customWelcome === 'string') {
@ -122,13 +138,18 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
return margin;
}, [lineCount, description, textHasMultipleLines, contentHeight]);
const greetingText =
typeof startupConfig?.interface?.customWelcome === 'string'
? getGreeting()
: getGreeting() + (user?.name ? ', ' + user.name : '');
return (
<div
className={`flex h-full transform-gpu flex-col items-center justify-center pb-16 transition-all duration-200 ${centerFormOnLanding ? 'max-h-full sm:max-h-0' : 'max-h-full'} ${getDynamicMargin}`}
>
<div ref={contentRef} className="flex flex-col items-center gap-0 p-2">
<div
className={`flex ${textHasMultipleLines ? 'flex-col' : 'flex-col md:flex-row'} items-center justify-center gap-4`}
className={`flex ${textHasMultipleLines ? 'flex-col' : 'flex-col md:flex-row'} items-center justify-center gap-2`}
>
<div className={`relative size-10 justify-center ${textHasMultipleLines ? 'mb-2' : ''}`}>
<ConvoIcon
@ -138,7 +159,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
endpointsConfig={endpointsConfig}
containerClassName={containerClassName}
context="landing"
className="h-2/3 w-2/3"
className="h-2/3 w-2/3 text-black dark:text-white"
size={41}
/>
{startupConfig?.showBirthdayIcon && (
@ -155,7 +176,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
<SplitText
key={`split-text-${name}`}
text={name}
className="text-4xl font-medium text-text-primary"
className={`${getTextSizeClass(name)} font-medium text-text-primary`}
delay={50}
textAlign="center"
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
@ -168,13 +189,9 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
</div>
) : (
<SplitText
key={`split-text-${getGreeting()}${user?.name ? '-user' : ''}`}
text={
typeof startupConfig?.interface?.customWelcome === 'string'
? getGreeting()
: getGreeting() + (user?.name ? ', ' + user.name : '')
}
className="text-2xl font-medium text-text-primary sm:text-4xl"
key={`split-text-${greetingText}${user?.name ? '-user' : ''}`}
text={greetingText}
className={`${getTextSizeClass(greetingText)} font-medium text-text-primary`}
delay={50}
textAlign="center"
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
@ -186,8 +203,8 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
/>
)}
</div>
{(isAgent || isAssistant) && description && (
<div className="animate-fadeIn mt-2 max-w-md text-center text-sm font-normal text-text-primary">
{description && (
<div className="animate-fadeIn mt-4 max-w-md text-center text-sm font-normal text-text-primary">
{description}
</div>
)}

View file

@ -160,6 +160,7 @@ const BookmarkMenu: FC = () => {
focusLoop={true}
menuId={menuId}
isOpen={isMenuOpen}
unmountOnHide={true}
setIsOpen={setIsMenuOpen}
keyPrefix={`${conversationId}-bookmark-`}
trigger={
@ -170,7 +171,7 @@ const BookmarkMenu: FC = () => {
id="bookmark-menu-button"
aria-label={localize('com_ui_bookmarks_add')}
className={cn(
'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-lg border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-xl border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
isMenuOpen ? 'bg-surface-hover' : '',
)}
data-testid="bookmark-menu"

View file

@ -1,36 +1,44 @@
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import { useMediaQuery, useLocalize } from '~/hooks';
import { Button, NewChatIcon } from '~/components';
import { TooltipAnchor, Button } from '~/components/ui';
import { NewChatIcon } from '~/components/svg';
import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks';
export default function HeaderNewChat() {
const localize = useLocalize();
const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const localize = useLocalize();
if (isSmallScreen) {
return null;
}
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
window.open('/c/new', '_blank');
return;
}
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
};
return (
<Button
size="icon"
variant="outline"
data-testid="wide-header-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover"
onClick={() => {
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
newConversation();
}}
>
<NewChatIcon />
</Button>
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
variant="outline"
data-testid="wide-header-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
onClick={clickHandler}
>
<NewChatIcon />
</Button>
}
/>
);
}

View file

@ -0,0 +1,33 @@
import { TooltipAnchor, Button } from '~/components/ui';
import { Sidebar } from '~/components/svg';
import { useLocalize } from '~/hooks';
export default function OpenSidebar({
setNavVisible,
}: {
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const localize = useLocalize();
return (
<TooltipAnchor
description={localize('com_nav_open_sidebar')}
render={
<Button
size="icon"
variant="outline"
data-testid="open-sidebar-button"
aria-label={localize('com_nav_open_sidebar')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
})
}
>
<Sidebar />
</Button>
}
/>
);
}

View file

@ -30,7 +30,7 @@ const PresetsMenu: FC = () => {
tabIndex={0}
role="button"
data-testid="presets-button"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<BookCopy size={16} aria-label="Preset Icon" />
</TooltipAnchor>

View file

@ -1,2 +1,3 @@
export { default as PresetsMenu } from './PresetsMenu';
export { default as OpenSidebar } from './OpenSidebar';
export { default as HeaderNewChat } from './HeaderNewChat';

View file

@ -60,7 +60,8 @@ const EditMessage = ({
conversationId,
},
{
resubmitFiles: true,
isResubmission: true,
overrideFiles: message.files,
},
);
@ -112,9 +113,9 @@ const EditMessage = ({
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
text: data.text,
}
...msg,
text: data.text,
}
: msg,
),
);

View file

@ -150,7 +150,7 @@ export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
return (
<a
href={filepath.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
href={filepath?.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
{...props}
>
{children}
@ -184,7 +184,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
const rehypePlugins = useMemo(
() => [
[rehypeKatex, { output: 'mathml' }],
[rehypeKatex],
[
rehypeHighlight,
{

View file

@ -13,7 +13,7 @@ import { langSubset } from '~/utils';
const MarkdownLite = memo(
({ content = '', codeExecution = true }: { content?: string; codeExecution?: boolean }) => {
const rehypePlugins: PluggableList = [
[rehypeKatex, { output: 'mathml' }],
[rehypeKatex],
[
rehypeHighlight,
{

View file

@ -1,6 +1,27 @@
import { memo } 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';
const FileAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
const { handleDownload } = useAttachmentLink({
href: attachment.filepath,
filename: attachment.filename,
});
const extension = attachment.filename.split('.').pop();
return (
<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"
/>
);
});
export default function Attachment({ attachment }: { attachment?: TAttachment }) {
if (!attachment) {
@ -21,5 +42,5 @@ export default function Attachment({ attachment }: { attachment?: TAttachment })
/>
);
}
return null;
return <FileAttachment attachment={attachment} />;
}

View file

@ -7,7 +7,7 @@ import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import { useProgress, useLocalize } from '~/hooks';
import { CodeInProgress } from './CodeProgress';
import Attachment from './Attachment';
import LogContent from './LogContent';
import Stdout from './Stdout';
import store from '~/store';
interface ParsedArgs {
@ -17,8 +17,17 @@ interface ParsedArgs {
export function useParseArgs(args: string): ParsedArgs {
return useMemo(() => {
let parsedArgs: ParsedArgs | string = args;
try {
parsedArgs = JSON.parse(args);
} catch {
// console.error('Failed to parse args:', e);
}
if (typeof parsedArgs === 'object') {
return parsedArgs;
}
const langMatch = args.match(/"lang"\s*:\s*"(\w+)"/);
const codeMatch = args.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"args"|$)/s);
const codeMatch = args.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"(session_id|args)"|"\s*})/s);
let code = '';
if (codeMatch) {
@ -26,7 +35,7 @@ export function useParseArgs(args: string): ParsedArgs {
if (code.endsWith('"}')) {
code = code.slice(0, -2);
}
code = code.replace(/\\n/g, '\n').replace(/\\/g, '');
code = code.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
return {
@ -99,15 +108,17 @@ export default function ExecuteCode({
color: 'white',
}}
>
<pre className="shrink-0">
<LogContent output={output} attachments={attachments} />
</pre>
<Stdout output={output} />
</div>
</div>
)}
</div>
)}
{attachments?.map((attachment, index) => <Attachment attachment={attachment} key={index} />)}
<div className="mb-2 flex flex-wrap items-center gap-2.5">
{attachments?.map((attachment, index) => (
<Attachment attachment={attachment} key={index} />
))}
</div>
</>
);
}

View file

@ -8,11 +8,11 @@ interface LogLinkProps {
children: React.ReactNode;
}
const LogLink: React.FC<LogLinkProps> = ({ href, filename, children }) => {
export const useAttachmentLink = ({ href, filename }: Pick<LogLinkProps, 'href' | 'filename'>) => {
const { showToast } = useToastContext();
const { refetch: downloadFile } = useCodeOutputDownload(href);
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement>) => {
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
event.preventDefault();
try {
const stream = await downloadFile();
@ -36,6 +36,11 @@ const LogLink: React.FC<LogLinkProps> = ({ href, filename, children }) => {
}
};
return { handleDownload };
};
const LogLink: React.FC<LogLinkProps> = ({ href, filename, children }) => {
const { handleDownload } = useAttachmentLink({ href, filename });
return (
<a
href={href}

View file

@ -0,0 +1,26 @@
import React, { useMemo } from 'react';
interface StdoutProps {
output?: string;
}
const Stdout: React.FC<StdoutProps> = ({ output = '' }) => {
const processedContent = useMemo(() => {
if (!output) {
return '';
}
const parts = output.split('Generated files:');
return parts[0].trim();
}, [output]);
return (
processedContent && (
<pre className="shrink-0">
<div>{processedContent}</div>
</pre>
)
);
};
export default Stdout;

View file

@ -35,7 +35,7 @@ const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) =>
} else {
return <>{text}</>;
}
}, [isCreatedByUser, enableUserMsgMarkdown, text, showCursorState, isLatestMessage]);
}, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]);
return (
<div

View file

@ -1,16 +1,23 @@
import { Suspense } from 'react';
import { Suspense, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import { ContentTypes } from 'librechat-data-provider';
import type { Agents, TMessage, TMessageContentParts } from 'librechat-data-provider';
import { UnfinishedMessage } from './MessageContent';
import { DelayedRender } from '~/components/ui';
import MarkdownLite from './MarkdownLite';
import { cn } from '~/utils';
import { cn, mapAttachments } from '~/utils';
import store from '~/store';
import Part from './Part';
const SearchContent = ({ message }: { message: TMessage }) => {
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const { messageId } = message;
const messageAttachmentsMap = useRecoilValue(store.messageAttachmentsMap);
const attachmentMap = useMemo(
() => mapAttachments(message?.attachments ?? messageAttachmentsMap[messageId] ?? []),
[message?.attachments, messageAttachmentsMap, messageId],
);
if (Array.isArray(message.content) && message.content.length > 0) {
return (
<>
@ -20,13 +27,17 @@ const SearchContent = ({ message }: { message: TMessage }) => {
if (!part) {
return null;
}
const toolCallId =
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const attachments = attachmentMap[toolCallId];
return (
<Part
key={`display-${messageId}-${idx}`}
showCursor={false}
isSubmitting={false}
isCreatedByUser={message.isCreatedByUser}
messageId={message.messageId}
attachments={attachments}
part={part}
/>
);

View file

@ -1,7 +1,7 @@
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessageAudio } from '~/common';
import { BrowserTTS, EdgeTTS, ExternalTTS } from '~/components/Audio/TTS';
import { BrowserTTS, ExternalTTS } from '~/components/Audio/TTS';
import { TTSEndpoints } from '~/common';
import store from '~/store';
@ -9,7 +9,6 @@ function MessageAudio(props: TMessageAudio) {
const engineTTS = useRecoilValue<string>(store.engineTTS);
const TTSComponents = {
[TTSEndpoints.edge]: EdgeTTS,
[TTSEndpoints.browser]: BrowserTTS,
[TTSEndpoints.external]: ExternalTTS,
};

View file

@ -14,10 +14,9 @@ export default function MessagesView({
messagesTree?: TMessage[] | null;
}) {
const localize = useLocalize();
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const { screenshotTargetRef } = useScreenshot();
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
const {

View file

@ -1,34 +1,62 @@
import { Link } from 'lucide-react';
import type { TMessage } from 'librechat-data-provider';
import { useRecoilValue } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import type { TMessage, TConversation } from 'librechat-data-provider';
import type { InfiniteData } from '@tanstack/react-query';
import type { ConversationCursorData } from '~/utils';
import { useLocalize, useNavigateToConvo } from '~/hooks';
import { useSearchContext } from '~/Providers';
import { getConversationById } from '~/utils';
import { findConversationInInfinite } from '~/utils';
import store from '~/store';
export default function SearchButtons({ message }: { message: TMessage }) {
const localize = useLocalize();
const { searchQueryRes } = useSearchContext();
const { navigateWithLastTools } = useNavigateToConvo();
const queryClient = useQueryClient();
const search = useRecoilValue(store.search);
const { navigateToConvo } = useNavigateToConvo();
const conversationId = message.conversationId ?? '';
const clickHandler = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
if (!conversationId) {
return;
}
let title = message.title ?? '';
let cachedConvo = queryClient.getQueryData<TConversation>([
QueryKeys.conversation,
conversationId,
]);
const convos = queryClient.getQueryData<InfiniteData<ConversationCursorData>>([
QueryKeys.allConversations,
{ search: search.debouncedQuery },
]);
if (!cachedConvo && convos) {
cachedConvo = findConversationInInfinite(convos, conversationId);
}
if (!title) {
title = cachedConvo?.title ?? '';
}
document.title = title;
navigateToConvo(
cachedConvo ??
({
conversationId,
title,
} as TConversation),
{ resetLatestMessage: true },
);
};
if (!conversationId) {
return null;
}
const clickHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const conversation = getConversationById(searchQueryRes?.data, conversationId);
if (!conversation) {
return;
}
document.title = message.title ?? '';
navigateWithLastTools(conversation, true, true);
};
return (
<div className="visible mt-0 flex items-center justify-center gap-1 self-end text-text-secondary lg:justify-start">
<button
type="button"
className="ml-0 flex cursor-pointer items-center gap-1.5 rounded-md p-1 text-xs hover:text-text-primary hover:underline"
onClick={clickHandler}
title={localize('com_ui_go_to_conversation')}

View file

@ -10,7 +10,30 @@ import SubRow from './SubRow';
import { cn } from '~/utils';
import store from '~/store';
export default function Message({ message }: Pick<TMessageProps, 'message'>) {
const MessageAvatar = ({ iconData }: { iconData: TMessageIcon }) => (
<div className="relative flex flex-shrink-0 flex-col items-end">
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon iconData={iconData} />
</div>
</div>
</div>
);
const MessageBody = ({ message, messageLabel, fontSize }) => (
<div
className={cn('relative flex w-11/12 flex-col', message.isCreatedByUser ? '' : 'agent-turn')}
>
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
<SearchContent message={message} />
<SubRow classes="text-xs">
<MinimalHoverButtons message={message} />
<SearchButtons message={message} />
</SubRow>
</div>
);
export default function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
const fontSize = useRecoilValue(store.fontSize);
const { user } = useAuthContext();
@ -18,60 +41,42 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
const iconData: TMessageIcon = useMemo(
() => ({
endpoint: message?.endpoint,
model: message?.model,
endpoint: message?.endpoint ?? '',
model: message?.model ?? '',
iconURL: message?.iconURL ?? '',
isCreatedByUser: message?.isCreatedByUser,
isCreatedByUser: message?.isCreatedByUser ?? false,
}),
[message?.model, message?.iconURL, message?.endpoint, message?.isCreatedByUser],
[message?.endpoint, message?.model, message?.iconURL, message?.isCreatedByUser],
);
const messageLabel = useMemo(() => {
if (message?.isCreatedByUser) {
return UsernameDisplay
? (user?.name ?? '') || (user?.username ?? '')
: localize('com_user_message');
}
return message?.sender ?? '';
}, [
message?.isCreatedByUser,
message?.sender,
UsernameDisplay,
user?.name,
user?.username,
localize,
]);
if (!message) {
return null;
}
const { isCreatedByUser } = message;
let messageLabel = '';
if (isCreatedByUser) {
messageLabel = UsernameDisplay
? (user?.name ?? '') || (user?.username ?? '')
: localize('com_user_message');
} else {
messageLabel = message.sender ?? '';
}
return (
<>
<div className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent">
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<div className="relative flex flex-shrink-0 flex-col items-end">
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon iconData={iconData} />
</div>
</div>
</div>
</div>
<div
className={cn('relative flex w-11/12 flex-col', isCreatedByUser ? '' : 'agent-turn')}
>
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
<SearchContent message={message} />
</div>
</div>
<SubRow classes="text-xs">
<MinimalHoverButtons message={message} />
<SearchButtons message={message} />
</SubRow>
</div>
</div>
<div className="text-token-text-primary w-full bg-transparent">
<div className="m-auto p-4 py-2 md:gap-6">
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<MessageAvatar iconData={iconData} />
<MessageBody message={message} messageLabel={messageLabel} fontSize={fontSize} />
</div>
</div>
</>
</div>
);
}

View file

@ -12,7 +12,7 @@ import store from '~/store';
export default function Presentation({ children }: { children: React.ReactNode }) {
const artifacts = useRecoilValue(store.artifactsState);
const artifactsVisible = useRecoilValue(store.artifactsVisible);
const artifactsVisibility = useRecoilValue(store.artifactsVisibility);
const setFilesToDelete = useSetFilesToDelete();
@ -64,7 +64,7 @@ export default function Presentation({ children }: { children: React.ReactNode }
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
artifacts={
artifactsVisible === true && Object.keys(artifacts ?? {}).length > 0 ? (
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
<EditorProvider>
<Artifacts />
</EditorProvider>

View file

@ -37,33 +37,28 @@ export function TemporaryChat() {
return (
<div className="relative flex flex-wrap items-center gap-2">
<div className="badge-icon h-full">
<TooltipAnchor
description={localize(temporaryBadge.label)}
render={
<motion.button
onClick={handleBadgeToggle}
className={cn(
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
isTemporary
? 'bg-surface-active shadow-md'
: 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md',
'active:scale-95 active:shadow-inner',
)}
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
>
{temporaryBadge.icon && (
<temporaryBadge.icon
className={cn(
'relative h-5 w-5 md:h-4 md:w-4',
!temporaryBadge.label && 'mx-auto',
)}
/>
)}
</motion.button>
}
/>
</div>
<TooltipAnchor
description={localize(temporaryBadge.label)}
render={
<button
onClick={handleBadgeToggle}
aria-label={localize(temporaryBadge.label)}
className={cn(
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
isTemporary
? 'bg-surface-active shadow-md'
: 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md',
'active:shadow-inner',
)}
>
{temporaryBadge.icon && (
<temporaryBadge.icon
className={cn('relative h-5 w-5 md:h-4 md:w-4', !temporaryBadge.label && 'mx-auto')}
/>
)}
</button>
}
/>
</div>
);
}

View file

@ -1,67 +1,231 @@
import { useMemo, memo } from 'react';
import { useMemo, memo, type FC, useCallback } from 'react';
import throttle from 'lodash/throttle';
import { parseISO, isToday } from 'date-fns';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { useLocalize, TranslationKeys, useMediaQuery } from '~/hooks';
import { TConversation } from 'librechat-data-provider';
import { useLocalize, TranslationKeys } from '~/hooks';
import { groupConversationsByDate } from '~/utils';
import { Spinner } from '~/components/svg';
import Convo from './Convo';
const Conversations = ({
conversations,
moveToTop,
toggleNav,
}: {
interface ConversationsProps {
conversations: Array<TConversation | null>;
moveToTop: () => void;
toggleNav: () => void;
}) => {
containerRef: React.RefObject<HTMLDivElement | List>;
loadMoreConversations: () => void;
isLoading: boolean;
isSearchLoading: boolean;
}
const LoadingSpinner = memo(() => {
const localize = useLocalize();
const groupedConversations = useMemo(
() => groupConversationsByDate(conversations),
[conversations],
return (
<div className="mx-auto mt-2 flex items-center justify-center gap-2">
<Spinner className="h-4 w-4 text-text-primary" />
<span className="animate-pulse text-text-primary">{localize('com_ui_loading')}</span>
</div>
);
});
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
const localize = useLocalize();
return (
<div className="mt-2 pl-2 pt-1 text-text-secondary" style={{ fontSize: '0.7rem' }}>
{localize(groupName as TranslationKeys) || groupName}
</div>
);
});
DateLabel.displayName = 'DateLabel';
type FlattenedItem =
| { type: 'header'; groupName: string }
| { type: 'convo'; convo: TConversation }
| { type: 'loading' };
const MemoizedConvo = memo(
({
conversation,
retainView,
toggleNav,
isLatestConvo,
}: {
conversation: TConversation;
retainView: () => void;
toggleNav: () => void;
isLatestConvo: boolean;
}) => {
return (
<Convo
conversation={conversation}
retainView={retainView}
toggleNav={toggleNav}
isLatestConvo={isLatestConvo}
/>
);
},
(prevProps, nextProps) => {
return (
prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
prevProps.conversation.title === nextProps.conversation.title &&
prevProps.isLatestConvo === nextProps.isLatestConvo &&
prevProps.conversation.endpoint === nextProps.conversation.endpoint
);
},
);
const Conversations: FC<ConversationsProps> = ({
conversations: rawConversations,
moveToTop,
toggleNav,
containerRef,
loadMoreConversations,
isLoading,
isSearchLoading,
}) => {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const convoHeight = isSmallScreen ? 44 : 34;
const filteredConversations = useMemo(
() => rawConversations.filter(Boolean) as TConversation[],
[rawConversations],
);
const groupedConversations = useMemo(
() => groupConversationsByDate(filteredConversations),
[filteredConversations],
);
const firstTodayConvoId = useMemo(
() =>
conversations.find((convo) => convo && convo.updatedAt && isToday(parseISO(convo.updatedAt)))
?.conversationId,
[conversations],
filteredConversations.find((convo) => convo.updatedAt && isToday(parseISO(convo.updatedAt)))
?.conversationId ?? undefined,
[filteredConversations],
);
const flattenedItems = useMemo(() => {
const items: FlattenedItem[] = [];
groupedConversations.forEach(([groupName, convos]) => {
items.push({ type: 'header', groupName });
items.push(...convos.map((convo) => ({ type: 'convo' as const, convo })));
});
if (isLoading) {
items.push({ type: 'loading' } as any);
}
return items;
}, [groupedConversations, isLoading]);
const cache = useMemo(
() =>
new CellMeasurerCache({
fixedWidth: true,
defaultHeight: convoHeight,
keyMapper: (index) => {
const item = flattenedItems[index];
if (item.type === 'header') {
return `header-${index}`;
}
if (item.type === 'convo') {
return `convo-${item.convo.conversationId}`;
}
if (item.type === 'loading') {
return `loading-${index}`;
}
return `unknown-${index}`;
},
}),
[flattenedItems, convoHeight],
);
const rowRenderer = useCallback(
({ index, key, parent, style }) => {
const item = flattenedItems[index];
if (item.type === 'loading') {
return (
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
{({ registerChild }) => (
<div ref={registerChild} style={style}>
<LoadingSpinner />
</div>
)}
</CellMeasurer>
);
}
return (
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
{({ registerChild }) => (
<div ref={registerChild} style={style}>
{item.type === 'header' ? (
<DateLabel groupName={item.groupName} />
) : item.type === 'convo' ? (
<MemoizedConvo
conversation={item.convo}
retainView={moveToTop}
toggleNav={toggleNav}
isLatestConvo={item.convo.conversationId === firstTodayConvoId}
/>
) : null}
</div>
)}
</CellMeasurer>
);
},
[cache, flattenedItems, firstTodayConvoId, moveToTop, toggleNav],
);
const getRowHeight = useCallback(
({ index }: { index: number }) => cache.getHeight(index, 0),
[cache],
);
const throttledLoadMore = useMemo(
() => throttle(loadMoreConversations, 300),
[loadMoreConversations],
);
const handleRowsRendered = useCallback(
({ stopIndex }: { stopIndex: number }) => {
if (stopIndex >= flattenedItems.length - 8) {
throttledLoadMore();
}
},
[flattenedItems.length, throttledLoadMore],
);
return (
<div className="text-token-text-primary flex flex-col gap-2 pb-2 text-sm">
<div>
<span>
{groupedConversations.map(([groupName, convos]) => (
<div key={groupName}>
<div
className="text-text-secondary"
style={{
fontSize: '0.7rem',
marginTop: '20px',
marginBottom: '5px',
paddingLeft: '10px',
}}
>
{localize(groupName as TranslationKeys) || groupName}
</div>
{convos.map((convo, i) => (
<Convo
key={`${groupName}-${convo.conversationId}-${i}`}
isLatestConvo={convo.conversationId === firstTodayConvoId}
conversation={convo}
retainView={moveToTop}
toggleNav={toggleNav}
/>
))}
<div
style={{
marginTop: '5px',
marginBottom: '5px',
}}
<div className="relative flex h-full flex-col pb-2 text-sm text-text-primary">
{isSearchLoading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner className="text-text-primary" />
<span className="ml-2 text-text-primary">Loading...</span>
</div>
) : (
<div className="flex-1">
<AutoSizer>
{({ width, height }) => (
<List
ref={containerRef as React.RefObject<List>}
width={width}
height={height}
deferredMeasurementCache={cache}
rowCount={flattenedItems.length}
rowHeight={getRowHeight}
rowRenderer={rowRenderer}
overscanRowCount={10}
className="outline-none"
style={{ outline: 'none' }}
role="list"
aria-label="Conversations"
onRowsRendered={handleRowsRendered}
tabIndex={-1}
/>
</div>
))}
</span>
</div>
)}
</AutoSizer>
</div>
)}
</div>
);
};

View file

@ -1,28 +1,26 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { Check, X } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { Constants } from 'librechat-data-provider';
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
import type { TConversation } from 'librechat-data-provider';
import { useNavigateToConvo, useMediaQuery, useLocalize } from '~/hooks';
import { useUpdateConversationMutation } from '~/data-provider';
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
import { useGetEndpointsQuery } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { ConvoOptions } from './ConvoOptions';
import { useToastContext } from '~/Providers';
import RenameForm from './RenameForm';
import ConvoLink from './ConvoLink';
import { cn } from '~/utils';
import store from '~/store';
type KeyEvent = KeyboardEvent<HTMLInputElement>;
type ConversationProps = {
interface ConversationProps {
conversation: TConversation;
retainView: () => void;
toggleNav: () => void;
isLatestConvo: boolean;
};
}
export default function Conversation({
conversation,
@ -31,27 +29,84 @@ export default function Conversation({
isLatestConvo,
}: ConversationProps) {
const params = useParams();
const localize = useLocalize();
const { showToast } = useToastContext();
const { navigateToConvo } = useNavigateToConvo();
const { data: endpointsConfig } = useGetEndpointsQuery();
const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]);
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
const activeConvos = useRecoilValue(store.allConversationsSelector);
const { data: endpointsConfig } = useGetEndpointsQuery();
const { navigateWithLastTools } = useNavigateToConvo();
const { showToast } = useToastContext();
const { conversationId, title } = conversation;
const inputRef = useRef<HTMLInputElement | null>(null);
const [titleInput, setTitleInput] = useState(title);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { conversationId, title = '' } = conversation;
const [titleInput, setTitleInput] = useState(title || '');
const [renaming, setRenaming] = useState(false);
const [isPopoverActive, setIsPopoverActive] = useState(false);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const localize = useLocalize();
const clickHandler = async (event: MouseEvent<HTMLAnchorElement>) => {
if (event.button === 0 && (event.ctrlKey || event.metaKey)) {
toggleNav();
const previousTitle = useRef(title);
useEffect(() => {
if (title !== previousTitle.current) {
setTitleInput(title as string);
previousTitle.current = title;
}
}, [title]);
const isActiveConvo = useMemo(() => {
if (conversationId === Constants.NEW_CONVO) {
return currentConvoId === Constants.NEW_CONVO;
}
if (currentConvoId !== Constants.NEW_CONVO) {
return currentConvoId === conversationId;
} else {
const latestConvo = activeConvos?.[0];
return latestConvo === conversationId;
}
}, [currentConvoId, conversationId, activeConvos]);
const handleRename = () => {
setIsPopoverActive(false);
setTitleInput(title as string);
setRenaming(true);
};
const handleRenameSubmit = async (newTitle: string) => {
if (!conversationId || newTitle === title) {
setRenaming(false);
return;
}
event.preventDefault();
try {
await updateConvoMutation.mutateAsync({
conversationId,
title: newTitle.trim() || localize('com_ui_untitled'),
});
setRenaming(false);
} catch (error) {
setTitleInput(title as string);
showToast({
message: localize('com_ui_rename_failed'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
setRenaming(false);
}
};
const handleCancelRename = () => {
setTitleInput(title as string);
setRenaming(false);
};
const handleNavigation = (ctrlOrMetaKey: boolean) => {
if (ctrlOrMetaKey) {
toggleNav();
const baseUrl = window.location.origin;
const path = `/c/${conversationId}`;
window.open(baseUrl + path, '_blank');
return;
}
if (currentConvoId === conversationId || isPopoverActive) {
return;
@ -59,138 +114,68 @@ export default function Conversation({
toggleNav();
// set document title
if (typeof title === 'string' && title.length > 0) {
document.title = title;
}
/* Note: Latest Message should not be reset if existing convo */
navigateWithLastTools(
conversation,
!(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
);
navigateToConvo(conversation, {
currentConvoId,
resetLatestMessage: !(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
});
};
const renameHandler = useCallback(() => {
setIsPopoverActive(false);
setTitleInput(title);
setRenaming(true);
}, [title]);
useEffect(() => {
if (renaming && inputRef.current) {
inputRef.current.focus();
}
}, [renaming]);
const onRename = useCallback(
(e: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement> | KeyEvent) => {
e.preventDefault();
setRenaming(false);
if (titleInput === title) {
return;
}
if (typeof conversationId !== 'string' || conversationId === '') {
return;
}
updateConvoMutation.mutate(
{ conversationId, title: titleInput ?? '' },
{
onError: () => {
setTitleInput(title);
showToast({
message: 'Failed to rename conversation',
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
},
);
},
[title, titleInput, conversationId, showToast, updateConvoMutation],
);
const handleKeyDown = useCallback(
(e: KeyEvent) => {
if (e.key === 'Escape') {
setTitleInput(title);
setRenaming(false);
} else if (e.key === 'Enter') {
onRename(e);
}
},
[title, onRename],
);
const cancelRename = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setTitleInput(title);
setRenaming(false);
},
[title],
);
const isActiveConvo: boolean = useMemo(
() =>
currentConvoId === conversationId ||
(isLatestConvo &&
currentConvoId === 'new' &&
activeConvos[0] != null &&
activeConvos[0] !== 'new'),
[currentConvoId, conversationId, isLatestConvo, activeConvos],
);
const convoOptionsProps = {
title,
retainView,
renameHandler: handleRename,
isActiveConvo,
conversationId,
isPopoverActive,
setIsPopoverActive,
};
return (
<div
className={cn(
'group relative mt-2 flex h-9 w-full items-center rounded-lg hover:bg-surface-active-alt',
isActiveConvo ? 'bg-surface-active-alt' : '',
isSmallScreen ? 'h-12' : '',
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
)}
role="listitem"
tabIndex={0}
onClick={(e) => {
if (renaming) {
return;
}
if (e.button === 0) {
handleNavigation(e.ctrlKey || e.metaKey);
}
}}
onKeyDown={(e) => {
if (renaming) {
return;
}
if (e.key === 'Enter') {
handleNavigation(false);
}
}}
style={{ cursor: renaming ? 'default' : 'pointer' }}
data-testid="convo-item"
>
{renaming ? (
<div className="absolute inset-0 z-20 flex w-full items-center rounded-lg bg-surface-active-alt p-1.5">
<input
ref={inputRef}
type="text"
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight focus-visible:outline-none"
value={titleInput ?? ''}
onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={handleKeyDown}
aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`}
/>
<div className="flex gap-1">
<button
onClick={cancelRename}
aria-label={`${localize('com_ui_cancel')} ${localize('com_ui_rename')}`}
>
<X
aria-hidden={true}
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
/>
</button>
<button
onClick={onRename}
aria-label={`${localize('com_ui_submit')} ${localize('com_ui_rename')}`}
>
<Check
aria-hidden={true}
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
/>
</button>
</div>
</div>
<RenameForm
titleInput={titleInput}
setTitleInput={setTitleInput}
onSubmit={handleRenameSubmit}
onCancel={handleCancelRename}
localize={localize}
/>
) : (
<a
href={`/c/${conversationId}`}
data-testid="convo-item"
onClick={clickHandler}
className={cn(
'flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2',
isActiveConvo ? 'bg-surface-active-alt' : '',
)}
title={title ?? ''}
<ConvoLink
isActiveConvo={isActiveConvo}
title={title}
onRename={handleRename}
isSmallScreen={isSmallScreen}
localize={localize}
>
<EndpointIcon
conversation={conversation}
@ -198,43 +183,18 @@ export default function Conversation({
size={20}
context="menu-item"
/>
<div
className="relative line-clamp-1 flex-1 grow overflow-hidden"
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTitleInput(title);
setRenaming(true);
}}
>
{title}
</div>
{isActiveConvo ? (
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
) : (
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%" />
)}
</a>
</ConvoLink>
)}
<div
className={cn(
'mr-2',
'mr-2 flex origin-left',
isPopoverActive || isActiveConvo
? 'flex'
: 'hidden group-focus-within:flex group-hover:flex',
? 'pointer-events-auto max-w-[28px] scale-x-100 opacity-100'
: 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[28px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[28px] group-hover:scale-x-100 group-hover:opacity-100',
)}
aria-hidden={!(isPopoverActive || isActiveConvo)}
>
{!renaming && (
<ConvoOptions
title={title}
retainView={retainView}
renameHandler={renameHandler}
isActiveConvo={isActiveConvo}
conversationId={conversationId}
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
/>
)}
{!renaming && <ConvoOptions {...convoOptionsProps} />}
</div>
</div>
);

View file

@ -0,0 +1,61 @@
import React from 'react';
import { cn } from '~/utils';
interface ConvoLinkProps {
isActiveConvo: boolean;
title: string | null;
onRename: () => void;
isSmallScreen: boolean;
localize: (key: any, options?: any) => string;
children: React.ReactNode;
}
const ConvoLink: React.FC<ConvoLinkProps> = ({
isActiveConvo,
title,
onRename,
isSmallScreen,
localize,
children,
}) => {
return (
<div
className={cn(
'flex grow items-center gap-2 overflow-hidden rounded-lg px-2',
isActiveConvo ? 'bg-surface-active-alt' : '',
)}
title={title ?? undefined}
aria-current={isActiveConvo ? 'page' : undefined}
style={{ width: '100%' }}
>
{children}
<div
className="relative flex-1 grow overflow-hidden whitespace-nowrap"
style={{ textOverflow: 'clip' }}
onDoubleClick={(e) => {
if (isSmallScreen) {
return;
}
e.preventDefault();
e.stopPropagation();
onRename();
}}
role="button"
aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')}
>
{title || localize('com_ui_untitled')}
</div>
<div
className={cn(
'absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l',
isActiveConvo
? 'from-surface-active-alt'
: 'from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%',
)}
aria-hidden="true"
/>
</div>
);
};
export default ConvoLink;

View file

@ -1,12 +1,17 @@
import { useState, useId, useRef, memo } from 'react';
import { useState, useId, useRef, memo, useCallback, useMemo } from 'react';
import * as Menu from '@ariakit/react/menu';
import { useParams, useNavigate } from 'react-router-dom';
import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react';
import type { MouseEvent } from 'react';
import type * as t from '~/common';
import { useDuplicateConversationMutation, useGetStartupConfig } from '~/data-provider';
import { useLocalize, useArchiveHandler, useNavigateToConvo } from '~/hooks';
import {
useDuplicateConversationMutation,
useGetStartupConfig,
useArchiveConvoMutation,
} from '~/data-provider';
import { useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks';
import { useToastContext, useChatContext } from '~/Providers';
import { DropdownPopup } from '~/components/ui';
import { DropdownPopup, Spinner } from '~/components';
import { NotificationSeverity } from '~/common';
import DeleteButton from './DeleteButton';
import ShareButton from './ShareButton';
import { cn } from '~/utils';
@ -31,14 +36,20 @@ function ConvoOptions({
const localize = useLocalize();
const { index } = useChatContext();
const { data: startupConfig } = useGetStartupConfig();
const archiveHandler = useArchiveHandler(conversationId, true, retainView);
const { navigateToConvo } = useNavigateToConvo(index);
const { showToast } = useToastContext();
const navigate = useNavigate();
const { conversationId: currentConvoId } = useParams();
const { newConversation } = useNewConvo();
const shareButtonRef = useRef<HTMLButtonElement>(null);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const [showShareDialog, setShowShareDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const archiveConvoMutation = useArchiveConvoMutation();
const duplicateConversation = useDuplicateConversationMutation({
onSuccess: (data) => {
navigateToConvo(data.conversation);
@ -46,6 +57,7 @@ function ConvoOptions({
message: localize('com_ui_duplication_success'),
status: 'success',
});
setIsPopoverActive(false);
},
onMutate: () => {
showToast({
@ -61,62 +73,128 @@ function ConvoOptions({
},
});
const shareHandler = () => {
const isDuplicateLoading = duplicateConversation.isLoading;
const isArchiveLoading = archiveConvoMutation.isLoading;
const handleShareClick = useCallback(() => {
setShowShareDialog(true);
};
}, []);
const deleteHandler = () => {
const handleDeleteClick = useCallback(() => {
setShowDeleteDialog(true);
};
}, []);
const duplicateHandler = () => {
setIsPopoverActive(false);
const handleArchiveClick = useCallback(async () => {
const convoId = conversationId ?? '';
if (!convoId) {
return;
}
archiveConvoMutation.mutate(
{ conversationId: convoId, isArchived: true },
{
onSuccess: () => {
if (currentConvoId === convoId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
retainView();
setIsPopoverActive(false);
},
onError: () => {
showToast({
message: localize('com_ui_archive_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
},
);
}, [
conversationId,
currentConvoId,
archiveConvoMutation,
navigate,
newConversation,
retainView,
setIsPopoverActive,
showToast,
localize,
]);
const handleDuplicateClick = useCallback(() => {
duplicateConversation.mutate({
conversationId: conversationId ?? '',
});
};
}, [conversationId, duplicateConversation]);
const dropdownItems: t.MenuItemProps[] = [
{
label: localize('com_ui_share'),
onClick: shareHandler,
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
show: startupConfig && startupConfig.sharedLinksEnabled,
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
hideOnClick: false,
ref: shareButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_ui_rename'),
onClick: renameHandler,
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_duplicate'),
onClick: duplicateHandler,
icon: <Copy className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_archive'),
onClick: archiveHandler,
icon: <Archive className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_delete'),
onClick: deleteHandler,
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
hideOnClick: false,
ref: deleteButtonRef,
render: (props) => <button {...props} />,
},
];
const dropdownItems = useMemo(
() => [
{
label: localize('com_ui_share'),
onClick: handleShareClick,
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
show: startupConfig && startupConfig.sharedLinksEnabled,
hideOnClick: false,
ref: shareButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_ui_rename'),
onClick: renameHandler,
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
},
{
label: localize('com_ui_duplicate'),
onClick: handleDuplicateClick,
hideOnClick: false,
icon: isDuplicateLoading ? (
<Spinner className="size-4" />
) : (
<Copy className="icon-sm mr-2 text-text-primary" />
),
},
{
label: localize('com_ui_archive'),
onClick: handleArchiveClick,
hideOnClick: false,
icon: isArchiveLoading ? (
<Spinner className="size-4" />
) : (
<Archive className="icon-sm mr-2 text-text-primary" />
),
},
{
label: localize('com_ui_delete'),
onClick: handleDeleteClick,
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
hideOnClick: false,
ref: deleteButtonRef,
render: (props) => <button {...props} />,
},
],
[
localize,
handleShareClick,
startupConfig,
renameHandler,
handleDuplicateClick,
isDuplicateLoading,
handleArchiveClick,
isArchiveLoading,
handleDeleteClick,
],
);
const menuId = useId();
return (
<>
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
@ -124,17 +202,26 @@ function ConvoOptions({
id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')}
className={cn(
'z-30 inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
isActiveConvo === true
'inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
isActiveConvo === true || isPopoverActive
? 'opacity-100'
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
)}
onClick={(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
}}
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
>
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
</Menu.MenuButton>
}
items={dropdownItems}
menuId={menuId}
className="z-30"
/>
{showShareDialog && (
<ShareButton
@ -148,14 +235,22 @@ function ConvoOptions({
<DeleteButton
title={title ?? ''}
retainView={retainView}
conversationId={conversationId ?? ''}
showDeleteDialog={showDeleteDialog}
setShowDeleteDialog={setShowDeleteDialog}
triggerRef={deleteButtonRef}
setMenuOpen={setIsPopoverActive}
showDeleteDialog={showDeleteDialog}
conversationId={conversationId ?? ''}
setShowDeleteDialog={setShowDeleteDialog}
/>
)}
</>
);
}
export default memo(ConvoOptions);
export default memo(ConvoOptions, (prevProps, nextProps) => {
return (
prevProps.conversationId === nextProps.conversationId &&
prevProps.title === nextProps.title &&
prevProps.isPopoverActive === nextProps.isPopoverActive &&
prevProps.isActiveConvo === nextProps.isActiveConvo
);
});

View file

@ -1,12 +1,20 @@
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import type { TMessage } from 'librechat-data-provider';
import {
Button,
Spinner,
OGDialog,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
} from '~/components';
import { useDeleteConversationMutation } from '~/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useNewConvo } from '~/hooks';
import { OGDialog, Label } from '~/components';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
type DeleteButtonProps = {
conversationId: string;
@ -15,13 +23,18 @@ type DeleteButtonProps = {
showDeleteDialog?: boolean;
setShowDeleteDialog?: (value: boolean) => void;
triggerRef?: React.RefObject<HTMLButtonElement>;
setMenuOpen?: React.Dispatch<React.SetStateAction<boolean>>;
};
export function DeleteConversationDialog({
setShowDeleteDialog,
conversationId,
setMenuOpen,
retainView,
title,
}: {
setMenuOpen?: React.Dispatch<React.SetStateAction<boolean>>;
setShowDeleteDialog: (value: boolean) => void;
conversationId: string;
retainView: () => void;
title: string;
@ -29,17 +42,27 @@ export function DeleteConversationDialog({
const localize = useLocalize();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const { newConversation } = useNewConvo();
const { conversationId: currentConvoId } = useParams();
const deleteConvoMutation = useDeleteConversationMutation({
const deleteMutation = useDeleteConversationMutation({
onSuccess: () => {
setShowDeleteDialog(false);
if (currentConvoId === conversationId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
setMenuOpen?.(false);
retainView();
},
onError: () => {
showToast({
message: localize('com_ui_convo_delete_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
const confirmDelete = useCallback(() => {
@ -47,32 +70,30 @@ export function DeleteConversationDialog({
const thread_id = messages?.[messages.length - 1]?.thread_id;
const endpoint = messages?.[messages.length - 1]?.endpoint;
deleteConvoMutation.mutate({ conversationId, thread_id, endpoint, source: 'button' });
}, [conversationId, deleteConvoMutation, queryClient]);
deleteMutation.mutate({ conversationId, thread_id, endpoint, source: 'button' });
}, [conversationId, deleteMutation, queryClient]);
return (
<OGDialogTemplate
<OGDialogContent
title={localize('com_ui_delete_confirm') + ' ' + title}
className="w-11/12 max-w-md"
showCloseButton={false}
title={localize('com_ui_delete_conversation')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
</OGDialogHeader>
<div>
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
</div>
<div className="flex justify-end gap-4 pt-4">
<Button aria-label="cancel" variant="outline" onClick={() => setShowDeleteDialog(false)}>
{localize('com_ui_cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteMutation.isLoading}>
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
</Button>
</div>
</OGDialogContent>
);
}
@ -80,11 +101,12 @@ export default function DeleteButton({
conversationId,
retainView,
title,
setMenuOpen,
showDeleteDialog,
setShowDeleteDialog,
triggerRef,
}: DeleteButtonProps) {
if (showDeleteDialog === undefined && setShowDeleteDialog === undefined) {
if (showDeleteDialog === undefined || setShowDeleteDialog === undefined) {
return null;
}
@ -93,9 +115,11 @@ export default function DeleteButton({
}
return (
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} triggerRef={triggerRef}>
<OGDialog open={showDeleteDialog!} onOpenChange={setShowDeleteDialog!} triggerRef={triggerRef}>
<DeleteConversationDialog
setShowDeleteDialog={setShowDeleteDialog}
conversationId={conversationId}
setMenuOpen={setMenuOpen}
retainView={retainView}
title={title}
/>

View file

@ -0,0 +1,4 @@
export * from './DeleteButton';
export { default as ShareButton } from './ShareButton';
export { default as SharedLinkButton } from './SharedLinkButton';
export { default as ConvoOptions } from './ConvoOptions';

View file

@ -1,21 +1,13 @@
import React, { useState, useRef } from 'react';
import { useRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { VisuallyHidden } from '@ariakit/react';
import { GitFork, InfoIcon } from 'lucide-react';
import * as Popover from '@radix-ui/react-popover';
import { ForkOptions } from 'librechat-data-provider';
import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react';
import {
Checkbox,
HoverCard,
HoverCardTrigger,
HoverCardPortal,
HoverCardContent,
} from '~/components/ui';
import OptionHover from '~/components/SidePanel/Parameters/OptionHover';
import { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks';
import { useForkConvoMutation } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { ESide } from '~/common';
import { cn } from '~/utils';
import store from '~/store';
@ -24,11 +16,11 @@ interface PopoverButtonProps {
setting: ForkOptions;
onClick: (setting: ForkOptions) => void;
setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>;
sideOffset?: number;
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
hoverInfo?: React.ReactNode | string;
hoverTitle?: React.ReactNode | string;
hoverDescription?: React.ReactNode | string;
label: string;
}
const optionLabels: Record<ForkOptions, TranslationKeys> = {
@ -38,57 +30,84 @@ const optionLabels: Record<ForkOptions, TranslationKeys> = {
[ForkOptions.DEFAULT]: 'com_ui_fork_from_message',
};
const chevronDown = (
<svg width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
);
const PopoverButton: React.FC<PopoverButtonProps> = ({
children,
setting,
onClick,
setActiveSetting,
sideOffset = 30,
timeoutRef,
hoverInfo,
hoverTitle,
hoverDescription,
label,
}) => {
const localize = useLocalize();
return (
<HoverCard openDelay={200}>
<Popover.Close
onClick={() => onClick(setting)}
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
<Ariakit.HovercardProvider>
<div className="flex flex-col items-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
onClick={() => onClick(setting)}
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setActiveSetting(optionLabels[setting]);
}}
onMouseLeave={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
}, 175);
}}
className="mx-1 max-w-14 flex-1 rounded-lg border-2 border-border-medium bg-surface-secondary text-text-secondary transition duration-300 ease-in-out hover:border-border-xheavy hover:bg-surface-hover hover:text-text-primary"
aria-label={label}
>
{children}
<VisuallyHidden>{label}</VisuallyHidden>
</Ariakit.Button>
}
setActiveSetting(optionLabels[setting]);
}}
onMouseLeave={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
}, 175);
}}
className="mx-1 max-w-14 flex-1 rounded-lg border-2 bg-white text-gray-700 transition duration-300 ease-in-out hover:bg-gray-200 hover:text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-gray-100"
type="button"
>
{children}
</Popover.Close>
{((hoverInfo != null && hoverInfo !== '') ||
(hoverTitle != null && hoverTitle !== '') ||
(hoverDescription != null && hoverDescription !== '')) && (
<HoverCardPortal>
<HoverCardContent side="right" className="z-[999] w-80 dark:bg-gray-700" sideOffset={sideOffset}>
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_details_about', { 0: label })}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
{((hoverInfo != null && hoverInfo !== '') ||
(hoverTitle != null && hoverTitle !== '') ||
(hoverDescription != null && hoverDescription !== '')) && (
<Ariakit.Hovercard
gutter={16}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-300">
<p className="flex flex-col gap-2 text-sm text-text-secondary">
{hoverInfo && hoverInfo}
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
{hoverDescription && hoverDescription}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
)}
</HoverCard>
</Ariakit.Hovercard>
)}
</div>
</Ariakit.HovercardProvider>
);
};
@ -114,6 +133,9 @@ export default function Fork({
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberDefaultFork);
const popoverStore = Ariakit.usePopoverStore({
placement: 'top',
});
const forkConvo = useForkConvoMutation({
onSuccess: (data) => {
navigateToConvo(data.conversation);
@ -157,173 +179,230 @@ export default function Fork({
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
className={cn(
'hover-button active rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
'data-[state=open]:active focus:opacity-100 data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
!isLast ? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={(e) => {
if (rememberGlobal) {
e.preventDefault();
forkConvo.mutate({
messageId,
splitAtTarget,
conversationId,
option: forkSetting,
latestMessageId,
});
}
}}
type="button"
title={localize('com_ui_fork')}
>
<GitFork className="h-4 w-4 hover:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
</Popover.Trigger>
<Popover.Portal>
<div dir="ltr">
<Popover.Content
side="top"
role="menu"
className="bg-token-surface-primary flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg bg-white p-2 px-3 shadow-lg dark:bg-gray-700"
style={{ outline: 'none', pointerEvents: 'auto', boxSizing: 'border-box' }}
tabIndex={-1}
sideOffset={5}
align="center"
<>
<Ariakit.PopoverAnchor
store={popoverStore}
render={
<button
className={cn(
'hover-button active rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
'data-[state=open]:active focus:opacity-100 data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
!isLast
? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100'
: '',
)}
onClick={(e) => {
if (rememberGlobal) {
e.preventDefault();
forkConvo.mutate({
messageId,
splitAtTarget,
conversationId,
option: forkSetting,
latestMessageId,
});
} else {
popoverStore.toggle();
}
}}
type="button"
aria-label={localize('com_ui_fork')}
>
<div className="flex h-6 w-full items-center justify-center text-sm dark:text-gray-200">
{localize(activeSetting )}
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-gray-500 dark:text-white/50" />
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent
side="right"
className="z-[999] w-80 dark:bg-gray-700"
sideOffset={19}
<GitFork className="h-4 w-4 hover:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
}
/>
<Ariakit.Popover
store={popoverStore}
gutter={5}
className="flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg border border-border-heavy bg-surface-secondary p-2 px-3 shadow-lg"
style={{
outline: 'none',
pointerEvents: 'auto',
zIndex: 50,
}}
portal={true}
unmountOnHide={true}
>
<div className="flex h-8 w-full items-center justify-center text-sm text-text-primary">
{localize(activeSetting)}
<Ariakit.HovercardProvider>
<div className="ml-auto flex h-6 w-6 items-center justify-center gap-1">
<Ariakit.HovercardAnchor
render={
<button
className="flex h-5 w-5 items-center rounded-full text-text-secondary"
aria-label={localize('com_ui_fork_info_button_label')}
>
<div className="flex flex-col gap-2 space-y-2 text-sm text-gray-600 dark:text-gray-300">
<span>{localize('com_ui_fork_info_1')}</span>
<span>{localize('com_ui_fork_info_2')}</span>
<span>
{localize('com_ui_fork_info_3', {
0: localize('com_ui_fork_split_target'),
})}
</span>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
</div>
<div className="flex h-full w-full items-center justify-center gap-1">
<PopoverButton
sideOffset={155}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.DIRECT_PATH}
hoverTitle={
<>
<GitCommit className="h-5 w-5 rotate-90" />
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
</>
<InfoIcon />
</button>
}
hoverDescription={localize('com_ui_fork_info_visible')}
>
<HoverCardTrigger asChild>
<GitCommit className="h-full w-full rotate-90 p-2" />
</HoverCardTrigger>
</PopoverButton>
<PopoverButton
sideOffset={90}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.INCLUDE_BRANCHES}
hoverTitle={
<>
<GitBranchPlus className="h-4 w-4 rotate-180" />
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
</>
}
hoverDescription={localize('com_ui_fork_info_branches')}
>
<HoverCardTrigger asChild>
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
</HoverCardTrigger>
</PopoverButton>
<PopoverButton
sideOffset={25}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.TARGET_LEVEL}
hoverTitle={
<>
<ListTree className="h-5 w-5" />
{`${localize(
optionLabels[ForkOptions.TARGET_LEVEL],
)} (${localize('com_endpoint_default')})`}
</>
}
hoverDescription={localize('com_ui_fork_info_target')}
>
<HoverCardTrigger asChild>
<ListTree className="h-full w-full p-2" />
</HoverCardTrigger>
</PopoverButton>
</div>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<div className="flex h-6 w-full items-center justify-start text-sm dark:text-gray-300 dark:hover:text-gray-200">
<Checkbox
checked={splitAtTarget}
onCheckedChange={(checked: boolean) => setSplitAtTarget(checked)}
className="m-2 transition duration-300 ease-in-out"
/>
{localize('com_ui_fork_split_target')}
</div>
</HoverCardTrigger>
<OptionHover
side={ESide.Right}
description="com_ui_fork_info_start"
langCode={true}
sideOffset={20}
/>
</HoverCard>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<div className="flex h-6 w-full items-center justify-start text-sm dark:text-gray-300 dark:hover:text-gray-200">
<Checkbox
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>{localize('com_ui_fork_more_info_options')}</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={19}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="flex flex-col gap-2 space-y-2 text-sm text-text-secondary">
<span>{localize('com_ui_fork_info_1')}</span>
<span>{localize('com_ui_fork_info_2')}</span>
<span>
{localize('com_ui_fork_info_3', {
0: localize('com_ui_fork_split_target'),
})}
</span>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
</div>
<div className="flex h-full w-full items-center justify-center gap-1">
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.DIRECT_PATH}
label={localize(optionLabels[ForkOptions.DIRECT_PATH])}
hoverTitle={
<>
<GitCommit className="h-5 w-5 rotate-90" />
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
</>
}
hoverDescription={localize('com_ui_fork_info_visible')}
>
<GitCommit className="h-full w-full rotate-90 p-2" />
</PopoverButton>
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.INCLUDE_BRANCHES}
label={localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
hoverTitle={
<>
<GitBranchPlus className="h-4 w-4 rotate-180" />
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
</>
}
hoverDescription={localize('com_ui_fork_info_branches')}
>
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
</PopoverButton>
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.TARGET_LEVEL}
label={localize(optionLabels[ForkOptions.TARGET_LEVEL])}
hoverTitle={
<>
<ListTree className="h-5 w-5" />
{`${localize(
optionLabels[ForkOptions.TARGET_LEVEL],
)} (${localize('com_endpoint_default')})`}
</>
}
hoverDescription={localize('com_ui_fork_info_target')}
>
<ListTree className="h-full w-full p-2" />
</PopoverButton>
</div>
<Ariakit.HovercardProvider>
<div className="flex items-center">
<Ariakit.HovercardAnchor
render={
<div className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary">
<Ariakit.Checkbox
id="split-target-checkbox"
checked={splitAtTarget}
onChange={(event) => setSplitAtTarget(event.target.checked)}
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
aria-label={localize('com_ui_fork_split_target')}
/>
<label htmlFor="split-target-checkbox" className="ml-2 cursor-pointer">
{localize('com_ui_fork_split_target')}
</label>
</div>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_info_split_target', {
0: localize('com_ui_fork_split_target'),
})}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={32}
className="z-[999] w-80 select-none rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_start')}</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
<Ariakit.HovercardProvider>
<div className="flex items-center">
<Ariakit.HovercardAnchor
render={
<div
onClick={() => setRemember((prev) => !prev)}
className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary"
>
<Ariakit.Checkbox
id="remember-checkbox"
checked={remember}
onCheckedChange={(checked: boolean) => {
onChange={(event) => {
const checked = event.target.checked;
console.log('checked', checked);
if (checked) {
showToast({
message: localize('com_ui_fork_remember_checked'),
status: 'info',
});
}
setRemember(checked);
return setRemember(checked);
}}
className="m-2 transition duration-300 ease-in-out"
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
aria-label={localize('com_ui_fork_remember')}
/>
{localize('com_ui_fork_remember')}
<label htmlFor="remember-checkbox" className="ml-2 cursor-pointer">
{localize('com_ui_fork_remember')}
</label>
</div>
</HoverCardTrigger>
<OptionHover
side={ESide.Right}
description="com_ui_fork_info_remember"
langCode={true}
sideOffset={20}
/>
</HoverCard>
</Popover.Content>
</div>
</Popover.Portal>
</Popover.Root>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_info_remember', {
0: localize('com_ui_fork_remember'),
})}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={14}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_remember')}</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
</Ariakit.Popover>
</>
);
}

View file

@ -0,0 +1,77 @@
import React, { useEffect, useRef } from 'react';
import { Check, X } from 'lucide-react';
import type { KeyboardEvent } from 'react';
interface RenameFormProps {
titleInput: string;
setTitleInput: (value: string) => void;
onSubmit: (title: string) => void;
onCancel: () => void;
localize: (key: any, options?: any) => string;
}
const RenameForm: React.FC<RenameFormProps> = ({
titleInput,
setTitleInput,
onSubmit,
onCancel,
localize,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, []);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'Escape':
onCancel();
break;
case 'Enter':
onSubmit(titleInput);
break;
}
};
return (
<div
className="absolute inset-0 z-20 flex w-full items-center rounded-lg bg-surface-active-alt p-1.5"
role="form"
aria-label={localize('com_ui_rename_conversation')}
>
<input
ref={inputRef}
type="text"
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight focus-visible:outline-none"
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSubmit(titleInput)}
maxLength={100}
aria-label={localize('com_ui_new_conversation_title')}
/>
<div className="flex gap-1" role="toolbar">
<button
onClick={() => onCancel()}
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
aria-label={localize('com_ui_cancel')}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
<button
onClick={() => onSubmit(titleInput)}
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
aria-label={localize('com_ui_save')}
>
<Check className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
);
};
export default RenameForm;

View file

@ -63,7 +63,6 @@ export default function EndpointIcon({
isCreatedByUser={false}
chatGptLabel={undefined}
modelLabel={undefined}
jailbreak={undefined}
/>
);
}

View file

@ -25,7 +25,7 @@ type EndpointIcon = {
function getOpenAIColor(_model: string | null | undefined) {
const model = _model?.toLowerCase() ?? '';
if (model && /\b(o1|o3)\b/i.test(model)) {
if (model && /\b(o\d)\b/i.test(model)) {
return '#000000';
}
return model.includes('gpt-4') ? '#AB68FF' : '#19C37D';
@ -34,10 +34,7 @@ function getOpenAIColor(_model: string | null | undefined) {
function getGoogleIcon(model: string | null | undefined, size: number) {
if (model?.toLowerCase().includes('code') === true) {
return <CodeyIcon size={size * 0.75} />;
} else if (
model?.toLowerCase().includes('gemini') === true ||
model?.toLowerCase().includes('learnlm') === true
) {
} else if (/gemini|learnlm|gemma/.test(model?.toLowerCase() ?? '')) {
return <GeminiIcon size={size * 0.7} />;
} else {
return <PaLMIcon size={size * 0.7} />;
@ -52,6 +49,8 @@ function getGoogleModelName(model: string | null | undefined) {
model?.toLowerCase().includes('learnlm') === true
) {
return 'Gemini';
} else if (model?.toLowerCase().includes('gemma') === true) {
return 'Gemma';
} else {
return 'PaLM2';
}

View file

@ -30,7 +30,7 @@ function AccountSettings() {
<Select.Select
aria-label={localize('com_nav_account_settings')}
data-testid="nav-user"
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-accent"
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover"
>
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
<div className="relative flex">

View file

@ -1,10 +1,12 @@
import { type FC } from 'react';
import { useMemo } from 'react';
import type { FC } from 'react';
import { useRecoilValue } from 'recoil';
import { Menu, MenuButton, MenuItems } from '@headlessui/react';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import { useGetConversationTags } from '~/data-provider';
import BookmarkNavItems from './BookmarkNavItems';
import { TooltipAnchor } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -19,33 +21,48 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
const localize = useLocalize();
const { data } = useGetConversationTags();
const conversation = useRecoilValue(store.conversationByIndex(0));
const label = useMemo(
() => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')),
[tags, localize],
);
return (
<Menu as="div" className="group relative">
{({ open }) => (
<>
<MenuButton
className={cn(
'mt-text-sm flex h-10 w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors duration-200 hover:bg-surface-active-alt',
open ? 'bg-surface-active-alt' : '',
isSmallScreen ? 'h-12' : '',
)}
data-testid="bookmark-menu"
>
<div className="h-7 w-7 flex-shrink-0">
<div className="relative flex h-full items-center justify-center rounded-full border border-border-medium bg-surface-primary-alt text-text-primary">
{tags.length > 0 ? (
<BookmarkFilledIcon className="h-4 w-4" aria-hidden="true" />
) : (
<BookmarkIcon className="h-4 w-4" aria-hidden="true" />
<TooltipAnchor
description={label}
render={
<MenuButton
id="bookmark-menu-button"
aria-label={localize('com_ui_bookmarks')}
className={cn(
'flex items-center justify-center',
'size-10 border-none text-text-primary hover:bg-accent hover:text-accent-foreground',
'rounded-full border-none p-2 hover:bg-surface-hover md:rounded-xl',
open ? 'bg-surface-hover' : '',
)}
</div>
</div>
<div className="grow overflow-hidden whitespace-nowrap text-left text-sm font-medium text-text-primary">
{tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')}
</div>
</MenuButton>
<MenuItems className="absolute left-0 top-full z-[100] mt-1 w-full translate-y-0 overflow-hidden rounded-lg bg-surface-active-alt p-1.5 shadow-lg outline-none">
data-testid="bookmark-menu"
>
{tags.length > 0 ? (
<BookmarkFilledIcon
/** `isSmallScreen` is used because lazy loading is not influencing `md:` prefix for some reason */
className={cn('text-text-primary', isSmallScreen ? 'icon-md-heavy' : 'icon-lg')}
aria-hidden="true"
/>
) : (
<BookmarkIcon
className={cn('text-text-primary', isSmallScreen ? 'icon-md-heavy' : 'icon-lg')}
aria-hidden="true"
/>
)}
</MenuButton>
}
/>
<MenuItems
anchor="bottom"
className="absolute left-0 top-full z-[100] mt-1 w-60 translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none"
>
{data && conversation && (
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
<BookmarkNavItems

View file

@ -1,10 +1,18 @@
import filenamify from 'filenamify';
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo, useCallback } from 'react';
import type { TConversation } from 'librechat-data-provider';
import { OGDialog, Button, Input, Label, Checkbox, Dropdown } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useExportConversation } from '~/hooks';
const TYPE_OPTIONS = [
{ value: 'screenshot', label: 'screenshot (.png)' },
{ value: 'text', label: 'text (.txt)' },
{ value: 'markdown', label: 'markdown (.md)' },
{ value: 'json', label: 'json (.json)' },
{ value: 'csv', label: 'csv (.csv)' },
];
export default function ExportModal({
open,
onOpenChange,
@ -21,20 +29,12 @@ export default function ExportModal({
const localize = useLocalize();
const [filename, setFileName] = useState('');
const [type, setType] = useState('Select a file type');
const [type, setType] = useState<string>('screenshot');
const [includeOptions, setIncludeOptions] = useState<boolean | 'indeterminate'>(true);
const [exportBranches, setExportBranches] = useState<boolean | 'indeterminate'>(false);
const [recursive, setRecursive] = useState<boolean | 'indeterminate'>(true);
const typeOptions = [
{ value: 'screenshot', label: 'screenshot (.png)' },
{ value: 'text', label: 'text (.txt)' },
{ value: 'markdown', label: 'markdown (.md)' },
{ value: 'json', label: 'json (.json)' },
{ value: 'csv', label: 'csv (.csv)' },
];
useEffect(() => {
if (!open && triggerRef && triggerRef.current) {
triggerRef.current.focus();
@ -49,17 +49,19 @@ export default function ExportModal({
setRecursive(true);
}, [conversation?.title, open]);
const _setType = (newType: string) => {
const exportBranchesSupport = newType === 'json' || newType === 'csv' || newType === 'webpage';
const exportOptionsSupport = newType !== 'csv' && newType !== 'screenshot';
setExportBranches(exportBranchesSupport);
setIncludeOptions(exportOptionsSupport);
const handleTypeChange = useCallback((newType: string) => {
const branches = newType === 'json' || newType === 'csv' || newType === 'webpage';
const options = newType !== 'csv' && newType !== 'screenshot';
setExportBranches(branches);
setIncludeOptions(options);
setType(newType);
};
}, []);
const exportBranchesSupport = type === 'json' || type === 'csv' || type === 'webpage';
const exportOptionsSupport = type !== 'csv' && type !== 'screenshot';
const exportBranchesSupport = useMemo(
() => type === 'json' || type === 'csv' || type === 'webpage',
[type],
);
const exportOptionsSupport = useMemo(() => type !== 'csv' && type !== 'screenshot', [type]);
const { exportConversation } = useExportConversation({
conversation,
@ -94,7 +96,13 @@ export default function ExportModal({
<Label htmlFor="type" className="text-left text-sm font-medium">
{localize('com_nav_export_type')}
</Label>
<Dropdown value={type} onChange={_setType} options={typeOptions} portal={false} />
<Dropdown
value={type}
onChange={handleTypeChange}
options={TYPE_OPTIONS}
className="z-50"
portal={false}
/>
</div>
</div>
<div className="grid w-full gap-6 sm:grid-cols-2">
@ -108,7 +116,6 @@ export default function ExportModal({
id="includeOptions"
disabled={!exportOptionsSupport}
checked={includeOptions}
className="focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
onCheckedChange={setIncludeOptions}
/>
<label
@ -131,7 +138,6 @@ export default function ExportModal({
id="exportBranches"
disabled={!exportBranchesSupport}
checked={exportBranches}
className="focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
onCheckedChange={setExportBranches}
/>
<label
@ -150,12 +156,7 @@ export default function ExportModal({
{localize('com_nav_export_recursive_or_sequential')}
</Label>
<div className="flex h-[40px] w-full items-center space-x-3">
<Checkbox
id="recursive"
checked={recursive}
className="focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
onCheckedChange={setRecursive}
/>
<Checkbox id="recursive" checked={recursive} onCheckedChange={setRecursive} />
<label
htmlFor="recursive"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"

View file

@ -61,6 +61,7 @@ export default function MobileNav({
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
}}
>

View file

@ -1,102 +0,0 @@
import 'test/resizeObserver.mock';
import 'test/matchMedia.mock';
import 'test/localStorage.mock';
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { AuthContextProvider } from '~/hooks/AuthContext';
import { SearchContext } from '~/Providers';
import Nav from './Nav';
const renderNav = ({ search, navVisible, setNavVisible }) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<RecoilRoot>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<AuthContextProvider>
<SearchContext.Provider value={search}>
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
</SearchContext.Provider>
</AuthContextProvider>
</QueryClientProvider>
</BrowserRouter>
</RecoilRoot>,
);
};
const mockMatchMedia = (mediaQueryList?: string[]) => {
mediaQueryList = mediaQueryList || [];
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: mediaQueryList.includes(query),
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
};
describe('Nav', () => {
beforeEach(() => {
mockMatchMedia();
});
it('renders visible', () => {
const { getByTestId } = renderNav({
search: { data: [], pageNumber: 1 },
navVisible: true,
setNavVisible: jest.fn(),
});
expect(getByTestId('nav')).toBeVisible();
});
it('renders hidden', async () => {
const { getByTestId } = renderNav({
search: { data: [], pageNumber: 1 },
navVisible: false,
setNavVisible: jest.fn(),
});
expect(getByTestId('nav')).not.toBeVisible();
});
it('renders hidden when small screen is detected', async () => {
mockMatchMedia(['(max-width: 768px)']);
const navVisible = true;
const mockSetNavVisible = jest.fn();
const { getByTestId } = renderNav({
search: { data: [], pageNumber: 1 },
navVisible: navVisible,
setNavVisible: mockSetNavVisible,
});
// nav is initially visible
expect(getByTestId('nav')).toBeVisible();
// when small screen is detected, the nav is hidden
expect(mockSetNavVisible.mock.calls).toHaveLength(1);
const updatedNavVisible = mockSetNavVisible.mock.calls[0][0](navVisible);
expect(updatedNavVisible).not.toEqual(navVisible);
expect(updatedNavVisible).toBeFalsy();
});
});

View file

@ -1,7 +1,8 @@
import { useCallback, useEffect, useState, useMemo, memo } from 'react';
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
import {
useLocalize,
useHasAccess,
@ -12,213 +13,229 @@ import {
} from '~/hooks';
import { useConversationsInfiniteQuery } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
import BookmarkNav from './Bookmarks/BookmarkNav';
import AccountSettings from './AccountSettings';
import { useSearchContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import SearchBar from './SearchBar';
import NavToggle from './NavToggle';
import NewChat from './NewChat';
import { cn } from '~/utils';
import store from '~/store';
const Nav = ({
navVisible,
setNavVisible,
}: {
navVisible: boolean;
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
const AccountSettings = lazy(() => import('./AccountSettings'));
const [navWidth, setNavWidth] = useState('260px');
const [isHovering, setIsHovering] = useState(false);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [newUser, setNewUser] = useLocalStorage('newUser', true);
const [isToggleHovering, setIsToggleHovering] = useState(false);
const NAV_WIDTH_DESKTOP = '260px';
const NAV_WIDTH_MOBILE = '320px';
const hasAccessToBookmarks = useHasAccess({
permissionType: PermissionTypes.BOOKMARKS,
permission: Permissions.USE,
});
const NavMask = memo(
({ navVisible, toggleNavVisible }: { navVisible: boolean; toggleNavVisible: () => void }) => (
<div
id="mobile-nav-mask-toggle"
role="button"
tabIndex={0}
className={`nav-mask ${navVisible ? 'active' : ''}`}
onClick={toggleNavVisible}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
toggleNavVisible();
}
}}
aria-label="Toggle navigation"
/>
),
);
const handleMouseEnter = useCallback(() => {
setIsHovering(true);
}, []);
const MemoNewChat = memo(NewChat);
const handleMouseLeave = useCallback(() => {
setIsHovering(false);
}, []);
const Nav = memo(
({
navVisible,
setNavVisible,
}: {
navVisible: boolean;
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
useEffect(() => {
if (isSmallScreen) {
const savedNavVisible = localStorage.getItem('navVisible');
if (savedNavVisible === null) {
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [newUser, setNewUser] = useLocalStorage('newUser', true);
const [showLoading, setShowLoading] = useState(false);
const [tags, setTags] = useState<string[]>([]);
const hasAccessToBookmarks = useHasAccess({
permissionType: PermissionTypes.BOOKMARKS,
permission: Permissions.USE,
});
const search = useRecoilValue(store.search);
const { data, fetchNextPage, isFetchingNextPage, isLoading, isFetching, refetch } =
useConversationsInfiniteQuery(
{
tags: tags.length === 0 ? undefined : tags,
search: search.debouncedQuery || undefined,
},
{
enabled: isAuthenticated,
staleTime: 30000,
cacheTime: 300000,
},
);
const computedHasNextPage = useMemo(() => {
if (data?.pages && data.pages.length > 0) {
const lastPage: ConversationListResponse = data.pages[data.pages.length - 1];
return lastPage.nextCursor !== null;
}
return false;
}, [data?.pages]);
const outerContainerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<any>(null);
const { moveToTop } = useNavScrolling<ConversationListResponse>({
setShowLoading,
fetchNextPage: async (options?) => {
if (computedHasNextPage) {
return fetchNextPage(options);
}
return Promise.resolve(
{} as InfiniteQueryObserverResult<ConversationListResponse, unknown>,
);
},
isFetchingNext: isFetchingNextPage,
});
const conversations = useMemo(() => {
return data ? data.pages.flatMap((page) => page.conversations) : [];
}, [data]);
const toggleNavVisible = useCallback(() => {
setNavVisible((prev: boolean) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
});
if (newUser) {
setNewUser(false);
}
}, [newUser, setNavVisible, setNewUser]);
const itemToggleNav = useCallback(() => {
if (isSmallScreen) {
toggleNavVisible();
}
setNavWidth('320px');
} else {
setNavWidth('260px');
}
}, [isSmallScreen]);
}, [isSmallScreen, toggleNavVisible]);
const [showLoading, setShowLoading] = useState(false);
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
const { pageNumber, searchQuery, setPageNumber, searchQueryRes } = useSearchContext();
const [tags, setTags] = useState<string[]>([]);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
useConversationsInfiniteQuery(
{
pageNumber: pageNumber.toString(),
isArchived: false,
tags: tags.length === 0 ? undefined : tags,
},
{ enabled: isAuthenticated },
);
useEffect(() => {
// When a tag is selected, refetch the list of conversations related to that tag
refetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({
setShowLoading,
hasNextPage: searchQuery ? searchQueryRes?.hasNextPage : hasNextPage,
fetchNextPage: searchQuery ? searchQueryRes?.fetchNextPage : fetchNextPage,
isFetchingNextPage: searchQuery
? searchQueryRes?.isFetchingNextPage ?? false
: isFetchingNextPage,
});
const conversations = useMemo(
() =>
(searchQuery ? searchQueryRes?.data : data)?.pages.flatMap((page) => page.conversations) ||
[],
[data, searchQuery, searchQueryRes?.data],
);
const toggleNavVisible = () => {
setNavVisible((prev: boolean) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
});
if (newUser) {
setNewUser(false);
}
};
const itemToggleNav = () => {
if (isSmallScreen) {
toggleNavVisible();
}
};
return (
<>
<div
data-testid="nav"
className={
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt md:max-w-[260px]'
useEffect(() => {
if (isSmallScreen) {
const savedNavVisible = localStorage.getItem('navVisible');
if (savedNavVisible === null) {
toggleNavVisible();
}
style={{
width: navVisible ? navWidth : '0px',
visibility: navVisible ? 'visible' : 'hidden',
transition: 'width 0.2s, visibility 0.2s',
}}
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full min-h-0 flex-col">
<div
className={cn(
'flex h-full min-h-0 flex-col transition-opacity',
isToggleHovering && !isSmallScreen ? 'opacity-50' : 'opacity-100',
)}
>
<div
className={cn(
'scrollbar-trigger relative h-full w-full flex-1 items-start border-white/20',
)}
>
<nav
id="chat-history-nav"
aria-label={localize('com_ui_chat_history')}
className="flex h-full w-full flex-col px-3 pb-3.5"
>
<div
className={cn(
'-mr-2 flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
isHovering ? '' : 'scrollbar-transparent',
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={containerRef}
>
<NewChat
toggleNav={itemToggleNav}
isSmallScreen={isSmallScreen}
subHeaders={
<>
{isSearchEnabled === true && (
<SearchBar
setPageNumber={setPageNumber}
isSmallScreen={isSmallScreen}
/>
)}
{hasAccessToBookmarks === true && (
<>
<div className="mt-1.5" />
<BookmarkNav
tags={tags}
setTags={setTags}
isSmallScreen={isSmallScreen}
/>
</>
)}
</>
}
/>
setNavWidth(NAV_WIDTH_MOBILE);
} else {
setNavWidth(NAV_WIDTH_DESKTOP);
}
}, [isSmallScreen, toggleNavVisible]);
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={itemToggleNav}
/>
{(isFetchingNextPage || showLoading) && (
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-text-primary')} />
)}
</div>
<AccountSettings />
</nav>
useEffect(() => {
refetch();
}, [tags, refetch]);
const loadMoreConversations = useCallback(() => {
if (isFetchingNextPage || !computedHasNextPage) {
return;
}
fetchNextPage();
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
const subHeaders = useMemo(
() => search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />,
[search.enabled, isSmallScreen],
);
const headerButtons = useMemo(
() =>
hasAccessToBookmarks && (
<>
<div className="mt-1.5" />
<Suspense fallback={null}>
<BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} />
</Suspense>
</>
),
[hasAccessToBookmarks, tags, isSmallScreen],
);
const [isSearchLoading, setIsSearchLoading] = useState(
!!search.query && (search.isTyping || isLoading || isFetching),
);
useEffect(() => {
if (search.isTyping) {
setIsSearchLoading(true);
} else if (!isLoading && !isFetching) {
setIsSearchLoading(false);
} else if (!!search.query && (isLoading || isFetching)) {
setIsSearchLoading(true);
}
}, [search.query, search.isTyping, isLoading, isFetching]);
return (
<>
<div
data-testid="nav"
className={cn(
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
'md:max-w-[260px]',
)}
style={{
width: navVisible ? navWidth : '0px',
visibility: navVisible ? 'visible' : 'hidden',
transition: 'width 0.2s, visibility 0.2s',
}}
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full flex-col">
<div className="flex h-full flex-col transition-opacity">
<div className="flex h-full flex-col">
<nav
id="chat-history-nav"
aria-label={localize('com_ui_chat_history')}
className="flex h-full flex-col px-2 pb-3.5 md:px-3"
>
<div className="flex flex-1 flex-col" ref={outerContainerRef}>
<MemoNewChat
subHeaders={subHeaders}
toggleNav={toggleNavVisible}
headerButtons={headerButtons}
isSmallScreen={isSmallScreen}
/>
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={itemToggleNav}
containerRef={listRef}
loadMoreConversations={loadMoreConversations}
isLoading={isFetchingNextPage || showLoading || isLoading}
isSearchLoading={isSearchLoading}
/>
</div>
<Suspense fallback={null}>
<AccountSettings />
</Suspense>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
<NavToggle
isHovering={isToggleHovering}
setIsHovering={setIsToggleHovering}
onToggle={toggleNavVisible}
navVisible={navVisible}
className="fixed left-0 top-1/2 z-40 hidden md:flex"
/>
{isSmallScreen && (
<div
id="mobile-nav-mask-toggle"
role="button"
tabIndex={0}
className={`nav-mask ${navVisible ? 'active' : ''}`}
onClick={toggleNavVisible}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
toggleNavVisible();
}
}}
aria-label="Toggle navigation"
/>
)}
</>
);
};
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
</>
);
},
);
export default memo(Nav);
Nav.displayName = 'Nav';
export default Nav;

View file

@ -1,124 +1,94 @@
import { Search } from 'lucide-react';
import React, { useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { useGetEndpointsQuery } from '~/data-provider';
import type { TMessage, TStartupConfig } from 'librechat-data-provider';
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
import { getDefaultModelSpec, getModelSpecPreset } from '~/utils';
import { TooltipAnchor, Button } from '~/components/ui';
import { useLocalize, useNewConvo } from '~/hooks';
import { icons } from '~/hooks/Endpoint/Icons';
import { NewChatIcon } from '~/components/svg';
import { cn } from '~/utils';
import store from '~/store';
const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | null }) => {
const searchQuery = useRecoilValue(store.searchQuery);
const { data: endpointsConfig } = useGetEndpointsQuery();
if (searchQuery) {
return (
<div className="shadow-stroke relative flex h-7 w-7 items-center justify-center rounded-full bg-white text-black dark:bg-white">
<Search className="h-5 w-5" />
</div>
);
}
let { endpoint = '' } = conversation ?? {};
const iconURL = conversation?.iconURL ?? '';
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType, endpointIconURL });
const Icon = icons[iconKey];
return (
<div className="h-7 w-7 flex-shrink-0">
{iconURL && iconURL.includes('http') ? (
<ConvoIconURL
iconURL={iconURL}
modelLabel={conversation?.chatGptLabel ?? conversation?.modelLabel ?? ''}
endpointIconURL={iconURL}
context="nav"
/>
) : (
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
{endpoint && Icon != null && (
<Icon
size={41}
context="nav"
className="h-2/3 w-2/3"
endpoint={endpoint}
endpointType={endpointType}
iconURL={endpointIconURL}
/>
)}
</div>
)}
</div>
);
};
export default function NewChat({
index = 0,
toggleNav,
subHeaders,
isSmallScreen,
headerButtons,
}: {
index?: number;
toggleNav: () => void;
isSmallScreen?: boolean;
subHeaders?: React.ReactNode;
isSmallScreen: boolean;
headerButtons?: React.ReactNode;
}) {
const queryClient = useQueryClient();
/** Note: this component needs an explicit index passed if using more than one */
const { newConversation: newConvo } = useNewConvo(index);
const navigate = useNavigate();
const localize = useLocalize();
const { conversation } = store.useCreateConversationAtom(index);
const clickHandler = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
event.preventDefault();
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
window.open('/c/new', '_blank');
return;
}
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
newConvo();
navigate('/c/new');
toggleNav();
}
};
navigate('/c/new', { state: { focusChat: true } });
if (isSmallScreen) {
toggleNav();
}
},
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
);
return (
<div className="sticky left-0 right-0 top-0 z-50 bg-surface-primary-alt pt-3.5">
<div className="pb-0.5 last:pb-0" style={{ transform: 'none' }}>
<a
href="/"
tabIndex={0}
data-testid="nav-new-chat-button"
onClick={clickHandler}
className={cn(
'group flex h-10 items-center gap-2 rounded-lg px-2 font-medium transition-colors duration-200 hover:bg-surface-hover',
isSmallScreen ? 'h-14' : '',
)}
aria-label={localize('com_ui_new_chat')}
>
<NewChatButtonIcon conversation={conversation} />
<div className="grow overflow-hidden text-ellipsis whitespace-nowrap text-sm text-text-primary">
{localize('com_ui_new_chat')}
</div>
<div className="flex gap-3">
<span className="flex items-center" data-state="closed">
<NewChatIcon className="size-5" />
</span>
</div>
</a>
<>
<div className="flex items-center justify-between py-[2px] md:py-2">
<TooltipAnchor
description={localize('com_nav_close_sidebar')}
render={
<Button
size="icon"
variant="outline"
data-testid="close-sidebar-button"
aria-label={localize('com_nav_close_sidebar')}
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
onClick={toggleNav}
>
<Sidebar className="max-md:hidden" />
<MobileSidebar className="m-1 inline-flex size-10 items-center justify-center md:hidden" />
</Button>
}
/>
<div className="flex">
{headerButtons}
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
variant="outline"
data-testid="nav-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
onClick={clickHandler}
>
<NewChatIcon className="icon-md md:h-6 md:w-6" />
</Button>
}
/>
</div>
</div>
{subHeaders != null ? subHeaders : null}
</div>
</>
);
}

View file

@ -1,46 +1,51 @@
import React, { forwardRef, useState, useCallback, useMemo, useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilState } from 'recoil';
import { Search, X } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { forwardRef, useState, useCallback, useMemo, Ref } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocalize, useNewConvo } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
type SearchBarProps = {
isSmallScreen?: boolean;
setPageNumber: React.Dispatch<React.SetStateAction<number>>;
};
const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) => {
const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivElement>) => {
const localize = useLocalize();
const location = useLocation();
const queryClient = useQueryClient();
const { setPageNumber, isSmallScreen } = props;
const navigate = useNavigate();
const { isSmallScreen } = props;
const [text, setText] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [showClearIcon, setShowClearIcon] = useState(false);
const { newConversation } = useNewConvo();
const clearConvoState = store.useClearConvoState();
const setSearchQuery = useSetRecoilState(store.searchQuery);
const setIsSearching = useSetRecoilState(store.isSearching);
const [search, setSearchState] = useRecoilState(store.search);
const clearSearch = useCallback(() => {
setPageNumber(1);
if (location.pathname.includes('/search')) {
newConversation({ disableFocus: true });
navigate('/c/new', { replace: true });
}
}, [newConversation, setPageNumber, location.pathname]);
}, [newConversation, location.pathname, navigate]);
const clearText = useCallback(() => {
setShowClearIcon(false);
setSearchQuery('');
clearSearch();
setText('');
}, [setSearchQuery, clearSearch]);
setSearchState((prev) => ({
...prev,
query: '',
debouncedQuery: '',
isTyping: false,
}));
clearSearch();
inputRef.current?.focus();
}, [setSearchState, clearSearch]);
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
const { value } = e.target as HTMLInputElement;
@ -51,27 +56,48 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
const sendRequest = useCallback(
(value: string) => {
setSearchQuery(value);
if (!value) {
return;
}
queryClient.invalidateQueries([QueryKeys.messages]);
clearConvoState();
},
[queryClient, clearConvoState, setSearchQuery],
[queryClient],
);
// TODO: make the debounce time configurable via yaml
const debouncedSendRequest = useMemo(() => debounce(sendRequest, 350), [sendRequest]);
const debouncedSetDebouncedQuery = useMemo(
() =>
debounce((value: string) => {
setSearchState((prev) => ({ ...prev, debouncedQuery: value, isTyping: false }));
sendRequest(value);
}, 500),
[setSearchState, sendRequest],
);
const onChange = (e: React.FormEvent<HTMLInputElement>) => {
const { value } = e.target as HTMLInputElement;
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setShowClearIcon(value.length > 0);
setText(value);
debouncedSendRequest(value);
setIsSearching(true);
setSearchState((prev) => ({
...prev,
query: value,
isTyping: true,
}));
debouncedSetDebouncedQuery(value);
if (value.length > 0 && location.pathname !== '/search') {
navigate('/search', { replace: true });
} else if (value.length === 0 && location.pathname === '/search') {
navigate('/c/new', { replace: true });
}
};
// Automatically set isTyping to false when loading is done and debouncedQuery matches query
// (prevents stuck loading state if input is still focused)
useEffect(() => {
if (search.isTyping && !search.isSearching && search.debouncedQuery === search.query) {
setSearchState((prev) => ({ ...prev, isTyping: false }));
}
}, [search.isTyping, search.isSearching, search.debouncedQuery, search.query, setSearchState]);
return (
<div
ref={ref}
@ -80,11 +106,10 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
)}
>
{
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
}
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
<input
type="text"
ref={inputRef}
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
value={text}
onChange={onChange}
@ -94,19 +119,25 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
aria-label={localize('com_nav_search_placeholder')}
placeholder={localize('com_nav_search_placeholder')}
onKeyUp={handleKeyUp}
onFocus={() => setIsSearching(true)}
onBlur={() => setIsSearching(true)}
onFocus={() => setSearchState((prev) => ({ ...prev, isSearching: true }))}
onBlur={() => setSearchState((prev) => ({ ...prev, isSearching: false }))}
autoComplete="off"
dir="auto"
/>
<X
<button
type="button"
aria-label={`${localize('com_ui_clear')} ${localize('com_ui_search')}`}
className={cn(
'absolute right-[7px] h-5 w-5 cursor-pointer transition-opacity duration-200',
'absolute right-[7px] flex h-5 w-5 items-center justify-center rounded-full border-none bg-transparent p-0 transition-opacity duration-200',
showClearIcon ? 'opacity-100' : 'opacity-0',
isSmallScreen === true ? 'right-[16px]' : '',
)}
onClick={clearText}
/>
tabIndex={showClearIcon ? 0 : -1}
disabled={!showClearIcon}
>
<X className="h-5 w-5 cursor-pointer" />
</button>
</div>
);
});

View file

@ -47,7 +47,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
}
};
const settingsTabs: { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[] = [
const settingsTabs: {
value: SettingsTabValues;
icon: React.JSX.Element;
label: TranslationKeys;
}[] = [
{
value: SettingsTabValues.GENERAL,
icon: <GearIcon />,
@ -144,7 +148,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<line x1="18" x2="6" y1="6" y2="18"></line>
<line x1="6" x2="18" y1="6" y2="18"></line>
</svg>
<span className="sr-only">Close</span>
<span className="sr-only">{localize('com_ui_close')}</span>
</button>
</DialogTitle>
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
@ -168,10 +172,10 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<Tabs.Trigger
key={value}
className={cn(
'group relative z-10 m-1 flex items-center justify-start gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out',
'group relative z-10 m-1 flex items-center justify-start gap-2 rounded-xl px-2 py-1.5 transition-all duration-200 ease-in-out',
isSmallScreen
? 'flex-1 justify-center text-nowrap rounded-xl p-1 px-3 text-sm text-text-secondary radix-state-active:bg-surface-hover radix-state-active:text-text-primary'
: 'rounded-md bg-transparent text-text-primary radix-state-active:bg-surface-tertiary',
? 'flex-1 justify-center text-nowrap p-1 px-3 text-sm text-text-secondary radix-state-active:bg-surface-hover radix-state-active:text-text-primary'
: 'bg-transparent text-text-secondary radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary',
)}
value={value}
ref={(el) => (tabRefs.current[value] = el)}

View file

@ -30,6 +30,7 @@ export default function FontSizeSelector() {
onChange={handleChange}
testId="font-size-selector"
sizeClasses="w-[150px]"
className="z-50"
/>
</div>
);

View file

@ -44,6 +44,7 @@ export const ForkSettings = () => {
options={forkOptions}
sizeClasses="w-[200px]"
testId="fork-setting-dropdown"
className="z-[50]"
/>
</div>
</div>

View file

@ -3,6 +3,7 @@ import { useClearConversationsMutation } from 'librechat-data-provider/react-que
import { Label, Button, OGDialog, OGDialogTrigger, Spinner } from '~/components';
import { useLocalize, useNewConvo } from '~/hooks';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { clearAllConversationStorage } from '~/utils';
export const ClearChats = () => {
const localize = useLocalize();
@ -15,6 +16,7 @@ export const ClearChats = () => {
{},
{
onSuccess: () => {
clearAllConversationStorage();
newConversation();
},
},

View file

@ -1,7 +1,7 @@
import { useCallback, useState, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import debounce from 'lodash/debounce';
import { TrashIcon, MessageSquare, ArrowUpDown } from 'lucide-react';
import { TrashIcon, MessageSquare, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import {
OGDialog,
@ -9,10 +9,11 @@ import {
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
Button,
TooltipAnchor,
Button,
Label,
} from '~/components/ui';
Spinner,
} from '~/components';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useMediaQuery } from '~/hooks';
@ -20,7 +21,6 @@ import DataTable from '~/components/ui/DataTable';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { formatDate } from '~/utils';
import { Spinner } from '~/components/svg';
const PAGE_SIZE = 25;
@ -37,6 +37,7 @@ export default function SharedLinks() {
const { showToast } = useToastContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
@ -144,8 +145,6 @@ export default function SharedLinks() {
await fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const confirmDelete = useCallback(() => {
if (deleteRow) {
handleDelete([deleteRow]);
@ -157,21 +156,30 @@ export default function SharedLinks() {
() => [
{
accessorKey: 'title',
header: ({ column }) => {
header: () => {
const isSorted = queryParams.sortBy === 'title';
const sortDirection = queryParams.sortDirection;
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() => handleSort('title', column.getIsSorted() === 'asc' ? 'desc' : 'asc')}
onClick={() =>
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
>
{localize('com_ui_name')}
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
</Button>
);
},
cell: ({ row }) => {
const { title, shareId } = row.original;
return (
<div className="flex items-center gap-2">
<Link
@ -193,17 +201,25 @@ export default function SharedLinks() {
},
{
accessorKey: 'createdAt',
header: ({ column }) => {
header: () => {
const isSorted = queryParams.sortBy === 'createdAt';
const sortDirection = queryParams.sortDirection;
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('createdAt', column.getIsSorted() === 'asc' ? 'desc' : 'asc')
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
>
{localize('com_ui_date')}
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
</Button>
);
},
@ -240,7 +256,7 @@ export default function SharedLinks() {
<MessageSquare className="size-4" />
</Button>
}
></TooltipAnchor>
/>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
@ -256,12 +272,12 @@ export default function SharedLinks() {
<TrashIcon className="size-4" />
</Button>
}
></TooltipAnchor>
/>
</div>
),
},
],
[isSmallScreen, localize],
[isSmallScreen, localize, queryParams, handleSort],
);
return (
@ -291,6 +307,7 @@ export default function SharedLinks() {
showCheckboxes={false}
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
isLoading={isLoading}
/>
</OGDialogContent>
</OGDialog>

View file

@ -1,15 +1,17 @@
import { useLocalize } from '~/hooks';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useState } from 'react';
import { OGDialog, OGDialogTrigger, Button } from '~/components';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import ArchivedChatsTable from './ArchivedChatsTable';
import { useLocalize } from '~/hooks';
export default function ArchivedChats() {
const localize = useLocalize();
const [isOpen, setIsOpen] = useState(false);
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_archived_chats')}</div>
<OGDialog>
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild>
<Button variant="outline" aria-label="Archived chats">
{localize('com_ui_manage')}
@ -19,7 +21,7 @@ export default function ArchivedChats() {
title={localize('com_nav_archived_chats')}
className="max-w-[1000px]"
showCancelButton={false}
main={<ArchivedChatsTable />}
main={<ArchivedChatsTable isOpen={isOpen} onOpenChange={setIsOpen} />}
/>
</OGDialog>
</div>

View file

@ -1,287 +1,307 @@
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useEffect } from 'react';
import debounce from 'lodash/debounce';
import { TrashIcon, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
import {
Search,
TrashIcon,
ChevronLeft,
ChevronRight,
// ChevronsLeft,
// ChevronsRight,
MessageCircle,
ArchiveRestore,
} from 'lucide-react';
import type { TConversation } from 'librechat-data-provider';
import {
Table,
Input,
Button,
TableRow,
Skeleton,
OGDialog,
Separator,
TableCell,
TableBody,
TableHead,
TableHeader,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
Label,
TooltipAnchor,
OGDialogTrigger,
Spinner,
} from '~/components';
import { useConversationsInfiniteQuery, useArchiveConvoMutation } from '~/data-provider';
import { DeleteConversationDialog } from '~/components/Conversations/ConvoOptions';
import { useAuthContext, useLocalize, useMediaQuery } from '~/hooks';
import { cn } from '~/utils';
import {
useArchiveConvoMutation,
useConversationsInfiniteQuery,
useDeleteConversationMutation,
} from '~/data-provider';
import { useLocalize, useMediaQuery } from '~/hooks';
import { MinimalIcon } from '~/components/Endpoints';
import DataTable from '~/components/ui/DataTable';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { formatDate } from '~/utils';
export default function ArchivedChatsTable() {
const DEFAULT_PARAMS: ConversationListParams = {
isArchived: true,
sortBy: 'createdAt',
sortDirection: 'desc',
search: '',
};
export default function ArchivedChatsTable({
onOpenChange,
}: {
onOpenChange: (isOpen: boolean) => void;
}) {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [isOpened, setIsOpened] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const { showToast } = useToastContext();
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
useConversationsInfiniteQuery(
{ pageNumber: currentPage.toString(), isArchived: true },
{ enabled: isAuthenticated && isOpened },
);
const mutation = useArchiveConvoMutation();
const handleUnarchive = useCallback(
(conversationId: string) => {
mutation.mutate({ conversationId, isArchived: false });
},
[mutation],
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
const [deleteConversation, setDeleteConversation] = useState<TConversation | null>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
useConversationsInfiniteQuery(queryParams, {
staleTime: 0,
cacheTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
setQueryParams((prev) => ({
...prev,
sortBy: sortField as 'title' | 'createdAt',
sortDirection: sortOrder,
}));
}, []);
const handleFilterChange = useCallback((value: string) => {
const encodedValue = encodeURIComponent(value.trim());
setQueryParams((prev) => ({
...prev,
search: encodedValue,
}));
}, []);
const debouncedFilterChange = useMemo(
() => debounce(handleFilterChange, 300),
[handleFilterChange],
);
const conversations = useMemo(
() => data?.pages[currentPage - 1]?.conversations ?? [],
[data, currentPage],
);
const totalPages = useMemo(() => Math.ceil(Number(data?.pages[0].pages ?? 1)) ?? 1, [data]);
useEffect(() => {
return () => {
debouncedFilterChange.cancel();
};
}, [debouncedFilterChange]);
const handleChatClick = useCallback((conversationId: string) => {
if (!conversationId) {
return;
const allConversations = useMemo(() => {
if (!data?.pages) {
return [];
}
window.open(`/c/${conversationId}`, '_blank');
}, []);
return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
}, [data?.pages]);
const handlePageChange = useCallback(
(newPage: number) => {
setCurrentPage(newPage);
if (!(hasNextPage ?? false)) {
return;
}
fetchNextPage({ pageParam: newPage });
const deleteMutation = useDeleteConversationMutation({
onSuccess: async () => {
setIsDeleteOpen(false);
await refetch();
},
onError: (error: unknown) => {
showToast({
message: localize('com_ui_archive_delete_error') as string,
severity: NotificationSeverity.ERROR,
});
},
[fetchNextPage, hasNextPage],
);
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
setCurrentPage(1);
}, []);
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
const skeletons = Array.from({ length: 11 }, (_, index) => {
const randomWidth = getRandomWidth();
return (
<div key={index} className="flex h-10 w-full items-center">
<div className="flex w-[410px] items-center">
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
</div>
<div className="flex flex-grow justify-center">
<Skeleton className="h-4 w-28" />
</div>
<div className="mr-2 flex justify-end">
<Skeleton className="h-4 w-12" />
</div>
</div>
);
});
if (isLoading || isFetchingNextPage) {
return <div className="text-text-secondary">{skeletons}</div>;
}
const unarchiveMutation = useArchiveConvoMutation({
onSuccess: async () => {
await refetch();
},
onError: (error: unknown) => {
showToast({
message: localize('com_ui_unarchive_error') as string,
severity: NotificationSeverity.ERROR,
});
},
});
if (!data || (conversations.length === 0 && totalPages === 0)) {
return <div className="text-text-secondary">{localize('com_nav_archived_chats_empty')}</div>;
}
const handleFetchNextPage = useCallback(async () => {
if (!hasNextPage || isFetchingNextPage) {
return;
}
await fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const columns = useMemo(
() => [
{
accessorKey: 'title',
header: () => {
const isSorted = queryParams.sortBy === 'title';
const sortDirection = queryParams.sortDirection;
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
>
{localize('com_nav_archive_name')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
</Button>
);
},
cell: ({ row }) => {
const { conversationId, title } = row.original;
return (
<button
type="button"
className="flex items-center gap-2 truncate"
onClick={() => window.open(`/c/${conversationId}`, '_blank')}
>
<MinimalIcon
endpoint={row.original.endpoint}
size={28}
isCreatedByUser={false}
iconClassName="size-4"
/>
<span className="underline">{title}</span>
</button>
);
},
meta: {
size: isSmallScreen ? '70%' : '50%',
mobileSize: '70%',
},
},
{
accessorKey: 'createdAt',
header: () => {
const isSorted = queryParams.sortBy === 'createdAt';
const sortDirection = queryParams.sortDirection;
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
>
{localize('com_nav_archive_created_at')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
</Button>
);
},
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
meta: {
size: isSmallScreen ? '30%' : '35%',
mobileSize: '30%',
},
},
{
accessorKey: 'actions',
header: () => (
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
{localize('com_assistants_actions')}
</Label>
),
cell: ({ row }) => {
const conversation = row.original;
return (
<div className="flex items-center gap-2">
<TooltipAnchor
description={localize('com_ui_unarchive')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() =>
unarchiveMutation.mutate({
conversationId: conversation.conversationId,
isArchived: false,
})
}
title={localize('com_ui_unarchive')}
disabled={unarchiveMutation.isLoading}
>
{unarchiveMutation.isLoading ? (
<Spinner />
) : (
<ArchiveRestore className="size-4" />
)}
</Button>
}
/>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteConversation(row.original);
setIsDeleteOpen(true);
}}
title={localize('com_ui_delete')}
>
<TrashIcon className="size-4" />
</Button>
}
/>
</div>
);
},
meta: {
size: '15%',
mobileSize: '25%',
},
},
],
[handleSort, isSmallScreen, localize, queryParams, unarchiveMutation],
);
return (
<div
className={cn(
'grid w-full gap-2',
'flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
'max-h-[629px]',
)}
onMouseEnter={() => setIsOpened(true)}
>
<div className="flex items-center">
<Search className="size-4 text-text-secondary" />
<Input
type="text"
placeholder={localize('com_nav_search_placeholder')}
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full border-none placeholder:text-text-secondary"
/>
</div>
<Separator />
{conversations.length === 0 ? (
<div className="mt-4 text-text-secondary">{localize('com_nav_no_search_results')}</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className={cn('p-4', isSmallScreen ? 'w-[70%]' : 'w-[50%]')}>
{localize('com_nav_archive_name')}
</TableHead>
{!isSmallScreen && (
<TableHead className="w-[35%] p-1">
{localize('com_nav_archive_created_at')}
</TableHead>
)}
<TableHead className={cn('p-1 text-right', isSmallScreen ? 'w-[30%]' : 'w-[15%]')}>
{localize('com_assistants_actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{conversations.map((conversation: TConversation) => (
<TableRow key={conversation.conversationId} className="hover:bg-transparent">
<TableCell className="py-3 text-text-primary">
<button
type="button"
className="flex max-w-full"
aria-label="Open conversation in a new tab"
onClick={() => {
const conversationId = conversation.conversationId ?? '';
if (!conversationId) {
return;
}
handleChatClick(conversationId);
}}
>
<MessageCircle className="mr-1 h-5 min-w-[20px]" />
<u className="truncate">{conversation.title}</u>
</button>
</TableCell>
{!isSmallScreen && (
<TableCell className="p-1">
<div className="flex justify-between">
<div className="flex justify-start text-text-secondary">
{new Date(conversation.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
</div>
</TableCell>
)}
<TableCell
className={cn(
'flex items-center gap-1 p-1',
isSmallScreen ? 'justify-end' : 'justify-end gap-2',
)}
>
<TooltipAnchor
description={localize('com_ui_unarchive')}
render={
<Button
type="button"
aria-label="Unarchive conversation"
variant="ghost"
size="icon"
className={cn('size-8', isSmallScreen && 'size-7')}
onClick={() => {
const conversationId = conversation.conversationId ?? '';
if (!conversationId) {
return;
}
handleUnarchive(conversationId);
}}
>
<ArchiveRestore className={cn('size-4', isSmallScreen && 'size-3.5')} />
</Button>
}
/>
<>
<DataTable
columns={columns}
data={allConversations}
filterColumn="title"
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
fetchNextPage={handleFetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
showCheckboxes={false}
/>
<OGDialog>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
type="button"
aria-label="Delete archived conversation"
variant="ghost"
size="icon"
className={cn('size-8', isSmallScreen && 'size-7')}
>
<TrashIcon className={cn('size-4', isSmallScreen && 'size-3.5')} />
</Button>
}
/>
</OGDialogTrigger>
<DeleteConversationDialog
conversationId={conversation.conversationId ?? ''}
retainView={refetch}
title={conversation.title ?? ''}
/>
</OGDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-end gap-6 px-2 py-4">
<div className="text-sm font-bold text-text-primary">
{localize('com_ui_page')} {currentPage} {localize('com_ui_of')} {totalPages}
</div>
<div className="flex space-x-2">
{/* <Button
variant="outline"
size="icon"
aria-label="Go to the previous 10 pages"
onClick={() => handlePageChange(Math.max(currentPage - 10, 1))}
disabled={currentPage === 1}
>
<ChevronsLeft className="size-4" />
</Button> */}
<Button
variant="outline"
size="icon"
aria-label="Go to the previous page"
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Go to the next page"
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
disabled={currentPage === totalPages}
>
<ChevronRight className="size-4" />
</Button>
{/* <Button
variant="outline"
size="icon"
aria-label="Go to the next 10 pages"
onClick={() => handlePageChange(Math.min(currentPage + 10, totalPages))}
disabled={currentPage === totalPages}
>
<ChevronsRight className="size-4" />
</Button> */}
</div>
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>
<OGDialogContent
title={localize('com_ui_delete_confirm') + ' ' + (deleteConversation?.title ?? '')}
className="w-11/12 max-w-md"
>
<OGDialogHeader>
<OGDialogTitle>
{localize('com_ui_delete_confirm')} <strong>{deleteConversation?.title}</strong>
</OGDialogTitle>
</OGDialogHeader>
<div className="flex justify-end gap-4 pt-4">
<Button aria-label="cancel" variant="outline" onClick={() => setIsDeleteOpen(false)}>
{localize('com_ui_cancel')}
</Button>
<Button
variant="destructive"
onClick={() =>
deleteMutation.mutate({
conversationId: deleteConversation?.conversationId ?? '',
})
}
disabled={deleteMutation.isLoading}
>
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
</Button>
</div>
</>
)}
</div>
</OGDialogContent>
</OGDialog>
</>
);
}

View file

@ -59,6 +59,7 @@ export const ThemeSelector = ({
options={themeOptions}
sizeClasses="w-[180px]"
testId="theme-selector"
className="z-50"
/>
</div>
);
@ -79,8 +80,10 @@ export const LangSelector = ({
{ value: 'zh-Hans', label: localize('com_nav_lang_chinese') },
{ value: 'zh-Hant', label: localize('com_nav_lang_traditional_chinese') },
{ value: 'ar-EG', label: localize('com_nav_lang_arabic') },
{ value: 'da-DK', label: localize('com_nav_lang_danish') },
{ value: 'de-DE', label: localize('com_nav_lang_german') },
{ value: 'es-ES', label: localize('com_nav_lang_spanish') },
{ value: 'ca-ES', label: localize('com_nav_lang_catalan') },
{ value: 'et-EE', label: localize('com_nav_lang_estonian') },
{ value: 'fa-IR', label: localize('com_nav_lang_persian') },
{ value: 'fr-FR', label: localize('com_nav_lang_french') },
@ -93,6 +96,7 @@ export const LangSelector = ({
{ value: 'ru-RU', label: localize('com_nav_lang_russian') },
{ value: 'ja-JP', label: localize('com_nav_lang_japanese') },
{ value: 'ka-GE', label: localize('com_nav_lang_georgian') },
{ value: 'cs-CZ', label: localize('com_nav_lang_czech') },
{ value: 'sv-SE', label: localize('com_nav_lang_swedish') },
{ value: 'ko-KR', label: localize('com_nav_lang_korean') },
{ value: 'vi-VN', label: localize('com_nav_lang_vietnamese') },
@ -112,6 +116,7 @@ export const LangSelector = ({
onChange={onChange}
sizeClasses="[--anchor-max-height:256px]"
options={languageOptions}
className="z-50"
/>
</div>
);

View file

@ -32,6 +32,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
options={endpointOptions}
sizeClasses="w-[180px]"
testId="EngineSTTDropdown"
className="z-50"
/>
</div>
);

View file

@ -102,8 +102,8 @@ export default function LanguageSTTDropdown() {
onChange={handleSelect}
options={languageOptions}
sizeClasses="[--anchor-max-height:256px]"
anchor="bottom start"
testId="LanguageSTTDropdown"
className="z-50"
/>
</div>
);

View file

@ -20,12 +20,14 @@ import {
EngineSTTDropdown,
DecibelSelector,
} from './STT';
import { useOnClickOutside, useMediaQuery, useLocalize } from '~/hooks';
import ConversationModeSwitch from './ConversationModeSwitch';
import { useOnClickOutside, useMediaQuery } from '~/hooks';
import { cn, logger } from '~/utils';
import store from '~/store';
function Speech() {
const localize = useLocalize();
const [confirmClear, setConfirmClear] = useState(false);
const { data } = useGetCustomConfigSpeechQuery();
const isSmallScreen = useMediaQuery('(max-width: 767px)');
@ -134,6 +136,14 @@ function Speech() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
// Reset engineTTS if it is set to a removed/invalid value (e.g., 'edge')
useEffect(() => {
const validEngines = ['browser', 'external'];
if (!validEngines.includes(engineTTS)) {
setEngineTTS('browser');
}
}, [engineTTS, setEngineTTS]);
logger.log({ sttExternal, ttsExternal });
const contentRef = useRef(null);
@ -158,7 +168,7 @@ function Speech() {
style={{ userSelect: 'none' }}
>
<Lightbulb />
Simple
{localize('com_ui_simple')}
</Tabs.Trigger>
<Tabs.Trigger
onClick={() => setAdvancedMode(true)}
@ -171,7 +181,7 @@ function Speech() {
style={{ userSelect: 'none' }}
>
<Cog />
Advanced
{localize('com_ui_advanced')}
</Tabs.Trigger>
</Tabs.List>
</div>

View file

@ -15,13 +15,9 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
const endpointOptions = external
? [
{ value: 'browser', label: localize('com_nav_browser') },
{ value: 'edge', label: localize('com_nav_edge') },
{ value: 'external', label: localize('com_nav_external') },
]
: [
{ value: 'browser', label: localize('com_nav_browser') },
{ value: 'edge', label: localize('com_nav_edge') },
];
: [{ value: 'browser', label: localize('com_nav_browser') }];
const handleSelect = (value: string) => {
setEngineTTS(value);
@ -35,8 +31,8 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
onChange={handleSelect}
options={endpointOptions}
sizeClasses="w-[180px]"
anchor="bottom start"
testId="EngineTTSDropdown"
className="z-50"
/>
</div>
);

View file

@ -1,14 +1,9 @@
import { useRecoilValue } from 'recoil';
import {
EdgeVoiceDropdown,
BrowserVoiceDropdown,
ExternalVoiceDropdown,
} from '~/components/Audio/Voices';
import { BrowserVoiceDropdown, ExternalVoiceDropdown } from '~/components/Audio/Voices';
import store from '~/store';
import { TTSEndpoints } from '~/common';
const voiceDropdownComponentsMap = {
[TTSEndpoints.edge]: EdgeVoiceDropdown,
[TTSEndpoints.browser]: BrowserVoiceDropdown,
[TTSEndpoints.external]: ExternalVoiceDropdown,
};
@ -17,5 +12,9 @@ export default function VoiceDropdown() {
const engineTTS = useRecoilValue<string>(store.engineTTS);
const VoiceDropdownComponent = voiceDropdownComponentsMap[engineTTS];
if (!VoiceDropdownComponent) {
return null;
}
return <VoiceDropdownComponent />;
}

View file

@ -59,8 +59,8 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
{...register(authField, {
required: `${config.label} is required.`,
minLength: {
value: 10,
message: `${config.label} must be at least 10 characters long`,
value: 1,
message: `${config.label} must be at least 1 character long`,
},
})}
className="flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0"

View file

@ -66,7 +66,7 @@ const AdminSettings = () => {
const [confirmAdminUseChange, setConfirmAdminUseChange] = useState<{
newValue: boolean;
callback: (value: boolean) => void;
} | null>(null);
} | null>(null);
const { mutate, isLoading } = useUpdatePromptPermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_ui_saved') });
@ -80,10 +80,10 @@ const AdminSettings = () => {
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
if (roles?.[selectedRole]) {
return roles[selectedRole][PermissionTypes.PROMPTS];
if (roles?.[selectedRole]?.permissions) {
return roles[selectedRole].permissions[PermissionTypes.PROMPTS];
}
return roleDefaults[selectedRole][PermissionTypes.PROMPTS];
return roleDefaults[selectedRole].permissions[PermissionTypes.PROMPTS];
}, [roles, selectedRole]);
const {
@ -99,10 +99,10 @@ const AdminSettings = () => {
});
useEffect(() => {
if (roles?.[selectedRole]?.[PermissionTypes.PROMPTS]) {
reset(roles[selectedRole][PermissionTypes.PROMPTS]);
if (roles?.[selectedRole]?.permissions?.[PermissionTypes.PROMPTS]) {
reset(roles[selectedRole].permissions[PermissionTypes.PROMPTS]);
} else {
reset(roleDefaults[selectedRole][PermissionTypes.PROMPTS]);
reset(roleDefaults[selectedRole].permissions[PermissionTypes.PROMPTS]);
}
}, [roles, selectedRole, reset]);
@ -166,6 +166,7 @@ const AdminSettings = () => {
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId="prompt-role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
@ -191,11 +192,11 @@ const AdminSettings = () => {
setValue={setValue}
{...(selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE
? {
confirmChange: (
newValue: boolean,
onChange: (value: boolean) => void,
) => setConfirmAdminUseChange({ newValue, callback: onChange }),
}
confirmChange: (
newValue: boolean,
onChange: (value: boolean) => void,
) => setConfirmAdminUseChange({ newValue, callback: onChange }),
}
: {})}
/>
{selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE && (

View file

@ -28,9 +28,9 @@ const categoryColorMap: Record<string, string> = {
code: 'text-red-500',
misc: 'text-blue-300',
shop: 'text-purple-400',
idea: 'text-yellow-300',
idea: 'text-yellow-500/90 dark:text-yellow-300 ',
write: 'text-purple-400',
travel: 'text-yellow-300',
travel: 'text-yellow-500/90 dark:text-yellow-300 ',
finance: 'text-orange-400',
roleplay: 'text-orange-400',
teach_or_explain: 'text-blue-300',

View file

@ -1,4 +1,6 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ReactNode } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { LocalStorageKeys } from 'librechat-data-provider';
import { Dropdown } from '~/components/ui';
@ -15,6 +17,7 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
onValueChange,
className = '',
}) => {
const { t } = useTranslation();
const formContext = useFormContext();
const { categories, emptyCategory } = useCategories();
@ -32,13 +35,25 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
[watchedCategory, categories, currentCategory, emptyCategory],
);
const displayCategory = useMemo(() => {
if (!categoryOption.value && !('icon' in categoryOption)) {
return {
...categoryOption,
icon: (<span className="i-heroicons-tag" />) as ReactNode,
label: categoryOption.label || t('com_ui_empty_category'),
};
}
return categoryOption;
}, [categoryOption, t]);
return formContext ? (
<Controller
name="category"
control={control}
render={() => (
<Dropdown
value={categoryOption.value ?? ''}
value={displayCategory.value ?? ''}
label={displayCategory.value ? undefined : t('com_ui_category')}
onChange={(value: string) => {
setValue('category', value, { shouldDirty: false });
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value);
@ -48,10 +63,12 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
ariaLabel="Prompt's category selector"
className={className}
options={categories || []}
renderValue={(option) => (
renderValue={() => (
<div className="flex items-center space-x-2">
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
<span>{option.label}</span>
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.label}</span>
</div>
)}
/>
@ -68,10 +85,12 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
ariaLabel="Prompt's category selector"
className={className}
options={categories || []}
renderValue={(option) => (
renderValue={() => (
<div className="flex items-center space-x-2">
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
<span>{option.label}</span>
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.label}</span>
</div>
)}
/>

View file

@ -57,7 +57,7 @@ function ChatGroupItem({
snippet={
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner
: group.productionPrompt?.prompt ?? ''
: (group.productionPrompt?.prompt ?? '')
}
>
<div className="flex flex-row items-center gap-2">
@ -83,7 +83,11 @@ function ChatGroupItem({
className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
<MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
<span className="sr-only">Open actions menu for {group.name}</span>
<span className="sr-only">
{localize('com_ui_sr_actions_menu', { 0: group.name }) +
' ' +
localize('com_ui_prompt')}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent

View file

@ -7,6 +7,7 @@ import PromptVariables from '~/components/Prompts/PromptVariables';
import { Button, TextareaAutosize, Input } from '~/components/ui';
import Description from '~/components/Prompts/Description';
import { useLocalize, useHasAccess } from '~/hooks';
import VariablesDropdown from '~/components/Prompts/VariablesDropdown';
import Command from '~/components/Prompts/Command';
import { useCreatePrompt } from '~/data-provider';
import { cn } from '~/utils';
@ -132,7 +133,8 @@ const CreatePromptForm = ({
<div className="flex w-full flex-col gap-4 md:mt-[1.075rem]">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-border-medium py-2 pl-4 pr-1 text-base font-semibold dark:text-gray-200">
{localize('com_ui_prompt_text')}*
<span>{localize('com_ui_prompt_text')}*</span>
<VariablesDropdown fieldName="prompt" className="mr-2" />
</h2>
<div className="min-h-32 rounded-b-lg border border-border-medium p-4 transition-all duration-150">
<Controller

View file

@ -24,7 +24,7 @@ export default function GroupSidePanel({
} & ReturnType<typeof usePromptGroupsNav>) {
const location = useLocation();
const isSmallerScreen = useMediaQuery('(max-width: 1024px)');
const isChatRoute = useMemo(() => location.pathname.startsWith('/c/'), [location.pathname]);
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
return (
<div

View file

@ -31,7 +31,7 @@ const VariableDialog: React.FC<VariableDialogProps> = ({ open, onClose, group })
return (
<OGDialog open={open} onOpenChange={handleOpenChange}>
<OGDialogContent className="max-w-full bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300 md:max-w-3xl">
<OGDialogContent className="max-h-[90vh] max-w-full overflow-y-auto bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300 md:max-w-[60vw]">
<OGDialogTitle>{group.name}</OGDialogTitle>
<VariableForm group={group} onClose={onClose} />
</OGDialogContent>

View file

@ -5,18 +5,14 @@ import supersub from 'remark-supersub';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import { replaceSpecialVars } from 'librechat-data-provider';
import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form';
import type { TPromptGroup } from 'librechat-data-provider';
import {
cn,
wrapVariable,
defaultTextProps,
replaceSpecialVars,
extractVariableInfo,
} from '~/utils';
import { cn, wrapVariable, defaultTextProps, extractVariableInfo } from '~/utils';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import { TextareaAutosize, InputCombobox, Button } from '~/components/ui';
import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks';
import { PromptVariableGfm } from '../Markdown';
type FieldType = 'text' | 'select';
@ -115,9 +111,12 @@ export default function VariableForm({
allVariables.forEach((variable) => {
const placeholder = `{{${variable}}}`;
const fieldIndex = variableIndexMap.get(variable) as string | number;
const fieldValue = fieldValues[fieldIndex].value as string;
const highlightText = fieldValue !== '' ? fieldValue : placeholder;
tempText = tempText.replaceAll(placeholder, `**${highlightText}**`);
const fieldValue = fieldValues[fieldIndex].value as string | undefined;
if (fieldValue === placeholder || fieldValue === '' || !fieldValue) {
return;
}
const highlightText = fieldValue !== '' ? `**${fieldValue}**` : placeholder;
tempText = tempText.replaceAll(placeholder, highlightText);
});
return tempText;
};
@ -141,19 +140,19 @@ export default function VariableForm({
return (
<div className="mx-auto p-1 md:container">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-gray-100 p-4 text-text-secondary dark:bg-gray-700/50 sm:max-w-full md:max-h-80">
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-surface-tertiary p-4 text-text-secondary dark:bg-surface-primary sm:max-w-full md:max-h-96">
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={[
/** @ts-ignore */
[rehypeKatex, { output: 'mathml' }],
[rehypeKatex],
/** @ts-ignore */
[rehypeHighlight, { ignoreMissing: true }],
]}
/** @ts-ignore */
components={{ code: codeNoExecution }}
className="prose dark:prose-invert light dark:text-gray-70 my-1 max-h-[50vh] break-words"
components={{ code: codeNoExecution, p: PromptVariableGfm }}
className="markdown prose dark:prose-invert light my-1 max-h-[50vh] max-w-full break-words dark:text-text-secondary"
>
{generateHighlightedMarkdown()}
</ReactMarkdown>

View file

@ -1,7 +1,7 @@
import React from 'react';
import { handleDoubleClick } from '~/utils';
export const CodeVariableGfm = ({ children }: { children: React.ReactNode }) => {
export const CodeVariableGfm: React.ElementType = ({ children }: { children: React.ReactNode }) => {
return (
<code
onDoubleClick={handleDoubleClick}
@ -29,7 +29,10 @@ export const PromptVariableGfm = ({
const parts = child.split(regex);
return parts.map((part, index) =>
index % 2 === 1 ? (
<b key={index} className="rounded-md bg-yellow-100/90 p-1 text-gray-700">
<b
key={index}
className="ml-[0.5] rounded-lg bg-amber-100 p-[1px] font-medium text-yellow-800 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90"
>
{`{{${part}}}`}
</b>
) : (

View file

@ -13,7 +13,7 @@ const PreviewPrompt = ({
}) => {
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="w-11/12 max-w-5xl">
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]">
<div className="p-2">
<PromptDetails group={group} />
</div>

View file

@ -5,13 +5,13 @@ import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import supersub from 'remark-supersub';
import rehypeHighlight from 'rehype-highlight';
import { replaceSpecialVars } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import { useLocalize, useAuthContext } from '~/hooks';
import CategoryIcon from './Groups/CategoryIcon';
import PromptVariables from './PromptVariables';
import { PromptVariableGfm } from './Markdown';
import { replaceSpecialVars } from '~/utils';
import { Label } from '~/components/ui';
import Description from './Description';
import Command from './Command';
@ -46,7 +46,7 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
<div className="flex h-full max-h-screen flex-col overflow-y-auto md:flex-row">
<div className="flex flex-1 flex-col gap-4 p-0 md:max-h-[calc(100vh-150px)] md:p-2">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-border-light py-2 pl-4 text-base font-semibold text-text-primary ">
<h2 className="flex items-center justify-between rounded-t-lg border border-border-light py-2 pl-4 text-base font-semibold text-text-primary">
{localize('com_ui_prompt_text')}
</h2>
<div className="group relative min-h-32 rounded-b-lg border border-border-light p-4 transition-all duration-150">
@ -59,13 +59,13 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
]}
rehypePlugins={[
/** @ts-ignore */
[rehypeKatex, { output: 'mathml' }],
[rehypeKatex],
/** @ts-ignore */
[rehypeHighlight, { ignoreMissing: true }],
]}
/** @ts-ignore */
components={{ p: PromptVariableGfm, code: codeNoExecution }}
className="prose dark:prose-invert light dark:text-gray-70 my-1"
className="markdown prose dark:prose-invert light dark:text-gray-70 my-1 break-words"
>
{mainText}
</ReactMarkdown>

View file

@ -12,6 +12,7 @@ import ReactMarkdown from 'react-markdown';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd';
import { SaveIcon, CrossIcon } from '~/components/svg';
import VariablesDropdown from './VariablesDropdown';
import { TextareaAutosize } from '~/components/ui';
import { PromptVariableGfm } from './Markdown';
import { PromptsEditorMode } from '~/common';
@ -42,7 +43,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
}, [isEditing, prompt]);
const rehypePlugins: PluggableList = [
[rehypeKatex, { output: 'mathml' }],
[rehypeKatex],
[
rehypeHighlight,
{
@ -59,10 +60,11 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
<span className="max-w-[200px] truncate sm:max-w-none">
{localize('com_ui_prompt_text')}
</span>
<div className="flex flex-shrink-0 flex-row gap-3 sm:gap-6">
<div className="flex flex-shrink-0 flex-row items-center gap-3 sm:gap-6">
{editorMode === PromptsEditorMode.ADVANCED && (
<AlwaysMakeProd className="hidden sm:flex" />
)}
<VariablesDropdown fieldName={name} />
<button
type="button"
onClick={() => setIsEditing((prev) => !prev)}
@ -105,6 +107,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
isEditing ? (
<TextareaAutosize
{...field}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
className="w-full resize-none overflow-y-auto rounded bg-transparent text-sm text-text-primary focus:outline-none sm:text-base"
minRows={3}
@ -123,8 +126,8 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
style={{ minHeight: '4.5em', maxHeight: '21em', overflow: 'auto' }}
>
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],

View file

@ -1,18 +1,18 @@
import React, { useMemo } from 'react';
import { Variable } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { specialVariables } from 'librechat-data-provider';
import { cn, extractUniqueVariables } from '~/utils';
import { CodeVariableGfm } from './Markdown';
import { Separator } from '~/components/ui';
import { useLocalize } from '~/hooks';
const specialVariables = {
current_date: true,
current_user: true,
};
const specialVariableClasses =
'bg-yellow-500/25 text-yellow-600 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90';
'bg-amber-100 text-yellow-800 border-yellow-600 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90';
const components: {
[nodeType: string]: React.ElementType;
} = { code: CodeVariableGfm };
const PromptVariables = ({
promptText,
@ -52,7 +52,7 @@ const PromptVariables = ({
</div>
) : (
<div className="text-sm text-text-secondary">
<ReactMarkdown components={{ code: CodeVariableGfm }}>
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
{localize('com_ui_variables_info')}
</ReactMarkdown>
</div>
@ -65,8 +65,8 @@ const PromptVariables = ({
{localize('com_ui_special_variables')}
</span>
<span className="text-sm text-text-secondary">
<ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_special_variables_info')}
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
{localize('com_ui_special_variables_more_info')}
</ReactMarkdown>
</span>
</div>
@ -75,7 +75,7 @@ const PromptVariables = ({
{localize('com_ui_dropdown_variables')}
</span>
<span className="text-sm text-text-secondary">
<ReactMarkdown components={{ code: CodeVariableGfm }}>
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
{localize('com_ui_dropdown_variables_info')}
</ReactMarkdown>
</span>

View file

@ -0,0 +1,75 @@
import { useState, useId } from 'react';
import { PlusCircle } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import { useFormContext } from 'react-hook-form';
import { specialVariables } from 'librechat-data-provider';
import type { TSpecialVarLabel } from 'librechat-data-provider';
import { DropdownPopup } from '~/components';
import { useLocalize } from '~/hooks';
interface VariableOption {
label: TSpecialVarLabel;
value: string;
}
const variableOptions: VariableOption[] = Object.keys(specialVariables).map((key) => ({
label: `com_ui_special_var_${key}` as TSpecialVarLabel,
value: `{{${key}}}`,
}));
interface VariablesDropdownProps {
fieldName?: string;
className?: string;
}
export default function VariablesDropdown({
fieldName = 'prompt',
className = '',
}: VariablesDropdownProps) {
const menuId = useId();
const localize = useLocalize();
const methods = useFormContext();
const { setValue, getValues } = methods;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const handleAddVariable = (label: TSpecialVarLabel, value: string) => {
const currentText = getValues(fieldName) || '';
const spacer = currentText.length > 0 ? '\n\n' : '';
const prefix = localize(label);
setValue(fieldName, currentText + spacer + prefix + ': ' + value);
setIsMenuOpen(false);
};
return (
<div
className={className}
title={`${localize('com_ui_add')} ${localize('com_ui_special_variables')}`}
>
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Menu.MenuButton
id="variables-menu-button"
aria-label={`${localize('com_ui_add')} ${localize('com_ui_special_variables')}`}
className="flex h-8 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
>
<PlusCircle className="mr-1 h-3 w-3 text-text-secondary" aria-hidden={true} />
{localize('com_ui_special_variables')}
</Menu.MenuButton>
}
items={variableOptions.map((option) => ({
label: localize(option.label) || option.label,
onClick: () => handleAddVariable(option.label, option.value),
}))}
menuId={menuId}
className="z-30"
/>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more