feat: Assistants API, General File Support, Side Panel, File Explorer (#1696)

* feat: assistant name/icon in Landing & Header

* feat: assistname in textarea placeholder, and use `Assistant` as default name

* feat: display non-image files in user messages

* fix: only render files if files.length is > 0

* refactor(config -> file-config): move file related configuration values to separate module, add excel types

* chore: spreadsheet file rendering

* fix(Landing): dark mode style for Assistant Name

* refactor: move progress incrementing to own hook, start smaller, cap near limit \(1\)

* refactor(useContentHandler): add empty Text part if last part was completed tool or image

* chore: add accordion trigger border styling for dark mode

* feat: Assistant Builder model selection

* chore: use Spinner when Assistant is mutating

* fix(get/assistants): return correct response object `AssistantListResponse`

* refactor(Spinner): pass size as prop

* refactor: make assistant crud mutations optimistic, add types for options

* chore: remove assistants route and view

* chore: move assistant builder components to separate directory

* feat(ContextButton): delete Assistant via context button/dialog, add localization

* refactor: conditionally show use and context menu buttons, add localization for create assistant

* feat: save side panel states to localStorage

* style(SidePanel): improve avatar menu and assistant select styling for dark mode

* refactor: make NavToggle reusable for either side (left or right), add SidePanel Toggle with ability to close it completely

* fix: resize handle and navToggle behavior

* fix(/avatar/:assistant_id): await `deleteFile` and assign unique name to uploaded image

* WIP: file UI components from PR #576

* refactor(OpenAIMinimalIcon): pass className

* feat: formatDate helper fn

* feat: DataTableColumnHeader

* feat: add row selection, formatted row values, number of rows selected

* WIP: add files to Side panel temporarily

* feat: `LB_QueueAsyncCall`: Leaky Bucket queue for external APIs, use in `processDeleteRequest`

* fix(TFile): correct `source` type with `FileSources`

* fix(useFileHandling): use `continue` instead of return when iterating multiple files, add file type to extendedFile

* chore: add generic setter type

* refactor(processDeleteRequest): settle promises to prevent rejections from processing deletions, log errors

* feat: `useFileDeletion` to reuse file deletion logic

* refactor(useFileDeletion): make `setFiles` an optional param and use object as param

* feat: useDeleteFilesFromTable

* feat: use real `files` data and add deletion action to data table

* fix(Table): make headers sticky

* feat: add dynamic filtering for columns; only show to user Host or OpenAI storage type

* style(DropdownMenu): replace `slate` with `gray`

* style(DataTable): apply dark mode themes and other misc styling

* style(Columns): add color to OpenAI Storage option

* refactor(FileContainer): make file preview reusable

* refactor(Images): make image preview reusable

* refactor(FilePreview): make file prop optional for FileIcon and FilePreview, fix relative style

* feat(Columns): add file/image previews, set a minimum size to show for file size in bytes

* WIP: File Panel with real files and formatted

* feat: open files dialog from panel

* style: file data table mobile and general column styling fixes

* refactor(api/files): return files sorted by the most recently updated

* refactor: provide fileMap through context to prevent re-selecting files to map in different areas; remove unused imports commented out in PanelColumns

* refactor(ExtendFile): make File type optional, add `attached` to prevent attached files from being deleted on remove, make Message.files a partial TFile type

* feat: attach files through file panel

* refactor(useFileHandling): move files to the start of cache list when uploaded

* refactor(useDeleteFilesMutation): delete files from cache when successfully deleted from server

* fix(FileRow): handle possible edge case of duplication due to attaching recently uploaded file

* style(SidePanel): make resize grip border transparent, remove unnecessary styling on close sidepanel button

* feat: action utilities and tests

* refactor(actions): add `ValidationResult` type and change wording for no server URL found

* refactor(actions): check for empty server URL

* fix(data-provider): revert tsconfig to fix type issue resolution

* feat(client): first pass of actions input for assistants

* refactor(FunctionSignature): change method to output object instead of string

* refactor(models/Assistant): add actions field to schema, use searchParams object for methods, and add `getAssistant`

* feat: post actions input first pass
- create new Action document
- add actions to Assistant DB document
- create /action/:assistant_id POST route
- pass more props down from PanelSwitcher, derive assistant_id from switcher
- move privacy policy to ActionInput
- reset data on input change/validation
- add `useUpdateAction`
- conform FunctionSignature type to FunctionTool
- add action, assistant doc, update hook related types

* refactor: optimize assistant/actions relationship
- past domain in metadata as hostname and not a URL
- include domain in tool name
- add `getActions` for actions retrieval by user
- add `getAssistants` for assistant docs retrieval by user
- add `assistant_id` to Action schema
- move actions to own module as a subroute to `api/assistants`
- add `useGetActionsQuery` and `useGetAssistantDocsQuery` hooks
- fix Action type def

* feat: show assistant actions in assistant builder

* feat: switch to actions on action click, editing action styling

* fix: add Assistant state for builder panel to allow immediate selection of newly created assistants as well as retaining the current assistant when switching to a different panel within the builder

* refactor(SidePanel/NavToggle): offset less from right when SidePanel is completely collapsed

* chore: rename `processActions` -> `processRequiredActions`

* chore: rename Assistant API Action to RequiredAction

* refactor(actions): avoid nesting actual API params under generic `requestBody` to optimize LLM token usage

* fix(handleTools): avoid calling `validTool` if not defined, add optional param to skip the loading of specs, which throws an error in the context of assistants

* WIP: working first pass of toolCalls generated from openapi specs

* WIP: first pass ToolCall styling

* feat: programmatic iv encryption/decryption helpers

* fix: correct ActionAuth types/enums, and define type for AuthForm

* feat: encryption/decryption helpers for Action AuthMetadata

* refactor(getActions): remove sensitive fields from query response

* refactor(POST/actions): encrypt and remove sensitive fields from mutation response

* fix(ActionService): change ESM import to CJS

* feat: frontend auth handling for actions + optimistic update on action update/creation

* refactor(actions): use the correct variables and types for setAuth method

* refactor: POST /:assistant_id action can now handle updating an existing action, add `saved_auth_fields` to determine when user explicitly saves new auth creds. only send auth metadata if user explicitly saved fields

* refactor(createActionTool): catch errors and send back meaningful error message, add flag to `getActions` to determine whether to retrieve sensitive values or not

* refactor(ToolService): add `action` property to ToolCall PartMetadata to determine if the tool call was an action, fix parsing function name issue with actionDelimiter

* fix(ActionRequest): use URL class to correctly join endpoint parts for `execute` call

* feat: delete assistant actions

* refactor: conditionally show Available actions

* refactor: show `retrieval` and `code_interpreter` as Capabilities, swap `Switch` for `Checkbox`

* chore: remove shadow-stroke from messages

* WIP: first pass of Assistants Knowledge attachments

* refactor: remove AssistantsProvider in favor of FormProvider, fix selectedAssistant re-render bug, map Assistant file_ids to files via fileMap, initialize Knowledge component with mapped files if any exist

* fix: prevent deleting files on assistant file upload

* chore: remove console.log

* refactor(useUploadFileMutation): update files and assistants cache on upload

* chore: disable oauth option as not supported yet

* feat: cancel assistant runs

* refactor: initialize OpenAI client with helper function, resolve all related circular dependencies

* fix(DALL-E): initialization

* fix(process): openai client initialization

* fix: select an existing Assistant when the active one is deleted

* chore: allow attaching files for assistant endpoint, send back relevant OpenAI error message when uploading, deconstruct openAI initialization correctly, add `message_file` to formData when a file is attached to the message but not the assistant

* fix: add assistant_id on newConvo

* fix(initializeClient): import fix

* chore: swap setAssistant for setOption in useEffect

* fix(DALL-E): add processFileURL to loadTools call

* chore: add customConfig to debug logs

* feat: delete threads on convo delete

* chore: replace Assistants icon

* chore: remove console.dir() in `abortRun`

* feat(AssistantService): accumulate text values from run in openai.responseText

* feat: titling for assistants endpoint

* chore: move panel file components to appropriate directory, add file checks for attaching files, change icon for Attach Files

* refactor: add localizations to tools, plugins, add condition for adding/remove user plugins so tool selections don't affect this value

* chore: disable `import from url` action for now

* chore: remove textMimeTypes from default fileConfig for now

* fix: catch tool errors and send as outputs with error messages

* fix: React warning about button as descendant of button

* style: retrieval and cancelled icon

* WIP: pass isSubmitting to Parts, use InProgressCall to display cancelled tool calls correctly, show domain/function name

* fix(meilisearch): fix `postSaveHook` issue where indexing expects a mongo document, and join all text content parts for meili indexing

* ci: fix dall-e tests

* ci: fix client tests

* fix: button types in actions panel

* fix: plugin auth form persisting across tool selections

* fix(ci): update AppService spec with `loadAndFormatTools`

* fix(clearConvos): add id check earlier on

* refactor(AssistantAvatar): set previewURL dynamically when emtadata.avatar changes

* feat(assistants): addTitle cache setting

* fix(useSSE): resolve rebase conflicts

* fix: delete mutation

* style(SidePanel): make grip visible on active and hover, invisible otherwise

* ci: add data-provider tests to workflow, also update eslint/tsconfig to recognize specs, and add `text/csv` to fileConfig

* fix: handle edge case where auth object is undefined, and log errors

* refactor(actions): resolve  schemas, add tests for resolving refs, import specs from separate file for tests

* chore: remove comment

* fix(ActionsInput): re-render bug when initializing states with action fields

* fix(patch/assistant): filter undefined tools

* chore: add logging for errors in assistants routes

* fix(updateAssistant): map actions to functions to avoid overwriting

* fix(actions): properly handle GET paths

* fix(convos): unhandled delete thread exception

* refactor(AssistantService): pass both thread_id and conversationId when sending intermediate assistant messages, remove `mapMessagesToSteps` from AssistantService

* refactor(useSSE): replace all messages with runMessages and pass latestMessageId to abortRun; fix(checkMessageGaps): include tool calls when  syncing messages

* refactor(assistants/chat): invoke `createOnTextProgress` after thread creation

* chore: add typing

* style: sidepanel styling

* style: action tool call domain styling

* feat(assistants): default models, limit retrieval to certain models, add env variables to to env.example

* feat: assistants api key in EndpointService

* refactor: set assistant model to conversation on assistant switch

* refactor: set assistant model to conversation on assistant select from panel

* fix(retrieveAndProcessFile): catch attempt to download file with `assistant` purpose which is not allowed; add logging

* feat: retrieval styling, handling, and logging

* chore: rename ASSISTANTS_REVERSE_PROXY to ASSISTANTS_BASE_URL

* feat: FileContext for file metadata

* feat: context file mgmt and filtering

* style(Select): hover/rounded changes

* refactor: explicit conversation switch, endpoint dependent, through `useSelectAssistant`, which does not create new chat if current endpoint is assistant endpoint

* fix(AssistantAvatar): make empty previewURL if no avatar present

* refactor: side panel mobile styling

* style: merge tool and action section, optimize mobile styling for action/tool buttons

* fix: localStorage issues

* fix(useSelectAssistant): invoke react query hook directly in select hook as Map was not being updated in time

* style: light mode fixes

* fix: prevent sidepanel nav styling from shifting layout up

* refactor: change default layout (collapsed by default)

* style: mobile optimization of DataTable

* style: datatable

* feat: client-side hide right-side panel

* chore(useNewConvo): add partial typing for preset

* fix(useSelectAssistant): pass correct model name by using template as preset

* WIP: assistant presets

* refactor(ToolService): add native solution for `TavilySearchResults` and log tool output errors

* refactor: organize imports and use native TavilySearchResults

* fix(TavilySearchResults): stringify result

* fix(ToolCall): show tool call outputs when not an action

* chore: rename Prompt Prefix to custom instructions (in user facing text only)

* refactor(EditPresetDialog): Optimize setting title by debouncing, reset preset on dialog close to avoid state mixture

* feat: add `presetOverride` to overwrite active conversation settings when saving a Preset (relevant for client side updates only)

* feat: Assistant preset settings (client-side)

* fix(Switcher): only set assistant_id and model if current endpoint is Assistants

* feat: use `useDebouncedInput` for updating conversation settings, starting with EditPresetDialog title setting and Assistant instructions setting

* feat(Assistants): add instructions field to settings

* feat(chat/assistants): pass conversation settings to run body

* wip: begin localization and only allow actions if the assistant is created

* refactor(AssistantsPanel): knowledge localization, allow tools on creation

* feat: experimental: allow 'priming' values before assistant is created, that would normally require an assistant_id to be defined

* chore: trim console logs and make more meaningful

* chore: toast messages

* fix(ci): date test

* feat: create file when uploading Assistant Avatar

* feat: file upload rate limiting from custom config with dynamic file route initialization

* refactor: use file upload limiters on post routes only

* refactor(fileConfig): add endpoints field for endpoint specific fileconfigs, add mergeConfig function, add tests

* refactor: fileConfig route, dynamic multer instances used on all '/' and '/images' POST routes, data service and query hook

* feat: supportedMimeTypesSchema, test for array of regex

* feat: configurable file config limits

* chore: clarify assistants file knowledge prereq.

* chore(useTextarea): default to localized 'Assistant' if assistant name is empty

* feat: configurable file limits and toggle file upload per endpoint

* fix(useUploadFileMutation): prevent updating assistant.files cache if file upload is a message_file attachment

* fix(AssistantSelect): set last selected assistant only when timeout successfully runs

* refactor(queries): disable assistant queries if assistants endpoint is not enabled

* chore(Switcher): add localization

* chore: pluralize `assistant` for `EModelEndpoint key and value

* feat: show/hide assistant UI components based on endpoint availability; librechat.yaml config for disabling builder section and setting polling/timeout intervals

* fix(compactEndpointSchemas): use EModelEndpoint for schema access

* feat(runAssistant): use configured values from `librechat.yaml` for `pollIntervalMs` and `timeout`

* fix: naming issue

* wip: revert landing

* 🎉 happy birthday LibreChat (#1768)

* happy birthday LibreChat

* Refactor endpoint condition in Landing component

* Update birthday message in Eng.tsx

* fix(/config): avoid nesting ternaries

* refactor(/config): check birthday

---------

Co-authored-by: Danny Avila <messagedaniel@protonmail.com>

* fix: landing

* fix: landing

* fix(useMessageHelpers): hardcoded check to use EModelEndpoint instead

* fix(ci): convo test revert to main

* fix(assistants/chat): fix issue where assistant_id was being saved as model for convo

* chore: added logging, promises racing to prevent longer timeouts, explicit setting of maxRetries and timeouts, robust catching of invalid abortRun params

* refactor: use recoil state for `showStopButton` and only show for assistants endpoint after syncing conversation data

* refactor: optimize abortRun strategy using localStorage, refactor `abortConversation` to use async/await and await the result, refactor how the abortKey cache is set for runs

* fix(checkMessageGaps): assign `assistant_id` to synced messages if defined; prevents UI from showing blank assistant for cancelled messages

* refactor: re-order sequence of chat route, only allow aborting messages after run is created, cancel abortRun if there was a cancelling error (likely due already cancelled in chat route), and add extra logging

* chore(typedefs): add httpAgent type to OpenAIClient

* refactor: use custom implementation of retrieving run with axios to allow for timing out run query

* fix(waitForRun): handle timed out run retrieval query

* refactor: update preset conditions:
- presets will retain settings when a different endpoint is selected; for existing convos, either when modular or is assistant switch
- no longer use `navigateToConvo` on preset select

* fix: temporary calculator hack as expects string input when invoked

* fix: cancel abortRun only when cancelling error is a result of the run already being cancelled

* chore: remove use of `fileMaxSizeMB` and total counterpart (redundant)

* docs: custom config documentation update

* docs: assistants api setup and dotenv, new custom config fields

* refactor(Switcher): make Assistant switcher sticky in SidePanel

* chore(useSSE): remove console log of data and message index

* refactor(AssistantPanel): button styling and add secondary select button to bottom of panel

* refactor(OpenAIClient): allow passing conversationId to RunManager through titleConvo and initializeLLM to properly record title context tokens used in cases where conversationId was not defined by the client

* feat(assistants): token tracking for assistant runs

* chore(spendTokens): improve logging

* feat: support/exclude specific assistant Ids

* chore: add update `librechat.example.yaml`, optimize `AppService` handling, new tests for `AppService`, optimize missing/outdate config logging

* chore: mount docker logs to root of project

* chore: condense axios errors

* chore: bump vite

* chore: vite hot reload fix using latest version

* chore(getOpenAIModels): sort instruct models to the end of models list

* fix(assistants): user provided key

* fix(assistants): user provided key, invalidate more queries on revoke

---------

Co-authored-by: Marco Beretta <81851188+Berry-13@users.noreply.github.com>
This commit is contained in:
Danny Avila 2024-02-13 20:42:27 -05:00 committed by GitHub
parent cd2786441a
commit ecd63eb9f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
316 changed files with 21873 additions and 6315 deletions

View file

@ -1,17 +1,30 @@
import { EModelEndpoint, supportsFiles } from 'librechat-data-provider';
import {
EModelEndpoint,
supportsFiles,
fileConfig as defaultFileConfig,
mergeFileConfig,
} from 'librechat-data-provider';
import { useGetFileConfig } from '~/data-provider';
import { AttachmentIcon } from '~/components/svg';
import { FileUpload } from '~/components/ui';
import { useFileHandling } from '~/hooks';
export default function AttachFile({
endpoint,
endpointType,
disabled = false,
}: {
endpoint: EModelEndpoint | '';
endpointType?: EModelEndpoint;
disabled?: boolean | null;
}) {
const { handleFileChange } = useFileHandling();
if (!supportsFiles[endpoint]) {
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
if (!supportsFiles[endpointType ?? endpoint ?? ''] || endpointFileConfig?.disabled) {
return null;
}

View file

@ -0,0 +1,34 @@
import type { TFile } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import FilePreview from './FilePreview';
import RemoveFile from './RemoveFile';
import { getFileType } from '~/utils';
const FileContainer = ({
file,
onDelete,
}: {
file: ExtendedFile | TFile;
onDelete?: () => void;
}) => {
const fileType = getFileType(file.type);
return (
<div className="group relative inline-block text-sm text-black/70 dark:text-white/90">
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-600">
<div className="w-60 p-2 dark:bg-gray-600">
<div className="flex flex-row items-center gap-2">
<FilePreview file={file} fileType={fileType} className="relative" />
<div className="overflow-hidden">
<div className="truncate font-medium">{file.filename}</div>
<div className="truncate text-gray-300">{fileType.title}</div>
</div>
</div>
</div>
</div>
{onDelete && <RemoveFile onRemove={onDelete} />}
</div>
);
};
export default FileContainer;

View file

@ -0,0 +1,44 @@
import type { TFile } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import FileIcon from '~/components/svg/Files/FileIcon';
import ProgressCircle from './ProgressCircle';
import { cn } from '~/utils';
const FilePreview = ({
file,
fileType,
className = '',
}: {
file?: ExtendedFile | TFile;
fileType: {
paths: React.FC;
fill: string;
title: string;
};
className?: string;
}) => {
const radius = 55; // Radius of the SVG circle
const circumference = 2 * Math.PI * radius;
const progress = file?.['progress'] ?? 1;
// Calculate the offset based on the loading progress
const offset = circumference - progress * circumference;
const circleCSSProperties = {
transition: 'stroke-dashoffset 0.3s linear',
};
return (
<div className={cn('h-10 w-10 shrink-0 overflow-hidden rounded-md', className)}>
<FileIcon file={file} fileType={fileType} />
{progress < 1 && (
<ProgressCircle
circumference={circumference}
offset={offset}
circleCSSProperties={circleCSSProperties}
/>
)}
</div>
);
};
export default FilePreview;

View file

@ -0,0 +1,100 @@
import { useEffect } from 'react';
import type { ExtendedFile } from '~/common';
import { useDeleteFilesMutation } from '~/data-provider';
import { useFileDeletion } from '~/hooks/Files';
import FileContainer from './FileContainer';
import Image from './Image';
export default function FileRow({
files: _files,
setFiles,
setFilesLoading,
assistant_id,
fileFilter,
Wrapper,
}: {
files: Map<string, ExtendedFile>;
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
fileFilter?: (file: ExtendedFile) => boolean;
assistant_id?: string;
Wrapper?: React.FC<{ children: React.ReactNode }>;
}) {
const files = Array.from(_files.values()).filter((file) =>
fileFilter ? fileFilter(file) : true,
);
const { mutateAsync } = useDeleteFilesMutation({
onMutate: async () => console.log('Deleting files: assistant_id', assistant_id),
onSuccess: () => {
console.log('Files deleted');
},
onError: (error) => {
console.log('Error deleting files:', error);
},
});
const { deleteFile } = useFileDeletion({ mutateAsync, assistant_id });
useEffect(() => {
if (!files) {
return;
}
if (files.length === 0) {
return;
}
if (files.some((file) => file.progress < 1)) {
return;
}
if (files.every((file) => file.progress === 1)) {
setFilesLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files]);
if (files.length === 0) {
return null;
}
const renderFiles = () => {
return (
<>
{files
.reduce(
(acc, current) => {
if (!acc.map.has(current.file_id)) {
acc.map.set(current.file_id, true);
acc.uniqueFiles.push(current);
}
return acc;
},
{ map: new Map(), uniqueFiles: [] as ExtendedFile[] },
)
.uniqueFiles.map((file: ExtendedFile, index: number) => {
const handleDelete = () => deleteFile({ file, setFiles });
if (file.type?.startsWith('image')) {
return (
<Image
key={index}
url={file.preview}
onDelete={handleDelete}
progress={file.progress}
/>
);
}
return <FileContainer key={index} file={file} onDelete={handleDelete} />;
})}
</>
);
};
if (Wrapper) {
return <Wrapper>{renderFiles()}</Wrapper>;
}
return renderFiles();
}

View file

@ -0,0 +1,40 @@
import { FileSources, FileContext } from 'librechat-data-provider';
import type { TFile } from 'librechat-data-provider';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
import { useGetFiles } from '~/data-provider';
import { DataTable, columns } from './Table';
import { cn } from '~/utils/';
export default function Files({ open, onOpenChange }) {
const { data: files = [] } = useGetFiles<TFile[]>({
select: (files) =>
files.map((file) => {
if (file.source === FileSources.local || file.source === FileSources.openai) {
file.context = file.context ?? FileContext.unknown;
return file;
} else {
return {
...file,
context: file.context ?? FileContext.unknown,
source: FileSources.local,
};
}
}),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn('overflow-x-auto shadow-2xl dark:bg-gray-900 dark:text-white')}>
<DialogHeader>
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
My Files
</DialogTitle>
</DialogHeader>
<div className="overflow-x-auto p-0 sm:p-6 sm:pt-4">
<DataTable columns={columns} data={files} />
<div className="mt-5 sm:mt-4" />
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,9 +1,5 @@
type styleProps = {
backgroundImage?: string;
backgroundSize?: string;
backgroundPosition?: string;
backgroundRepeat?: string;
};
import ImagePreview from './ImagePreview';
import RemoveFile from './RemoveFile';
const Image = ({
imageBase64,
@ -16,96 +12,12 @@ const Image = ({
onDelete: () => void;
progress: number; // between 0 and 1
}) => {
let style: styleProps = {
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
};
if (imageBase64) {
style = {
...style,
backgroundImage: `url(${imageBase64})`,
};
} else if (url) {
style = {
...style,
backgroundImage: `url(${url})`,
};
}
if (!style.backgroundImage) {
return null;
}
const radius = 55; // Radius of the SVG circle
const circumference = 2 * Math.PI * radius;
// Calculate the offset based on the loading progress
const offset = circumference - progress * circumference;
const circleCSSProperties = {
transition: 'stroke-dashoffset 0.3s linear',
};
return (
<div className="group relative inline-block text-sm text-black/70 dark:text-white/90">
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-600">
<div className="h-14 w-14">
<button
type="button"
aria-haspopup="dialog"
aria-expanded="false"
className="h-full w-full"
style={style}
/>
{progress < 1 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/5 text-white">
<svg width="120" height="120" viewBox="0 0 120 120" className="h-6 w-6">
<circle
className="origin-[50%_50%] -rotate-90 stroke-gray-400"
strokeWidth="10"
fill="transparent"
r="55"
cx="60"
cy="60"
/>
<circle
className="origin-[50%_50%] -rotate-90 transition-[stroke-dashoffset]"
stroke="currentColor"
strokeWidth="10"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
fill="transparent"
r="55"
cx="60"
cy="60"
style={circleCSSProperties}
/>
</svg>
</div>
)}
</div>
<ImagePreview imageBase64={imageBase64} url={url} progress={progress} />
</div>
<button
type="button"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full border border-white bg-gray-500 p-0.5 text-white transition-colors hover:bg-black hover:opacity-100 group-hover:opacity-100 md:opacity-0"
onClick={onDelete}
>
<span>
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="icon-sm"
xmlns="http://www.w3.org/2000/svg"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</span>
</button>
<RemoveFile onRemove={onDelete} />
</div>
);
};

View file

@ -0,0 +1,72 @@
import ProgressCircle from './ProgressCircle';
import { cn } from '~/utils';
type styleProps = {
backgroundImage?: string;
backgroundSize?: string;
backgroundPosition?: string;
backgroundRepeat?: string;
};
const ImagePreview = ({
imageBase64,
url,
progress = 1,
className = '',
}: {
imageBase64?: string;
url?: string;
progress?: number; // between 0 and 1
className?: string;
}) => {
let style: styleProps = {
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
};
if (imageBase64) {
style = {
...style,
backgroundImage: `url(${imageBase64})`,
};
} else if (url) {
style = {
...style,
backgroundImage: `url(${url})`,
};
}
if (!style.backgroundImage) {
return null;
}
const radius = 55; // Radius of the SVG circle
const circumference = 2 * Math.PI * radius;
// Calculate the offset based on the loading progress
const offset = circumference - progress * circumference;
const circleCSSProperties = {
transition: 'stroke-dashoffset 0.3s linear',
};
return (
<div className={cn('h-14 w-14', className)}>
<button
type="button"
aria-haspopup="dialog"
aria-expanded="false"
className="h-full w-full"
style={style}
/>
{progress < 1 && (
<ProgressCircle
circumference={circumference}
offset={offset}
circleCSSProperties={circleCSSProperties}
/>
)}
</div>
);
};
export default ImagePreview;

View file

@ -1,116 +0,0 @@
import debounce from 'lodash/debounce';
import { useState, useEffect, useCallback } from 'react';
import { FileSources } from 'librechat-data-provider';
import type { BatchFile } from 'librechat-data-provider';
import { useDeleteFilesMutation } from '~/data-provider';
import { useSetFilesToDelete } from '~/hooks';
import { ExtendedFile } from '~/common';
import Image from './Image';
export default function Images({
files: _files,
setFiles,
setFilesLoading,
}: {
files: Map<string, ExtendedFile>;
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const setFilesToDelete = useSetFilesToDelete();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_batch, setFileDeleteBatch] = useState<BatchFile[]>([]);
const files = Array.from(_files.values());
useEffect(() => {
if (!files) {
return;
}
if (files.length === 0) {
return;
}
if (files.some((file) => file.progress < 1)) {
return;
}
if (files.every((file) => file.progress === 1)) {
setFilesLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files]);
const { mutateAsync } = useDeleteFilesMutation({
onSuccess: () => {
console.log('Files deleted');
},
onError: (error) => {
console.log('Error deleting files:', error);
},
});
const executeBatchDelete = useCallback(
(filesToDelete: BatchFile[]) => {
console.log('Deleting files:', filesToDelete);
mutateAsync({ files: filesToDelete });
setFileDeleteBatch([]);
},
[mutateAsync],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedDelete = useCallback(debounce(executeBatchDelete, 1000), []);
useEffect(() => {
// Cleanup function for debouncedDelete when component unmounts or before re-render
return () => debouncedDelete.cancel();
}, [debouncedDelete]);
if (files.length === 0) {
return null;
}
const deleteFile = (_file: ExtendedFile) => {
const {
file_id,
progress,
temp_file_id = '',
filepath = '',
source = FileSources.local,
} = _file;
if (progress < 1) {
return;
}
const file: BatchFile = {
file_id,
filepath,
source,
};
setFiles((currentFiles) => {
const updatedFiles = new Map(currentFiles);
updatedFiles.delete(file_id);
updatedFiles.delete(temp_file_id);
const files = Object.fromEntries(updatedFiles);
setFilesToDelete(files);
return updatedFiles;
});
setFileDeleteBatch((prevBatch) => {
const newBatch = [...prevBatch, file];
debouncedDelete(newBatch);
return newBatch;
});
};
return (
<div className="mx-2 mt-2 flex flex-wrap gap-2 px-2.5 md:pl-0 md:pr-4">
{files.map((file: ExtendedFile, index: number) => {
const handleDelete = () => deleteFile(file);
return (
<Image key={index} url={file.preview} onDelete={handleDelete} progress={file.progress} />
);
})}
</div>
);
}

View file

@ -0,0 +1,36 @@
export default function ProgressCircle({
circumference,
offset,
circleCSSProperties,
}: {
circumference: number;
offset: number;
circleCSSProperties: React.CSSProperties;
}) {
return (
<div className="absolute inset-0 flex items-center justify-center bg-black/5 text-white">
<svg width="120" height="120" viewBox="0 0 120 120" className="h-6 w-6">
<circle
className="origin-[50%_50%] -rotate-90 stroke-gray-400"
strokeWidth="10"
fill="transparent"
r="55"
cx="60"
cy="60"
/>
<circle
className="origin-[50%_50%] -rotate-90 transition-[stroke-dashoffset]"
stroke="currentColor"
strokeWidth="10"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
fill="transparent"
r="55"
cx="60"
cy="60"
style={circleCSSProperties}
/>
</svg>
</div>
);
}

View file

@ -0,0 +1,25 @@
export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
return (
<button
type="button"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full border border-white bg-gray-500 p-0.5 text-white transition-colors hover:bg-black hover:opacity-100 group-hover:opacity-100 md:opacity-0"
onClick={onRemove}
>
<span>
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="icon-sm"
xmlns="http://www.w3.org/2000/svg"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</span>
</button>
);
}

View file

@ -0,0 +1,187 @@
import { ArrowUpDown, Database } from 'lucide-react';
import { FileSources, FileContext } from 'librechat-data-provider';
import type { ColumnDef } from '@tanstack/react-table';
import type { TFile } from 'librechat-data-provider';
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { SortFilterHeader } from './SortFilterHeader';
import { OpenAIMinimalIcon } from '~/components/svg';
import { Button, Checkbox } from '~/components/ui';
import { formatDate, getFileType } from '~/utils';
const contextMap = {
[FileContext.avatar]: 'Avatar',
[FileContext.unknown]: 'Unknown',
[FileContext.assistants]: 'Assistants',
[FileContext.image_generation]: 'Image Gen',
[FileContext.assistants_output]: 'Assistant Output',
[FileContext.message_attachment]: 'Attachment',
};
export const columns: ColumnDef<TFile>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="flex"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="flex"
/>
),
enableSorting: false,
enableHiding: false,
},
{
meta: {
size: '150px',
},
accessorKey: 'filename',
header: ({ column }) => {
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
Name
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
},
cell: ({ row }) => {
const file = row.original;
if (file.type?.startsWith('image')) {
return (
<div className="flex gap-2 ">
<ImagePreview
url={file.filepath}
className="h-10 w-10 shrink-0 overflow-hidden rounded-md"
/>
<span className="self-center truncate ">{file.filename}</span>
</div>
);
}
const fileType = getFileType(file.type);
return (
<div className="flex gap-2">
{fileType && <FilePreview fileType={fileType} />}
<span className="self-center truncate">{file.filename}</span>
</div>
);
},
},
{
accessorKey: 'updatedAt',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm"
>
Date
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
},
cell: ({ row }) => formatDate(row.original.updatedAt),
},
{
accessorKey: 'source',
header: ({ column }) => {
return (
<SortFilterHeader
column={column}
title="Storage"
filters={{
Storage: Object.values(FileSources).filter(
(value) => value === FileSources.local || value === FileSources.openai,
),
}}
valueMap={{
[FileSources.openai]: 'OpenAI',
[FileSources.local]: 'Host',
}}
/>
);
},
cell: ({ row }) => {
const { source } = row.original;
if (source === FileSources.openai) {
return (
<div className="flex flex-wrap items-center gap-2">
<OpenAIMinimalIcon className="icon-sm text-green-600/50" />
{'OpenAI'}
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-2">
<Database className="icon-sm text-cyan-700" />
{'Host'}
</div>
);
},
},
{
accessorKey: 'context',
header: ({ column }) => {
return (
<SortFilterHeader
column={column}
title="Context"
filters={{
Context: Object.values(FileContext).filter(
(value) => value === FileContext[value ?? ''],
),
}}
valueMap={contextMap}
/>
);
},
cell: ({ row }) => {
const { context } = row.original;
return (
<div className="flex flex-wrap items-center gap-2">
{contextMap[context ?? FileContext.unknown] ?? 'Unknown'}
</div>
);
},
},
{
accessorKey: 'bytes',
header: ({ column }) => {
return (
<Button
variant="ghost"
className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
Size
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
},
cell: ({ row }) => {
const suffix = ' MB';
const value = Number((Number(row.original.bytes) / 1024 / 1024).toFixed(2));
if (value < 0.01) {
return '< 0.01 MB';
}
return `${value}${suffix}`;
},
},
];

View file

@ -0,0 +1,224 @@
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 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';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
type Style = { width?: number | string; maxWidth?: number | string; minWidth?: number | string };
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
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="flex items-center gap-4 py-4">
<Button
variant="ghost"
onClick={() => {
setIsDeleting(true);
const filesToDelete = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original);
deleteFiles({ files: filesToDelete as TFile[] });
setRowSelection({});
}}
className="gap-2"
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
>
{isDeleting ? (
<Spinner className="ml-2 h-4 w-4" />
) : (
<NewTrashIcon className="ml-2 h-4 w-4 text-red-400" />
)}
Delete
</Button>
<Input
placeholder="Filter files..."
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
className="max-w-sm dark:border-gray-700"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
<ListFilter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
{/* Filter Menu */}
<DropdownMenuContent align="end" className="z-[1001] dark:border-gray-700 dark:bg-black">
{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)}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="relative 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 = '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-900 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">
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">
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} file(s) selected.
</div>
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,106 @@
import { Column } from '@tanstack/react-table';
import { ListFilter, FilterX } from 'lucide-react';
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon } from '@radix-ui/react-icons';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '~/components/ui/DropdownMenu';
import { Button } from '~/components/ui/Button';
import { cn } from '~/utils';
interface SortFilterHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
title: string;
column: Column<TData, TValue>;
filters?: Record<string, string[] | number[]>;
valueMap?: Record<string, string>;
}
export function SortFilterHeader<TData, TValue>({
column,
title,
className = '',
filters,
valueMap,
}: SortFilterHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm"
// className="data-[state=open]:bg-accent -ml-3 h-8"
>
<span>{title}</span>
{column.getIsFiltered() ? (
<ListFilter className="icon-sm text-muted-foreground/70 ml-2" />
) : (
<ListFilter className="icon-sm ml-2 opacity-30" />
)}
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className="icon-sm ml-2" />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className="icon-sm ml-2" />
) : (
<CaretSortIcon className="icon-sm ml-2" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[1001] dark:border-gray-700 dark:bg-black">
<DropdownMenuItem
onClick={() => column.toggleSorting(false)}
className="cursor-pointer dark:text-white dark:hover:bg-gray-800"
>
<ArrowUpIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Asc
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => column.toggleSorting(true)}
className="cursor-pointer dark:text-white dark:hover:bg-gray-800"
>
<ArrowDownIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
{filters &&
Object.entries(filters).map(([key, values]) =>
values.map((value: string | number) => (
<DropdownMenuItem
className="cursor-pointer dark:text-white dark:hover:bg-gray-800"
key={`${key}-${value}`}
onClick={() => {
column.setFilterValue(value);
}}
>
<ListFilter className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
{valueMap?.[value] ?? value}
</DropdownMenuItem>
)),
)}
{filters && (
<DropdownMenuItem
className={
column.getIsFiltered()
? 'cursor-pointer dark:text-white dark:hover:bg-gray-800'
: 'pointer-events-none opacity-30'
}
onClick={() => {
column.setFilterValue(undefined);
}}
>
<FilterX className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Show All
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,88 @@
import { DotsIcon, TrashIcon } from '~/components/svg';
export default function Template() {
return (
<div className="max-h-[28rem] overflow-y-auto rounded-md border border-black/10 dark:border-white/10">
<table className="w-full border-separate border-spacing-0">
<thead>
<tr>
<th className="sticky top-0 rounded-t border-b border-black/10 bg-white px-4 py-2 text-left font-medium text-gray-700 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100">
Name
</th>
<th className="sticky top-0 rounded-t border-b border-black/10 bg-white px-4 py-2 text-left font-medium text-gray-700 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100">
Date
</th>
<th className="sticky top-0 rounded-t border-b border-black/10 bg-white px-4 py-2 text-left font-medium text-gray-700 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100">
Size
</th>
<th className="sticky top-0 rounded-t border-b border-black/10 bg-white px-4 py-2 text-right font-medium text-gray-700 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100">
<button
className="text-gray-500 hover:text-gray-600 radix-state-open:text-gray-600 dark:hover:text-gray-400 dark:radix-state-open:text-gray-400"
type="button"
id="radix-:r67:"
aria-haspopup="menu"
aria-expanded="false"
data-state="closed"
>
<DotsIcon />
</button>
</th>
</tr>
</thead>
<tbody>
<tr className="">
<td 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">
<div className="px-4 py-2 [tr[data-disabled=true]_&]:opacity-50">
File Transfer: Node to FastAPI
</div>
</td>
<td 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">
<div className="px-4 py-2 [tr[data-disabled=true]_&]:opacity-50">June 11, 2023</div>
</td>
<td 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">
<div className="px-4 py-2 [tr[data-disabled=true]_&]:opacity-50">11 mb</div>
</td>
<td 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">
<div className="px-4 py-2 [tr[data-disabled=true]_&]:opacity-50">
<div className="flex items-center justify-end gap-2">
<span className="" data-state="closed">
<a
href="/c/da3130ea-830c-4dd2-9d2d-d875e71e3867"
target="_blank"
rel="noreferrer"
aria-label="View source chat"
className="text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</a>
</span>
<span className="" data-state="closed">
<button
aria-label="Delete shared link"
className="text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
>
<TrashIcon />
</button>
</span>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,72 @@
import { FileSources } from 'librechat-data-provider';
import type { TFile } from 'librechat-data-provider';
export const files: TFile[] = [
{
_id: '65b004acd70ce86b9146e9dd',
file_id: 'file-CbxzlOiGvaG2uwhuAdKXdUpX',
__v: 0,
bytes: 18740,
createdAt: '2024-01-23T18:25:48.153Z',
filename: 'dataset.xlsx',
filepath: 'https://api.openai.com/v1/files/file-CbxzlOiGvaG2uwhuAdKXdUpX',
object: 'file',
source: FileSources.openai,
temp_file_id: '63214c34-2d2c-445f-9c60-5cf04c15607c',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
updatedAt: '2024-01-23T18:25:48.153Z',
usage: 0,
user: '652ac880c4102a77fe54c5db',
},
{
_id: '65b004abd70ce86b9146e861',
file_id: '86fe0534-803c-4e88-b730-73ec4187742f',
__v: 0,
bytes: 3147861,
createdAt: '2024-01-23T18:25:47.698Z',
filename: 'img-337c49c7-fb1f-4a14-939d-40d12de11d5c.png',
filepath: '/images/652ac880c4102a77fe54c5db/img-337c49c7-fb1f-4a14-939d-40d12de11d5c.png',
height: 1024,
object: 'file',
source: FileSources.local,
type: 'image/png',
updatedAt: '2024-01-23T18:25:47.698Z',
usage: 0,
user: '652ac880c4102a77fe54c5db',
width: 1024,
},
{
_id: '65b00495d70ce86b9146adc1',
file_id: 'e301fdff-6fae-48d3-a9a2-c7fe66357890',
__v: 0,
bytes: 3147861,
createdAt: '2024-01-23T18:25:25.324Z',
filename: 'img-459c76d1-16b7-48f9-9ff7-85ba6464e204.png',
filepath: '/images/652ac880c4102a77fe54c5db/img-459c76d1-16b7-48f9-9ff7-85ba6464e204.png',
height: 1024,
object: 'file',
source: FileSources.local,
type: 'image/png',
updatedAt: '2024-01-23T18:25:25.324Z',
usage: 0,
user: '652ac880c4102a77fe54c5db',
width: 1024,
},
{
_id: '65b00494d70ce86b9146ace6',
file_id: '63cf2058-3ad1-4712-afbe-6b475119c33a',
__v: 0,
bytes: 3147861,
createdAt: '2024-01-23T18:25:25.035Z',
filename: 'img-c3fb2935-e578-4d72-b397-d1dcb122af67.png',
filepath: '/images/652ac880c4102a77fe54c5db/img-c3fb2935-e578-4d72-b397-d1dcb122af67.png',
height: 1024,
object: 'file',
source: FileSources.local,
type: 'image/png',
updatedAt: '2024-01-23T18:25:25.035Z',
usage: 0,
user: '652ac880c4102a77fe54c5db',
width: 1024,
},
];

View file

@ -0,0 +1,4 @@
export { columns } from './Columns';
export { default as DataTable } from './DataTable';
export { default as TemplateTable } from './TemplateTable';
export { files } from './fakeData';