🗨️ feat: Prompts (#3131)

* 🗨️ feat: Prompts (#7)

* WIP: MERGE prompts/frontend (#1)

* added schema for prompt and promptgroup, added model methods for prompts, added routes for prompts

* * updated promptGroup Schema

* updated model methods for prompts (get, add, delete)

* slight fixes in prompt routes

* * Created Files Management components

* Created Vector Stores components

* Added file management route in the routes folder

* Completed UI for Files list, Compeleted UI for vector stores list, Completed UI for upload file modal, Completed UI for preview file, Completed UI for preview vector store

* Fixed style and UI fixes for file dashboard, file list and vector stores list

* added responsiveness classes for vector store page

* fixed responsiveness of file page, dashboard page, and main page

* fixed styling and responsiveness issues on dashboard page, file list page and vector store page

* added queries and mutations for prompts and promptGroups, added relevant endpoints in data-provider, added relevant components prompts, added and updated relevant APIs

* added types on mutation queries data service, updated prompt attributes

* feature: Prompts and prompt groups management, added relevant APIs, added types for data service/queries/mutations, added relevant mutation and queries

* chore: typing clarifications

* added drop down on prompts mgmt dashboard

* Fixes: fixed version switching issue on tags update or labels update, added cross button on create prompt group, fixed list updation on prompt group renaiming, added CSV upload button

* Feature: Added oneliner and category attributes in prompt group, added schema for categories, added schema methods and route for categories

* chore: typing and lint issues

* chore: more type and linter fixes

* chore: linting

* chore: prompt controller and backend typing example; MOVE TO CONTROLLER DIRECTORY

* chore: more type fixes

* style: prompt name changes

* chore: more type changes, and stateful prompt name change without flickering

* fix: Return result of savePrompt in patchPrompt API endpoint

* fix: navigation prompt queries; refactor: name 'prompt-groups' to just 'groups'

* refactor: fetch prompt groups rewrite

* refactor(prompts): query/mutation statefulness

* refactor: remove `isActive` field

* refactor: remove labels, consolidate logic

* style: width, layout shift

* refactor: improve hover toggle behavior and styling

* refactor: add useParams hook to PromptListItem for dynamic rendering and add timeout ref for blur timeout

* chore: hide upload button

* refactor: import Button component from correct location in PromptSidePanel

* style: prompt editor styling

* style: fix more layout shifts

* style: container scroll

* refactor: Rename CreatePrompt component to CreatePromptForm

* refactor: use react-hook-form

* refactor: Add Prompts components and routes to Dashboard

* style: skeletons for loading

* fix: optimize makePromptProduction

* refactor: consolidate variables

* feat: create prompt form validation

* refactor: Consolidate variables and update mutation hooks

* style: minor touchups

* chore: Update lucide-react npm dependency to version 0.394.0 and npm audit fix

* refactor: add a new icon for the Prompts heading.

* style: Update PromptsView heading to use h1 instead of h2 and other minor margin issues

* chore: wording

* refactor: Update PromptsView heading to use h1 instead of h2, consolidate variables, and add new icons

* refactor: Prompts Button for Mobile

* feature: added category field in prompt group, added relevant API and static data on BE to support FE UI for category in prompt group

* chore: template for prompt cards

---------

Co-authored-by: Fawadpot <contactfawada@gmail.com>

* WIP: Prompts/frontend Continued (#2)

* chore: loading style, remove unused component

* feat: Add CategorySelector component for prompt group category selection

* feat: add categories to create prompt

* feat: prompt versions styling

* feat: optimistic updates for prompt production state

* refactor: optimize form state and show if prompt field is dirty with cross icon, also other styling changes

* chore: remove unused code and localizations

* fix: light mode styling

* WIP: SidePanel Prompts

* refactor: move to groups directory

* refactor: rename GroupsSidePanel to GroupSidePanel and update imports

* style: ListCard

* refactor: isProduction changes

* refactor: infinite query with productionPrompt

* refactor: optimize snippets and prompts, and styling

* refactor: Update getSnippet function to accept a length parameter

* chore: localizations

* feat: prompts navigation to chat and vice versa

* fix: create prompt

* feat: remember last selected category for creating prompts

* fix(promptGroups): fix pagination and add usePromptGroupsNav hook

* Prompts/frontend 3 (#3)

* fix: stateful issues with prompt groups

* style: improved layout

* refactor: improve variable naming in Eng.ts

* refactor: theme selector styling improvements

* added prompt cards on chat new page, with dark mode, added API to fetch random prompts, added types for useQuery

Slightly improved usePromptGroupNav logic to fetch updated result for pageSize, updated prompt cards view with darkmode and responsiveness

fixed page size option buttons styling to match the theme

added dark mode on create prompt page and prompt edit/preview page

fixed page size option buttons styling to match the theme

added dark mode on create prompt page and prompt edit/preview page

* WIP: Prompts/frontend (#4)

* fix: optimize and fix paginated query

* fix: remove unique constraint on names

* refactor: button links and styling

* style: menu border light mode

* feat: Add Auto-Send Switch component for prompts groups

* refactor(ChatView): use form context for submission text

* chore: clear convo state on navigation to dashboard routes

* chore: save prompt edit name on tab, remove console log

* feat: basic prompt submission

* refactor: move Auto-Send Switch

* style(ListCard): border styling

* feat: Add function to detect variables in text

* feat: Add OriginalDialog component to UI library

* chore(ui): Update SelectDropDown options list class to use text-xs size

* refactor: submitMessage hook now includes submitPrompt, make compatible to document query selector

* WIP: Variable Dialog

* feat: variable submission working for both auto-send and non-autosend

* feat: dashboard breadcrumbs and prompts/chat navigation

* refactor: dashboard breadcrumb and dashboard link to chat navigation

* refactor: Update VariableDialog and VariableForm styles

* Prompts: Admin features (#5)

* fix: link issue

* fix: usePromptGroupsNav add missing dep.

* style: dashbreadcrumb and sidepanel text color

* temp fix: remove refetch on pageNumber change

* fix: handle multiple variable replacement

* WIP: create project schema and add project groups to fetch

* feat: Add functionality to add prompt group IDs to a project

* feat: Add caching for startup config in config route

* chore: remove prompt landing

* style: Update Skeleton component with additional background styling

* chore: styling and types

* WIP: SharePrompt first draft

* feat(SharePrompt): form validation

* feat: shared global indicators

* refactor: prompt details

* refactor: change NoPromptGroup directory

* feat: preview prompt

* feat: remove/add global prompts, add rbac-related enums

* refactor: manage prompts location

* WIP: first draft admin settings for prompts

* feat: SystemRoles enum

* refactor: update PromptDetails component styling

* style: ellipsis custom class for showing more preview text

* WIP: initial role schema and initialization

* style: improved margins for single unordered lists

* fix: use custom chat form context to prevent re-renders from FormProvider

* feat: Role mutations for Prompt Permissions

* feat: fetch user role

* feat: update AdminSettings form default values from user role values

* refactor: rename PromptPermissions to Permissions for general definitions

* feat: initial role checks

* feat: Add optional `bodyProps` parameter to generateCheckAccess middleware

* refactor: UI access checks

* Prompts: delete (#6)

* Fixed delete prompt version API, fixed types and logic for prompt version deletion, updated prompt delete mutation logic

* chore: Update return type of deletePrompt function in Prompt.js

---------

Co-authored-by: Fawadpot <contactfawada@gmail.com>

* chore: Update package-lock.json version to 0.7.4-rc1 and fast-xml-parser to 4.4.0

* feat: toast for saving admin settings, add timer no-access navigation

* feat: always make prod

* feat: Add localization to category labels in CategorySelector component

* feat: Update category label localization in CategorySelector component

* fix: Enable making prompt production in Prompt API

---------

Co-authored-by: Fawadpot <contactfawada@gmail.com>

* feat: Add helper fn for dark mode detection in ThemeProvider

* style: surface-primary definition

* fix(useHasAccess): utilize user.role and not just USER role

* fix: empty category and role fetch

* refactort: increase max height to options list and use label if no localization is found

* fix: update CategorySelector to handle empty category value and improve localization

* refactor: move prompts to own store/reactquery modules, add in filter WIP

* refactor: Rename AutoSendSwitch to AutoSendPrompt

* style: theming commit

* style: fix slight coloring issue for convos in dark mode

* style: better composition for prompts side panel

* style: remove gray-750 and make it gray-850

* chore: adjust theming

* feat: filter all prompt groups and properly remove prompts from projects

* refactor: optimize delete prompt groups further

* chore: localization

* feat: Add uniqueProperty filtering to normalizeData function

* WIP: filter prompts

* chore: Update FilterPrompts component to include User icon in FilterItem

* feat(FilterPrompts): set categories

* feat: more system filters and show selected category icon

* style: always make prod, flips switch to avoid mis-clicks

* style: ui/ux loading/no prompts

* chore: style FilterPrompts ChatView

* fix: handle missing role edge case

* style: special variables

* feat: special variables

* refactor: improve replaceSpecialVars function in prompts.ts

* feat: simple/advanced editor modes

* chore: bump versions

* feat: localizations and hide production button on simple mode

* fix: error connecting layout shift

* fix: prompts CRUD for admins

* fix: secure single group fetch

* style: sidepanel styling

* style(PromptName): bring edit button closer to name

* style: mobile prompts header

* style: mobile prompts header continued

* style: align send prompts switch right

* feat: description

* Update special variables description in Eng.ts

* feat: update/create/preview oneliner

* fix: allow empty oneliner update

* style: loading improvement and always make selected prompt Production if simple mode

* fix: production index set and remove unused props

* fix(ci): mock initializeRoles

* fix: address #3128

* fix: address #3128

* feat: add deletion confirmation dialog

* fix: mobile UI issues

* style: prompt library UI update

* style: focus, logcal tab order

* style: Refactor SelectDropDown component to improve code readability and maintainability

* chore: bump data-provider

* chore: fix labels

* refactor: confirm delete prompt version

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
Danny Avila 2024-06-20 20:24:32 -04:00 committed by GitHub
parent 302b28fc9b
commit 0cd3c83328
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
216 changed files with 8741 additions and 797 deletions

View file

@ -1,8 +1,8 @@
import { ThemeSelector } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { BlinkAnimation } from './BlinkAnimation';
import { TStartupConfig } from 'librechat-data-provider';
import SocialLoginRender from './SocialLoginRender';
import { ThemeSelector } from '~/components/ui';
import Footer from './Footer';
const ErrorRender = ({ children }: { children: React.ReactNode }) => (

View file

@ -1,8 +1,10 @@
import { memo } 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 { ChatContext, useFileMapContext } from '~/Providers';
import type { ChatFormValues } from '~/common';
import { ChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import MessagesView from './Messages/MessagesView';
import { useChatHelpers, useSSE } from '~/hooks';
import { Spinner } from '~/components/svg';
@ -30,25 +32,37 @@ function ChatView({ index = 0 }: { index?: number }) {
});
const chatHelpers = useChatHelpers(index, conversationId);
const methods = useForm<ChatFormValues>({
defaultValues: { text: '' },
});
return (
<ChatContext.Provider value={chatHelpers}>
<Presentation useSidePanel={true}>
{isLoading && conversationId !== 'new' ? (
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
<ChatFormProvider
reset={methods.reset}
control={methods.control}
setValue={methods.setValue}
register={methods.register}
getValues={methods.getValues}
handleSubmit={methods.handleSubmit}
>
<ChatContext.Provider value={chatHelpers}>
<Presentation useSidePanel={true}>
{isLoading && conversationId !== 'new' ? (
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
</div>
) : messagesTree && messagesTree.length !== 0 ? (
<MessagesView messagesTree={messagesTree} Header={<Header />} />
) : (
<Landing Header={<Header />} />
)}
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<ChatForm index={index} />
<Footer />
</div>
) : messagesTree && messagesTree.length !== 0 ? (
<MessagesView messagesTree={messagesTree} Header={<Header />} />
) : (
<Landing Header={<Header />} />
)}
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<ChatForm index={index} />
<Footer />
</div>
</Presentation>
</ChatContext.Provider>
</Presentation>
</ChatContext.Provider>
</ChatFormProvider>
);
}

View file

@ -1,8 +1,8 @@
import { useEffect } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui/';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { ListeningIcon, Spinner } from '~/components/svg';
import { useLocalize, useSpeechToText } from '~/hooks';
import { useChatFormContext } from '~/Providers';
import { globalAudioId } from '~/common';
export default function AudioRecorder({
@ -12,7 +12,7 @@ export default function AudioRecorder({
disabled,
}: {
textAreaRef: React.RefObject<HTMLTextAreaElement>;
methods: UseFormReturn<{ text: string }>;
methods: ReturnType<typeof useChatFormContext>;
ask: (data: { text: string }) => void;
disabled: boolean;
}) {

View file

@ -1,15 +1,14 @@
import { useForm } from 'react-hook-form';
import { memo, useRef, useMemo } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { memo, useCallback, useRef, useMemo, useState, useEffect } from 'react';
import {
supportsFiles,
mergeFileConfig,
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import { useChatContext, useAssistantsMapContext, useChatFormContext } from '~/Providers';
import { useRequiresKey, useTextarea, useSubmitMessage } from '~/hooks';
import { useAutoSave } from '~/hooks/Input/useAutoSave';
import { useRequiresKey, useTextarea } from '~/hooks';
import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider';
import { cn, removeFocusRings } from '~/utils';
@ -35,10 +34,6 @@ const ChatForm = ({ index = 0 }) => {
);
const { requiresKey } = useRequiresKey();
const methods = useForm<{ text: string }>({
defaultValues: { text: '' },
});
const { handlePaste, handleKeyDown, handleKeyUp, handleCompositionStart, handleCompositionEnd } =
useTextarea({
textAreaRef,
@ -47,7 +42,6 @@ const ChatForm = ({ index = 0 }) => {
});
const {
ask,
files,
setFiles,
conversation,
@ -56,28 +50,17 @@ const ChatForm = ({ index = 0 }) => {
setFilesLoading,
handleStopGenerating,
} = useChatContext();
const methods = useChatFormContext();
const { clearDraft } = useAutoSave({
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
textAreaRef,
setValue: methods.setValue,
files,
setFiles,
});
const assistantMap = useAssistantsMapContext();
const submitMessage = useCallback(
(data?: { text: string }) => {
if (!data) {
return console.warn('No data provided to submitMessage');
}
ask({ text: data.text });
methods.reset();
clearDraft();
},
[ask, methods, clearDraft],
);
const { submitMessage } = useSubmitMessage({ clearDraft });
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpoint = endpointType ?? _endpoint;

View file

@ -66,7 +66,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
<label
htmlFor={`file-upload-${id}`}
className={cn(
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
statusColor,
containerClassName,
)}

View file

@ -7,7 +7,7 @@ const sourceToEndpoint = {
[FileSources.azure]: EModelEndpoint.azureOpenAI,
};
const sourceToClassname = {
[FileSources.openai]: 'bg-black/65',
[FileSources.openai]: 'bg-white/75 dark:bg-black/65',
[FileSources.azure]: 'azure-bg-color opacity-85',
};

View file

@ -96,7 +96,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
deleteFiles({ files: filesToDelete as TFile[] });
setRowSelection({});
}}
className="ml-1 gap-2 dark:hover:bg-gray-750/25 sm:ml-0"
className="dark:hover:bg-gray-850/25 ml-1 gap-2 sm:ml-0"
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
>
{isDeleting ? (
@ -121,7 +121,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
{/* Filter Menu */}
<DropdownMenuContent
align="end"
className="z-[1001] dark:border-gray-700 dark:bg-gray-750"
className="z-[1001] dark:border-gray-700 dark:bg-gray-850"
>
{table
.getAllColumns()

View file

@ -57,7 +57,7 @@ export function SortFilterHeader<TData, TValue>({
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="z-[1001] dark:border-gray-700 dark:bg-gray-750"
className="z-[1001] dark:border-gray-700 dark:bg-gray-850"
>
<DropdownMenuItem
onClick={() => column.toggleSorting(false)}

View file

@ -78,7 +78,10 @@ const PresetItems: FC<{
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
<Label
htmlFor="preset-item-clear-all"
className="text-left text-sm font-medium"
>
{localize('com_endpoint_presets_clear_warning')}
</Label>
</div>

View file

@ -21,7 +21,7 @@ export const ErrorMessage = ({
return (
<Suspense
fallback={
<div className="text-message mb-[0.625rem] mt-1 flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto">
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto">
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
<div className="absolute">
<p className="relative">

View file

@ -0,0 +1,15 @@
import { TPromptGroup } from 'librechat-data-provider';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
export default function PromptCard({ promptGroup }: { promptGroup: TPromptGroup }) {
return (
<div className="hover:bg-token-main-surface-secondary relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition transition-colors duration-300 ease-in-out fade-in hover:bg-slate-100 dark:border-gray-600 dark:hover:bg-gray-700">
<div className="">
<CategoryIcon className="size-4" category={promptGroup.category || ''} />
</div>
<p className="break-word line-clamp-3 text-balance text-gray-600 dark:text-gray-400">
{promptGroup?.oneliner || promptGroup?.productionPrompt?.prompt}
</p>
</div>
);
}

View file

@ -0,0 +1,62 @@
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import { EModelEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import { TooltipProvider, Tooltip } from '~/components/ui';
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
import { getIconEndpoint, cn } from '~/utils';
import Prompts from './Prompts';
export default function Landing({ Header }: { Header?: ReactNode }) {
const { conversation } = useChatContext();
const assistantMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
let { endpoint = '' } = conversation ?? {};
const { assistant_id = null } = conversation ?? {};
if (
endpoint === EModelEndpoint.chatGPTBrowser ||
endpoint === EModelEndpoint.azureOpenAI ||
endpoint === EModelEndpoint.gptPlugins
) {
endpoint = EModelEndpoint.openAI;
}
const iconURL = conversation?.iconURL;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const isAssistant = isAssistantsEndpoint(endpoint);
const assistant = isAssistant && assistantMap?.[endpoint]?.[assistant_id ?? ''];
const assistantName = (assistant && assistant?.name) || '';
const avatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
return (
<TooltipProvider delayDuration={50}>
<Tooltip>
<div className="relative h-full">
<div className="absolute left-0 right-0">{Header && Header}</div>
<div className="flex h-full flex-col items-center justify-center">
<div className={cn('relative h-12 w-12', assistantName && avatar ? 'mb-0' : 'mb-3')}>
<ConvoIcon
conversation={conversation}
assistantMap={assistantMap}
endpointsConfig={endpointsConfig}
containerClassName={containerClassName}
context="landing"
className="h-2/3 w-2/3"
size={41}
/>
</div>
<div className="h-3/5">
<Prompts />
</div>
</div>
</div>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -0,0 +1,95 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { usePromptGroupsNav } from '~/hooks';
import PromptCard from './PromptCard';
import { Button } from '../ui';
export default function Prompts() {
const { prevPage, nextPage, hasNextPage, promptGroups, hasPreviousPage, setPageSize, pageSize } =
usePromptGroupsNav();
const renderPromptCards = (start = 0, count) => {
return promptGroups
.slice(start, count + start)
.map((promptGroup) => <PromptCard key={promptGroup._id} promptGroup={promptGroup} />);
};
const getRows = () => {
switch (pageSize) {
case 4:
return [4];
case 8:
return [4, 4];
case 12:
return [4, 4, 4];
default:
return [];
}
};
const rows = getRows();
return (
<div className="mx-3 flex h-full max-w-3xl flex-col items-stretch justify-center gap-4">
<div className="mt-2 flex justify-end gap-2">
<Button
variant={'ghost'}
onClick={() => setPageSize(4)}
className={`rounded px-3 py-2 hover:bg-transparent ${
pageSize === 4 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
}`}
>
4
</Button>
<Button
variant={'ghost'}
onClick={() => setPageSize(8)}
className={`rounded px-3 py-2 hover:bg-transparent ${
pageSize === 8 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
}`}
>
8
</Button>
<Button
variant={'ghost'}
onClick={() => setPageSize(12)}
className={`rounded p-2 hover:bg-transparent ${
pageSize === 12 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
}`}
>
12
</Button>
</div>
<div className="flex h-full flex-col items-start gap-2">
<div
className={
'flex min-h-[121.1px] min-w-full max-w-3xl flex-col gap-4 overflow-y-auto md:min-w-[22rem] lg:min-w-[43rem]'
}
>
{rows.map((rowSize, index) => (
<div key={index} className="flex flex-wrap justify-center gap-4">
{renderPromptCards(rowSize * index, rowSize)}
</div>
))}
</div>
<div className="flex w-full justify-between">
<Button
variant={'ghost'}
onClick={prevPage}
disabled={!hasPreviousPage}
className="m-0 self-start p-0 hover:bg-transparent"
>
<ChevronLeft className={`${hasPreviousPage ? '' : 'text-gray-500'}`} />
</Button>
<Button
variant={'ghost'}
onClick={nextPage}
disabled={!hasNextPage}
className="m-0 self-end p-0 hover:bg-transparent"
>
<ChevronRight className={`${hasNextPage ? '' : 'text-gray-500'}`} />
</Button>
</div>
</div>
</div>
);
}

View file

@ -168,7 +168,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
className={cn(
isActiveConvo || isPopoverActive
? 'group relative mt-2 flex cursor-pointer items-center gap-2 break-all rounded-lg bg-gray-200 px-2 py-2 active:opacity-50 dark:bg-gray-700'
: 'group relative mt-2 flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg rounded-lg px-2 py-2 hover:bg-gray-200 active:opacity-50 dark:hover:bg-gray-700',
: 'group relative mt-2 flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2 hover:bg-gray-200 active:opacity-50 dark:hover:bg-gray-700',
!isActiveConvo && !renaming ? 'peer-hover:bg-gray-200 dark:peer-hover:bg-gray-800' : '',
)}
title={title}
@ -190,7 +190,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
)}
/>
) : (
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-gray-50 from-0% to-transparent group-hover:from-gray-200 group-hover:from-60% dark:from-[#181818] dark:group-hover:from-gray-700" />
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-gray-50 from-0% to-transparent group-hover:from-gray-200 group-hover:from-60% dark:from-gray-850 dark:group-hover:from-gray-700" />
)}
</a>
</div>

View file

@ -84,8 +84,8 @@ export default function DeleteButton({
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
{localize('com_ui_delete_conversation_confirm')} <strong>{title}</strong>
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{title}</strong>
</Label>
</div>
</div>

View file

@ -8,23 +8,26 @@ const HoverToggle = ({
isPopoverActive,
setIsPopoverActive,
className = 'absolute bottom-0 right-0 top-0',
onClick,
}: {
children: React.ReactNode;
isActiveConvo: boolean;
isPopoverActive: boolean;
setIsPopoverActive: (isActive: boolean) => void;
className?: string;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
}) => {
const setPopoverActive = (value: boolean) => setIsPopoverActive(value);
return (
<ToggleContext.Provider value={{ isPopoverActive, setPopoverActive }}>
<div
onClick={onClick}
className={cn(
'peer items-center gap-1.5 rounded-r-lg from-gray-500 from-gray-900 pl-2 pr-2 dark:text-white',
'peer items-center gap-1.5 rounded-r-lg from-gray-900 pl-2 pr-2 dark:text-white',
isPopoverActive || isActiveConvo ? 'flex' : 'hidden group-hover:flex',
isActiveConvo
? 'from-gray-50 from-85% to-transparent group-hover:bg-gradient-to-l group-hover:from-gray-200 dark:from-gray-800 dark:group-hover:from-gray-800'
: 'z-50 from-gray-200 from-gray-50 from-0% to-transparent hover:bg-gradient-to-l hover:from-gray-200 dark:from-gray-750 dark:from-gray-800 dark:hover:from-gray-800',
: 'z-50 from-gray-50 from-0% to-transparent hover:bg-gradient-to-l hover:from-gray-200 dark:from-gray-800 dark:hover:from-gray-800',
isPopoverActive && !isActiveConvo ? 'from-gray-50 dark:from-gray-800' : '',
className,
)}

View file

@ -6,17 +6,19 @@ import { cn } from '~/utils';
interface RenameButtonProps {
renaming: boolean;
renameHandler: (e: MouseEvent<HTMLButtonElement>) => void;
onRename: (e: MouseEvent<HTMLButtonElement>) => void;
onRename?: (e: MouseEvent<HTMLButtonElement>) => void;
appendLabel?: boolean;
className?: string;
disabled?: boolean;
}
export default function RenameButton({
renaming,
renameHandler,
onRename,
appendLabel = false,
renameHandler,
className = '',
disabled = false,
appendLabel = false,
}: RenameButtonProps): ReactElement {
const localize = useLocalize();
const handler = renaming ? onRename : renameHandler;
@ -27,6 +29,7 @@ export default function RenameButton({
'group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600',
className,
)}
disabled={disabled}
onClick={handler}
>
{renaming ? (

View file

@ -1,13 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useCreatePresetMutation } from 'librechat-data-provider/react-query';
import type { TEditPresetProps } from '~/common';
import {
cn,
defaultTextPropsLabel,
removeFocusOutlines,
cleanupPreset,
defaultTextProps,
} from '~/utils/';
import { cn, removeFocusOutlines, cleanupPreset, defaultTextProps } from '~/utils/';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { Dialog, Input, Label } from '~/components/ui/';
import { NotificationSeverity } from '~/common';
@ -61,7 +55,7 @@ const SaveAsPresetDialog = ({ open, onOpenChange, preset }: TEditPresetProps) =>
main={
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
<Label htmlFor="dialog-preset-name" className="text-left text-sm font-medium">
{localize('com_endpoint_preset_name')}
</Label>
<Input

View file

@ -0,0 +1,20 @@
import React from 'react';
import { CrossIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type ActionButtonProps = {
onClick: () => void;
};
export default function ActionButton({ onClick }: ActionButtonProps) {
return (
<div className="w-32">
<Button
className="w-full rounded-md border border-black bg-white p-0 text-black hover:bg-black hover:text-white"
onClick={onClick}
>
Action Button
</Button>
</div>
);
}

View file

@ -0,0 +1,17 @@
import React from 'react';
import { CrossIcon, NewTrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type DeleteIconButtonProps = {
onClick: () => void;
};
export default function DeleteIconButton({ onClick }: DeleteIconButtonProps) {
return (
<div className="w-fit">
<Button className="bg-red-400 p-3" onClick={onClick}>
<NewTrashIcon />
</Button>
</div>
);
}

View file

@ -0,0 +1,39 @@
import React from 'react';
import VectorStoreSidePanel from './VectorStore/VectorStoreSidePanel';
import { Outlet, useNavigate, useParams } from 'react-router-dom';
import { Button } from '../ui';
const FileDashboardView = () => {
const params = useParams();
const navigate = useNavigate();
return (
<div className="bg-[#f9f9f9] p-0 lg:p-7">
<div className="ml-3 mt-3 flex flex-row justify-between">
{params?.vectorStoreId && (
<Button
className="block lg:hidden"
variant={'outline'}
size={'sm'}
onClick={() => {
navigate('/d');
}}
>
Go back
</Button>
)}
</div>
<div className="flex h-screen max-w-full flex-row divide-x bg-[#f9f9f9]">
<div className={`w-full lg:w-1/3 ${params.vectorStoreId ? 'hidden lg:block' : ''}`}>
<VectorStoreSidePanel />
</div>
<div className={`w-full lg:w-2/3 ${params.vectorStoreId ? '' : 'hidden lg:block'}`}>
<div className="m-2 overflow-x-auto">
<Outlet />
</div>
</div>
</div>
</div>
);
};
export default FileDashboardView;

View file

@ -0,0 +1,277 @@
import * as React from 'react';
import { ListFilter } from 'lucide-react';
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import type {
ColumnDef,
SortingState,
VisibilityState,
ColumnFiltersState,
} from '@tanstack/react-table';
import { FileContext } from 'librechat-data-provider';
import type { AugmentedColumnDef } from '~/common';
import type { TFile } from 'librechat-data-provider';
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '~/components/ui';
import { useDeleteFilesFromTable } from '~/hooks/Files';
import { NewTrashIcon, Spinner } from '~/components/svg';
import useLocalize from '~/hooks/useLocalize';
import ActionButton from '../ActionButton';
import UploadFileButton from './UploadFileButton';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
const contextMap = {
[FileContext.filename]: 'com_ui_name',
[FileContext.updatedAt]: 'com_ui_date',
[FileContext.source]: 'com_ui_storage',
[FileContext.context]: 'com_ui_context',
[FileContext.bytes]: 'com_ui_size',
};
type Style = { width?: number | string; maxWidth?: number | string; minWidth?: number | string };
export default function DataTableFile<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const localize = useLocalize();
const [isDeleting, setIsDeleting] = React.useState(false);
const [rowSelection, setRowSelection] = React.useState({});
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
getPaginationRowModel: getPaginationRowModel(),
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<>
<div className="mt-2 flex flex-col items-start">
<h2 className="text-lg">
<strong>Files</strong>
</h2>
<div className="mt-3 flex w-full flex-col-reverse justify-between md:flex-row">
<div className="mt-3 flex w-full flex-row justify-center gap-x-3 md:m-0 md:justify-start">
<ActionButton
onClick={() => {
console.log('click');
}}
/>
<Button
variant="ghost"
onClick={() => {
setIsDeleting(true);
const filesToDelete = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original);
deleteFiles({ files: filesToDelete as TFile[] });
setRowSelection({});
}}
className="dark:hover:bg-gray-850/25 ml-1 gap-2 sm:ml-0"
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
>
{isDeleting ? (
<Spinner className="h-4 w-4" />
) : (
<NewTrashIcon className="h-4 w-4 text-red-400" />
)}
{localize('com_ui_delete')}
</Button>
</div>
<div className="flex w-full flex-row gap-x-3">
{' '}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="ml-auto">
<ListFilter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="z-[1001] dark:border-gray-700 dark:bg-gray-850"
>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="cursor-pointer capitalize dark:text-white dark:hover:bg-gray-800"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{localize(contextMap[column.id])}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Input
placeholder={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
className="max-w-sm dark:border-gray-500"
/>
<UploadFileButton
onClick={() => {
console.log('click');
}}
/>
</div>
</div>
</div>
<div className="relative mt-3 max-h-[25rem] min-h-0 overflow-y-auto rounded-md border border-black/10 pb-4 dark:border-white/10 sm:min-h-[28rem]">
<Table className="w-full min-w-[600px] border-separate border-spacing-0">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const style: Style = { maxWidth: '32px', minWidth: '125px' };
if (header.id === 'filename') {
style.maxWidth = '25%';
style.width = '25%';
style.minWidth = '150px';
}
if (header.id === 'icon') {
style.width = '25px';
style.maxWidth = '25px';
style.minWidth = '35px';
}
if (header.id === 'vectorStores') {
style.maxWidth = '50%';
style.width = '50%';
style.minWidth = '300px';
}
if (index === 0 && header.id === 'select') {
style.width = '25px';
style.maxWidth = '25px';
style.minWidth = '35px';
}
return (
<TableHead
key={header.id}
className="align-start sticky top-0 rounded-t border-b border-black/10 bg-white px-2 py-1 text-left font-medium text-gray-700 dark:border-white/10 dark:bg-gray-700 dark:text-gray-100 sm:px-4 sm:py-2"
style={style}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className="border-b border-black/10 text-left text-gray-600 dark:border-white/10 dark:text-gray-300 [tr:last-child_&]:border-b-0"
>
{row.getVisibleCells().map((cell, index) => {
const maxWidth =
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>)?.meta?.size ??
'auto';
const style: Style = {};
if (cell.column.id === 'filename') {
style.maxWidth = maxWidth;
} else if (index === 0) {
style.maxWidth = '20px';
}
return (
<TableCell
key={cell.id}
className="align-start overflow-x-auto px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm [tr[data-disabled=true]_&]:opacity-50"
style={style}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{localize('com_files_no_results')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="ml-4 mr-4 mt-4 flex h-auto items-center justify-end space-x-2 py-4 sm:ml-0 sm:mr-0 sm:h-0">
<div className="text-muted-foreground ml-2 flex-1 text-sm">
{localize(
'com_files_number_selected',
`${table.getFilteredSelectedRowModel().rows.length}`,
`${table.getFilteredRowModel().rows.length}`,
)}
</div>
<Button
className="dark:border-gray-500 dark:hover:bg-gray-600"
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{localize('com_ui_prev')}
</Button>
<Button
className="dark:border-gray-500 dark:hover:bg-gray-600"
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{localize('com_ui_next')}
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,81 @@
import React from 'react';
import DataTableFile from './DataTableFile';
import { TVectorStore } from '~/common';
import { files } from '../../Chat/Input/Files/Table';
import { fileTableColumns } from './../FileList/FileTableColumns';
const vectorStoresAttached: TVectorStore[] = [
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
];
files.forEach((file) => {
file['vectorsAttached'] = vectorStoresAttached;
});
export default function DataTableFilePreview() {
return (
<div>
<DataTableFile columns={fileTableColumns} data={files} />
<div className="mt-5 sm:mt-4" />
</div>
);
}

View file

@ -0,0 +1,9 @@
import React from 'react';
export default function EmptyFilePreview() {
return (
<div className="h-full w-full content-center text-center font-bold">
Select a file to view details.
</div>
);
}

View file

@ -0,0 +1,26 @@
import type { TFile } from 'librechat-data-provider';
import React from 'react';
import FileListItem from './FileListItem';
import FileListItem2 from './FileListItem2';
type FileListProps = {
files: TFile[];
deleteFile: (id: string | undefined) => void;
attachedVectorStores: { name: string }[];
};
export default function FileList({ files, deleteFile, attachedVectorStores }: FileListProps) {
return (
<div className="h-[85vh] overflow-y-auto">
{files.map((file) => (
// <FileListItem key={file._id} file={file} deleteFile={deleteFile} width="100%" />
<FileListItem2
key={file._id}
file={file}
deleteFile={deleteFile}
attachedVectorStores={attachedVectorStores}
/>
))}
</div>
);
}

View file

@ -0,0 +1,33 @@
import type { TFile } from 'librechat-data-provider';
import React from 'react';
import { NewTrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type FileListItemProps = {
file: TFile;
deleteFile: (id: string | undefined) => void;
width?: string;
};
export default function FileListItem({ file, deleteFile, width = '400px' }: FileListItemProps) {
return (
<div className="w-100 my-3 mr-2 flex cursor-pointer flex-row rounded-md border border-0 bg-white p-4 transition duration-300 ease-in-out hover:bg-slate-200">
<div className="flex w-1/2 flex-col justify-around align-middle">
<strong>{file.filename}</strong>
<p className="text-sm text-gray-500">{file.object}</p>
</div>
<div className="w-2/6 text-gray-500">
<p>({file.bytes / 1000}KB)</p>
<p className="text-sm">{file.createdAt?.toString()}</p>
</div>
<div className="flex w-1/6 justify-around">
<Button
className="my-0 ml-3 bg-transparent p-0 text-[#666666] hover:bg-slate-200"
onClick={() => deleteFile(file._id)}
>
<NewTrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,76 @@
import type { TFile } from 'librechat-data-provider';
import { FileIcon, PlusIcon } from 'lucide-react';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { DotsIcon, NewTrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type FileListItemProps = {
file: TFile;
deleteFile: (id: string | undefined) => void;
attachedVectorStores: { name: string }[];
};
export default function FileListItem2({
file,
deleteFile,
attachedVectorStores,
}: FileListItemProps) {
const navigate = useNavigate();
return (
<div
onClick={() => {
navigate('file_id_abcdef');
}}
className="w-100 mt-2 flex h-fit cursor-pointer flex-row rounded-md border border-0 bg-white p-4 transition duration-300 ease-in-out hover:bg-slate-200"
>
<div className="flex w-10/12 flex-col justify-around md:flex-row">
<div className="flex w-2/5 flex-row">
<div className="w-1/4 content-center">
<FileIcon className="m-0 size-5 p-0" />
</div>
<div className="w-3/4 content-center">{file.filename}</div>
</div>
<div className="flex w-fit flex-row flex-wrap text-gray-500 md:w-3/5">
{attachedVectorStores.map((vectorStore, index) => {
if (index === 4) {
return (
<span
key={index}
className="ml-2 mt-1 flex flex-row items-center rounded-full bg-[#f5f5f5] px-2 text-xs"
>
<PlusIcon className="h-3 w-3" />
&nbsp;
{attachedVectorStores.length - index} more
</span>
);
}
if (index > 4) {
return null;
}
return (
<span
key={index}
className="ml-2 mt-1 content-center rounded-full bg-[#f2f8ec] px-2 text-xs text-[#91c561]"
>
{vectorStore.name}
</span>
);
})}
</div>
</div>
<div className="mr-0 flex w-2/12 flex-col items-center justify-evenly sm:mr-4 md:flex-row">
<Button className="w-min content-center bg-transparent text-gray-500 hover:bg-slate-200">
<DotsIcon className="text-grey-100" />
</Button>
<Button
className="w-min bg-transparent text-[#666666] hover:bg-slate-200"
onClick={() => deleteFile(file._id)}
>
<NewTrashIcon className="" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,180 @@
import { TFile } from 'librechat-data-provider/dist/types';
import React, { useState } from 'react';
import { TThread, TVectorStore } from '~/common';
import { CheckMark, NewTrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
import DeleteIconButton from '../DeleteIconButton';
import VectorStoreButton from '../VectorStore/VectorStoreButton';
import { CircleIcon, Clock3Icon, InfoIcon } from 'lucide-react';
import { useParams } from 'react-router-dom';
const tempFile: TFile = {
filename: 'File1.jpg',
object: 'file',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
type: 'image',
usage: 12,
user: 'abc',
file_id: 'file_id',
embedded: true,
filepath: 'filepath',
};
const tempThreads: TThread[] = [
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
];
const tempVectorStoresAttached: TVectorStore[] = [
{ name: 'vector 1', created_at: '2022-01-01T10:00:00', _id: 'id', object: 'vector_store' },
{ name: 'vector 1', created_at: '2022-01-01T10:00:00', _id: 'id', object: 'vector_store' },
{ name: 'vector 1', created_at: '2022-01-01T10:00:00', _id: 'id', object: 'vector_store' },
];
export default function FilePreview() {
const [file, setFile] = useState(tempFile);
const [threads, setThreads] = useState(tempThreads);
const [vectorStoresAttached, setVectorStoresAttached] = useState(tempVectorStoresAttached);
const params = useParams();
return (
<div className="m-3 bg-white p-2 sm:p-4 md:p-6 lg:p-10">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex flex-col">
<b className="hidden text-sm md:text-base lg:block lg:text-lg">FILE</b>
<b className="text-center text-xl md:text-2xl lg:text-left lg:text-3xl">
{file.filename}
</b>
</div>
<div className="mt-3 flex flex-row gap-x-3 md:mt-0">
<div>
<DeleteIconButton
onClick={() => {
console.log('click');
}}
/>
</div>
<div className="w-40">
<VectorStoreButton
onClick={() => {
console.log('click');
}}
/>
</div>
</div>
</div>
<div className="mt-3 flex flex-col">
<div className="flex flex-row">
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
<InfoIcon className="size-4 text-gray-500" />
&nbsp; File ID
</span>
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">{file._id}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
<CircleIcon className="m-0 size-4 p-0 text-gray-500" />
&nbsp; Status
</span>
<div className="w-1/2 sm:w-3/4 md:w-3/5">
<span className="flex w-20 flex-row items-center justify-evenly rounded-full bg-[#f2f8ec] p-1 text-[#91c561]">
<CheckMark className="m-0 p-0" />
<div>{file.object}</div>
</span>
</div>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
<Clock3Icon className="m-0 size-4 p-0 text-gray-500" />
&nbsp; Purpose
</span>
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">{file.message}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
<Clock3Icon className="m-0 size-4 p-0 text-gray-500" />
&nbsp; Size
</span>
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">{file.bytes}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
<Clock3Icon className="m-0 size-4 p-0 text-gray-500" />
&nbsp; Created At
</span>
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">
{file.createdAt?.toString()}
</span>
</div>
</div>
<div className="mt-10 flex flex-col">
<div>
<b className="text-sm md:text-base lg:text-lg">Attached To</b>
</div>
<div className="flex flex-col divide-y">
<div className="mt-2 flex flex-row">
<div className="w-2/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-2/3">
Vector Stores
</div>
<div className="w-3/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-1/3">Uploaded</div>
</div>
<div>
{vectorStoresAttached.map((vectors, index) => (
<div key={index} className="mt-2 flex flex-row">
<div className="ml-4 w-2/5 content-center md:w-1/2 xl:w-2/3">{vectors.name}</div>
<div className="flex w-3/5 flex-row md:w-1/2 xl:w-1/3">
<div className="content-center text-nowrap">{vectors.created_at.toString()}</div>
<Button
className="m-0 ml-3 h-full bg-transparent p-0 text-[#666666] hover:bg-slate-200"
onClick={() => {
console.log('Remove from vector store');
}}
variant={'ghost'}
>
<NewTrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
<div className="mt-10 flex flex-col">
<div className="flex flex-col divide-y">
<div className="flex flex-row">
<div className="w-2/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-2/3">Threads</div>
<div className="w-3/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-1/3">Uploaded</div>
</div>
<div>
{threads.map((thread, index) => (
<div key={index} className="mt-2 flex flex-row">
<div className="ml-4 w-2/5 content-center md:w-1/2 xl:w-2/3">ID: {thread.id}</div>
<div className="flex w-3/5 flex-row md:w-1/2 xl:w-1/3">
<div className="content-center text-nowrap">{thread.createdAt}</div>
<Button
className="m-0 ml-3 h-full bg-transparent p-0 text-[#666666] hover:bg-slate-200"
onClick={() => {
console.log('Remove from thread');
}}
>
<NewTrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,187 @@
import React from 'react';
import FileList from './FileList';
import { TFile } from 'librechat-data-provider/dist/types';
import FilesSectionSelector from '../FilesSectionSelector';
import { Button, Input } from '~/components/ui';
import { ListFilter } from 'lucide-react';
import UploadFileButton from './UploadFileButton';
import { useLocalize } from '~/hooks';
const fakeFiles = [
{
filename: 'File1.jpg',
object: 'Description 1',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
},
{
filename: 'File2.jpg',
object: 'Description 2',
bytes: 15000,
createdAt: '2022-01-02T15:30:00',
_id: '2',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
];
const attachedVectorStores = [
{ name: 'VectorStore1' },
{ name: 'VectorStore2' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
];
export default function FileSidePanel() {
const localize = useLocalize();
const deleteFile = (id: string | undefined) => {
// Define delete functionality here
console.log(`Deleting File with id: ${id}`);
};
return (
<div className="w-30">
<h2 className="m-3 text-lg">
<strong>Files</strong>
</h2>
<div className="m-3 mt-2 flex w-full flex-row justify-between gap-x-2 lg:m-0">
<div className="flex w-2/3 flex-row">
<Button variant="ghost" className="m-0 mr-2 p-0">
<ListFilter className="h-4 w-4" />
</Button>
<Input
placeholder={localize('com_files_filter')}
value={''}
onChange={() => {
console.log('changed');
}}
className="max-w-sm dark:border-gray-500"
/>
</div>
<div className="w-1/3">
<UploadFileButton
onClick={() => {
console.log('Upload');
}}
/>
</div>
</div>
<div className="mt-3">
<FileList
files={fakeFiles as TFile[]}
deleteFile={deleteFile}
attachedVectorStores={attachedVectorStores}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,123 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { FileSources, FileContext } from 'librechat-data-provider';
import type { ColumnDef } from '@tanstack/react-table';
import type { TFile } from 'librechat-data-provider';
import { CrossIcon, DotsIcon } from '~/components/svg';
import { Button, Checkbox } from '~/components/ui';
import { formatDate, getFileType } from '~/utils';
import useLocalize from '~/hooks/useLocalize';
import FileIcon from '~/components/svg/Files/FileIcon';
import { PlusIcon } from 'lucide-react';
export const fileTableColumns: ColumnDef<TFile>[] = [
{
id: 'select',
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="flex"
/>
);
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="flex"
/>
);
},
enableSorting: false,
enableHiding: false,
},
{
meta: {
size: '50px',
},
accessorKey: 'icon',
header: () => {
return 'Icon';
},
cell: ({ row }) => {
const file = row.original;
return <FileIcon file={file} fileType={getFileType(file.type)} />;
},
},
{
meta: {
size: '150px',
},
accessorKey: 'filename',
header: ({ column }) => {
const localize = useLocalize();
return <>{localize('com_ui_name')}</>;
},
cell: ({ row }) => {
const file = row.original;
return <span className="self-center truncate">{file.filename}</span>;
},
},
{
accessorKey: 'vectorStores',
header: () => {
return 'Vector Stores';
},
cell: ({ row }) => {
const { vectorsAttached: attachedVectorStores } = row.original;
return (
<>
{attachedVectorStores.map((vectorStore, index) => {
if (index === 4)
{return (
<span
key={index}
className="ml-2 mt-2 flex w-fit flex-row items-center rounded-full bg-[#f5f5f5] px-2 text-gray-500"
>
<PlusIcon className="h-3 w-3" />
&nbsp;
{attachedVectorStores.length - index} more
</span>
);}
if (index > 4) {return null;}
return (
<span key={index} className="ml-2 mt-2 rounded-full bg-[#f2f8ec] px-2 text-[#91c561]">
{vectorStore.name}
</span>
);
})}
</>
);
},
},
{
accessorKey: 'updatedAt',
header: () => {
const localize = useLocalize();
return 'Modified';
},
cell: ({ row }) => formatDate(row.original.updatedAt),
},
{
accessorKey: 'actions',
header: () => {
return 'Actions';
},
cell: ({ row }) => {
return (
<>
<Button className="w-min content-center bg-transparent text-gray-500 hover:bg-slate-200">
<DotsIcon className="text-grey-100 m-0 size-5 p-0" />
</Button>
</>
);
},
},
];

View file

@ -0,0 +1,18 @@
import { PlusIcon } from 'lucide-react';
import React from 'react';
import { Button } from '~/components/ui';
type UploadFileProps = {
onClick: () => void;
};
export default function UploadFileButton({ onClick }: UploadFileProps) {
return (
<div className="w-full">
<Button className="w-full bg-black px-3 text-white" onClick={onClick}>
<PlusIcon className="h-4 w-4 font-bold" />
&nbsp; <span className="text-nowrap">Upload New File</span>
</Button>
</div>
);
}

View file

@ -0,0 +1,88 @@
import React, { useState, ChangeEvent } from 'react';
import AttachFile from '~/components/Chat/Input/Files/AttachFile';
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const UploadFileModal = ({ open, onOpenChange }) => {
const localize = useLocalize();
const [file, setFile] = useState<File | null>(null);
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFile = e.target.files[0];
setFile(selectedFile);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'w-11/12 overflow-x-auto p-3 shadow-2xl dark:bg-gray-700 dark:text-white lg:w-2/3 xl:w-2/5',
)}
>
<DialogHeader>
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
Upoad a File
</DialogTitle>
</DialogHeader>
<div className="flex w-full flex-col p-0 sm:p-6 sm:pb-0 sm:pt-4">
<div className="flex w-full flex-row">
<div className="hidden w-1/5 sm:block">
<img />
</div>
<div className="flex w-full flex-col text-center sm:w-4/5 sm:text-left">
<div className="italic">Please upload square file, size less than 100KB</div>
<div className="mt-4 flex w-full flex-row items-center bg-[#f9f9f9] p-2">
<div className="w-1/2 sm:w-1/3">
<Button>Choose File</Button>
</div>
<div className="w-1/2 sm:w-1/3"> No File Chosen</div>
</div>
</div>
</div>
<div className="mt-3 flex w-full flex-col">
<label htmlFor="name">Name</label>
<label className="hidden text-[#808080] sm:block">The name of the uploaded file</label>
<Input type="text" id="name" name="name" placeholder="Name" />
</div>
<div className="mt-3 flex w-full flex-col">
<label htmlFor="purpose">Purpose</label>
<label className="hidden text-[#808080] sm:block">
The purpose of the uploaded file
</label>
<Input type="text" id="purpose" name="purpose" placeholder="Purpose" />
</div>
<div className="mt-3 flex w-full flex-row justify-between">
<div className="hidden w-1/3 sm:block">
<span className="font-bold">Learn about file purpose</span>
</div>
<div className="flex w-full flex-row justify-evenly sm:w-1/3">
<Button
className="mr-3 w-full rounded-md border border-black bg-white p-0 text-black hover:bg-white"
onClick={() => {
onOpenChange(false);
}}
>
Cancel
</Button>
<Button
className="w-full rounded-md border border-black bg-black p-0 text-white"
onClick={() => {
console.log('upload file');
}}
>
Upload
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default UploadFileModal;

View file

@ -0,0 +1,45 @@
import React from 'react';
import FileSidePanel from './FileList/FileSidePanel';
import { Outlet, useNavigate, useParams } from 'react-router-dom';
import FilesSectionSelector from './FilesSectionSelector';
import { Button } from '../ui';
export default function FilesListView() {
const params = useParams();
const navigate = useNavigate();
return (
<div className="bg-[#f9f9f9] p-0 lg:p-7">
<div className="m-4 flex w-full flex-row justify-between md:m-2">
<FilesSectionSelector />
{params?.fileId && (
<Button
className="block lg:hidden"
variant={'outline'}
size={'sm'}
onClick={() => {
navigate('/d/files');
}}
>
Go back
</Button>
)}
</div>
<div className="flex w-full flex-row divide-x">
<div
className={`mr-2 w-full xl:w-1/3 ${
params.fileId ? 'hidden w-1/2 lg:block lg:w-1/2' : 'md:w-full'
}`}
>
<FileSidePanel />
</div>
<div
className={`h-[85vh] w-full overflow-y-auto xl:w-2/3 ${
params.fileId ? 'lg:w-1/2' : 'hidden md:w-1/2 lg:block'
}`}
>
<Outlet />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { Button } from '../ui';
import { useLocation, useNavigate } from 'react-router-dom';
export default function FilesSectionSelector() {
const navigate = useNavigate();
const location = useLocation();
let selectedPage = '/vector-stores';
if (location.pathname.includes('vector-stores')) {
selectedPage = '/vector-stores';
}
if (location.pathname.includes('files')) {
selectedPage = '/files';
}
const darkButton = { backgroundColor: 'black', color: 'white' };
const lightButton = { backgroundColor: '#f9f9f9', color: 'black' };
return (
<div className="flex h-12 w-52 flex-row justify-center rounded border bg-white p-1">
<div className="flex w-2/3 items-center pr-1">
<Button
className="w-full rounded rounded-lg border"
style={selectedPage === '/vector-stores' ? darkButton : lightButton}
onClick={() => {
selectedPage = '/vector-stores';
navigate('/d/vector-stores');
}}
>
Vector Stores
</Button>
</div>
<div className="flex w-1/3 items-center">
<Button
className="w-full rounded rounded-lg border"
style={selectedPage === '/files' ? darkButton : lightButton}
onClick={() => {
selectedPage = '/files';
navigate('/d/files');
}}
>
Files
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,9 @@
import React from 'react';
export default function EmptyVectorStorePreview() {
return (
<div className="h-full w-full content-center text-center font-bold">
Select a vector store to view details.
</div>
);
}

View file

@ -0,0 +1,18 @@
import { PlusIcon } from 'lucide-react';
import React from 'react';
import { Button } from '~/components/ui';
type VectorStoreButtonProps = {
onClick: () => void;
};
export default function VectorStoreButton({ onClick }: VectorStoreButtonProps) {
return (
<div className="w-full">
<Button className="w-full bg-black p-0 text-white" onClick={onClick}>
<PlusIcon className="h-4 w-4 font-bold" />
&nbsp; <span className="text-nowrap">Add Store</span>
</Button>
</div>
);
}

View file

@ -0,0 +1,7 @@
import React from 'react';
const VectorStoreFilter = () => {
return <div>VectorStoreFilter</div>;
};
export default VectorStoreFilter;

View file

@ -0,0 +1,22 @@
import React from 'react';
import VectorStoreListItem from './VectorStoreListItem';
import { TVectorStore } from '~/common';
type VectorStoreListProps = {
vectorStores: TVectorStore[];
deleteVectorStore: (id: string | undefined) => void;
};
export default function VectorStoreList({ vectorStores, deleteVectorStore }: VectorStoreListProps) {
return (
<div>
{vectorStores.map((vectorStore, index) => (
<VectorStoreListItem
key={index}
vectorStore={vectorStore}
deleteVectorStore={deleteVectorStore}
/>
))}
</div>
);
}

View file

@ -0,0 +1,47 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { TVectorStore } from '~/common';
import { DotsIcon, NewTrashIcon, TrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type VectorStoreListItemProps = {
vectorStore: TVectorStore;
deleteVectorStore: (id: string) => void;
};
export default function VectorStoreListItem({
vectorStore,
deleteVectorStore,
}: VectorStoreListItemProps) {
const navigate = useNavigate();
return (
<div
onClick={() => {
navigate('vs_id_abcdef');
}}
className="w-100 mt-2 flex cursor-pointer flex-row justify-around rounded-md border border-0 bg-white p-4 transition duration-300 ease-in-out hover:bg-slate-200"
>
<div className="flex w-1/2 flex-col justify-around align-middle">
<strong>{vectorStore.name}</strong>
<p className="text-sm text-gray-500">{vectorStore.object}</p>
</div>
<div className="w-2/6 text-gray-500">
<p>
{vectorStore.file_counts.total} Files ({vectorStore.bytes / 1000}KB)
</p>
<p className="text-sm">{vectorStore.created_at.toString()}</p>
</div>
<div className="flex w-1/6 flex-col justify-around sm:flex-row">
<Button className="m-0 w-full content-center bg-transparent p-0 text-gray-500 hover:bg-slate-200 sm:w-min">
<DotsIcon className="text-grey-100 m-0 p-0" />
</Button>
<Button
className="m-0 w-full bg-transparent p-0 text-[#666666] hover:bg-slate-200 sm:w-fit"
onClick={() => deleteVectorStore(vectorStore._id)}
>
<NewTrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,244 @@
import React, { useState } from 'react';
import DeleteIconButton from '../DeleteIconButton';
import { Button } from '~/components/ui';
import { NewTrashIcon } from '~/components/svg';
import { TFile } from 'librechat-data-provider/dist/types';
import UploadFileButton from '../FileList/UploadFileButton';
import UploadFileModal from '../FileList/UploadFileModal';
import { BarChart4Icon, Clock3, FileClock, FileIcon, InfoIcon, PlusIcon } from 'lucide-react';
import { useParams } from 'react-router-dom';
const tempVectorStore = {
_id: 'vs_NeHK4JidLKJ2qo23dKLLK',
name: 'Vector Store 1',
usageThisMonth: '1,000,000',
bytes: 1000000,
lastActive: '2022-01-01T10:00:00',
expirationPolicy: 'Never',
expires: 'Never',
createdAt: '2022-01-01T10:00:00',
};
const tempFilesAttached: TFile[] = [
{
filename: 'File1.jpg',
object: 'file',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
type: 'image',
usage: 12,
user: 'abc',
file_id: 'file_id',
embedded: true,
filepath: 'filepath',
},
{
filename: 'File1.jpg',
object: 'file',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
type: 'image',
usage: 12,
user: 'abc',
file_id: 'file_id',
embedded: true,
filepath: 'filepath',
},
{
filename: 'File1.jpg',
object: 'file',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
type: 'image',
usage: 12,
user: 'abc',
file_id: 'file_id',
embedded: true,
filepath: 'filepath',
},
{
filename: 'File1.jpg',
object: 'file',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
type: 'image',
usage: 12,
user: 'abc',
file_id: 'file_id',
embedded: true,
filepath: 'filepath',
},
];
const tempAssistants = [
{
id: 'Lorum Ipsum',
resource: 'Lorum Ipsum',
},
{
id: 'Lorum Ipsum',
resource: 'Lorum Ipsum',
},
{
id: 'Lorum Ipsum',
resource: 'Lorum Ipsum',
},
{
id: 'Lorum Ipsum',
resource: 'Lorum Ipsum',
},
];
export default function VectorStorePreview() {
const [open, setOpen] = useState(false);
const [vectorStore, setVectorStore] = useState(tempVectorStore);
const [filesAttached, setFilesAttached] = useState(tempFilesAttached);
const [assistants, setAssistants] = useState(tempAssistants);
const params = useParams();
return (
<div className="m-3 ml-1 mr-7 bg-white p-2 sm:p-4 md:p-6 lg:p-10">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex flex-col">
<b className="hidden text-base md:text-lg lg:block lg:text-xl">VECTOR STORE</b>
<b className="text-center text-xl md:text-2xl lg:text-left lg:text-3xl">
{vectorStore.name}
</b>
</div>
<div className="mt-3 flex flex-row gap-x-3 md:mt-0">
<div>
<DeleteIconButton
onClick={() => {
console.log('click');
}}
/>
</div>
<div>
<UploadFileButton
onClick={() => {
setOpen(true);
}}
/>
</div>
</div>
</div>
<div className="mt-3 flex flex-col">
<div className="flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<InfoIcon className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp; ID
</span>
<span className="w-1/2 break-words text-gray-500 md:w-3/5">{vectorStore._id}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<BarChart4Icon className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Usage this &nbsp;month
</span>
<div className="w-1/2 md:w-3/5">
<p className="text-gray-500">
<span className="text-[#91c561]">0 KB hours</span>
&nbsp; Free until end of 2024
</p>
</div>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<InfoIcon className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Size
</span>
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.bytes} bytes</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<Clock3 className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Last active
</span>
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.lastActive}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<InfoIcon className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Expiration policy
</span>
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.expirationPolicy}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<FileClock className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Expires
</span>
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.expires}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<Clock3 className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Created At
</span>
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.createdAt?.toString()}</span>
</div>
</div>
<div className="mt-10 flex flex-col">
<div>
<b className="text-base md:text-lg lg:text-xl">Files attached</b>
</div>
<div className="flex flex-col divide-y">
<div className="mt-2 flex flex-row">
<div className="w-1/2 text-base md:text-lg lg:w-2/3 lg:text-xl">File</div>
<div className="w-1/2 text-base md:text-lg lg:w-1/3 lg:text-xl">Uploaded</div>
</div>
<div>
{filesAttached.map((file, index) => (
<div key={index} className="my-2 flex h-5 flex-row">
<div className="lg:w flex w-1/2 flex-row content-center lg:w-2/3">
<FileIcon className="m-0 size-5 p-0" />
<div className="ml-2 content-center">{file.filename}</div>
</div>
<div className="flex w-1/2 flex-row lg:w-1/3">
<div className="content-center text-nowrap">{file.createdAt?.toString()}</div>
<Button
className="my-0 ml-3 h-min bg-transparent p-0 text-[#666666] hover:bg-slate-200"
onClick={() => console.log('click')}
>
<NewTrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
<div className="mt-10 flex flex-col">
<div className="flex flex-row justify-between">
<b className="text-base md:text-lg lg:text-xl">Used by</b>
<Button variant={'default'}>
<PlusIcon className="h-4 w-4 font-bold" />
&nbsp; Create Assistant
</Button>
</div>
<div className="flex flex-col divide-y">
<div className="mt-2 flex flex-row">
<div className="w-1/2 text-base md:text-lg lg:w-2/3 lg:text-xl">Resource</div>
<div className="w-1/2 text-base md:text-lg lg:w-1/3 lg:text-xl">ID</div>
</div>
<div>
{assistants.map((assistant, index) => (
<div key={index} className="flex flex-row">
<div className="w-1/2 content-center lg:w-2/3">{assistant.resource}</div>
<div className="flex w-1/2 flex-row lg:w-1/3">
<div className="content-center">{assistant.id}</div>
</div>
</div>
))}
</div>
</div>
</div>
{open && <UploadFileModal open={open} onOpenChange={setOpen} />}
</div>
);
}

View file

@ -0,0 +1,252 @@
import React from 'react';
import VectorStoreList from './VectorStoreList';
import { TVectorStore } from '~/common';
import VectorStoreButton from './VectorStoreButton';
import { Button, Input } from '~/components/ui';
import FilesSectionSelector from '../FilesSectionSelector';
import ActionButton from '../ActionButton';
import DeleteIconButton from '../DeleteIconButton';
import { ListFilter } from 'lucide-react';
import { useLocalize } from '~/hooks';
const fakeVectorStores: TVectorStore[] = [
{
name: 'VectorStore 1',
bytes: 10000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '1',
},
{
name: 'VectorStore 2',
bytes: 10000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '2',
},
{
name: 'VectorStore 3',
bytes: 10000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '3',
},
{
name: 'VectorStore 4',
bytes: 10000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '4',
},
{
name: 'VectorStore 5',
bytes: 10000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '5',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
];
export default function VectorStoreSidePanel() {
const localize = useLocalize();
const deleteVectorStore = (id: string | undefined) => {
// Define delete functionality here
console.log(`Deleting VectorStore with id: ${id}`);
};
return (
<div className="flex flex-col">
<div className="m-3 flex max-h-[10vh] flex-col">
<h2 className="text-lg">
<strong>Vector Stores</strong>
</h2>
<div className="m-1 mt-2 flex w-full flex-row justify-between gap-x-2 lg:m-0">
<div className="flex w-2/3 flex-row">
<Button variant="ghost" className="m-0 mr-2 p-0">
<ListFilter className="h-4 w-4" />
</Button>
<Input
placeholder={localize('com_files_filter')}
value={''}
onChange={() => {
console.log('changed');
}}
className="max-w-sm dark:border-gray-500"
/>
</div>
<div className="w-1/3">
<VectorStoreButton
onClick={() => {
console.log('Add Vector Store');
}}
/>
</div>
</div>
</div>
<div className="mr-2 mt-2 max-h-[80vh] w-full overflow-y-auto">
<VectorStoreList vectorStores={fakeVectorStores} deleteVectorStore={deleteVectorStore} />
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
import React from 'react';
import VectorStoreSidePanel from './VectorStore/VectorStoreSidePanel';
import FilesSectionSelector from './FilesSectionSelector';
import { Button } from '../ui';
import { Outlet, useNavigate, useParams } from 'react-router-dom';
export default function VectorStoreView() {
const params = useParams();
const navigate = useNavigate();
return (
<div className="max-h-[100vh] bg-[#f9f9f9] p-0 lg:p-7">
<div className="m-4 flex max-h-[10vh] w-full flex-row justify-between md:m-2">
<FilesSectionSelector />
<Button
className="block lg:hidden"
variant={'outline'}
size={'sm'}
onClick={() => {
navigate('/d/vector-stores');
}}
>
Go back
</Button>
</div>
<div className="flex max-h-[90vh] w-full flex-row divide-x">
<div
className={`max-h-full w-full xl:w-1/3 ${
params.vectorStoreId ? 'hidden w-1/2 lg:block lg:w-1/2' : 'md:w-full'
}`}
>
<VectorStoreSidePanel />
</div>
<div
className={`max-h-full w-full overflow-y-auto xl:w-2/3 ${
params.vectorStoreId ? 'lg:w-1/2' : 'hidden md:w-1/2 lg:block'
}`}
>
<Outlet />
</div>
</div>
</div>
);
}

View file

@ -8,7 +8,7 @@ export default function ScrollToBottom({ scrollHandler }: Props) {
return (
<button
onClick={scrollHandler}
className="absolute bottom-5 right-1/2 cursor-pointer rounded-full border border-gray-200 bg-white bg-clip-padding text-gray-600 dark:border-white/10 dark:bg-gray-750/90 dark:text-gray-200"
className="dark:bg-gray-850/90 absolute bottom-5 right-1/2 cursor-pointer rounded-full border border-gray-200 bg-white bg-clip-padding text-gray-600 dark:border-white/10 dark:text-gray-200"
>
<svg
width="24"

View file

@ -103,7 +103,7 @@ const Nav = ({ navVisible, setNavVisible }) => {
<Tooltip>
<div
className={
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-gray-50 dark:bg-gray-750 md:max-w-[260px]'
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-gray-50 dark:bg-gray-850 md:max-w-[260px]'
}
style={{
width: navVisible ? navWidth : '0px',

View file

@ -82,7 +82,7 @@ export default function NewChat({
return (
<TooltipProvider delayDuration={250}>
<Tooltip>
<div className="sticky left-0 right-0 top-0 z-20 bg-gray-50 pt-3.5 dark:bg-gray-750">
<div className="sticky left-0 right-0 top-0 z-20 bg-gray-50 pt-3.5 dark:bg-gray-850">
<div className="pb-0.5 last:pb-0" tabIndex={0} style={{ transform: 'none' }}>
<a
href="/"

View file

@ -58,7 +58,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
return (
<div
ref={ref}
className="relative mt-1 flex flex h-10 cursor-pointer items-center gap-3 rounded-lg border-white bg-gray-50 px-2 px-3 py-2 text-black transition-colors duration-200 focus-within:bg-gray-200 hover:bg-gray-200 dark:bg-gray-750 dark:text-white dark:focus-within:bg-gray-800 dark:hover:bg-gray-800"
className="relative mt-1 flex flex h-10 cursor-pointer items-center gap-3 rounded-lg border-white bg-gray-50 px-2 px-3 py-2 text-black transition-colors duration-200 focus-within:bg-gray-200 hover:bg-gray-200 dark:bg-gray-850 dark:text-white dark:focus-within:bg-gray-800 dark:hover:bg-gray-800"
>
{<Search className="absolute left-3 h-4 w-4" />}
<input

View file

@ -7,8 +7,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/u
import { useUploadAvatarMutation, useGetFileConfig } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { cn, formatBytes } from '~/utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
import store from '~/store';
function Avatar() {
@ -55,8 +55,9 @@ function Avatar() {
setinput(file);
setDialogOpen(true);
} else {
const megabytes = fileConfig.avatarSizeLimit ? formatBytes(fileConfig.avatarSizeLimit) : 2;
showToast({
message: localize('com_ui_upload_invalid'),
message: localize('com_ui_upload_invalid_var', megabytes + ''),
status: 'error',
});
}
@ -81,7 +82,7 @@ function Avatar() {
<span>{localize('com_nav_profile_picture')}</span>
<label
htmlFor={'file-upload-avatar'}
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500"
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500"
>
<FileImage className="mr-1 flex w-[22px] items-center stroke-1" />
<span>{localize('com_nav_change_picture')}</span>

View file

@ -0,0 +1,162 @@
import { useMemo, useEffect } from 'react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui';
import { useUpdatePromptPermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
import { Button, Switch } from '~/components/ui';
import { useToastContext } from '~/Providers';
type FormValues = Record<Permissions, boolean>;
type LabelControllerProps = {
label: string;
promptPerm: Permissions;
control: Control<FormValues, unknown, FormValues>;
setValue: UseFormSetValue<FormValues>;
getValues: UseFormGetValues<FormValues>;
};
const defaultValues = roleDefaults[SystemRoles.USER];
const LabelController: React.FC<LabelControllerProps> = ({
control,
promptPerm,
label,
getValues,
setValue,
}) => (
<div className="mb-4 flex items-center justify-between gap-2">
<label
className="cursor-pointer select-none"
htmlFor={promptPerm}
onClick={() =>
setValue(promptPerm, !getValues(promptPerm), {
shouldDirty: true,
})
}
>
{label}
</label>
<Controller
name={promptPerm}
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field?.value?.toString()}
/>
)}
/>
</div>
);
const AdminSettings = () => {
const localize = useLocalize();
const { user, roles } = useAuthContext();
const { showToast } = useToastContext();
const { mutate, isLoading } = useUpdatePromptPermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_endpoint_preset_saved') });
},
onError: () => {
showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') });
},
});
const {
reset,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: useMemo(() => {
if (roles?.[SystemRoles.USER]) {
return roles[SystemRoles.USER][PermissionTypes.PROMPTS];
}
return defaultValues[PermissionTypes.PROMPTS];
}, [roles]),
});
useEffect(() => {
if (roles?.[SystemRoles.USER]?.[PermissionTypes.PROMPTS]) {
reset(roles[SystemRoles.USER][PermissionTypes.PROMPTS]);
}
}, [roles, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
}
const labelControllerData = [
{
promptPerm: Permissions.SHARED_GLOBAL,
label: localize('com_ui_prompts_allow_share_global'),
},
{
promptPerm: Permissions.USE,
label: localize('com_ui_prompts_allow_use'),
},
{
promptPerm: Permissions.CREATE,
label: localize('com_ui_prompts_allow_create'),
},
];
const onSubmit = (data: FormValues) => {
mutate({ roleName: SystemRoles.USER, updates: data });
};
return (
<OGDialog>
<OGDialogTrigger asChild>
<Button
size={'sm'}
variant={'outline'}
className="h-10 w-fit gap-1 border transition-all dark:bg-transparent"
>
<ShieldEllipsis className="cursor-pointer" />
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
</Button>
</OGDialogTrigger>
<OGDialogContent className="bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300">
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
'com_ui_prompts',
)}`}</OGDialogTitle>
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ promptPerm, label }) => (
<LabelController
key={promptPerm}
control={control}
promptPerm={promptPerm}
label={label}
getValues={getValues}
setValue={setValue}
/>
))}
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting || isLoading}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
>
{localize('com_ui_save')}
</button>
</div>
</form>
</OGDialogContent>
</OGDialog>
);
};
export default AdminSettings;

View file

@ -0,0 +1,39 @@
import { useRecoilState, useSetRecoilState } from 'recoil';
import { Tabs, TabsList, TabsTrigger } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
const { PromptsEditorMode, promptsEditorMode, alwaysMakeProd } = store;
const AdvancedSwitch = () => {
const localize = useLocalize();
const [mode, setMode] = useRecoilState(promptsEditorMode);
const setAlwaysMakeProd = useSetRecoilState(alwaysMakeProd);
return (
<Tabs
defaultValue={mode}
className="w-auto rounded-lg"
onValueChange={(value) => {
value === PromptsEditorMode.SIMPLE && setAlwaysMakeProd(true);
setMode(value);
}}
>
<TabsList className="grid w-auto grid-cols-2 bg-surface-tertiary">
<TabsTrigger
value={PromptsEditorMode.SIMPLE}
className="w-20 min-w-0 rounded-md text-xs md:w-auto md:text-sm"
>
{localize('com_ui_simple')}
</TabsTrigger>
<TabsTrigger
value={PromptsEditorMode.ADVANCED}
className="w-20 min-w-0 rounded-md text-xs md:w-auto md:text-sm"
>
{localize('com_ui_advanced')}
</TabsTrigger>
</TabsList>
</Tabs>
);
};
export default AdvancedSwitch;

View file

@ -0,0 +1,26 @@
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { buttonVariants } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function BackToChat({ className }: { className?: string }) {
const navigate = useNavigate();
const localize = useLocalize();
const clickHandler = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
event.preventDefault();
navigate('/c/new');
}
};
return (
<a
className={cn(buttonVariants({ variant: 'outline' }), className)}
href="/"
onClick={clickHandler}
>
<ArrowLeft className="icon-xs mr-2" />
{localize('com_ui_back_to_chat')}
</a>
);
}

View file

@ -0,0 +1,60 @@
import { Button, Dialog, DialogTrigger, Label } from '~/components/ui';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { TrashIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
const DeleteVersion = ({
name,
disabled,
selectHandler,
}: {
name: string;
disabled?: boolean;
selectHandler: () => void;
}) => {
const localize = useLocalize();
return (
<Dialog>
<DialogTrigger asChild>
<Button
size={'sm'}
className="h-10 w-10 border border-transparent bg-red-600 text-red-500 transition-all hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-800"
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
}}
>
<TrashIcon className="icon-lg cursor-pointer text-white dark:text-white" />
</Button>
</DialogTrigger>
<DialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_prompt')}
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-delete-confirm-prompt"
className="text-left text-sm font-medium"
>
{localize('com_ui_delete_confirm_prompt_version_var', name)}
</Label>
</div>
</div>
</>
}
selection={{
selectHandler,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</Dialog>
);
};
export default DeleteVersion;

View file

@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import { Info } from 'lucide-react';
import { useLocalize } from '~/hooks';
const MAX_LENGTH = 56;
const Description = ({
initialValue,
onValueChange,
disabled,
tabIndex,
}: {
initialValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
tabIndex?: number;
}) => {
const localize = useLocalize();
const [description, setDescription] = useState(initialValue || '');
const [charCount, setCharCount] = useState(initialValue?.length || 0);
useEffect(() => {
setDescription(initialValue || '');
setCharCount(initialValue?.length || 0);
}, [initialValue]);
useEffect(() => {
setCharCount(description.length);
}, [description]);
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.value.length <= MAX_LENGTH) {
setDescription(e.target.value);
onValueChange?.(e.target.value);
}
};
if (disabled && !description) {
return null;
}
return (
<div className="rounded-lg border border-border-medium">
<h3 className="flex h-10 items-center gap-2 pl-4 text-sm text-text-secondary">
<Info className="icon-sm" />
<input
type="text"
tabIndex={tabIndex}
disabled={disabled}
placeholder={localize('com_ui_description_placeholder')}
value={description}
onChange={handleInputChange}
className="w-full rounded-lg border-none bg-transparent p-1 text-text-primary placeholder:text-text-tertiary placeholder:underline placeholder:underline-offset-2 focus:bg-surface-tertiary focus:outline-none focus:ring-0 md:w-96"
/>
{!disabled && (
<span className="mr-1 w-10 text-xs text-text-tertiary md:text-sm">{`${charCount}/${MAX_LENGTH}`}</span>
)}
</h3>
</div>
);
};
export default Description;

View file

@ -0,0 +1,9 @@
import React from 'react';
export default function EmptyPromptPreview() {
return (
<div className="h-full w-full content-center text-center font-bold dark:text-gray-200">
Select or Create a Prompt
</div>
);
}

View file

@ -0,0 +1,35 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
export default function AlwaysMakeProd({
onCheckedChange,
className = '',
}: {
onCheckedChange?: (value: boolean) => void;
className?: string;
}) {
const [alwaysMakeProd, setAlwaysMakeProd] = useRecoilState<boolean>(store.alwaysMakeProd);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setAlwaysMakeProd(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className={cn('flex select-none items-center justify-end gap-2 text-xs', className)}>
<Switch
id="alwaysMakeProd"
checked={alwaysMakeProd}
onCheckedChange={handleCheckedChange}
data-testid="alwaysMakeProd"
/>
<div>{localize('com_nav_always_make_prod')} </div>
</div>
);
}

View file

@ -0,0 +1,40 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
export default function AutoSendPrompt({
onCheckedChange,
className = '',
}: {
onCheckedChange?: (value: boolean) => void;
className?: string;
}) {
const [autoSendPrompts, setAutoSendPrompts] = useRecoilState<boolean>(store.autoSendPrompts);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setAutoSendPrompts(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div
className={cn(
'flex select-none items-center justify-end gap-2 text-right text-sm',
className,
)}
>
<div> {localize('com_nav_auto_send_prompts')} </div>
<Switch
id="autoSendPrompts"
checked={autoSendPrompts}
onCheckedChange={handleCheckedChange}
data-testid="autoSendPrompts"
/>
</div>
);
}

View file

@ -0,0 +1,52 @@
import React from 'react';
import {
Dices,
BoxIcon,
PenLineIcon,
LightbulbIcon,
LineChartIcon,
ShoppingBagIcon,
PlaneTakeoffIcon,
GraduationCapIcon,
TerminalSquareIcon,
} from 'lucide-react';
import { cn } from '~/utils';
const categoryIconMap: Record<string, React.ElementType> = {
misc: BoxIcon,
roleplay: Dices,
write: PenLineIcon,
idea: LightbulbIcon,
shop: ShoppingBagIcon,
finance: LineChartIcon,
code: TerminalSquareIcon,
travel: PlaneTakeoffIcon,
teach_or_explain: GraduationCapIcon,
};
const categoryColorMap: Record<string, string> = {
code: 'text-red-500',
misc: 'text-blue-300',
shop: 'text-purple-400',
idea: 'text-yellow-300',
write: 'text-purple-400',
travel: 'text-yellow-300',
finance: 'text-orange-400',
roleplay: 'text-orange-400',
teach_or_explain: 'text-blue-300',
};
export default function CategoryIcon({
category,
className = '',
}: {
category: string;
className?: string;
}) {
const IconComponent = categoryIconMap[category];
const colorClass = categoryColorMap[category] + ' ' + className;
if (!IconComponent) {
return null;
}
return <IconComponent className={cn(colorClass, className)} />;
}

View file

@ -0,0 +1,60 @@
import React, { useMemo } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { LocalStorageKeys } from 'librechat-data-provider';
import { useLocalize, useCategories } from '~/hooks';
import { SelectDropDown } from '~/components/ui';
import { cn } from '~/utils';
const CategorySelector = ({
currentCategory,
onValueChange,
className = '',
tabIndex,
}: {
currentCategory?: string;
onValueChange?: (value: string) => void;
className?: string;
tabIndex?: number;
}) => {
const localize = useLocalize();
const { control, watch, setValue } = useFormContext();
const { categories, emptyCategory } = useCategories();
const watchedCategory = watch('category');
const categoryOption = useMemo(
() =>
categories.find((category) => category.value === (watchedCategory ?? currentCategory)) ??
emptyCategory,
[watchedCategory, categories, currentCategory, emptyCategory],
);
return (
<Controller
name="category"
control={control}
render={() => (
<SelectDropDown
title="Category"
tabIndex={tabIndex}
value={categoryOption || ''}
setValue={(value) => {
setValue('category', value, { shouldDirty: false });
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value);
onValueChange?.(value);
}}
availableValues={categories}
showAbove={false}
showLabel={false}
emptyTitle={true}
showOptionIcon={true}
searchPlaceholder={localize('com_ui_search_var', localize('com_ui_categories'))}
className={cn('h-10 w-56 cursor-pointer', className)}
currentValueClass="text-md gap-2"
optionsListClass="text-sm max-h-72"
/>
)}
/>
);
};
export default CategorySelector;

View file

@ -0,0 +1,114 @@
import { useState, useMemo } from 'react';
import { Menu as MenuIcon, Edit as EditIcon, EarthIcon, TextSearch } from 'lucide-react';
import type { TPromptGroup } from 'librechat-data-provider';
import {
Button,
DropdownMenu,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuContent,
DropdownMenuTrigger,
} from '~/components/ui';
import { useLocalize, useSubmitMessage, useCustomLink, useAuthContext } from '~/hooks';
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
import PreviewPrompt from '~/components/Prompts/PreviewPrompt';
import ListCard from '~/components/Prompts/Groups/ListCard';
import { detectVariables } from '~/utils';
export default function ChatGroupItem({
group,
instanceProjectId,
}: {
group: TPromptGroup;
instanceProjectId?: string;
}) {
const localize = useLocalize();
const { user } = useAuthContext();
const { submitPrompt } = useSubmitMessage();
const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
const onEditClick = useCustomLink<HTMLDivElement>(`/d/prompts/${group._id}`);
const groupIsGlobal = useMemo(
() => instanceProjectId && group?.projectIds?.includes(instanceProjectId),
[group, instanceProjectId],
);
const isOwner = useMemo(() => user?.id === group?.author, [user, group]);
const onCardClick = () => {
const text = group.productionPrompt?.prompt ?? '';
if (!text) {
return;
}
const hasVariables = detectVariables(text);
if (hasVariables) {
return setVariableDialogOpen(true);
}
submitPrompt(text);
};
return (
<>
<ListCard
name={group.name}
category={group.category ?? ''}
onClick={onCardClick}
snippet={group.oneliner ? group.oneliner : group?.productionPrompt?.prompt ?? ''}
>
<div className="flex flex-row items-center gap-2">
{groupIsGlobal && <EarthIcon className="icon-md text-green-400" />}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
onClick={(e) => {
e.stopPropagation();
}}
className="z-50 h-7 w-7 p-0 transition-all duration-300 ease-in-out hover:border-white dark:bg-gray-800 dark:hover:border-gray-400 dark:focus:border-gray-500"
>
<MenuIcon className="icon-md dark:text-gray-300" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="z-50 mt-2 w-36 rounded-lg"
collisionPadding={2}
align="end"
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPreviewDialogOpen(true);
}}
className="w-full cursor-pointer rounded-lg disabled:cursor-not-allowed dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:bg-gray-700"
>
<TextSearch className="mr-2 h-4 w-4" />
<span>{localize('com_ui_preview')}</span>
</DropdownMenuItem>
{isOwner && (
<DropdownMenuGroup>
<DropdownMenuItem
disabled={!isOwner}
className="cursor-pointer rounded-lg disabled:cursor-not-allowed dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:bg-gray-700"
onClick={(e) => {
e.stopPropagation();
onEditClick(e);
}}
>
<EditIcon className="mr-2 h-4 w-4" />
<span>{localize('com_ui_edit')}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</ListCard>
<PreviewPrompt group={group} open={isPreviewDialogOpen} onOpenChange={setPreviewDialogOpen} />
<VariableDialog
open={isVariableDialogOpen}
onClose={() => setVariableDialogOpen(false)}
group={group}
/>
</>
);
}

View file

@ -0,0 +1,178 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
import CategorySelector from '~/components/Prompts/Groups/CategorySelector';
import PromptVariables from '~/components/Prompts/PromptVariables';
import { Button, TextareaAutosize, Input } from '~/components/ui';
import Description from '~/components/Prompts/Description';
import { useCreatePrompt } from '~/data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
import { cn } from '~/utils';
type CreateFormValues = {
name: string;
prompt: string;
type: 'text' | 'chat';
category: string;
oneliner?: string;
};
const defaultPrompt: CreateFormValues = {
name: '',
prompt: '',
type: 'text',
category: '',
oneliner: undefined,
};
const CreatePromptForm = ({
defaultValues = defaultPrompt,
}: {
defaultValues?: CreateFormValues;
}) => {
const localize = useLocalize();
const navigate = useNavigate();
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
});
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (!hasAccess) {
timeoutId = setTimeout(() => {
navigate('/c/new');
}, 1000);
}
return () => {
clearTimeout(timeoutId);
};
}, [hasAccess, navigate]);
const methods = useForm({
defaultValues: {
...defaultValues,
category: localStorage.getItem(LocalStorageKeys.LAST_PROMPT_CATEGORY) ?? '',
},
});
const {
watch,
control,
handleSubmit,
formState: { isDirty, isSubmitting, errors, isValid },
} = methods;
const createPromptMutation = useCreatePrompt({
onSuccess: (response) => {
navigate(`/d/prompts/${response.prompt.groupId}`, { replace: true });
},
});
const promptText = watch('prompt');
const onSubmit = (data: CreateFormValues) => {
const { name, category, oneliner, ...rest } = data;
const groupData = { name, category } as Pick<
CreateFormValues,
'name' | 'category' | 'oneliner'
>;
if ((oneliner?.length || 0) > 0) {
groupData.oneliner = oneliner;
}
createPromptMutation.mutate({
prompt: rest,
group: groupData,
});
};
if (!hasAccess) {
return null;
}
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="w-full px-4 py-2">
<div className="mb-1 flex flex-col items-center justify-between font-bold sm:text-xl md:mb-0 md:text-2xl">
<div className="flex w-full flex-col items-center justify-between sm:flex-row">
<Controller
name="name"
control={control}
rules={{ required: localize('com_ui_is_required', localize('com_ui_prompt_name')) }}
render={({ field }) => (
<div className="mb-1 flex items-center md:mb-0">
<Input
{...field}
type="text"
className="mr-2 w-full border border-gray-300 p-2 text-2xl dark:border-gray-600"
placeholder={`${localize('com_ui_prompt_name')}*`}
tabIndex={1}
autoFocus={true}
/>
<div
className={cn(
'mt-1 w-56 text-sm text-red-500',
errors.name ? 'visible h-auto' : 'invisible h-0',
)}
>
{errors.name ? errors.name.message : ' '}
</div>
</div>
)}
/>
<CategorySelector tabIndex={4} />
</div>
</div>
<div className="w-full md:mt-[1.075rem]">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-gray-300 py-2 pl-4 pr-1 text-base font-semibold dark:border-gray-600 dark:text-gray-200">
{localize('com_ui_text_prompt')}*
</h2>
<div className="mb-4 min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600">
<Controller
name="prompt"
control={control}
rules={{ required: localize('com_ui_is_required', localize('com_ui_text_prompt')) }}
render={({ field }) => (
<div>
<TextareaAutosize
{...field}
className="w-full rounded border border-gray-300 px-2 py-1 focus:outline-none dark:border-gray-600 dark:bg-transparent dark:text-gray-200"
minRows={6}
tabIndex={2}
/>
<div
className={`mt-1 text-sm text-red-500 ${
errors.prompt ? 'visible h-auto' : 'invisible h-0'
}`}
>
{errors.prompt ? errors.prompt.message : ' '}
</div>
</div>
)}
/>
</div>
</div>
<PromptVariables promptText={promptText} />
<Description
onValueChange={(value) => methods.setValue('oneliner', value)}
tabIndex={3}
/>
<div className="flex justify-end">
<Button
tabIndex={5}
type="submit"
variant="default"
disabled={!isDirty || isSubmitting || !isValid}
>
{localize('com_ui_create_var', localize('com_ui_prompt'))}
</Button>
</div>
</div>
</form>
</FormProvider>
);
};
export default CreatePromptForm;

View file

@ -0,0 +1,217 @@
import { useState, useRef, useMemo } from 'react';
import { MenuIcon, EarthIcon } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { SystemRoles, type TPromptGroup } from 'librechat-data-provider';
import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
import {
Input,
Label,
Button,
Dialog,
DropdownMenu,
DialogTrigger,
DropdownMenuGroup,
DropdownMenuContent,
DropdownMenuTrigger,
} from '~/components/ui';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { RenameButton } from '~/components/Conversations';
import { useLocalize, useAuthContext } from '~/hooks';
import { NewTrashIcon } from '~/components/svg';
import { cn } from '~/utils/';
export default function DashGroupItem({
group,
instanceProjectId,
}: {
group: TPromptGroup;
instanceProjectId?: string;
}) {
const params = useParams();
const navigate = useNavigate();
const localize = useLocalize();
const { user } = useAuthContext();
const blurTimeoutRef = useRef<NodeJS.Timeout>();
const [nameEditFlag, setNameEditFlag] = useState(false);
const [nameInputField, setNameInputField] = useState(group.name);
const isOwner = useMemo(() => user?.id === group?.author, [user, group]);
const groupIsGlobal = useMemo(
() => instanceProjectId && group?.projectIds?.includes(instanceProjectId),
[group, instanceProjectId],
);
const updateGroup = useUpdatePromptGroup({
onMutate: () => {
clearTimeout(blurTimeoutRef.current);
setNameEditFlag(false);
},
});
const deletePromptGroupMutation = useDeletePromptGroup({
onSuccess: (response, variables) => {
if (variables.id === group._id) {
navigate('/d/prompts');
}
},
});
const cancelRename = () => {
setNameEditFlag(false);
};
const saveRename = () => {
updateGroup.mutate({ payload: { name: nameInputField }, id: group?._id || '' });
};
const handleBlur = () => {
blurTimeoutRef.current = setTimeout(() => {
cancelRename();
}, 100);
};
return (
<div
className={cn(
'w-100 mx-2 my-3 flex cursor-pointer flex-row rounded-md border-0 bg-white p-4 transition-all duration-300 ease-in-out hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600',
params.promptId === group._id && 'bg-gray-100/50 dark:bg-gray-600 ',
)}
onClick={() => {
if (nameEditFlag) {
return;
}
navigate(`/d/prompts/${group._id}`, { replace: true });
}}
>
<div className="flex w-full flex-row items-center justify-start truncate">
{/* <Checkbox /> */}
<div className="relative flex w-full cursor-pointer flex-col gap-1 text-start align-top">
{nameEditFlag ? (
<>
<div className="flex w-full gap-2">
<Input
defaultValue={nameInputField}
className="w-full"
onClick={(e) => {
e.stopPropagation();
}}
onChange={(e) => {
setNameInputField(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
cancelRename();
} else if (e.key === 'Enter') {
saveRename();
}
}}
onBlur={handleBlur}
/>
<Button
variant="subtle"
className="w-min bg-green-500 text-white hover:bg-green-600 dark:bg-green-400 dark:hover:bg-green-500"
onClick={(e) => {
e.stopPropagation();
saveRename();
}}
>
{localize('com_ui_save')}
</Button>
</div>
<div className="break-word line-clamp-3 text-balance text-sm text-gray-600 dark:text-gray-400">
{localize('com_ui_renaming_var', group.name)}
</div>
</>
) : (
<>
<div className="flex w-full justify-between">
<div className="flex flex-row gap-2">
<CategoryIcon category={group.category ?? ''} className="icon-md" />
<h3 className="break-word text-balance text-sm font-semibold text-gray-800 dark:text-gray-200">
{group.name}
</h3>
</div>
<div className="flex flex-row items-center gap-1">
{groupIsGlobal && <EarthIcon className="icon-md text-green-400" />}
{(isOwner || user?.role === SystemRoles.ADMIN) && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-7 w-7 p-0 hover:bg-gray-200 dark:bg-gray-800/50 dark:text-gray-400 dark:hover:border-gray-400 dark:focus:border-gray-500"
>
<MenuIcon className="icon-md dark:text-gray-300" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="mt-2 w-36 rounded-lg" collisionPadding={2}>
<DropdownMenuGroup>
<RenameButton
renaming={false}
renameHandler={(e) => {
e.stopPropagation();
setNameEditFlag(true);
}}
appendLabel={true}
className={cn('m-0 w-full p-2')}
/>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<Dialog>
<DialogTrigger asChild>
<Button
variant="outline"
className={cn(
'h-7 w-7 p-0 hover:bg-gray-200 dark:bg-gray-800/50 dark:text-gray-400 dark:hover:border-gray-400 dark:focus:border-gray-500',
)}
onClick={(e) => {
e.stopPropagation();
}}
>
<NewTrashIcon className="icon-md text-gray-600 dark:text-gray-300" />
</Button>
</DialogTrigger>
<DialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_prompt')}
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="chatGptLabel"
className="text-left text-sm font-medium"
>
{localize('com_ui_delete_confirm')}{' '}
<strong>{group.name}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: () => {
deletePromptGroupMutation.mutate({ id: group?._id || '' });
},
selectClasses:
'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</Dialog>
</>
)}
</div>
</div>
<div className="ellipsis text-balance text-sm text-gray-600 dark:text-gray-400">
{group.oneliner ? group.oneliner : group?.productionPrompt?.prompt ?? ''}
</div>
</>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,153 @@
import { ListFilter, User, Share2, Dot } from 'lucide-react';
import React, { useState, useCallback, useMemo } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { SystemCategories } from 'librechat-data-provider';
import type { OptionWithIcon } from '~/common';
import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks';
import {
Input,
Button,
DropdownMenu,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuSeparator,
} from '~/components/ui';
import { cn } from '~/utils';
import store from '~/store';
export function FilterItem({
label,
icon,
onClick,
isActive,
}: {
label: string;
icon: React.ReactNode;
onClick?: () => void;
isActive?: boolean;
}) {
return (
<DropdownMenuItem
onClick={onClick}
className="relative cursor-pointer gap-2 text-text-secondary hover:bg-surface-tertiary focus:bg-surface-tertiary dark:focus:bg-surface-tertiary"
>
{icon}
<span>{label}</span>
{isActive && (
<span className="absolute bottom-0 right-0 top-0 flex items-center">
<Dot />
</span>
)}
</DropdownMenuItem>
);
}
export function FilterMenu({
onSelect,
}: {
onSelect: (category: string, icon?: React.ReactNode | null) => void;
}) {
const localize = useLocalize();
const { categories } = useCategories('h-4 w-4');
const memoizedCategories = useMemo(() => {
const noCategory = {
label: localize('com_ui_no_category'),
value: SystemCategories.NO_CATEGORY,
};
if (!categories) {
return [noCategory];
}
return [noCategory, ...categories];
}, [categories, localize]);
const categoryFilter = useRecoilValue(store.promptsCategory);
return (
<DropdownMenuContent className="max-h-xl min-w-48 overflow-y-auto">
<DropdownMenuGroup>
<FilterItem
label={localize('com_ui_all_proper')}
icon={<ListFilter className="h-4 w-4 text-text-primary" />}
onClick={() => onSelect(SystemCategories.ALL, <ListFilter className="icon-sm" />)}
isActive={categoryFilter === ''}
/>
<FilterItem
label={localize('com_ui_my_prompts')}
icon={<User className="h-4 w-4 text-text-primary" />}
onClick={() => onSelect(SystemCategories.MY_PROMPTS, <User className="h-4 w-4" />)}
isActive={categoryFilter === SystemCategories.MY_PROMPTS}
/>
<FilterItem
label={localize('com_ui_shared_prompts')}
icon={<Share2 className="h-4 w-4 text-text-primary" />}
onClick={() => onSelect(SystemCategories.SHARED_PROMPTS, <Share2 className="h-4 w-4" />)}
isActive={categoryFilter === SystemCategories.SHARED_PROMPTS}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{memoizedCategories
.filter((category) => category.value)
.map((category, i) => (
<FilterItem
key={`${category.value}-${i}`}
label={category.label}
icon={(category as OptionWithIcon).icon}
onClick={() => onSelect(category.value, (category as OptionWithIcon).icon)}
isActive={category.value === categoryFilter}
/>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
);
}
export default function FilterPrompts({
setName,
className = '',
}: Pick<ReturnType<typeof usePromptGroupsNav>, 'setName'> & {
className?: string;
}) {
const localize = useLocalize();
const [displayName, setDisplayName] = useState('');
const setCategory = useSetRecoilState(store.promptsCategory);
const [selectedIcon, setSelectedIcon] = useState(<ListFilter className="icon-sm" />);
const onSelect = useCallback(
(category: string, icon?: React.ReactNode | null) => {
if (category === SystemCategories.ALL) {
setSelectedIcon(<ListFilter className="icon-sm" />);
return setCategory('');
}
setCategory(category);
if (icon && React.isValidElement(icon)) {
setSelectedIcon(icon);
}
},
[setCategory],
);
return (
<div className={cn('flex gap-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-10 w-10 flex-shrink-0">
{selectedIcon}
</Button>
</DropdownMenuTrigger>
<FilterMenu onSelect={onSelect} />
</DropdownMenu>
<Input
placeholder={localize('com_ui_filter_prompts_name')}
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value);
setName(e.target.value);
}}
className="max-w-xs border-border-light focus:bg-surface-tertiary"
/>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
import { useMediaQuery, usePromptGroupsNav } from '~/hooks';
import List from '~/components/Prompts/Groups/List';
import { cn } from '~/utils';
export default function GroupSidePanel({
children,
isDetailView,
className = '',
/* usePromptGroupsNav */
nextPage,
prevPage,
isFetching,
hasNextPage,
groupsQuery,
promptGroups,
hasPreviousPage,
}: {
children?: React.ReactNode;
isDetailView?: boolean;
className?: string;
} & ReturnType<typeof usePromptGroupsNav>) {
const location = useLocation();
const isSmallerScreen = useMediaQuery('(max-width: 1024px)');
const isChatRoute = useMemo(() => location.pathname.startsWith('/c/'), [location.pathname]);
return (
<div
className={cn(
'mr-2 flex w-full min-w-72 flex-col gap-2 overflow-y-auto md:w-full lg:w-1/4 xl:w-1/4',
isDetailView && isSmallerScreen ? 'hidden' : '',
className,
)}
>
{children}
<div className="flex-grow overflow-y-auto">
<List
groups={promptGroups}
isChatRoute={isChatRoute}
isLoading={!!groupsQuery?.isLoading}
/>
</div>
<PanelNavigation
nextPage={nextPage}
prevPage={prevPage}
isFetching={isFetching}
hasNextPage={hasNextPage}
isChatRoute={isChatRoute}
hasPreviousPage={hasPreviousPage}
/>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { useNavigate } from 'react-router-dom';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { TPromptGroup, TStartupConfig } from 'librechat-data-provider';
import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem';
import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem';
import { useLocalize, useHasAccess } from '~/hooks';
import { Button, Skeleton } from '~/components/ui';
export default function List({
groups = [],
isChatRoute,
isLoading,
}: {
groups?: TPromptGroup[];
isChatRoute?: boolean;
isLoading: boolean;
}) {
const navigate = useNavigate();
const localize = useLocalize();
const { data: startupConfig = {} as Partial<TStartupConfig> } = useGetStartupConfig();
const { instanceProjectId } = startupConfig;
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
});
return (
<div className="flex h-full flex-col">
{hasCreateAccess && (
<div className="flex w-full justify-end">
<Button
variant="outline"
className="mx-2 w-full px-3"
onClick={() => navigate('/d/prompts/new')}
>
+ {localize('com_ui_create_var', localize('com_ui_prompt'))}
</Button>
</div>
)}
<div className="flex-grow overflow-y-auto">
<div className="overflow-y-auto">
{isLoading && isChatRoute && (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
)}
{isLoading && !isChatRoute && (
<Skeleton className="w-100 mx-2 my-3 flex h-[72px] rounded-md border-0 p-4" />
)}
{!isLoading && groups.length === 0 && isChatRoute && (
<div className="my-2 flex h-[84px] w-full items-center justify-center rounded-2xl border border-border-light bg-transparent px-3 pb-4 pt-3 text-text-primary">
{localize('com_ui_nothing_found')}
</div>
)}
{!isLoading && groups.length === 0 && !isChatRoute && (
<div className="w-100 mx-2 my-3 flex h-[72px] items-center justify-center rounded-md border border-border-light bg-transparent p-4 text-text-primary">
{localize('com_ui_nothing_found')}
</div>
)}
{groups?.map((group) => {
if (isChatRoute) {
return (
<ChatGroupItem
key={group._id}
group={group}
instanceProjectId={instanceProjectId}
/>
);
}
return (
<DashGroupItem key={group._id} group={group} instanceProjectId={instanceProjectId} />
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,36 @@
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
export default function ListCard({
category,
name,
snippet,
onClick,
children,
}: {
category: string;
name: string;
snippet: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
children?: React.ReactNode;
}) {
return (
<div
onClick={onClick}
className="relative my-2 flex w-full cursor-pointer flex-col gap-2 rounded-2xl border px-3 pb-4 pt-3 text-start align-top
text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-all duration-300 ease-in-out hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-700"
>
<div className="flex w-full justify-between">
<div className="flex flex-row gap-2">
<CategoryIcon category={category} className="icon-md" />
<h3 className="break-word select-none text-balance text-sm font-semibold text-gray-800 dark:text-gray-200">
{name}
</h3>
</div>
<div>{children}</div>
</div>
<div className="ellipsis select-none text-balance text-sm text-gray-600 dark:text-gray-400">
{snippet}
</div>
</div>
);
}

View file

@ -0,0 +1,27 @@
import { useNavigate } from 'react-router-dom';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';
export default function NoPromptGroup() {
const navigate = useNavigate();
const localize = useLocalize();
return (
<div className="relative min-h-full w-full px-4">
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center font-bold dark:text-gray-200">
<h1 className="text-lg font-bold dark:text-gray-200 md:text-2xl">
{localize('com_ui_prompt_preview_not_shared')}
</h1>
<Button
className="mt-4"
onClick={() => {
navigate('/d/prompts');
}}
>
{localize('com_ui_back_to_var', localize('com_ui_prompts'))}
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,38 @@
import { memo } from 'react';
import { Button, ThemeSelector } from '~/components/ui';
import { useLocalize } from '~/hooks';
function PanelNavigation({
prevPage,
nextPage,
hasPreviousPage,
hasNextPage,
isFetching,
isChatRoute,
}: {
prevPage: () => void;
nextPage: () => void;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetching: boolean;
isChatRoute: boolean;
}) {
const localize = useLocalize();
return (
<div className="my-1 flex justify-between px-4">
<div className="mb-2 flex gap-2">
{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}
</div>
<div className="mb-2 flex gap-2">
<Button variant="outline" onClick={() => prevPage()} disabled={!hasPreviousPage}>
{localize('com_ui_prev')}
</Button>
<Button variant="outline" onClick={() => nextPage()} disabled={!hasNextPage || isFetching}>
{localize('com_ui_next')}
</Button>
</div>
</div>
);
}
export default memo(PanelNavigation);

View file

@ -0,0 +1,38 @@
import React, { useMemo } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import type { TPromptGroup } from 'librechat-data-provider';
import { OGDialog, OGDialogTitle, OGDialogContent } from '~/components/ui';
import { detectVariables } from '~/utils';
import VariableForm from './VariableForm';
interface VariableDialogProps extends Omit<DialogPrimitive.DialogProps, 'onOpenChange'> {
onClose: () => void;
group: TPromptGroup;
}
const VariableDialog: React.FC<VariableDialogProps> = ({ open, onClose, group }) => {
const handleOpenChange = (open: boolean) => {
if (!open) {
onClose();
}
};
const hasVariables = useMemo(
() => detectVariables(group.productionPrompt?.prompt ?? ''),
[group.productionPrompt?.prompt],
);
if (!hasVariables) {
return null;
}
return (
<OGDialog open={open} onOpenChange={handleOpenChange}>
<OGDialogContent className="max-w-3xl bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300">
<OGDialogTitle>{group.name}</OGDialogTitle>
<VariableForm group={group} onClose={onClose} />
</OGDialogContent>
</OGDialog>
);
};
export default VariableDialog;

View file

@ -0,0 +1,128 @@
import { useMemo } from 'react';
import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form';
import type { TPromptGroup } from 'librechat-data-provider';
import { extractVariableInfo, wrapVariable, replaceSpecialVars } from '~/utils';
import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks';
import { Input } from '~/components/ui';
type FormValues = {
fields: { variable: string; value: string }[];
};
export default function VariableForm({
group,
onClose,
}: {
group: TPromptGroup;
onClose: () => void;
}) {
const localize = useLocalize();
const { user } = useAuthContext();
const mainText = useMemo(() => {
const initialText = group.productionPrompt?.prompt ?? '';
return replaceSpecialVars({ text: initialText, user });
}, [group.productionPrompt?.prompt, user]);
const { allVariables, uniqueVariables, variableIndexMap } = useMemo(
() => extractVariableInfo(mainText),
[mainText],
);
const { submitPrompt } = useSubmitMessage();
const { control, handleSubmit } = useForm<FormValues>({
defaultValues: {
fields: uniqueVariables.map((variable) => ({ variable: wrapVariable(variable), value: '' })),
},
});
const { fields } = useFieldArray({
control,
name: 'fields',
});
const fieldValues = useWatch({
control,
name: 'fields',
});
if (!uniqueVariables.length) {
return null;
}
const generateHighlightedText = () => {
let tempText = mainText;
const parts: JSX.Element[] = [];
allVariables.forEach((variable, index) => {
const placeholder = `{{${variable}}}`;
const partsBeforePlaceholder = tempText.split(placeholder);
const fieldIndex = variableIndexMap.get(variable) as string | number;
const fieldValue = fieldValues[fieldIndex].value as string;
parts.push(
<span key={`before-${index}`}>{partsBeforePlaceholder[0]}</span>,
<span
key={`highlight-${index}`}
className="rounded bg-yellow-100 p-1 font-medium dark:text-gray-800"
>
{fieldValue !== '' ? fieldValue : placeholder}
</span>,
);
tempText = partsBeforePlaceholder.slice(1).join(placeholder);
});
parts.push(<span key="last-part">{tempText}</span>);
return parts;
};
const onSubmit = (data: FormValues) => {
let text = mainText;
data.fields.forEach(({ variable, value }) => {
if (value) {
const regex = new RegExp(variable, 'g');
text = text.replace(regex, value);
}
});
submitPrompt(text);
onClose();
};
return (
<div className="container mx-auto p-1">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="mb-6 max-h-screen overflow-auto rounded-md bg-gray-100 p-4 dark:bg-gray-700/50 dark:text-gray-300 md:max-h-80">
<p className="text-md whitespace-pre-wrap">{generateHighlightedText()}</p>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{fields.map((field, index) => (
<div key={field.id} className="flex flex-col">
<Controller
name={`fields.${index}.value`}
control={control}
render={({ field }) => (
<Input
{...field}
id={`fields.${index}.value`}
className="input text-grey-darker rounded border px-3 py-2 focus:bg-white dark:border-gray-500 dark:focus:bg-gray-700"
placeholder={uniqueVariables[index]}
/>
)}
/>
</div>
))}
</div>
<div className="flex justify-end">
<button
type="submit"
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
>
{localize('com_ui_submit')}
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,27 @@
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { useLocalize, useCustomLink } from '~/hooks';
import { buttonVariants } from '~/components/ui';
import { cn } from '~/utils';
import store from '~/store';
export default function ManagePrompts({ className }: { className?: string }) {
const localize = useLocalize();
const setPromptsName = useSetRecoilState(store.promptsName);
const setPromptsCategory = useSetRecoilState(store.promptsCategory);
const clickCallback = useCallback(() => {
setPromptsName('');
setPromptsCategory('');
}, [setPromptsName, setPromptsCategory]);
const clickHandler = useCustomLink('/d/prompts', clickCallback);
return (
<a
className={cn(buttonVariants({ variant: 'outline' }), className)}
href="/d/prompts"
onClick={clickHandler}
>
{localize('com_ui_manage')}
</a>
);
}

View file

@ -0,0 +1,69 @@
import { useState } from 'react';
import { Cross1Icon } from '@radix-ui/react-icons';
import type { TPrompt } from 'librechat-data-provider';
import { useUpdatePromptLabels } from '~/data-provider';
import { Input } from '~/components/ui';
const PromptForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
const [labelInput, setLabelInput] = useState<string>('');
const [labels, setLabels] = useState<string[]>([]);
const updatePromptLabelsMutation = useUpdatePromptLabels();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLabelInput(e.target.value);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && labelInput.trim()) {
const newLabels = [...labels, labelInput.trim()];
setLabels(newLabels);
setLabelInput('');
updatePromptLabelsMutation.mutate({
id: selectedPrompt?._id || '',
payload: { labels: newLabels },
});
}
};
return (
<>
<Input
type="text"
className="mb-4"
placeholder="+ Add Labels"
// defaultValue={selectedPrompt?.labels.join(', ')}
value={labelInput}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
/>
<h3 className="rounded-t-lg border border-gray-300 px-4 text-base font-semibold">Labels</h3>
<div className="mb-4 flex w-full flex-row flex-wrap rounded-b-lg border border-gray-300 p-4">
{labels.length ? (
labels.map((label, index) => (
<label
className="mb-1 mr-1 flex items-center gap-x-2 rounded-full border px-2"
key={index}
>
{label}
<Cross1Icon
onClick={() => {
const newLabels = labels.filter((l) => l !== label);
setLabels(newLabels);
updatePromptLabelsMutation.mutate({
id: selectedPrompt?._id || '',
payload: { labels: newLabels },
});
}}
className="cursor-pointer"
/>
</label>
))
) : (
<label className="rounded-full border px-2">No Labels</label>
)}
</div>
</>
);
};
export default PromptForm;

View file

@ -0,0 +1,25 @@
import type { TPromptGroup } from 'librechat-data-provider';
import { OGDialogContent, OGDialog } from '~/components/ui';
import PromptDetails from './PromptDetails';
const PreviewPrompt = ({
group,
open,
onOpenChange,
}: {
group: TPromptGroup;
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="max-w-3xl bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300">
<div className="p-2">
<PromptDetails group={group} />
</div>
</OGDialogContent>
</OGDialog>
);
};
export default PreviewPrompt;

View file

@ -0,0 +1,47 @@
import type { TPromptGroup } from 'librechat-data-provider';
import CategoryIcon from './Groups/CategoryIcon';
import PromptVariables from './PromptVariables';
import Description from './Description';
import { useLocalize } from '~/hooks';
const PromptDetails = ({ group }: { group: TPromptGroup }) => {
const localize = useLocalize();
if (!group) {
return null;
}
const promptText = group.productionPrompt?.prompt ?? '';
return (
<div>
<div className="flex flex-col items-center justify-between px-4 dark:text-gray-200 sm:flex-row">
<div className="mb-1 flex flex-row items-center font-bold sm:text-xl md:mb-0 md:text-2xl">
<div className="mb-1 flex items-center md:mb-0">
<div className="rounded p-2">
{(group.category?.length ?? 0) > 0 ? (
<CategoryIcon category={group.category ?? ''} />
) : null}
</div>
<span className="mr-2 border border-transparent p-2">{group.name}</span>
</div>
</div>
</div>
<div className="flex h-full w-full flex-col md:flex-row">
<div className="flex-1 overflow-y-auto border-gray-300 p-0 dark:border-gray-600 md:max-h-[calc(100vh-150px)] md:p-4">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-gray-300 py-2 pl-4 text-base font-semibold dark:border-gray-600 dark:text-gray-200">
{localize('com_ui_text_prompt')}
</h2>
<div className="group relative mb-4 min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600">
<span className="block break-words px-2 py-1 dark:text-gray-200">{promptText}</span>
</div>
</div>
<PromptVariables promptText={promptText} />
<Description initialValue={group.oneliner} disabled={true} />
</div>
</div>
</div>
);
};
export default PromptDetails;

View file

@ -0,0 +1,82 @@
import { useMemo, memo } from 'react';
import { useRecoilValue } from 'recoil';
import { EditIcon } from 'lucide-react';
import { Controller, useFormContext, useFormState } from 'react-hook-form';
import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd';
import { SaveIcon, CrossIcon } from '~/components/svg';
import { TextareaAutosize } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
const { PromptsEditorMode, promptsEditorMode } = store;
type Props = {
name: string;
isEditing: boolean;
setIsEditing: React.Dispatch<React.SetStateAction<boolean>>;
};
const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
const localize = useLocalize();
const { control } = useFormContext();
const editorMode = useRecoilValue(promptsEditorMode);
const { dirtyFields } = useFormState({ control: control });
const EditorIcon = useMemo(() => {
if (isEditing && !dirtyFields.prompt) {
return CrossIcon;
}
return isEditing ? SaveIcon : EditIcon;
}, [isEditing, dirtyFields.prompt]);
return (
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-gray-300 py-2 pl-4 text-base font-semibold dark:border-gray-600 dark:text-gray-200">
{localize('com_ui_text_prompt')}
<div className="flex flex-row gap-6">
{editorMode === PromptsEditorMode.ADVANCED && (
<AlwaysMakeProd className="hidden sm:flex" />
)}
<button type="button" onClick={() => setIsEditing((prev) => !prev)} className="mr-2">
<EditorIcon
className={cn(
'icon-lg',
isEditing ? 'p-[0.05rem]' : 'text-gray-400 hover:text-gray-600',
)}
/>
</button>
</div>
</h2>
<div
className={cn(
'group relative mb-4 min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 hover:opacity-90 dark:border-gray-600',
{ 'cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-100/10': !isEditing },
)}
onClick={() => !isEditing && setIsEditing(true)}
>
{!isEditing && (
<EditIcon className="icon-xl absolute inset-0 m-auto hidden opacity-25 group-hover:block dark:text-gray-200" />
)}
<Controller
name={name}
control={control}
render={({ field }) =>
isEditing ? (
<TextareaAutosize
{...field}
className="w-full rounded border border-gray-300 bg-transparent px-2 py-1 focus:outline-none dark:border-gray-600 dark:text-gray-200"
minRows={3}
onBlur={() => setIsEditing(false)}
/>
) : (
<span className="block break-words px-2 py-1 dark:text-gray-200">{field.value}</span>
)
}
/>
</div>
</div>
);
};
export default memo(PromptEditor);

View file

@ -0,0 +1,319 @@
import { Rocket } from 'lucide-react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { useForm, FormProvider } from 'react-hook-form';
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { useNavigate, useParams, useOutletContext } from 'react-router-dom';
import { PermissionTypes, Permissions, SystemRoles } from 'librechat-data-provider';
import type { TCreatePrompt } from 'librechat-data-provider';
import {
useGetPrompts,
useCreatePrompt,
useDeletePrompt,
useGetPromptGroup,
useUpdatePromptGroup,
useMakePromptProduction,
} from '~/data-provider';
import { useAuthContext, usePromptGroupsNav, useHasAccess } from '~/hooks';
import CategorySelector from './Groups/CategorySelector';
import AlwaysMakeProd from './Groups/AlwaysMakeProd';
import NoPromptGroup from './Groups/NoPromptGroup';
import { Button, Skeleton } from '~/components/ui';
import PromptVariables from './PromptVariables';
import PromptVersions from './PromptVersions';
import DeleteConfirm from './DeleteVersion';
import PromptDetails from './PromptDetails';
import { findPromptGroup } from '~/utils';
import PromptEditor from './PromptEditor';
import SkeletonForm from './SkeletonForm';
import Description from './Description';
import SharePrompt from './SharePrompt';
import PromptName from './PromptName';
import store from '~/store';
const { PromptsEditorMode, promptsEditorMode } = store;
const PromptForm = () => {
const params = useParams();
const navigate = useNavigate();
const { user } = useAuthContext();
const editorMode = useRecoilValue(promptsEditorMode);
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(params.promptId || '');
const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts(
{ groupId: params.promptId ?? '' },
{ enabled: !!params.promptId },
);
const prevIsEditingRef = useRef(false);
const [isEditing, setIsEditing] = useState(false);
const [initialLoad, setInitialLoad] = useState(true);
const [selectionIndex, setSelectionIndex] = useState<number>(0);
const isOwner = useMemo(() => user?.id === group?.author, [user, group]);
const selectedPrompt = useMemo(() => prompts[selectionIndex], [prompts, selectionIndex]);
const hasShareAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.SHARED_GLOBAL,
});
const methods = useForm({
defaultValues: {
prompt: '',
promptName: group?.name || '',
category: group?.category || '',
},
});
const { handleSubmit, setValue, reset, watch } = methods;
const promptText = watch('prompt');
const createPromptMutation = useCreatePrompt({
onMutate: (variables) => {
reset(
{
prompt: variables.prompt.prompt,
category: variables.group?.category || '',
},
{ keepDirtyValues: true },
);
},
onSuccess(data) {
if (alwaysMakeProd && data.prompt._id && data.prompt.groupId) {
makeProductionMutation.mutate(
{
id: data.prompt._id,
groupId: data.prompt.groupId,
productionPrompt: { prompt: data.prompt.prompt },
},
{
onSuccess: () => setSelectionIndex(0),
},
);
}
reset({
prompt: data.prompt.prompt,
promptName: data.group?.name || '',
category: data.group?.category || '',
});
setSelectionIndex(0);
},
});
const updateGroupMutation = useUpdatePromptGroup();
const makeProductionMutation = useMakePromptProduction();
const deletePromptMutation = useDeletePrompt({
onSuccess: (response) => {
if (response.promptGroup) {
navigate('/d/prompts');
} else {
setSelectionIndex(0);
}
},
});
const onSave = useCallback(
(value: string) => {
if (!value) {
// TODO: show toast, cannot be empty.
return;
}
const tempPrompt: TCreatePrompt = {
prompt: {
type: selectedPrompt?.type ?? 'text',
groupId: selectedPrompt?.groupId ?? '',
prompt: value,
},
};
if (value === selectedPrompt?.prompt) {
return;
}
createPromptMutation.mutate(tempPrompt);
},
[selectedPrompt, createPromptMutation],
);
const handleLoadingComplete = useCallback(() => {
if (isLoadingGroup || isLoadingPrompts) {
return;
}
setInitialLoad(false);
}, [isLoadingGroup, isLoadingPrompts]);
useEffect(() => {
if (prevIsEditingRef.current && !isEditing) {
handleSubmit((data) => onSave(data.prompt))();
}
prevIsEditingRef.current = isEditing;
}, [isEditing, onSave, handleSubmit]);
useEffect(() => {
if (editorMode === PromptsEditorMode.SIMPLE) {
const productionIndex = prompts.findIndex((prompt) => prompt._id === group?.productionId);
setSelectionIndex(productionIndex !== -1 ? productionIndex : 0);
}
handleLoadingComplete();
}, [params.promptId, editorMode, group?.productionId, prompts, handleLoadingComplete]);
useEffect(() => {
setValue('prompt', selectedPrompt?.prompt || '', { shouldDirty: false });
setValue('category', group?.category || '', { shouldDirty: false });
}, [selectedPrompt, group?.category, setValue]);
const debouncedUpdateOneliner = useCallback(
debounce((oneliner: string) => {
if (!group) {
return console.warn('Group not found');
}
updateGroupMutation.mutate({ id: group._id || '', payload: { oneliner } });
}, 950),
[updateGroupMutation, group],
);
const { groupsQuery } = useOutletContext<ReturnType<typeof usePromptGroupsNav>>();
if (initialLoad) {
return <SkeletonForm />;
}
if (!isOwner && groupsQuery.data && user?.role !== SystemRoles.ADMIN) {
const fetchedPrompt = findPromptGroup(
groupsQuery.data,
(group) => group._id === params.promptId,
);
if (!fetchedPrompt) {
return <NoPromptGroup />;
}
return <PromptDetails group={fetchedPrompt} />;
}
if (!group) {
return null;
}
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit((data) => onSave(data.prompt))}>
<div>
<div className="flex flex-col items-center justify-between px-4 dark:text-gray-200 sm:flex-row">
{isLoadingGroup ? (
<Skeleton className="mb-1 flex h-10 w-32 flex-row items-center font-bold sm:text-xl md:mb-0 md:h-12 md:text-2xl" />
) : (
<PromptName
name={group?.name}
onSave={(value) => {
if (!group) {
return console.warn('Group not found');
}
updateGroupMutation.mutate({ id: group._id || '', payload: { name: value } });
}}
/>
)}
<div className="flex h-10 flex-row gap-x-2">
<CategorySelector
className="w-48 md:w-56"
currentCategory={group?.category}
onValueChange={(value) =>
updateGroupMutation.mutate({
id: group?._id || '',
payload: { name: group?.name || '', category: value },
})
}
/>
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
{editorMode === PromptsEditorMode.ADVANCED && (
<Button
size={'sm'}
className="h-10 border border-transparent bg-green-500 transition-all hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600"
variant={'default'}
onClick={() => {
const { _id: promptVersionId = '', prompt } = selectedPrompt;
makeProductionMutation.mutate(
{
id: promptVersionId || '',
groupId: group?._id || '',
productionPrompt: { prompt },
},
{
onSuccess: (_data, variables) => {
const productionIndex = prompts.findIndex(
(prompt) => variables.id === prompt._id,
);
setSelectionIndex(productionIndex);
},
},
);
}}
disabled={
isLoadingGroup ||
selectedPrompt?._id === group?.productionId ||
makeProductionMutation.isLoading
}
>
<Rocket className="cursor-pointer text-white" />
</Button>
)}
<DeleteConfirm
name={group.name}
disabled={isLoadingGroup}
selectHandler={() => {
deletePromptMutation.mutate({
_id: selectedPrompt?._id || '',
groupId: group?._id || '',
});
}}
/>
</div>
</div>
{editorMode === PromptsEditorMode.ADVANCED && (
<div className="mt-4 flex items-center justify-center text-text-primary sm:hidden">
<AlwaysMakeProd />
</div>
)}
<div className="flex h-full w-full flex-col md:flex-row">
{/* Left Section */}
<div className="flex-1 overflow-y-auto border-r border-gray-300 p-4 dark:border-gray-600 md:max-h-[calc(100vh-150px)]">
{isLoadingPrompts ? (
<Skeleton className="h-96" />
) : (
<>
<PromptEditor name="prompt" isEditing={isEditing} setIsEditing={setIsEditing} />
<PromptVariables promptText={promptText} />
<Description
initialValue={group?.oneliner ?? ''}
onValueChange={debouncedUpdateOneliner}
/>
</>
)}
</div>
{/* Right Section */}
{editorMode === PromptsEditorMode.ADVANCED && (
<div className="flex-1 overflow-y-auto p-4 md:max-h-[calc(100vh-150px)] md:w-1/4 md:max-w-[35%] lg:max-w-[30%] xl:max-w-[25%]">
{isLoadingPrompts ? (
<Skeleton className="h-96 w-full" />
) : (
!!prompts.length && (
<PromptVersions
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
setSelectionIndex={setSelectionIndex}
/>
)
)}
</div>
)}
</div>
</div>
</form>
</FormProvider>
);
};
export default PromptForm;

View file

@ -0,0 +1,101 @@
import React, { useEffect, useState, useRef } from 'react';
import { EditIcon, SaveIcon } from '~/components/svg';
type Props = {
name?: string;
onSave: (newName: string) => void;
};
const PromptName: React.FC<Props> = ({ name, onSave }) => {
const inputRef = useRef<HTMLInputElement>(null);
const blurTimeoutRef = useRef<NodeJS.Timeout>();
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(name);
const handleEditClick = () => {
setIsEditing(true);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewName(e.target.value);
};
const saveName = () => {
const savedName = newName?.trim();
onSave(savedName || '');
setIsEditing(false);
};
const handleSaveClick: React.MouseEventHandler<HTMLButtonElement> = () => {
saveName();
clearTimeout(blurTimeoutRef.current);
};
const handleBlur = () => {
blurTimeoutRef.current = setTimeout(() => {
if (document.activeElement !== inputRef.current) {
setIsEditing(false);
setNewName(name);
}
}, 200);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsEditing(false);
setNewName(name);
}
if (e.key === 'Enter' || e.key === 'Tab') {
saveName();
}
};
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
}
}, [isEditing]);
useEffect(() => {
setNewName(name);
}, [name]);
return (
<div className="mb-1 flex flex-row items-center font-bold sm:text-xl md:mb-0 md:text-2xl">
{isEditing ? (
<div className="mb-1 flex items-center md:mb-0">
<input
type="text"
value={newName ?? ''}
onChange={handleInputChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
ref={inputRef}
className="mr-2 w-56 rounded-md border bg-transparent p-2 focus:outline-none dark:border-gray-600 md:w-auto"
autoFocus={true}
/>
<button
type="button"
onClick={handleSaveClick}
className="rounded p-2 hover:bg-gray-300/50 dark:hover:bg-gray-700"
>
<SaveIcon className="icon-md" size="1.2em" />
</button>
</div>
) : (
<div className="mb-1 flex items-center md:mb-0">
<span className="border border-transparent p-2">{newName}</span>
<button
type="button"
onClick={handleEditClick}
className="rounded p-2 hover:bg-gray-300/50 dark:hover:bg-gray-700"
>
<EditIcon className="icon-md" />
</button>
</div>
)}
</div>
);
};
export default PromptName;

View file

@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { Variable } from 'lucide-react';
import { extractUniqueVariables, cn } from '~/utils';
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';
const PromptVariables = ({ promptText }: { promptText: string }) => {
const localize = useLocalize();
const variables = useMemo(() => {
return extractUniqueVariables(promptText || '');
}, [promptText]);
return (
<>
<h3 className="flex items-center gap-2 rounded-t-lg border border-border-medium py-2 pl-4 text-base font-semibold text-text-secondary">
<Variable className="icon-sm" />
{localize('com_ui_variables')}
</h3>
<div className="mb-4 flex w-full flex-row flex-wrap rounded-b-lg border border-border-medium p-4 md:min-h-16">
{variables.length ? (
<div className="flex h-7 items-center">
{variables.map((variable, index) => (
<label
className={cn(
'mr-1 rounded-full border border-border-medium px-2 text-text-secondary',
specialVariables[variable.toLowerCase()] ? specialVariableClasses : '',
)}
key={index}
>
{specialVariables[variable.toLowerCase()] ? variable.toLowerCase() : variable}
</label>
))}
</div>
) : (
<div className="flex h-7 items-center">
<span className="text-xs text-text-tertiary md:text-sm">
{localize('com_ui_variables_info')}
</span>
</div>
)}
<Separator className="my-3 bg-border-medium" />
<span className="text-xs text-text-tertiary md:text-sm">
{localize('com_ui_special_variables')}
</span>
</div>
</>
);
};
export default PromptVariables;

View file

@ -0,0 +1,89 @@
import React from 'react';
import { format } from 'date-fns';
import { Layers3 } from 'lucide-react';
import type { TPrompt, TPromptGroup } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { Tag } from '~/components/ui';
import { cn } from '~/utils';
const PromptVersions = ({
prompts,
group,
selectionIndex,
setSelectionIndex,
}: {
prompts: TPrompt[];
group?: TPromptGroup;
selectionIndex: React.SetStateAction<number>;
setSelectionIndex: React.Dispatch<React.SetStateAction<number>>;
}) => {
const localize = useLocalize();
return (
<>
<h2 className="mb-4 flex gap-2 text-base font-semibold dark:text-gray-200">
<Layers3 className="icon-lg text-green-500" />
{localize('com_ui_versions')}
</h2>
<ul className="flex flex-col gap-3">
{prompts.map((prompt: TPrompt, index: number) => {
const tags: string[] = [];
if (index === 0) {
tags.push('latest');
}
if (prompt._id === group?.productionId) {
tags.push('production');
}
return (
<li
key={index}
className={cn(
'relative cursor-pointer rounded-lg border p-4 dark:border-gray-600 dark:bg-transparent',
index === selectionIndex ? 'bg-gray-100 dark:bg-gray-700' : 'bg-white',
)}
onClick={() => setSelectionIndex(index)}
>
<p className="font-bold dark:text-gray-200">
{localize('com_ui_version_var', `${prompts.length - index}`)}
</p>
<p className="absolute right-4 top-5 whitespace-nowrap text-xs text-gray-600 dark:text-gray-400">
{format(new Date(prompt.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
{tags.length > 0 && (
<span className="flex flex-wrap gap-1 text-sm">
{tags.map((tag, i) => {
return (
<Tag
key={`${tag}-${i}`}
label={tag}
className={cn(
'w-fit border border-transparent bg-blue-100 text-blue-500 dark:border-blue-500 dark:bg-transparent dark:text-blue-500',
tag === 'production' &&
'bg-green-100 text-green-500 dark:border-green-500 dark:bg-transparent dark:text-green-500',
)}
labelClassName="flex m-0 justify-center gap-1"
LabelNode={
tag === 'production' ? (
<div className="flex items-center ">
<span className="slow-pulse h-[0.4rem] w-[0.4rem] rounded-full bg-green-400" />
</div>
) : null
}
/>
);
})}
</span>
)}
{group?.authorName && (
<p className="text-xs text-gray-600 dark:text-gray-400">by {group.authorName}</p>
)}
</li>
);
})}
</ul>
</>
);
};
export default PromptVersions;

View file

@ -0,0 +1,20 @@
import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel';
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
import ManagePrompts from '~/components/Prompts/ManagePrompts';
import { usePromptGroupsNav } from '~/hooks';
export default function PromptsAccordion() {
const groupsNav = usePromptGroupsNav();
return (
<div className="flex h-full w-full flex-col">
<PromptSidePanel className="lg:w-full xl:w-full" {...groupsNav}>
<div className="flex w-full flex-row items-center justify-between px-2 pt-2">
<ManagePrompts className="select-none" />
<AutoSendPrompt className="text-xs dark:text-white" />
</div>
<FilterPrompts setName={groupsNav.setName} className="items-center justify-center px-2" />
</PromptSidePanel>
</div>
);
}

View file

@ -0,0 +1,58 @@
import { useMemo, useEffect } from 'react';
import { Outlet, useParams, useNavigate } from 'react-router-dom';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb';
import { usePromptGroupsNav, useHasAccess } from '~/hooks';
import GroupSidePanel from './Groups/GroupSidePanel';
import { cn } from '~/utils';
export default function PromptsView() {
const params = useParams();
const navigate = useNavigate();
const groupsNav = usePromptGroupsNav();
const isDetailView = useMemo(() => !!(params.promptId || params['*'] === 'new'), [params]);
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE,
});
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (!hasAccess) {
timeoutId = setTimeout(() => {
navigate('/c/new');
}, 1000);
}
return () => {
clearTimeout(timeoutId);
};
}, [hasAccess, navigate]);
if (!hasAccess) {
return null;
}
return (
<div className="flex h-screen w-full flex-col bg-[#f9f9f9] p-0 dark:bg-transparent lg:p-2">
<DashBreadcrumb />
<div className="flex w-full flex-grow flex-row divide-x overflow-hidden dark:divide-gray-600">
<GroupSidePanel isDetailView={isDetailView} {...groupsNav}>
<div className="mx-2 mt-1 flex flex-row items-center justify-between">
<FilterPrompts setName={groupsNav.setName} />
<AutoSendPrompt className="text-xs dark:text-white" />
</div>
</GroupSidePanel>
<div
className={cn(
'w-full overflow-y-auto lg:w-3/4 xl:w-3/4',
isDetailView ? 'block' : 'hidden md:block',
)}
>
<Outlet context={groupsNav} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,151 @@
import React, { useEffect, useMemo } from 'react';
import { Share2Icon } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions } from 'librechat-data-provider';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import type {
TPromptGroup,
TStartupConfig,
TUpdatePromptGroupPayload,
} from 'librechat-data-provider';
import {
OGDialog,
OGDialogTitle,
OGDialogContent,
OGDialogTrigger,
OGDialogClose,
} from '~/components/ui';
import { useUpdatePromptGroup } from '~/data-provider';
import { Button, Switch } from '~/components/ui';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
type FormValues = {
[Permissions.SHARED_GLOBAL]: boolean;
};
const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: boolean }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateGroup = useUpdatePromptGroup();
const { data: startupConfig = {} as TStartupConfig, isFetching } = useGetStartupConfig();
const { instanceProjectId } = startupConfig;
const groupIsGlobal = useMemo(
() => !!group?.projectIds?.includes(instanceProjectId),
[group, instanceProjectId],
);
const {
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: {
[Permissions.SHARED_GLOBAL]: groupIsGlobal,
},
});
useEffect(() => {
setValue(Permissions.SHARED_GLOBAL, groupIsGlobal);
}, [groupIsGlobal, setValue]);
if (!group || !instanceProjectId) {
return null;
}
const onSubmit = (data: FormValues) => {
if (!group._id || !instanceProjectId) {
return;
}
const payload = {} as TUpdatePromptGroupPayload;
if (data[Permissions.SHARED_GLOBAL]) {
payload.projectIds = [startupConfig.instanceProjectId];
} else {
payload.removeProjectIds = [startupConfig.instanceProjectId];
}
updateGroup.mutate({
id: group._id,
payload,
});
};
return (
<OGDialog>
<OGDialogTrigger asChild>
<Button
variant={'default'}
size={'sm'}
className="h-10 w-10 border border-transparent bg-blue-500/90 transition-all hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-800"
disabled={disabled}
>
<Share2Icon className="cursor-pointer text-white " />
</Button>
</OGDialogTrigger>
<OGDialogContent className="bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300">
<OGDialogTitle>{localize('com_ui_share_var', `"${group.name}"`)}</OGDialogTitle>
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 flex items-center justify-between gap-2 py-4">
<label
className="cursor-pointer select-none"
htmlFor={Permissions.SHARED_GLOBAL}
onClick={() =>
setValue(Permissions.SHARED_GLOBAL, !getValues(Permissions.SHARED_GLOBAL), {
shouldDirty: true,
})
}
>
{localize('com_ui_share_to_all_users')}
{groupIsGlobal && (
<span className="ml-2 text-xs">{localize('com_ui_prompt_shared_to_all')}</span>
)}
</label>
<Controller
name={Permissions.SHARED_GLOBAL}
control={control}
disabled={isFetching || updateGroup.isLoading || !instanceProjectId}
rules={{
validate: (value) => {
const isValid = !(value && groupIsGlobal);
if (!isValid) {
showToast({
message: localize('com_ui_prompt_already_shared_to_all'),
status: 'warning',
});
}
return isValid;
},
}}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field?.value?.toString()}
/>
)}
/>
</div>
<div className="flex justify-end">
<OGDialogClose asChild>
<button
type="submit"
disabled={isSubmitting || isFetching}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
>
{localize('com_ui_save')}
</button>
</OGDialogClose>
</div>
</form>
</OGDialogContent>
</OGDialog>
);
};
export default SharePrompt;

View file

@ -0,0 +1,17 @@
import { Skeleton } from '~/components/ui';
export default function SkeletonForm() {
return (
<div>
<div className="flex flex-col items-center justify-between px-4 dark:text-gray-200 sm:flex-row">
<Skeleton className="mb-1 flex h-10 w-32 flex-row items-center font-bold sm:text-xl md:mb-0 md:h-12 md:text-2xl" />
</div>
<div className="flex h-full w-full flex-col md:flex-row">
{/* Left Section */}
<div className="flex-1 overflow-y-auto border-r border-border-medium-alt p-4 md:max-h-[calc(100vh-150px)]">
<Skeleton className="h-96" />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,10 @@
export { default as PromptName } from './PromptName';
export { default as PromptsView } from './PromptsView';
export { default as PromptEditor } from './PromptEditor';
export { default as PromptForm } from './PromptForm';
export { default as PreviewLabels } from './PreviewLabels';
export { default as PromptGroupsList } from './Groups/List';
export { default as DashGroupItem } from './Groups/DashGroupItem';
export { default as EmptyPromptPreview } from './EmptyPromptPreview';
export { default as PromptSidePanel } from './Groups/GroupSidePanel';
export { default as CreatePromptForm } from './Groups/CreatePromptForm';

View file

@ -16,11 +16,11 @@ import type {
AssistantListResponse,
} from 'librechat-data-provider';
import { useUploadAssistantAvatarMutation, useGetFileConfig } from '~/data-provider';
import { AssistantAvatar, NoImage, AvatarMenu } from './Images';
import { useToastContext, useAssistantsMapContext } from '~/Providers';
import { AssistantAvatar, NoImage, AvatarMenu } from './Images';
// import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
// import { cn } from '~/utils/';
import { formatBytes } from '~/utils';
function Avatar({
endpoint,
@ -207,8 +207,9 @@ function Avatar({
version,
});
} else {
const megabytes = fileConfig.avatarSizeLimit ? formatBytes(fileConfig.avatarSizeLimit) : 2;
showToast({
message: localize('com_ui_upload_invalid'),
message: localize('com_ui_upload_invalid_var', megabytes + ''),
status: 'error',
});
}

View file

@ -184,7 +184,7 @@ const SidePanel = ({
localStorage.setItem('react-resizable-panels:collapsed', 'true');
}}
className={cn(
'sidenav hide-scrollbar border-l border-gray-200 bg-white transition-opacity dark:border-gray-800/50 dark:bg-gray-850',
'sidenav hide-scrollbar border-l border-border-light bg-surface-primary-alt transition-opacity',
isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]',
(isSmallScreen && isCollapsed && (minSize === 0 || collapsedSize === 0)) ||
fullCollapse
@ -195,7 +195,7 @@ const SidePanel = ({
{interfaceConfig.modelSelect && (
<div
className={cn(
'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-white dark:bg-gray-850',
'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-surface-primary-alt',
isCollapsed ? 'h-[52px]' : 'px-2',
)}
>

View file

@ -54,3 +54,4 @@ export { default as BirthdayIcon } from './BirthdayIcon';
export { default as AssistantIcon } from './AssistantIcon';
export { default as Sparkles } from './Sparkles';
export { default as SpeechIcon } from './SpeechIcon';
export { default as SaveIcon } from './SaveIcon';

View file

@ -0,0 +1,101 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '~/utils';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<'ol'>>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5',
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<'li'>>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn('inline-flex items-center gap-1.5', className)} {...props} />
),
);
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<'span'>>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View file

@ -1,14 +1,13 @@
import * as React from 'react';
import { VariantProps, cva } from 'class-variance-authority';
import { cn } from '../../utils';
import { cn } from '~/utils';
const buttonVariants = cva(
'rounded-md inline-flex items-center justify-center text-sm font-medium transition-colors dark:hover:bg-gray-700 dark:hover:text-gray-100 disabled:opacity-50 disabled:pointer-events-none data-[state=open]:bg-gray-100 dark:data-[state=open]:bg-gray-700',
{
variants: {
variant: {
default: 'bg-gray-750 text-white hover:bg-gray-800 dark:bg-gray-50 dark:text-gray-900',
default: 'bg-gray-850 text-white hover:bg-gray-800 dark:bg-gray-50 dark:text-gray-900',
destructive: 'bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600',
outline:
'bg-transparent border border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100',

View file

@ -1,115 +0,0 @@
import React from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import LightningIcon from '~/components/svg/LightningIcon';
import useDocumentTitle from '~/hooks/useDocumentTitle';
import CautionIcon from '~/components/svg/CautionIcon';
import SunIcon from '~/components/svg/SunIcon';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function Landing() {
const { data: config } = useGetStartupConfig();
const setText = useSetRecoilState(store.text);
const conversation = useRecoilValue(store.conversation);
const localize = useLocalize();
const { title = localize('com_ui_new_chat') } = conversation ?? {};
useDocumentTitle(title);
const clickHandler = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
const { innerText } = e.target as HTMLButtonElement;
const quote = innerText.split('"')[1].trim();
setText(quote);
};
return (
<div className="flex h-full flex-col items-center overflow-y-auto pt-0 text-sm dark:bg-gray-800">
<div className="w-full px-6 text-gray-800 dark:text-gray-200 md:flex md:max-w-2xl md:flex-col lg:max-w-3xl">
<h1
id="landing-title"
data-testid="landing-title"
className="mb-10 ml-auto mr-auto mt-6 flex items-center justify-center gap-2 text-center text-4xl font-semibold dark:text-gray-600 sm:mb-16 md:mt-[10vh]"
>
{config?.appTitle || 'LibreChat'}
</h1>
<div className="items-start gap-3.5 text-center md:flex">
<div className="mb-8 flex flex-1 flex-col gap-3.5 md:mb-auto">
<h2 className="m-auto flex items-center gap-3 text-lg font-normal md:flex-col md:gap-2">
<SunIcon />
{localize('com_ui_examples')}
</h2>
<ul className="m-auto flex w-full flex-col gap-3.5 sm:max-w-md">
<button
onClick={clickHandler}
className="w-full rounded-md bg-gray-50 p-3 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-gray-600"
>
&quot;{localize('com_ui_example_quantum_computing')}&quot;
</button>
<button
onClick={clickHandler}
className="w-full rounded-md bg-gray-50 p-3 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-gray-600"
>
&quot;{localize('com_ui_example_10_year_old_b_day')}&quot;
</button>
<button
onClick={clickHandler}
className="w-full rounded-md bg-gray-50 p-3 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-gray-600"
>
&quot;{localize('com_ui_example_http_in_js')}&quot;
</button>
</ul>
</div>
<div className="mb-8 flex flex-1 flex-col gap-3.5 md:mb-auto">
<h2 className="m-auto flex items-center gap-3 text-lg font-normal md:flex-col md:gap-2">
<LightningIcon />
{localize('com_ui_capabilities')}
</h2>
<ul className="m-auto flex w-full flex-col gap-3.5 sm:max-w-md">
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
{localize('com_ui_capability_remember')}
</li>
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
{localize('com_ui_capability_correction')}
</li>
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
{localize('com_ui_capability_decline_requests')}
</li>
</ul>
</div>
<div className="mb-8 flex flex-1 flex-col gap-3.5 md:mb-auto">
<h2 className="m-auto flex items-center gap-3 text-lg font-normal md:flex-col md:gap-2">
<CautionIcon />
{localize('com_ui_limitations')}
</h2>
<ul className="m-auto flex w-full flex-col gap-3.5 sm:max-w-md">
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
{localize('com_ui_limitation_incorrect_info')}
</li>
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
{localize('com_ui_limitation_harmful_biased')}
</li>
<li className="w-full rounded-md bg-gray-50 p-3 dark:bg-white/5">
{localize('com_ui_limitation_limited_2021')}
</li>
</ul>
</div>
</div>
{/* {!showingTemplates && (
<div className="mt-8 mb-4 flex flex-col items-center gap-3.5 md:mt-16">
<button
onClick={showTemplates}
className="btn btn-neutral justify-center gap-2 border-0 md:border"
>
<ChatIcon />
Show Prompt Templates
</button>
</div>
)}
{!!showingTemplates && <Templates showTemplates={showTemplates}/>} */}
{/* <div className="group h-32 w-full flex-shrink-0 dark:border-gray-800/50 dark:bg-gray-800 md:h-48" /> */}
</div>
</div>
);
}

View file

@ -0,0 +1,101 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '~/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog as OGDialog,
DialogPortal as OGDialogPortal,
DialogOverlay as OGDialogOverlay,
DialogClose as OGDialogClose,
DialogTrigger as OGDialogTrigger,
DialogContent as OGDialogContent,
DialogHeader as OGDialogHeader,
DialogFooter as OGDialogFooter,
DialogTitle as OGDialogTitle,
DialogDescription as OGDialogDescription,
};

View file

@ -1,6 +1,6 @@
import React from 'react';
import { Listbox, Transition } from '@headlessui/react';
import type { Option } from '~/common';
import type { Option, OptionWithIcon } from '~/common';
import CheckMark from '../svg/CheckMark';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
@ -9,10 +9,11 @@ import { useMultiSearch } from './MultiSearch';
type SelectDropDownProps = {
id?: string;
title?: string;
value: string | null | Option;
value: string | null | Option | OptionWithIcon;
disabled?: boolean;
setValue: (value: string) => void;
availableValues: string[] | Option[];
tabIndex?: number;
availableValues: string[] | Option[] | OptionWithIcon[];
emptyTitle?: boolean;
showAbove?: boolean;
showLabel?: boolean;
@ -26,6 +27,7 @@ type SelectDropDownProps = {
className?: string;
searchClassName?: string;
searchPlaceholder?: string;
showOptionIcon?: boolean;
};
function SelectDropDown({
@ -33,6 +35,7 @@ function SelectDropDown({
value,
disabled,
setValue,
tabIndex,
availableValues,
showAbove = false,
showLabel = true,
@ -47,6 +50,7 @@ function SelectDropDown({
renderOption,
searchClassName,
searchPlaceholder,
showOptionIcon,
}: SelectDropDownProps) {
const localize = useLocalize();
const transitionProps = { className: 'top-full mt-3' };
@ -81,9 +85,10 @@ function SelectDropDown({
{({ open }) => (
<>
<Listbox.Button
tabIndex={tabIndex}
data-testid="select-dropdown-button"
className={cn(
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-gray-600 dark:bg-gray-700 sm:text-sm',
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left dark:border-gray-600 dark:bg-gray-700 sm:text-sm',
className ?? '',
)}
>
@ -108,6 +113,11 @@ function SelectDropDown({
{!showLabel && !emptyTitle && (
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
)}
{showOptionIcon && value && (value as OptionWithIcon)?.icon && (
<span className="icon-md flex items-center">
{(value as OptionWithIcon).icon}
</span>
)}
{typeof value !== 'string' && value ? value?.label ?? '' : value ?? ''}
</span>
</span>
@ -139,7 +149,7 @@ function SelectDropDown({
>
<Listbox.Options
className={cn(
'absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded border bg-white text-base text-xs ring-black/10 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:ring-white/20 md:w-[100%]',
'absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded border bg-white text-xs ring-black/10 dark:border-gray-600 dark:bg-gray-700 dark:ring-white/20 md:w-[100%]',
optionsListClass ?? '',
)}
>
@ -163,6 +173,8 @@ function SelectDropDown({
const currentLabel = typeof option === 'string' ? option : option?.label ?? '';
const currentValue = typeof option === 'string' ? option : option?.value ?? '';
const currentIcon =
typeof option === 'string' ? null : (option?.icon as React.ReactNode) ?? null;
let activeValue: string | number | null | Option = value;
if (typeof activeValue !== 'string') {
activeValue = activeValue?.value ?? '';
@ -172,10 +184,13 @@ function SelectDropDown({
<Listbox.Option
key={i}
value={currentValue}
className={cn(
'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden border-b border-black/10 pl-3 pr-9 text-gray-800 last:border-0 hover:bg-gray-20 dark:border-white/20 dark:text-white dark:hover:bg-gray-700',
optionsClass ?? '',
)}
className={({ active }) =>
cn(
'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden border-b border-black/10 pl-3 pr-9 text-gray-800 last:border-0 hover:bg-gray-20 dark:border-white/20 dark:text-white dark:hover:bg-gray-700',
active ? 'bg-surface-tertiary' : '',
optionsClass ?? '',
)
}
>
<span className="flex items-center gap-1.5 truncate">
<span
@ -185,6 +200,7 @@ function SelectDropDown({
iconSide === 'left' ? 'ml-4' : '',
)}
>
{currentIcon && <span className="mr-1">{currentIcon}</span>}
{currentLabel}
</span>
{currentValue === activeValue && (

View file

@ -0,0 +1,15 @@
import { cn } from '~/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'animate-pulse rounded-md bg-surface-tertiary opacity-50 dark:opacity-25',
className,
)}
{...props}
/>
);
}
export { Skeleton };

View file

@ -6,34 +6,41 @@ type TagProps = React.ComponentPropsWithoutRef<'div'> & {
label: string;
labelClassName?: string;
CancelButton?: React.ReactNode;
onRemove: (e: React.MouseEvent<HTMLButtonElement>) => void;
LabelNode?: React.ReactNode;
onRemove?: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
const TagPrimitiveRoot = React.forwardRef<HTMLDivElement, TagProps>(
({ CancelButton, label, onRemove, className = '', labelClassName = '', ...props }, ref) => (
(
{ CancelButton, LabelNode, label, onRemove, className = '', labelClassName = '', ...props },
ref,
) => (
<div
ref={ref}
{...props}
className={cn(
'flex max-h-8 items-center overflow-y-hidden rounded rounded-3xl border-2 border-green-600 bg-green-600/20 text-sm text-xs text-green-600 dark:text-white',
'flex max-h-8 items-center overflow-y-hidden rounded-3xl border-2 border-green-600 bg-green-600/20 text-xs text-green-600 dark:text-white',
className,
)}
>
<div className={cn('ml-1 whitespace-pre-wrap px-2 py-1', labelClassName)}>{label}</div>
{CancelButton ? (
CancelButton
) : (
<button
onClick={(e) => {
e.stopPropagation();
onRemove(e);
}}
className="rounded-full bg-green-600/50"
aria-label={`Remove ${label}`}
>
<X className="m-[1.5px] p-1" />
</button>
)}
<div className={cn('ml-1 whitespace-pre-wrap px-2 py-1', labelClassName)}>
{LabelNode ? <>{LabelNode} </> : null}
{label}
</div>
{CancelButton
? CancelButton
: onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove(e);
}}
className="rounded-full bg-green-600/50"
aria-label={`Remove ${label}`}
>
<X className="m-[1.5px] p-1" />
</button>
)}
</div>
),
);

View file

@ -1,68 +0,0 @@
import ChatIcon from '../svg/ChatIcon';
import { useLocalize } from '~/hooks';
export default function Templates({ showTemplates }: { showTemplates: () => void }) {
const localize = useLocalize();
return (
<div id="templates-wrapper" className="mt-6 flex items-start gap-3.5 text-center ">
<div className="flex flex-1 flex-col gap-3.5">
<ChatIcon />
<h2 className="text-lg font-normal">{localize('com_ui_prompt_templates')}</h2>
<ul className="flex flex-col gap-3.5">
<ul className="flex flex-col gap-3.5"></ul>
<div className="flex flex-1 flex-col items-center gap-3.5">
<span className="text-sm text-gray-700 dark:text-gray-400">
{localize('com_ui_showing')}{' '}
<span className="font-semibold text-gray-800 dark:text-white">1</span>{' '}
{localize('com_ui_of')}{' '}
<a id="prompt-link">
<span className="font-semibold text-gray-800 dark:text-white">
1 {localize('com_ui_entries')}
</span>
</a>
</span>
<button
onClick={showTemplates}
className="btn justify-center gap-2 border-0 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 md:border"
>
<ChatIcon />
{localize('com_ui_hide_prompt_templates')}
</button>
<div
// onclick="selectPromptTemplate(0)"
className="flex w-full flex-col gap-2 rounded-md bg-gray-50 p-4 text-left hover:bg-gray-200 dark:bg-white/5 "
>
<h2 className="m-auto flex items-center gap-3 text-lg font-normal md:flex-col md:gap-2">
{localize('com_ui_dan')}
</h2>
<button>
<p className="w-full rounded-md bg-gray-50 p-3 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-gray-800">
{localize('com_ui_dan_template')}
</p>
</button>
<span className="font-medium">{localize('com_ui_use_prompt')} </span>
</div>
<div className="xs:mt-0 mt-2 inline-flex">
<button
// onclick="prevPromptTemplatesPage()"
className="bg-gray-200 px-4 py-2 font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-white"
style={{ borderRadius: '6px 0 0 6px' }}
>
{localize('com_ui_prev')}
</button>
<button
// onclick="nextPromptTemplatesPage()"
className="border-0 border-l border-gray-500 bg-gray-200 px-4 py-2 font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-white"
style={{ borderRadius: '6px 0 0 6px' }}
>
{localize('com_ui_next')}
</button>
</div>
</div>
</ul>
</div>
</div>
);
}

View file

@ -18,7 +18,7 @@ const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) =
);
};
const ThemeSelector = () => {
const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
const { theme, setTheme } = useContext(ThemeContext);
const changeTheme = useCallback(
(value: string) => {
@ -27,6 +27,10 @@ const ThemeSelector = () => {
[setTheme],
);
if (returnThemeOnly) {
return <Theme theme={theme} onChange={changeTheme} />;
}
return (
<div className="flex flex-col items-center justify-center bg-white pt-6 dark:bg-gray-900 sm:pt-0">
<div className="absolute bottom-0 left-0 m-4">

View file

@ -1,4 +1,5 @@
export * from './AlertDialog';
export * from './Breadcrumb';
export * from './Button';
export * from './Checkbox';
export * from './DataTableColumnHeader';
@ -8,16 +9,16 @@ export * from './HoverCard';
export * from './Input';
export * from './InputNumber';
export * from './Label';
export * from './Landing';
export * from './OriginalDialog';
export * from './Prompt';
export * from './QuestionMark';
export * from './Slider';
export * from './Separator';
export * from './Skeleton';
export * from './Switch';
export * from './Table';
export * from './Tabs';
export * from './Tag';
export * from './Templates';
export * from './Textarea';
export * from './TextareaAutosize';
export * from './Tooltip';