mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-21 19:00:13 +01:00
✨ 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:
parent
cd2786441a
commit
ecd63eb9f1
316 changed files with 21873 additions and 6315 deletions
|
|
@ -6,7 +6,7 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
|
|||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
||||
import { ScreenshotProvider, ThemeProvider, useApiErrorBoundary } from './hooks';
|
||||
import { ToastProvider, AssistantsProvider } from './Providers';
|
||||
import { ToastProvider } from './Providers';
|
||||
import Toast from './components/ui/Toast';
|
||||
import { router } from './routes';
|
||||
|
||||
|
|
@ -29,14 +29,12 @@ const App = () => {
|
|||
<ThemeProvider>
|
||||
<RadixToast.Provider>
|
||||
<ToastProvider>
|
||||
<AssistantsProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
</DndProvider>
|
||||
</AssistantsProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
</DndProvider>
|
||||
</ToastProvider>
|
||||
</RadixToast.Provider>
|
||||
</ThemeProvider>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { defaultAssistantFormValues } from 'librechat-data-provider';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import type { CreationForm } from '~/common';
|
||||
import useCreationForm from './useCreationForm';
|
||||
import type { AssistantForm } from '~/common';
|
||||
|
||||
// type AssistantsContextType = {
|
||||
// // open: boolean;
|
||||
// // setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
// form: UseFormReturn<CreationForm>;
|
||||
// };
|
||||
type AssistantsContextType = UseFormReturn<CreationForm>;
|
||||
type AssistantsContextType = UseFormReturn<AssistantForm>;
|
||||
|
||||
export const AssistantsContext = createContext<AssistantsContextType>({} as AssistantsContextType);
|
||||
|
||||
|
|
@ -23,7 +19,9 @@ export function useAssistantsContext() {
|
|||
}
|
||||
|
||||
export default function AssistantsProvider({ children }) {
|
||||
const hookValues = useCreationForm();
|
||||
const methods = useForm<AssistantForm>({
|
||||
defaultValues: defaultAssistantFormValues,
|
||||
});
|
||||
|
||||
return <AssistantsContext.Provider value={hookValues}>{children}</AssistantsContext.Provider>;
|
||||
return <FormProvider {...methods}>{children}</FormProvider>;
|
||||
}
|
||||
|
|
|
|||
8
client/src/Providers/AssistantsMapContext.tsx
Normal file
8
client/src/Providers/AssistantsMapContext.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import { useAssistantsMap } from '~/hooks/Assistants';
|
||||
type AssistantsMapContextType = ReturnType<typeof useAssistantsMap>;
|
||||
|
||||
export const AssistantsMapContext = createContext<AssistantsMapContextType>(
|
||||
{} as AssistantsMapContextType,
|
||||
);
|
||||
export const useAssistantsMapContext = () => useContext(AssistantsMapContext);
|
||||
6
client/src/Providers/FileMapContext.tsx
Normal file
6
client/src/Providers/FileMapContext.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import { useFileMap } from '~/hooks/Files';
|
||||
type FileMapContextType = ReturnType<typeof useFileMap>;
|
||||
|
||||
export const FileMapContext = createContext<FileMapContextType>({} as FileMapContextType);
|
||||
export const useFileMapContext = () => useContext(FileMapContext);
|
||||
|
|
@ -2,4 +2,6 @@ export { default as ToastProvider } from './ToastContext';
|
|||
export { default as AssistantsProvider } from './AssistantsContext';
|
||||
export * from './ChatContext';
|
||||
export * from './ToastContext';
|
||||
export * from './FileMapContext';
|
||||
export * from './AssistantsContext';
|
||||
export * from './AssistantsMapContext';
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
// import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { CreationForm } from '~/common';
|
||||
|
||||
export default function useViewPromptForm() {
|
||||
return useForm<CreationForm>({
|
||||
defaultValues: {
|
||||
assistant: '',
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-3.5-turbo-1106',
|
||||
function: false,
|
||||
code_interpreter: false,
|
||||
retrieval: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,19 +1,21 @@
|
|||
import type { Option } from './types';
|
||||
import type { Assistant } from 'librechat-data-provider';
|
||||
import type { Option, ExtendedFile } from './types';
|
||||
|
||||
export type TAssistantOption = string | (Option & Assistant);
|
||||
export type TAssistantOption =
|
||||
| string
|
||||
| (Option & Assistant & { files?: Array<[string, ExtendedFile]> });
|
||||
|
||||
export type Actions = {
|
||||
function: boolean;
|
||||
code_interpreter: boolean;
|
||||
retrieval: boolean;
|
||||
};
|
||||
|
||||
export type CreationForm = {
|
||||
export type AssistantForm = {
|
||||
assistant: TAssistantOption;
|
||||
id: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
model: string;
|
||||
functions: string[];
|
||||
} & Actions;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { FileSources } from 'librechat-data-provider';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type {
|
||||
TConversation,
|
||||
TMessage,
|
||||
|
|
@ -6,10 +8,80 @@ import type {
|
|||
TLoginUser,
|
||||
TUser,
|
||||
EModelEndpoint,
|
||||
Action,
|
||||
AuthTypeEnum,
|
||||
AuthorizationTypeEnum,
|
||||
TokenExchangeMethodEnum,
|
||||
} from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type TSetOption = (param: number | string) => (newValue: number | string | boolean) => void;
|
||||
export type GenericSetter<T> = (value: T | ((currentValue: T) => T)) => void;
|
||||
|
||||
export type NavLink = {
|
||||
title: string;
|
||||
label?: string;
|
||||
icon: LucideIcon;
|
||||
Component?: React.ComponentType;
|
||||
variant?: 'default' | 'ghost';
|
||||
id: string;
|
||||
};
|
||||
|
||||
export interface NavProps {
|
||||
isCollapsed: boolean;
|
||||
links: NavLink[];
|
||||
resize?: (size: number) => void;
|
||||
defaultActive?: string;
|
||||
}
|
||||
|
||||
interface ColumnMeta {
|
||||
meta: {
|
||||
size: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
export enum Panel {
|
||||
builder = 'builder',
|
||||
actions = 'actions',
|
||||
}
|
||||
|
||||
export type FileSetter =
|
||||
| SetterOrUpdater<Map<string, ExtendedFile>>
|
||||
| React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
|
||||
|
||||
export type ActionAuthForm = {
|
||||
/* General */
|
||||
type: AuthTypeEnum;
|
||||
saved_auth_fields: boolean;
|
||||
/* API key */
|
||||
api_key: string; // not nested
|
||||
authorization_type: AuthorizationTypeEnum;
|
||||
custom_auth_header: string;
|
||||
/* OAuth */
|
||||
oauth_client_id: string; // not nested
|
||||
oauth_client_secret: string; // not nested
|
||||
authorization_url: string;
|
||||
client_url: string;
|
||||
scope: string;
|
||||
token_exchange_method: TokenExchangeMethodEnum;
|
||||
};
|
||||
|
||||
export type AssistantPanelProps = {
|
||||
index?: number;
|
||||
action?: Action;
|
||||
actions?: Action[];
|
||||
assistant_id?: string;
|
||||
activePanel?: string;
|
||||
setAction: React.Dispatch<React.SetStateAction<Action | undefined>>;
|
||||
setCurrentAssistantId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
};
|
||||
|
||||
export type AugmentedColumnDef<TData, TValue> = ColumnDef<TData, TValue> & ColumnMeta;
|
||||
|
||||
export type TSetOption = (
|
||||
param: number | string,
|
||||
) => (newValue: number | string | boolean | Partial<TPreset>) => void;
|
||||
export type TSetExample = (
|
||||
i: number,
|
||||
type: string,
|
||||
|
|
@ -72,7 +144,7 @@ export type TSetOptionsPayload = {
|
|||
setAgentOption: TSetOption;
|
||||
// getConversation: () => TConversation | TPreset | null;
|
||||
checkPluginSelection: (value: string) => boolean;
|
||||
setTools: (newValue: string) => void;
|
||||
setTools: (newValue: string, remove?: boolean) => void;
|
||||
};
|
||||
|
||||
export type TPresetItemProps = {
|
||||
|
|
@ -136,7 +208,7 @@ export type TAdditionalProps = {
|
|||
setSiblingIdx: (value: number) => void;
|
||||
};
|
||||
|
||||
export type TMessageContent = TInitialProps & TAdditionalProps;
|
||||
export type TMessageContentProps = TInitialProps & TAdditionalProps;
|
||||
|
||||
export type TText = Pick<TInitialProps, 'text'>;
|
||||
export type TEditProps = Pick<TInitialProps, 'text' | 'isSubmitting'> &
|
||||
|
|
@ -172,6 +244,11 @@ export type TDialogProps = {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export type TPluginStoreDialogProps = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export type TResError = {
|
||||
response: { data: { message: string } };
|
||||
message: string;
|
||||
|
|
@ -198,7 +275,7 @@ export type TAuthConfig = {
|
|||
test?: boolean;
|
||||
};
|
||||
|
||||
export type IconProps = Pick<TMessage, 'isCreatedByUser' | 'model' | 'error'> &
|
||||
export type IconProps = Pick<TMessage, 'isCreatedByUser' | 'model'> &
|
||||
Pick<TConversation, 'chatGptLabel' | 'modelLabel' | 'jailbreak'> & {
|
||||
size?: number;
|
||||
button?: boolean;
|
||||
|
|
@ -207,6 +284,8 @@ export type IconProps = Pick<TMessage, 'isCreatedByUser' | 'model' | 'error'> &
|
|||
className?: string;
|
||||
endpoint?: EModelEndpoint | string | null;
|
||||
endpointType?: EModelEndpoint | null;
|
||||
assistantName?: string;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
export type Option = Record<string, unknown> & {
|
||||
|
|
@ -220,7 +299,7 @@ export type TOptionSettings = {
|
|||
};
|
||||
|
||||
export interface ExtendedFile {
|
||||
file: File;
|
||||
file?: File;
|
||||
file_id: string;
|
||||
temp_file_id?: string;
|
||||
type?: string;
|
||||
|
|
@ -229,9 +308,10 @@ export interface ExtendedFile {
|
|||
width?: number;
|
||||
height?: number;
|
||||
size: number;
|
||||
preview: string;
|
||||
preview?: string;
|
||||
progress: number;
|
||||
source?: FileSources;
|
||||
attached?: boolean;
|
||||
}
|
||||
|
||||
export type ContextType = { navVisible: boolean; setNavVisible: (visible: boolean) => void };
|
||||
|
|
|
|||
|
|
@ -2,16 +2,13 @@ import { memo } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
|
||||
import { useChatHelpers, useSSE } from '~/hooks';
|
||||
// import GenerationButtons from './Input/GenerationButtons';
|
||||
import { ChatContext, useFileMapContext } from '~/Providers';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
// import OptionsBar from './Input/OptionsBar';
|
||||
import { useGetFiles } from '~/data-provider';
|
||||
import { buildTree, mapFiles } from '~/utils';
|
||||
import { useChatHelpers, useSSE } from '~/hooks';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { ChatContext } from '~/Providers';
|
||||
import Presentation from './Presentation';
|
||||
import ChatForm from './Input/ChatForm';
|
||||
import { buildTree } from '~/utils';
|
||||
import Landing from './Landing';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
|
@ -22,9 +19,7 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
const submissionAtIndex = useRecoilValue(store.submissionByIndex(0));
|
||||
useSSE(submissionAtIndex);
|
||||
|
||||
const { data: fileMap } = useGetFiles({
|
||||
select: mapFiles,
|
||||
});
|
||||
const fileMap = useFileMapContext();
|
||||
|
||||
const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId(conversationId ?? '', {
|
||||
select: (data) => {
|
||||
|
|
@ -38,7 +33,7 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
|
||||
return (
|
||||
<ChatContext.Provider value={chatHelpers}>
|
||||
<Presentation>
|
||||
<Presentation useSidePanel={true}>
|
||||
{isLoading && conversationId !== 'new' ? (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Spinner className="opacity-0" />
|
||||
|
|
@ -48,8 +43,6 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
) : (
|
||||
<Landing Header={<Header />} />
|
||||
)}
|
||||
{/* <OptionsBar messagesTree={messagesTree} /> */}
|
||||
{/* <GenerationButtons endpoint={chatHelpers.conversation.endpoint ?? ''} /> */}
|
||||
<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 />
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
// import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useListAssistantsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { Assistant } from 'librechat-data-provider';
|
||||
import type { UseFormReset, UseFormSetValue } from 'react-hook-form';
|
||||
import type { CreationForm, Actions, Option } from '~/common';
|
||||
import SelectDropDown from '~/components/ui/SelectDropDown';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
const keys = new Set(['name', 'id', 'description', 'instructions', 'model']);
|
||||
|
||||
type TAssistantOption = string | (Option & Assistant);
|
||||
|
||||
export default function CreationHeader({
|
||||
reset,
|
||||
value,
|
||||
onChange,
|
||||
setValue,
|
||||
}: {
|
||||
reset: UseFormReset<CreationForm>;
|
||||
value: TAssistantOption;
|
||||
onChange: (value: TAssistantOption) => void;
|
||||
setValue: UseFormSetValue<CreationForm>;
|
||||
}) {
|
||||
const assistants = useListAssistantsQuery(
|
||||
{
|
||||
order: 'asc',
|
||||
},
|
||||
{
|
||||
select: (res) =>
|
||||
res.data.map((assistant) => ({
|
||||
...assistant,
|
||||
label: assistant?.name ?? '',
|
||||
value: assistant.id,
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
const onSelect = (value: string) => {
|
||||
const assistant = assistants.data?.find((assistant) => assistant.id === value);
|
||||
if (!assistant) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...assistant,
|
||||
label: assistant?.name ?? '',
|
||||
value: assistant?.id ?? '',
|
||||
});
|
||||
const actions: Actions = {
|
||||
function: false,
|
||||
code_interpreter: false,
|
||||
retrieval: false,
|
||||
};
|
||||
assistant?.tools
|
||||
?.map((tool) => tool.type)
|
||||
.forEach((tool) => {
|
||||
actions[tool] = true;
|
||||
});
|
||||
|
||||
Object.entries(assistant).forEach(([name, value]) => {
|
||||
if (typeof value === 'number') {
|
||||
return;
|
||||
} else if (typeof value === 'object') {
|
||||
return;
|
||||
}
|
||||
if (keys.has(name)) {
|
||||
setValue(name as keyof CreationForm, value);
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(actions).forEach(([name, value]) => setValue(name as keyof Actions, value));
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectDropDown
|
||||
value={!value ? 'Create Assistant' : value}
|
||||
setValue={onSelect}
|
||||
availableValues={
|
||||
assistants.data ?? [
|
||||
{
|
||||
label: 'Loading...',
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
iconSide="left"
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
emptyTitle={true}
|
||||
optionsClass="hover:bg-gray-20/50"
|
||||
optionsListClass="rounded-lg shadow-lg"
|
||||
currentValueClass={cn(
|
||||
'text-md font-semibold text-gray-900 dark:text-gray-100',
|
||||
value === '' ? 'text-gray-500' : '',
|
||||
)}
|
||||
className={cn(
|
||||
'rounded-none',
|
||||
'z-50 flex h-[40px] w-full flex-none items-center justify-center px-4 hover:cursor-pointer hover:border-green-500 focus:border-green-500',
|
||||
)}
|
||||
renderOption={() => (
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-2 text-gray-800 dark:text-gray-100">
|
||||
<Plus className="w-[16px]" />
|
||||
</span>
|
||||
<span className={cn('ml-4 flex h-6 items-center gap-1 text-gray-800 dark:text-gray-100')}>
|
||||
{'Create Assistant'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
import { Controller, useWatch } from 'react-hook-form';
|
||||
import { Tools, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useCreateAssistantMutation } from 'librechat-data-provider/react-query';
|
||||
import type { CreationForm, Actions } from '~/common';
|
||||
import type { Tool } from 'librechat-data-provider';
|
||||
import { Separator } from '~/components/ui/Separator';
|
||||
import { useAssistantsContext } from '~/Providers';
|
||||
import { Switch } from '~/components/ui/Switch';
|
||||
import CreationHeader from './CreationHeader';
|
||||
import { useNewConvo } from '~/hooks';
|
||||
|
||||
export default function CreationPanel({ index = 0 }) {
|
||||
const { switchToConversation } = useNewConvo(index);
|
||||
const create = useCreateAssistantMutation();
|
||||
const { control, handleSubmit, reset, setValue } = useAssistantsContext();
|
||||
|
||||
const onSubmit = (data: CreationForm) => {
|
||||
const tools: Tool[] = [];
|
||||
console.log(data);
|
||||
if (data.function) {
|
||||
tools.push({ type: Tools.function });
|
||||
}
|
||||
if (data.code_interpreter) {
|
||||
tools.push({ type: Tools.code_interpreter });
|
||||
}
|
||||
if (data.retrieval) {
|
||||
tools.push({ type: Tools.retrieval });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
model,
|
||||
// file_ids,
|
||||
} = data;
|
||||
|
||||
create.mutate({
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
model,
|
||||
tools,
|
||||
});
|
||||
};
|
||||
|
||||
const assistant_id = useWatch({ control, name: 'id' });
|
||||
|
||||
// Render function for the Switch component
|
||||
const renderSwitch = (name: keyof Actions) => (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full data-[state=checked]:bg-green-500"
|
||||
value={field?.value?.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="h-auto w-1/3 flex-shrink-0 overflow-x-hidden"
|
||||
>
|
||||
<Controller
|
||||
name="assistant"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CreationHeader
|
||||
reset={reset}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
setValue={setValue}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="h-auto bg-white px-8 pb-8 pt-6">
|
||||
{/* Name */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="name">
|
||||
Name
|
||||
</label>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
{...{ max: 256 }}
|
||||
className="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Optional: The name of the assistant"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<p className="h-3 text-xs italic text-gray-600">{field.value ?? ''}</p>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="description">
|
||||
Description
|
||||
</label>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
{...{ max: 512 }}
|
||||
className="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
id="description"
|
||||
type="text"
|
||||
placeholder="Optional: Describe your Assistant here"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mb-6">
|
||||
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="instructions">
|
||||
Instructions
|
||||
</label>
|
||||
<Controller
|
||||
name="instructions"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<textarea
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
{...{ max: 32768 }}
|
||||
className="focus:shadow-outline w-full resize-none appearance-none rounded border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
id="instructions"
|
||||
placeholder="The system instructions that the assistant uses"
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="mb-6">
|
||||
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="model">
|
||||
Model
|
||||
</label>
|
||||
<Controller
|
||||
name="model"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<select
|
||||
{...field}
|
||||
className="focus:shadow-outline block w-full appearance-none rounded border border-gray-200 bg-white px-4 py-2 pr-8 text-sm leading-tight shadow hover:border-gray-100 focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
id="model"
|
||||
>
|
||||
<option value="gpt-3.5-turbo-1106">gpt-3.5-turbo-1106</option>
|
||||
{/* Additional model options here */}
|
||||
</select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
<div className="mb-6">
|
||||
<label className="mb-2 block text-xs font-bold text-gray-700">Tools</label>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Separator orientation="horizontal" className="bg-gray-100/50" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">Functions</span>
|
||||
{renderSwitch('function')}
|
||||
</div>
|
||||
<Separator orientation="horizontal" className="bg-gray-100/50" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">Code Interpreter</span>
|
||||
{renderSwitch('code_interpreter')}
|
||||
</div>
|
||||
<Separator orientation="horizontal" className="bg-gray-100/50" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">Retrieval</span>
|
||||
{renderSwitch('retrieval')}
|
||||
</div>
|
||||
<Separator orientation="horizontal" className="bg-gray-100/50" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{/* Use Button */}
|
||||
<button
|
||||
className="focus:shadow-outline mx-2 rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
switchToConversation({
|
||||
endpoint: EModelEndpoint.assistant,
|
||||
conversationId: 'new',
|
||||
assistant_id,
|
||||
title: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
className="focus:shadow-outline rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,12 +5,14 @@ import { useRequiresKey } from '~/hooks';
|
|||
import AttachFile from './Files/AttachFile';
|
||||
import StopButton from './StopButton';
|
||||
import SendButton from './SendButton';
|
||||
import Images from './Files/Images';
|
||||
import FileRow from './Files/FileRow';
|
||||
import Textarea from './Textarea';
|
||||
import store from '~/store';
|
||||
|
||||
export default function ChatForm({ index = 0 }) {
|
||||
const [text, setText] = useRecoilState(store.textByIndex(index));
|
||||
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
|
||||
|
||||
const {
|
||||
ask,
|
||||
files,
|
||||
|
|
@ -20,8 +22,6 @@ export default function ChatForm({ index = 0 }) {
|
|||
handleStopGenerating,
|
||||
filesLoading,
|
||||
setFilesLoading,
|
||||
showStopButton,
|
||||
setShowStopButton,
|
||||
} = useChatContext();
|
||||
|
||||
const submitMessage = () => {
|
||||
|
|
@ -44,7 +44,16 @@ export default function ChatForm({ index = 0 }) {
|
|||
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="[&:has(textarea:focus)]:border-token-border-xheavy border-token-border-heavy shadow-xs dark:shadow-xs relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-black/10 bg-white shadow-[0_0_0_2px_rgba(255,255,255,0.95)] dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:shadow-[0_0_0_2px_rgba(52,53,65,0.95)] [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<Images files={files} setFiles={setFiles} setFilesLoading={setFilesLoading} />
|
||||
<FileRow
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
setFilesLoading={setFilesLoading}
|
||||
Wrapper={({ children }) => (
|
||||
<div className="mx-2 mt-2 flex flex-wrap gap-2 px-2.5 md:pl-0 md:pr-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{endpoint && (
|
||||
<Textarea
|
||||
value={text}
|
||||
|
|
@ -52,10 +61,15 @@ export default function ChatForm({ index = 0 }) {
|
|||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
|
||||
setText={setText}
|
||||
submitMessage={submitMessage}
|
||||
endpoint={endpoint}
|
||||
endpoint={_endpoint}
|
||||
endpointType={endpointType}
|
||||
/>
|
||||
)}
|
||||
<AttachFile endpoint={endpoint ?? ''} disabled={requiresKey} />
|
||||
<AttachFile
|
||||
endpoint={_endpoint ?? ''}
|
||||
endpointType={endpointType}
|
||||
disabled={requiresKey}
|
||||
/>
|
||||
{isSubmitting && showStopButton ? (
|
||||
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
34
client/src/components/Chat/Input/Files/FileContainer.tsx
Normal file
34
client/src/components/Chat/Input/Files/FileContainer.tsx
Normal 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;
|
||||
44
client/src/components/Chat/Input/Files/FilePreview.tsx
Normal file
44
client/src/components/Chat/Input/Files/FilePreview.tsx
Normal 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;
|
||||
100
client/src/components/Chat/Input/Files/FileRow.tsx
Normal file
100
client/src/components/Chat/Input/Files/FileRow.tsx
Normal 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();
|
||||
}
|
||||
40
client/src/components/Chat/Input/Files/FilesView.tsx
Normal file
40
client/src/components/Chat/Input/Files/FilesView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
72
client/src/components/Chat/Input/Files/ImagePreview.tsx
Normal file
72
client/src/components/Chat/Input/Files/ImagePreview.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
36
client/src/components/Chat/Input/Files/ProgressCircle.tsx
Normal file
36
client/src/components/Chat/Input/Files/ProgressCircle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
client/src/components/Chat/Input/Files/RemoveFile.tsx
Normal file
25
client/src/components/Chat/Input/Files/RemoveFile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
client/src/components/Chat/Input/Files/Table/Columns.tsx
Normal file
187
client/src/components/Chat/Input/Files/Table/Columns.tsx
Normal 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}`;
|
||||
},
|
||||
},
|
||||
];
|
||||
224
client/src/components/Chat/Input/Files/Table/DataTable.tsx
Normal file
224
client/src/components/Chat/Input/Files/Table/DataTable.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
72
client/src/components/Chat/Input/Files/Table/fakeData.ts
Normal file
72
client/src/components/Chat/Input/Files/Table/fakeData.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
4
client/src/components/Chat/Input/Files/Table/index.ts
Normal file
4
client/src/components/Chat/Input/Files/Table/index.ts
Normal 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';
|
||||
|
|
@ -1,9 +1,25 @@
|
|||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { supportsFiles } from 'librechat-data-provider';
|
||||
import {
|
||||
supportsFiles,
|
||||
fileConfig as defaultFileConfig,
|
||||
mergeFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useTextarea } from '~/hooks';
|
||||
|
||||
export default function Textarea({ value, disabled, onChange, setText, submitMessage, endpoint }) {
|
||||
export default function Textarea({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
setText,
|
||||
submitMessage,
|
||||
endpoint,
|
||||
endpointType,
|
||||
}) {
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
const {
|
||||
inputRef,
|
||||
handlePaste,
|
||||
|
|
@ -12,7 +28,7 @@ export default function Textarea({ value, disabled, onChange, setText, submitMes
|
|||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
} = useTextarea({ setText, submitMessage, disabled });
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
|
||||
return (
|
||||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
|
|
@ -31,7 +47,9 @@ export default function Textarea({ value, disabled, onChange, setText, submitMes
|
|||
style={{ height: 44, overflowY: 'auto' }}
|
||||
rows={1}
|
||||
className={cn(
|
||||
supportsFiles[endpoint] ? ' pl-10 md:pl-[55px]' : 'pl-3 md:pl-4',
|
||||
supportsFiles[endpointType ?? endpoint ?? ''] && !endpointFileConfig?.disabled
|
||||
? ' pl-10 md:pl-[55px]'
|
||||
: 'pl-3 md:pl-4',
|
||||
'm-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 placeholder-black/50 focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder-white/50 md:py-3.5 md:pr-12 ',
|
||||
removeFocusOutlines,
|
||||
'max-h-52',
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { useGetEndpointsQuery, useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery, useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
|
||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
||||
import { icons } from './Menus/Endpoints/Icons';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { BirthdayIcon } from '~/components/svg';
|
||||
import { getEndpointField } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { BirthdayIcon } from '~/components/svg';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui/';
|
||||
|
||||
export default function Landing({ Header }: { Header?: ReactNode }) {
|
||||
const { conversation } = useChatContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
|
||||
const localize = useLocalize();
|
||||
|
||||
let { endpoint } = conversation ?? {};
|
||||
const { assistant_id = null } = conversation ?? {};
|
||||
|
||||
if (
|
||||
endpoint === EModelEndpoint.assistant ||
|
||||
endpoint === EModelEndpoint.chatGPTBrowser ||
|
||||
endpoint === EModelEndpoint.azureOpenAI ||
|
||||
endpoint === EModelEndpoint.gptPlugins
|
||||
|
|
@ -29,6 +32,18 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
|
|||
const iconKey = endpointType ? 'unknown' : endpoint ?? 'unknown';
|
||||
const Icon = icons[iconKey];
|
||||
|
||||
const assistant = endpoint === EModelEndpoint.assistants && assistantMap?.[assistant_id ?? ''];
|
||||
const assistantName = (assistant && assistant?.name) || '';
|
||||
const assistantDesc = (assistant && assistant?.description) || '';
|
||||
const avatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
|
||||
|
||||
let className =
|
||||
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
|
||||
|
||||
if (assistantName && avatar) {
|
||||
className = 'shadow-stroke overflow-hidden rounded-full';
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
|
|
@ -36,7 +51,7 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
|
|||
<div className="absolute left-0 right-0">{Header && Header}</div>
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="relative mb-3 h-[72px] w-[72px]">
|
||||
<div className="gizmo-shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
|
||||
<div className={className}>
|
||||
{endpoint &&
|
||||
Icon &&
|
||||
Icon({
|
||||
|
|
@ -45,20 +60,36 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
|
|||
className: 'h-2/3 w-2/3',
|
||||
endpoint: endpoint,
|
||||
iconURL: iconURL,
|
||||
assistantName,
|
||||
avatar,
|
||||
})}
|
||||
<TooltipTrigger>
|
||||
{(startupConfig?.showBirthdayIcon ?? false) && (
|
||||
<BirthdayIcon className="absolute bottom-12 right-5" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={85} className="left-[-20%]">
|
||||
{localize('com_ui_happy_birthday')}
|
||||
</TooltipContent>
|
||||
</div>
|
||||
<TooltipTrigger>
|
||||
{(startupConfig?.showBirthdayIcon ?? false) && (
|
||||
<BirthdayIcon className="absolute bottom-12 right-5" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={115} className="left-[20%]">
|
||||
{localize('com_ui_happy_birthday')}
|
||||
</TooltipContent>
|
||||
</div>
|
||||
<div className="mb-5 text-2xl font-medium dark:text-white">
|
||||
{localize('com_nav_welcome_message')}
|
||||
</div>
|
||||
{assistantName ? (
|
||||
<div className="flex flex-col items-center gap-0 p-2">
|
||||
<div className="text-center text-2xl font-medium dark:text-white">
|
||||
{assistantName}
|
||||
</div>
|
||||
<div className="text-token-text-secondary max-w-md text-center text-xl font-normal ">
|
||||
{assistantDesc ? assistantDesc : localize('com_nav_welcome_message')}
|
||||
</div>
|
||||
{/* <div className="mt-1 flex items-center gap-1 text-token-text-tertiary">
|
||||
<div className="text-sm text-token-text-tertiary">By Daniel Avila</div>
|
||||
</div> */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-5 text-2xl font-medium dark:text-white">
|
||||
{localize('com_nav_welcome_message')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import {
|
|||
BingAIMinimalIcon,
|
||||
GoogleMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
AssistantIcon,
|
||||
LightningIcon,
|
||||
Sparkles,
|
||||
} from '~/components/svg';
|
||||
import UnknownIcon from './UnknownIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -21,26 +23,32 @@ export const icons = {
|
|||
[EModelEndpoint.google]: GoogleMinimalIcon,
|
||||
[EModelEndpoint.bingAI]: BingAIMinimalIcon,
|
||||
[EModelEndpoint.custom]: CustomMinimalIcon,
|
||||
[EModelEndpoint.assistant]: ({ className = '' }) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn('icon-md shrink-0', className)}
|
||||
>
|
||||
<path
|
||||
d="M19.3975 1.35498C19.3746 1.15293 19.2037 1.00021 19.0004 1C18.7971 0.999793 18.6259 1.15217 18.6026 1.35417C18.4798 2.41894 18.1627 3.15692 17.6598 3.65983C17.1569 4.16274 16.4189 4.47983 15.3542 4.60264C15.1522 4.62593 14.9998 4.79707 15 5.00041C15.0002 5.20375 15.1529 5.37457 15.355 5.39746C16.4019 5.51605 17.1562 5.83304 17.6716 6.33906C18.1845 6.84269 18.5078 7.57998 18.6016 8.63539C18.6199 8.84195 18.7931 9.00023 19.0005 9C19.2078 8.99977 19.3806 8.84109 19.3985 8.6345C19.4883 7.59673 19.8114 6.84328 20.3273 6.32735C20.8433 5.81142 21.5967 5.48834 22.6345 5.39851C22.8411 5.38063 22.9998 5.20782 23 5.00045C23.0002 4.79308 22.842 4.61992 22.6354 4.60157C21.58 4.50782 20.8427 4.18447 20.3391 3.67157C19.833 3.15623 19.516 2.40192 19.3975 1.35498Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11 3C11.4833 3 11.8974 3.34562 11.9839 3.82111C12.4637 6.46043 13.279 8.23983 14.5196 9.48039C15.7602 10.721 17.5396 11.5363 20.1789 12.0161C20.6544 12.1026 21 12.5167 21 13C21 13.4833 20.6544 13.8974 20.1789 13.9839C17.5396 14.4637 15.7602 15.279 14.5196 16.5196C13.279 17.7602 12.4637 19.5396 11.9839 22.1789C11.8974 22.6544 11.4833 23 11 23C10.5167 23 10.1026 22.6544 10.0161 22.1789C9.53625 19.5396 8.72096 17.7602 7.48039 16.5196C6.23983 15.279 4.46043 14.4637 1.82111 13.9839C1.34562 13.8974 1 13.4833 1 13C1 12.5167 1.34562 12.1026 1.82111 12.0161C4.46043 11.5363 6.23983 10.721 7.48039 9.48039C8.72096 8.23983 9.53625 6.46043 10.0161 3.82111C10.1026 3.34562 10.5167 3 11 3ZM5.66618 13C6.9247 13.5226 7.99788 14.2087 8.89461 15.1054C9.79134 16.0021 10.4774 17.0753 11 18.3338C11.5226 17.0753 12.2087 16.0021 13.1054 15.1054C14.0021 14.2087 15.0753 13.5226 16.3338 13C15.0753 12.4774 14.0021 11.7913 13.1054 10.8946C12.2087 9.99788 11.5226 8.9247 11 7.66618C10.4774 8.9247 9.79134 9.99788 8.89461 10.8946C7.99788 11.7913 6.9247 12.4774 5.66618 13Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
[EModelEndpoint.assistants]: ({
|
||||
className = '',
|
||||
assistantName,
|
||||
avatar,
|
||||
size,
|
||||
}: {
|
||||
className?: string;
|
||||
assistantName?: string;
|
||||
avatar?: string;
|
||||
size?: number;
|
||||
}) => {
|
||||
if (assistantName && avatar) {
|
||||
return (
|
||||
<img
|
||||
src={avatar}
|
||||
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full"
|
||||
alt={assistantName}
|
||||
width="80"
|
||||
height="80"
|
||||
/>
|
||||
);
|
||||
} else if (assistantName) {
|
||||
return <AssistantIcon className={cn('text-token-secondary', className)} size={size} />;
|
||||
}
|
||||
|
||||
return <Sparkles className={className} />;
|
||||
},
|
||||
unknown: UnknownIcon,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useState } from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, modularEndpoints } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { TPreset, TConversation } from 'librechat-data-provider';
|
||||
import type { FC } from 'react';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import { useLocalize, useUserKey, useDefaultConvo } from '~/hooks';
|
||||
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
||||
import { cn, getEndpointField } from '~/utils';
|
||||
|
|
@ -47,10 +47,35 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
if (!expiryTime) {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
const template: Partial<TPreset> = { endpoint: newEndpoint, conversationId: 'new' };
|
||||
|
||||
const currentEndpoint = conversation?.endpoint;
|
||||
const template: Partial<TPreset> = {
|
||||
...conversation,
|
||||
endpoint: newEndpoint,
|
||||
conversationId: 'new',
|
||||
};
|
||||
const isAssistantSwitch =
|
||||
newEndpoint === EModelEndpoint.assistants &&
|
||||
currentEndpoint === EModelEndpoint.assistants &&
|
||||
currentEndpoint === newEndpoint;
|
||||
|
||||
const { conversationId } = conversation ?? {};
|
||||
if (modularChat && conversationId && conversationId !== 'new') {
|
||||
template.endpointType = getEndpointField(endpointsConfig, newEndpoint, 'type');
|
||||
const isExistingConversation = conversationId && conversationId !== 'new';
|
||||
const currentEndpointType =
|
||||
getEndpointField(endpointsConfig, currentEndpoint, 'type') ?? currentEndpoint;
|
||||
const newEndpointType = getEndpointField(endpointsConfig, newEndpoint, 'type') ?? newEndpoint;
|
||||
|
||||
if (
|
||||
isExistingConversation &&
|
||||
(modularEndpoints.has(endpoint ?? '') ||
|
||||
modularEndpoints.has(currentEndpointType ?? '') ||
|
||||
isAssistantSwitch) &&
|
||||
(modularEndpoints.has(newEndpoint ?? '') ||
|
||||
modularEndpoints.has(newEndpointType ?? '') ||
|
||||
isAssistantSwitch) &&
|
||||
(endpoint === newEndpoint || modularChat || isAssistantSwitch)
|
||||
) {
|
||||
template.endpointType = newEndpointType;
|
||||
|
||||
const currentConvo = getDefaultConversation({
|
||||
/* target endpointType is necessary to avoid endpoint mixing */
|
||||
|
|
@ -59,10 +84,10 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
});
|
||||
|
||||
/* We don't reset the latest message, only when changing settings mid-converstion */
|
||||
newConversation({ template: currentConvo, keepLatestMessage: true });
|
||||
newConversation({ template: currentConvo, preset: currentConvo, keepLatestMessage: true });
|
||||
return;
|
||||
}
|
||||
newConversation({ template });
|
||||
newConversation({ template: { ...(template as Partial<TConversation>) } });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { alternateName } from 'librechat-data-provider';
|
||||
import { Content, Portal, Root } from '@radix-ui/react-popover';
|
||||
import { alternateName, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { FC } from 'react';
|
||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
||||
import EndpointItems from './Endpoints/MenuItems';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import TitleButton from './UI/TitleButton';
|
||||
import { mapEndpoints } from '~/utils';
|
||||
|
||||
|
|
@ -13,15 +13,22 @@ const EndpointsMenu: FC = () => {
|
|||
});
|
||||
|
||||
const { conversation } = useChatContext();
|
||||
const selected = conversation?.endpoint ?? '';
|
||||
const { endpoint = '', assistant_id = null } = conversation ?? {};
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
|
||||
if (!selected) {
|
||||
const assistant = endpoint === EModelEndpoint.assistants && assistantMap?.[assistant_id ?? ''];
|
||||
const assistantName = (assistant && assistant?.name) || 'Assistant';
|
||||
|
||||
if (!endpoint) {
|
||||
console.warn('No endpoint selected');
|
||||
return null;
|
||||
}
|
||||
|
||||
const primaryText = assistant ? assistantName : (alternateName[endpoint] ?? endpoint ?? '') + ' ';
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<TitleButton primaryText={(alternateName[selected] ?? selected ?? '') + ' '} />
|
||||
<TitleButton primaryText={primaryText + ' '} />
|
||||
<Portal>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -38,7 +45,7 @@ const EndpointsMenu: FC = () => {
|
|||
align="start"
|
||||
className="mt-2 max-h-[65vh] min-w-[340px] overflow-y-auto rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900 dark:text-white lg:max-h-[75vh]"
|
||||
>
|
||||
<EndpointItems endpoints={endpoints} selected={selected} />
|
||||
<EndpointItems endpoints={endpoints} selected={endpoint} />
|
||||
</Content>
|
||||
</div>
|
||||
</Portal>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { cn, defaultTextProps, removeFocusOutlines, mapEndpoints } from '~/utils
|
|||
import { Input, Label, Dropdown, Dialog, DialogClose, DialogButton } from '~/components/';
|
||||
import PopoverButtons from '~/components/Chat/Input/PopoverButtons';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { useSetIndexOptions, useLocalize } from '~/hooks';
|
||||
import { useSetIndexOptions, useLocalize, useDebouncedInput } from '~/hooks';
|
||||
import { EndpointSettings } from '~/components/Endpoints';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
|
@ -17,8 +17,9 @@ const EditPresetDialog = ({
|
|||
submitPreset: () => void;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { preset } = useChatContext();
|
||||
const { preset, setPreset } = useChatContext();
|
||||
const { setOption } = useSetIndexOptions(preset);
|
||||
const [onTitleChange, title] = useDebouncedInput(setOption, 'title', preset?.title);
|
||||
const [presetModalVisible, setPresetModalVisible] = useRecoilState(store.presetModalVisible);
|
||||
|
||||
const { data: availableEndpoints = [] } = useGetEndpointsQuery({
|
||||
|
|
@ -31,7 +32,15 @@ const EditPresetDialog = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<Dialog open={presetModalVisible} onOpenChange={setPresetModalVisible}>
|
||||
<Dialog
|
||||
open={presetModalVisible}
|
||||
onOpenChange={(open) => {
|
||||
setPresetModalVisible(open);
|
||||
if (!open) {
|
||||
setPreset(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTemplate
|
||||
title={`${localize('com_ui_edit') + ' ' + localize('com_endpoint_preset')} - ${
|
||||
preset?.title
|
||||
|
|
@ -47,8 +56,8 @@ const EditPresetDialog = ({
|
|||
</Label>
|
||||
<Input
|
||||
id="preset-name"
|
||||
value={preset?.title || ''}
|
||||
onChange={(e) => setOption('title')(e.target.value || '')}
|
||||
value={(title as string | undefined) ?? ''}
|
||||
onChange={onTitleChange}
|
||||
placeholder={localize('com_endpoint_set_custom_name')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
|
|
@ -104,7 +113,7 @@ const EditPresetDialog = ({
|
|||
onClick={submitPreset}
|
||||
className="dark:hover:gray-400 ml-2 border-gray-700 bg-green-600 text-white hover:bg-green-700 dark:hover:bg-green-800"
|
||||
>
|
||||
{localize('com_endpoint_save')}
|
||||
{localize('com_ui_save')}
|
||||
</DialogClose>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
172
client/src/components/Chat/Messages/Content/ActionIcon.tsx
Normal file
172
client/src/components/Chat/Messages/Content/ActionIcon.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
export default function ActionIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
height="20"
|
||||
style={{ width: '100%', height: '100%', transform: 'translate3d(0px, 0px, 0px)' }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="__lottie_element_232">
|
||||
<rect width="20" height="20" x="0" y="0"></rect>
|
||||
</clipPath>
|
||||
<clipPath id="__lottie_element_242">
|
||||
<path d="M0,0 L20000,0 L20000,20000 L0,20000z"></path>
|
||||
</clipPath>
|
||||
<g id="__lottie_element_245">
|
||||
<g
|
||||
clipPath="url(#__lottie_element_246)"
|
||||
style={{ display: 'block' }}
|
||||
transform="matrix(1,0,0,1,0,0)"
|
||||
opacity="1"
|
||||
>
|
||||
<g style={{ display: 'block' }} transform="matrix(1,0,0,1,10006,10006)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,0,0)">
|
||||
<path
|
||||
fill="rgb(255,255,255)"
|
||||
fillOpacity="1"
|
||||
d=" M4.5,1 C4.5,1 4.5,3.5 4.5,3.5 C4.5,4.05 4.05,4.5 3.5,4.5 C3.5,4.5 1,4.5 1,4.5 C0.45,4.5 0,4.05 0,3.5 C0,3.5 0,1 0,1 C0,0.45 0.45,0 1,0 C1,0 3.5,0 3.5,0 C4.05,0 4.5,0.45 4.5,1z"
|
||||
></path>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,2.25,2.25)"></g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<clipPath id="__lottie_element_246">
|
||||
<path d="M0,0 L20000,0 L20000,20000 L0,20000z"></path>
|
||||
</clipPath>
|
||||
<clipPath id="__lottie_element_256">
|
||||
<path d="M0,0 L20000,0 L20000,20000 L0,20000z"></path>
|
||||
</clipPath>
|
||||
{/* eslint-disable-next-line react/no-unknown-property */}
|
||||
<mask id="__lottie_element_245_1" mask-type="alpha">
|
||||
<use xlinkHref="#__lottie_element_245"></use>
|
||||
</mask>
|
||||
<clipPath id="__lottie_element_269">
|
||||
<path d="M0,0 L20000,0 L20000,20000 L0,20000z"></path>
|
||||
</clipPath>
|
||||
<g id="__lottie_element_272">
|
||||
<g
|
||||
clipPath="url(#__lottie_element_273)"
|
||||
style={{ display: 'block' }}
|
||||
transform="matrix(1,0,0,1,0,0)"
|
||||
opacity="1"
|
||||
>
|
||||
<g style={{ display: 'block' }} transform="matrix(1,0,0,1,10006,10006)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,0,0)">
|
||||
<path
|
||||
fill="rgb(255,255,255)"
|
||||
fillOpacity="1"
|
||||
d=" M4.5,1 C4.5,1 4.5,3.5 4.5,3.5 C4.5,4.05 4.05,4.5 3.5,4.5 C3.5,4.5 1,4.5 1,4.5 C0.45,4.5 0,4.05 0,3.5 C0,3.5 0,1 0,1 C0,0.45 0.45,0 1,0 C1,0 3.5,0 3.5,0 C4.05,0 4.5,0.45 4.5,1z"
|
||||
></path>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,2.25,2.25)"></g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<clipPath id="__lottie_element_273">
|
||||
<path d="M0,0 L20000,0 L20000,20000 L0,20000z"></path>
|
||||
</clipPath>
|
||||
<clipPath id="__lottie_element_283">
|
||||
<path d="M0,0 L20000,0 L20000,20000 L0,20000z"></path>
|
||||
</clipPath>
|
||||
{/* eslint-disable-next-line react/no-unknown-property */}
|
||||
<mask id="__lottie_element_272_1" mask-type="alpha">
|
||||
<use xlinkHref="#__lottie_element_272"></use>
|
||||
</mask>
|
||||
</defs>
|
||||
<g clipPath="url(#__lottie_element_232)">
|
||||
<g
|
||||
clipPath="url(#__lottie_element_269)"
|
||||
style={{ display: 'block' }}
|
||||
transform="matrix(-1,0,0,-1,10014,10018.5)"
|
||||
opacity="1"
|
||||
>
|
||||
<g style={{ display: 'block' }} mask="url(#__lottie_element_272_1)">
|
||||
<g clipPath="url(#__lottie_element_283)" transform="matrix(1,0,0,1,0,0)" opacity="1">
|
||||
<g style={{ display: 'block' }} transform="matrix(1,0,0,1,10006,10006)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,0,0)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M4.5,1 C4.5,1 4.5,3.5 4.5,3.5 C4.5,4.05 4.05,4.5 3.5,4.5 C3.5,4.5 1,4.5 1,4.5 C0.45,4.5 0,4.05 0,3.5 C0,3.5 0,1 0,1 C0,0.45 0.45,0 1,0 C1,0 3.5,0 3.5,0 C4.05,0 4.5,0.45 4.5,1z"
|
||||
></path>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(177,98,253)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="3"
|
||||
d=" M4.5,1 C4.5,1 4.5,3.5 4.5,3.5 C4.5,4.05 4.05,4.5 3.5,4.5 C3.5,4.5 1,4.5 1,4.5 C0.45,4.5 0,4.05 0,3.5 C0,3.5 0,1 0,1 C0,0.45 0.45,0 1,0 C1,0 3.5,0 3.5,0 C4.05,0 4.5,0.45 4.5,1z"
|
||||
></path>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,2.25,2.25)"></g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block' }} transform="matrix(-1,0,0,-1,5.75,10.25)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,-2.25,-0.75)">
|
||||
<path
|
||||
fill="rgb(247,247,248)"
|
||||
fillOpacity="1"
|
||||
d=" M0,0 C0.75,0 1.5,0 2.25,0 C2.6642000675201416,0 3,0.3357999920845032 3,0.75 C3,0.75 3,0.75 3,0.75 C3,1.164199948310852 2.6642000675201416,1.5 2.25,1.5 C1.5,1.5 0.75,1.5 0,1.5 C0,1 0,0.5 0,0 C0,0 0,0 0,0 C0,0 0,0 0,0"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
clipPath="url(#__lottie_element_242)"
|
||||
style={{ display: 'block' }}
|
||||
transform="matrix(-1,0,0,-1,10022.5,10018.5)"
|
||||
opacity="1"
|
||||
>
|
||||
<g style={{ display: 'block' }} mask="url(#__lottie_element_245_1)">
|
||||
<g clipPath="url(#__lottie_element_256)" transform="matrix(1,0,0,1,0,0)" opacity="1">
|
||||
<g style={{ display: 'block' }} transform="matrix(1,0,0,1,10006,10006)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,0,0)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M4.5,1 C4.5,1 4.5,3.5 4.5,3.5 C4.5,4.05 4.05,4.5 3.5,4.5 C3.5,4.5 1,4.5 1,4.5 C0.45,4.5 0,4.05 0,3.5 C0,3.5 0,1 0,1 C0,0.45 0.45,0 1,0 C1,0 3.5,0 3.5,0 C4.05,0 4.5,0.45 4.5,1z"
|
||||
></path>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(177,98,253)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="3"
|
||||
d=" M4.5,1 C4.5,1 4.5,3.5 4.5,3.5 C4.5,4.05 4.05,4.5 3.5,4.5 C3.5,4.5 1,4.5 1,4.5 C0.45,4.5 0,4.05 0,3.5 C0,3.5 0,1 0,1 C0,0.45 0.45,0 1,0 C1,0 3.5,0 3.5,0 C4.05,0 4.5,0.45 4.5,1z"
|
||||
></path>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,2.25,2.25)"></g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block' }} transform="matrix(-1,0,0,-1,14.25,10.25)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,-0.75,-0.75)">
|
||||
<path
|
||||
fill="rgb(247,247,248)"
|
||||
fillOpacity="1"
|
||||
d=" M0,0.75 C0,0.3357999920845032 0.3357900083065033,0 0.75,0 C1.5,0 2.25,0 3,0 C3,0.5 3,1 3,1.5 C2.25,1.5 1.5,1.5 0.75,1.5 C0.3357900083065033,1.5 0,1.164199948310852 0,0.75 C0,0.75 0,0.75 0,0.75 C0,0.75 0,0.75 0,0.75 C0,0.75 0,0.75 0,0.75"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block' }} transform="matrix(1,0,0,1,0,0)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,10,10.25)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M2,-0.75 C2,-0.75 2,0.75 2,0.75 C2,0.75 -2,0.75 -2,0.75 C-2,0.75 -2,-0.75 -2,-0.75 C-2,-0.75 2,-0.75 2,-0.75z"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export default function CancelledIcon() {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-gray-300 text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.32256 1.48447C7.59011 1.16827 7.55068 0.695034 7.23447 0.427476C6.91827 0.159918 6.44503 0.199354 6.17748 0.515559L4.00002 3.08892L1.82256 0.515559C1.555 0.199354 1.08176 0.159918 0.765559 0.427476C0.449355 0.695034 0.409918 1.16827 0.677476 1.48447L3.01755 4.25002L0.677476 7.01556C0.409918 7.33176 0.449354 7.805 0.765559 8.07256C1.08176 8.34011 1.555 8.30068 1.82256 7.98447L4.00002 5.41111L6.17748 7.98447C6.44503 8.30068 6.91827 8.34011 7.23447 8.07256C7.55068 7.805 7.59011 7.33176 7.32256 7.01556L4.98248 4.25002L7.32256 1.48447Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
client/src/components/Chat/Messages/Content/CodeAnalyze.tsx
Normal file
143
client/src/components/Chat/Messages/Content/CodeAnalyze.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useState } from 'react';
|
||||
import ProgressCircle from './ProgressCircle';
|
||||
import ProgressText from './ProgressText';
|
||||
import FinishedIcon from './FinishedIcon';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import { useProgress } from '~/hooks';
|
||||
|
||||
export default function CodeAnalyze({
|
||||
initialProgress = 0.1,
|
||||
code,
|
||||
outputs = [],
|
||||
}: {
|
||||
initialProgress: number;
|
||||
code: string;
|
||||
outputs: Record<string, unknown>[];
|
||||
}) {
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const progress = useProgress(initialProgress);
|
||||
const radius = 56.08695652173913;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
const logs = outputs.reduce((acc, output) => {
|
||||
if (output['logs']) {
|
||||
return acc + output['logs'] + '\n';
|
||||
}
|
||||
return acc;
|
||||
}, '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="my-2.5 flex items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">
|
||||
{progress < 1 ? (
|
||||
<CodeInProgress offset={offset} circumference={circumference} radius={radius} />
|
||||
) : (
|
||||
<FinishedIcon />
|
||||
)}
|
||||
</div>
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => setShowCode((prev) => !prev)}
|
||||
inProgressText="Analyzing"
|
||||
finishedText="Finished analyzing"
|
||||
hasInput={!!code?.length}
|
||||
/>
|
||||
</div>
|
||||
{showCode && (
|
||||
<div className="mb-3 mt-0.5 overflow-hidden rounded-xl bg-black">
|
||||
<MarkdownLite content={code ? `\`\`\`python\n${code}\n\`\`\`` : ''} />
|
||||
{logs && (
|
||||
<div className="bg-gray-700 p-4 text-xs">
|
||||
<div className="mb-1 text-gray-400">Result</div>
|
||||
<div
|
||||
className="prose flex flex-col-reverse text-white"
|
||||
style={{
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<pre className="shrink-0">{logs}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CodeInProgress = ({
|
||||
offset,
|
||||
circumference,
|
||||
radius,
|
||||
}: {
|
||||
offset: number;
|
||||
circumference: number;
|
||||
radius: number;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="77"
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
height="20"
|
||||
style={{ width: '100%', height: '100%', transform: 'translate3d(0px, 0px, 0px)' }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="__lottie_element_11">
|
||||
<rect width="20" height="20" x="0" y="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#__lottie_element_11)">
|
||||
<g style={{ display: 'block', transform: 'matrix(1,0,0,1,-2,-2)', opacity: 1 }}>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,7.026679992675781,8.834091186523438)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M1.2870399951934814,0.2207774966955185 C0.992609977722168,-0.07359249889850616 0.5152599811553955,-0.07359249889850616 0.22082999348640442,0.2207774966955185 C-0.07361000031232834,0.5151575207710266 -0.07361000031232834,0.992437481880188 0.22082999348640442,1.2868175506591797 C0.8473266959190369,1.9131841659545898 1.4738233089447021,2.53955078125 2.1003201007843018,3.16591739654541 C1.4738233089447021,3.7922842502593994 0.8473266959190369,4.4186506271362305 0.22082999348640442,5.045017719268799 C-0.07361000031232834,5.339417457580566 -0.07361000031232834,5.816617488861084 0.22082999348640442,6.11101770401001 C0.5152599811553955,6.405417442321777 0.992609977722168,6.405417442321777 1.2870399951934814,6.11101770401001 C2.091266632080078,5.306983947753906 2.895493268966675,4.502950668334961 3.6997199058532715,3.6989173889160156 C3.994119882583618,3.404517412185669 3.994119882583618,2.927217483520508 3.6997199058532715,2.6329174041748047 C2.895493268966675,1.8288708925247192 2.091266632080078,1.0248241424560547 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(177,98,253)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="0.201031"
|
||||
d=" M1.2870399951934814,0.2207774966955185 C0.992609977722168,-0.07359249889850616 0.5152599811553955,-0.07359249889850616 0.22082999348640442,0.2207774966955185 C-0.07361000031232834,0.5151575207710266 -0.07361000031232834,0.992437481880188 0.22082999348640442,1.2868175506591797 C0.8473266959190369,1.9131841659545898 1.4738233089447021,2.53955078125 2.1003201007843018,3.16591739654541 C1.4738233089447021,3.7922842502593994 0.8473266959190369,4.4186506271362305 0.22082999348640442,5.045017719268799 C-0.07361000031232834,5.339417457580566 -0.07361000031232834,5.816617488861084 0.22082999348640442,6.11101770401001 C0.5152599811553955,6.405417442321777 0.992609977722168,6.405417442321777 1.2870399951934814,6.11101770401001 C2.091266632080078,5.306983947753906 2.895493268966675,4.502950668334961 3.6997199058532715,3.6989173889160156 C3.994119882583618,3.404517412185669 3.994119882583618,2.927217483520508 3.6997199058532715,2.6329174041748047 C2.895493268966675,1.8288708925247192 2.091266632080078,1.0248241424560547 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block', transform: 'matrix(1,0,0,1,-2,-2)', opacity: 1 }}>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,11.79640007019043,13.512199401855469)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M4.3225998878479,0 C3.1498000621795654,0 1.9769999980926514,0 0.8041999936103821,0 C0.36010000109672546,0 0,0.36000001430511475 0,0.804099977016449 C0,1.2482000589370728 0.36010000109672546,1.6081000566482544 0.8041999936103821,1.6081000566482544 C1.9769999980926514,1.6081000566482544 3.1498000621795654,1.6081000566482544 4.3225998878479,1.6081000566482544 C4.7667999267578125,1.6081000566482544 5.126800060272217,1.2482000589370728 5.126800060272217,0.804099977016449 C5.126800060272217,0.36000001430511475 4.7667999267578125,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(177,98,253)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="0.100515"
|
||||
d=" M4.3225998878479,0 C3.1498000621795654,0 1.9769999980926514,0 0.8041999936103821,0 C0.36010000109672546,0 0,0.36000001430511475 0,0.804099977016449 C0,1.2482000589370728 0.36010000109672546,1.6081000566482544 0.8041999936103821,1.6081000566482544 C1.9769999980926514,1.6081000566482544 3.1498000621795654,1.6081000566482544 4.3225998878479,1.6081000566482544 C4.7667999267578125,1.6081000566482544 5.126800060272217,1.2482000589370728 5.126800060272217,0.804099977016449 C5.126800060272217,0.36000001430511475 4.7667999267578125,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
client/src/components/Chat/Messages/Content/ContentParts.tsx
Normal file
51
client/src/components/Chat/Messages/Content/ContentParts.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Suspense } from 'react';
|
||||
// import type { ContentPart } from 'librechat-data-provider';
|
||||
import { UnfinishedMessage } from './MessageContent';
|
||||
import { DelayedRender } from '~/components/ui';
|
||||
import Part from './Part';
|
||||
|
||||
// Content Component
|
||||
const ContentParts = ({
|
||||
edit,
|
||||
error,
|
||||
unfinished,
|
||||
isSubmitting,
|
||||
isLast,
|
||||
content,
|
||||
...props
|
||||
}: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
any) => {
|
||||
if (error) {
|
||||
// return <ErrorMessage text={text} />;
|
||||
} else if (edit) {
|
||||
// return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
|
||||
} else {
|
||||
const { message } = props;
|
||||
const { messageId } = message;
|
||||
|
||||
return (
|
||||
<>
|
||||
{content.map((part, idx) => {
|
||||
return (
|
||||
<Part
|
||||
key={`display-${messageId}-${idx}`}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
isSubmitting={isSubmitting}
|
||||
part={part}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{!isSubmitting && unfinished && (
|
||||
<Suspense>
|
||||
<DelayedRender delay={250}>
|
||||
<UnfinishedMessage key={`unfinished-${messageId}`} />
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ContentParts;
|
||||
18
client/src/components/Chat/Messages/Content/FinishedIcon.tsx
Normal file
18
client/src/components/Chat/Messages/Content/FinishedIcon.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export default function FinishedIcon() {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-brand-purple text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="162"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.66607 0.376042C8.01072 0.605806 8.10385 1.07146 7.87408 1.4161L3.54075 7.9161C3.40573 8.11863 3.18083 8.24304 2.93752 8.24979C2.69421 8.25654 2.46275 8.1448 2.31671 7.95008L0.150044 5.06119C-0.098484 4.72982 -0.0313267 4.25972 0.300044 4.01119C0.631415 3.76266 1.10152 3.82982 1.35004 4.16119L2.88068 6.20204L6.62601 0.584055C6.85577 0.239408 7.32142 0.146278 7.66607 0.376042Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ const Image = ({
|
|||
altText,
|
||||
height,
|
||||
width,
|
||||
placeholderDimensions,
|
||||
}: // n,
|
||||
// i,
|
||||
{
|
||||
|
|
@ -16,6 +17,10 @@ const Image = ({
|
|||
altText: string;
|
||||
height: number;
|
||||
width: number;
|
||||
placeholderDimensions?: {
|
||||
height: string;
|
||||
width: string;
|
||||
};
|
||||
// n: number;
|
||||
// i: number;
|
||||
}) => {
|
||||
|
|
@ -33,7 +38,9 @@ const Image = ({
|
|||
// const makeSquare = n >= 3 && i < 2;
|
||||
|
||||
let placeholderHeight = '288px';
|
||||
if (height > width) {
|
||||
if (placeholderDimensions?.height && placeholderDimensions?.width) {
|
||||
placeholderHeight = placeholderDimensions.height;
|
||||
} else if (height > width) {
|
||||
placeholderHeight = '900px';
|
||||
} else if (height === width) {
|
||||
placeholderHeight = width + 'px';
|
||||
|
|
@ -49,6 +56,7 @@ const Image = ({
|
|||
// loading="lazy"
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
visibleByDefault={true}
|
||||
className={cn(
|
||||
'max-h-[900px] max-w-full opacity-100 transition-opacity duration-300',
|
||||
// n >= 3 && i < 2 ? 'aspect-square object-cover' : '',
|
||||
|
|
@ -57,14 +65,14 @@ const Image = ({
|
|||
src={imagePath}
|
||||
style={{
|
||||
height: isLoaded && minDisplayTimeElapsed ? 'auto' : placeholderHeight,
|
||||
width,
|
||||
width: placeholderDimensions?.width ?? width,
|
||||
color: 'transparent',
|
||||
}}
|
||||
placeholder={
|
||||
<div
|
||||
style={{
|
||||
height: isLoaded && minDisplayTimeElapsed ? 'auto' : placeholderHeight,
|
||||
width,
|
||||
width: placeholderDimensions?.width ?? width,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
|
@ -73,7 +81,9 @@ const Image = ({
|
|||
</Dialog.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
<DialogImage src={imagePath} height={height} width={width} />
|
||||
{isLoaded && minDisplayTimeElapsed && (
|
||||
<DialogImage src={imagePath} height={height} width={width} />
|
||||
)}
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
141
client/src/components/Chat/Messages/Content/ImageGen.tsx
Normal file
141
client/src/components/Chat/Messages/Content/ImageGen.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { useState } from 'react';
|
||||
import ProgressCircle from './ProgressCircle';
|
||||
import ProgressText from './ProgressText';
|
||||
import { useProgress } from '~/hooks';
|
||||
|
||||
export default function ImageGen({
|
||||
initialProgress = 0.1,
|
||||
args = '',
|
||||
}: {
|
||||
initialProgress: number;
|
||||
args: string;
|
||||
}) {
|
||||
const progress = useProgress(initialProgress);
|
||||
const radius = 56.08695652173913;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
const offset = circumference - progress * circumference;
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
// const [translate, setTranslate] = useState(0);
|
||||
// useEffect(() => {
|
||||
// const timer = setInterval(() => {
|
||||
// setTranslate((prevTranslate) => (prevTranslate + 1) % 360);
|
||||
// }, 20);
|
||||
// return () => clearInterval(timer);
|
||||
// }, []);
|
||||
// if (progress >= 1) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="my-2.5 flex items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="106"
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
height="20"
|
||||
style={{ width: '100%', height: '100%', transform: 'translate3d(0px, 0px, 0px)' }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="__lottie_element_24">
|
||||
<rect width="20" height="20" x="0" y="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#__lottie_element_24)">
|
||||
<g
|
||||
style={{ display: 'block' }}
|
||||
transform="matrix(1,0,0,1,9.999999046325684,10.170669555664062)"
|
||||
opacity="1"
|
||||
>
|
||||
<g
|
||||
opacity="1"
|
||||
transform="matrix(1,0,0,1,-2.0937423706054688,-4.2560648918151855)"
|
||||
>
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M4.7193779945373535,0.4155600070953369 C4.442327976226807,-0.1385200023651123 3.651618003845215,-0.1385200023651123 3.374567985534668,0.4155600070953369 C2.276491403579712,2.6116867065429688 1.1784147024154663,4.8078131675720215 0.08033806085586548,7.003940105438232 C-0.16959194839000702,7.503779888153076 0.19388805329799652,8.091899871826172 0.7527380585670471,8.091899871826172 C2.948878049850464,8.091899871826172 5.145018100738525,8.091899871826172 7.341157913208008,8.091899871826172 C7.900058269500732,8.091899871826172 8.263558387756348,7.503779888153076 8.01365852355957,7.003940105438232 C6.91556453704834,4.8078131675720215 5.817471504211426,2.6116867065429688 4.7193779945373535,0.4155600070953369 C4.7193779945373535,0.4155600070953369 4.7193779945373535,0.4155600070953369 4.7193779945373535,0.4155600070953369 C4.7193779945373535,0.4155600070953369 4.7193779945373535,0.4155600070953369 4.7193779945373535,0.4155600070953369"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
style={{ display: 'block' }}
|
||||
transform="matrix(1,0,0,1,9.999999046325684,10.170669555664062)"
|
||||
// transform={`matrix(1,0,0,1,${translate},10.170669555664062)`}
|
||||
opacity="1"
|
||||
>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,-6.000233173370361,-1.244344711303711)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M3.2158396244049072,0.4155600070953369 C2.9387896060943604,-0.1385200023651123 2.1480696201324463,-0.1385200023651123 1.8710296154022217,0.4155600070953369 C1.2741318941116333,1.609339952468872 0.6772342324256897,2.8031198978424072 0.08033652603626251,3.9969000816345215 C-0.16959036886692047,4.496739864349365 0.19388863444328308,5.084849834442139 0.7527406215667725,5.084849834442139 C1.9465336799621582,5.084849834442139 3.140326738357544,5.084849834442139 4.33411979675293,5.084849834442139 C4.892979621887207,5.084849834442139 5.2564496994018555,4.496739864349365 5.006529808044434,3.9969000816345215 C4.409633159637451,2.8031198978424072 3.8127362728118896,1.609339952468872 3.2158396244049072,0.4155600070953369 C3.2158396244049072,0.4155600070953369 3.2158396244049072,0.4155600070953369 3.2158396244049072,0.4155600070953369 C3.2158396244049072,0.4155600070953369 3.2158396244049072,0.4155600070953369 3.2158396244049072,0.4155600070953369"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
style={{ display: 'block' }}
|
||||
transform="matrix(1,0,0,1,9.999999046325684,10.170669555664062)"
|
||||
opacity="1"
|
||||
>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,-2.9004335403442383,3.5173749923706055)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M0,0 C1.396156668663025,0 2.79231333732605,0 4.188469886779785,0 C4.188469886779785,0.1073966696858406 4.188469886779785,0.2147933393716812 4.188469886779785,0.32218998670578003 C2.79231333732605,0.32218998670578003 1.396156668663025,0.32218998670578003 0,0.32218998670578003 C0,0.2147933393716812 0,0.1073966696858406 0,0 C0,0 0,0 0,0 C0,0 0,0 0,0"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block' }} transform="matrix(-1,0,0,-1,10,10)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,0,0)">
|
||||
<path
|
||||
fill="rgb(255,214,64)"
|
||||
fillOpacity="0"
|
||||
d=" M9,-9 C9,-9 9,9 9,9 C9,9 -9,9 -9,9 C-9,9 -9,-9 -9,-9 C-9,-9 9,-9 9,-9z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block' }} transform="matrix(-1,0,0,-1,10,10)" opacity="1">
|
||||
<g opacity="0" transform="matrix(1,0,0,1,-2.1999998092651367,-3.507655143737793)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M0,-1.5035200119018555 C0.8298037648200989,-1.5035200119018555 1.5035400390625,-0.8297926783561707 1.5035400390625,0 C1.5035400390625,0.8297926783561707 0.8298037648200989,1.5035200119018555 0,1.5035200119018555 C-0.8298037648200989,1.5035200119018555 -1.5035400390625,0.8297926783561707 -1.5035400390625,0 C-1.5035400390625,-0.8297926783561707 -0.8298037648200989,-1.5035200119018555 0,-1.5035200119018555z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
{/* Moon SVG */}
|
||||
<g style={{ display: 'block' }} transform="matrix(-1,0,0,-1,10,10)" opacity="1">
|
||||
<g opacity="1" transform="matrix(-1,0,0,-1,3.75,5.5)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M2.660290002822876,2.2502501010894775 C2.7567598819732666,2.2502501010894775 2.850860118865967,2.241950035095215 2.9425699710845947,2.225330114364624 C3.034290075302124,2.208709955215454 3.1081299781799316,2.1867599487304688 3.164109945297241,2.1594600677490234 C3.239150047302246,2.120300054550171 3.305850028991699,2.100709915161133 3.364219903945923,2.100709915161133 C3.405900001525879,2.100709915161133 3.438659906387329,2.113770008087158 3.462480068206787,2.1398799419403076 C3.487489938735962,2.165990114212036 3.5,2.2009999752044678 3.5,2.2449100017547607 C3.5,2.2698400020599365 3.4958300590515137,2.2983200550079346 3.487489938735962,2.3303699493408203 C3.4803500175476074,2.362410068511963 3.468440055847168,2.3968300819396973 3.4517600536346436,2.433619976043701 C3.3803000450134277,2.5950300693511963 3.287990093231201,2.7410099506378174 3.1748299598693848,2.871570110321045 C3.0628700256347656,3.002120018005371 2.9348299503326416,3.1142799854278564 2.790709972381592,3.2080399990081787 C2.646589994430542,3.3029799461364746 2.4905600547790527,3.375380039215088 2.3226099014282227,3.425230026245117 C2.15585994720459,3.4750800132751465 1.9825600385665894,3.5 1.8027100563049316,3.5 C1.5430500507354736,3.5 1.3036400079727173,3.4554901123046875 1.0844800472259521,3.3664801120758057 C0.8653200268745422,3.2786500453948975 0.6741499900817871,3.1540400981903076 0.5109699964523315,2.9926199913024902 C0.34898999333381653,2.831209897994995 0.22333000600337982,2.641319990158081 0.1340000033378601,2.4229400157928467 C0.04467000067234039,2.2045600414276123 0,1.9660099744796753 0,1.7072700262069702 C0,1.4639699459075928 0.04645000025629997,1.2325400114059448 0.1393599957227707,1.012969970703125 C0.23226000368595123,0.7922199964523315 0.3626900017261505,0.5975800156593323 0.5306299924850464,0.4290440082550049 C0.6997600197792053,0.2593249976634979 0.8968899846076965,0.12877200543880463 1.121999979019165,0.03738600015640259 C1.1541600227355957,0.024329999461770058 1.1833399534225464,0.01483600027859211 1.2095500230789185,0.008901000022888184 C1.2369400262832642,0.0029670000076293945 1.2631399631500244,2.220446049250313e-16 1.288159966468811,2.220446049250313e-16 C1.335800051689148,2.220446049250313e-16 1.3733199834823608,0.014241999946534634 1.4007099866867065,0.042725998908281326 C1.4292999505996704,0.07121100276708603 1.4435900449752808,0.10681600123643875 1.4435900449752808,0.14954200387001038 C1.4435900449752808,0.1780260056257248 1.438230037689209,0.2076980024576187 1.4275100231170654,0.23855499923229218 C1.41798996925354,0.2682270109653473 1.404289960861206,0.2996779978275299 1.3864200115203857,0.3329089879989624 C1.3625999689102173,0.3768230080604553 1.3423500061035156,0.4302310049533844 1.3256800174713135,0.493133008480072 C1.309000015258789,0.5548499822616577 1.296489953994751,0.6225000023841858 1.288159966468811,0.6960800290107727 C1.2798199653625488,0.7684800028800964 1.2756500244140625,0.8414700031280518 1.2756500244140625,0.9150599837303162 C1.2756500244140625,1.1215699911117554 1.3072099685668945,1.3073099851608276 1.3703399896621704,1.4722800254821777 C1.4346599578857422,1.6372499465942383 1.5269700288772583,1.7778899669647217 1.6472699642181396,1.8941999673843384 C1.7675700187683105,2.0093300342559814 1.9128799438476562,2.097749948501587 2.083209991455078,2.1594600677490234 C2.2547199726104736,2.2199900150299072 2.44707989692688,2.2502501010894775 2.660290002822876,2.2502501010894775 C2.660290002822876,2.2502501010894775 2.660290002822876,2.2502501010894775 2.660290002822876,2.2502501010894775 C2.660290002822876,2.2502501010894775 2.660290002822876,2.2502501010894775 2.660290002822876,2.2502501010894775"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
|
||||
</div>
|
||||
</div>
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => setShowDetails((prev) => !prev)}
|
||||
inProgressText="Creating Image"
|
||||
finishedText="Finished."
|
||||
hasInput={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import CancelledIcon from './CancelledIcon';
|
||||
|
||||
export default function InProgressCall({
|
||||
error,
|
||||
isSubmitting,
|
||||
progress,
|
||||
children,
|
||||
}: {
|
||||
error?: boolean;
|
||||
isSubmitting: boolean;
|
||||
progress: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if ((!isSubmitting && progress < 1) || error) {
|
||||
return <CancelledIcon />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ import store from '~/store';
|
|||
|
||||
type TCodeProps = {
|
||||
inline: boolean;
|
||||
className: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ type TContentProps = {
|
|||
showCursor?: boolean;
|
||||
};
|
||||
|
||||
const code = memo(({ inline, className, children }: TCodeProps) => {
|
||||
export const code = memo(({ inline, className, children }: TCodeProps) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const lang = match && match[1];
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ const code = memo(({ inline, className, children }: TCodeProps) => {
|
|||
}
|
||||
});
|
||||
|
||||
const p = memo(({ children }: { children: React.ReactNode }) => {
|
||||
export const p = memo(({ children }: { children: React.ReactNode }) => {
|
||||
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
||||
});
|
||||
|
||||
|
|
|
|||
44
client/src/components/Chat/Messages/Content/MarkdownLite.tsx
Normal file
44
client/src/components/Chat/Messages/Content/MarkdownLite.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { memo } from 'react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import supersub from 'remark-supersub';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import type { PluggableList } from 'unified';
|
||||
import { langSubset } from '~/utils';
|
||||
import { code, p } from './Markdown';
|
||||
|
||||
const MarkdownLite = memo(({ content = '' }: { content?: string }) => {
|
||||
const rehypePlugins: PluggableList = [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: langSubset,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
linkTarget="_new"
|
||||
components={
|
||||
{
|
||||
code,
|
||||
p,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
}
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
|
||||
export default MarkdownLite;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Fragment, Suspense } from 'react';
|
||||
import type { TResPlugin } from 'librechat-data-provider';
|
||||
import type { TMessageContent, TText, TDisplayProps } from '~/common';
|
||||
import type { TResPlugin, TFile } from 'librechat-data-provider';
|
||||
import type { TMessageContentProps, TText, TDisplayProps } from '~/common';
|
||||
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
||||
import Plugin from '~/components/Messages/Content/Plugin';
|
||||
import Error from '~/components/Messages/Content/Error';
|
||||
import { DelayedRender } from '~/components/ui';
|
||||
|
|
@ -11,7 +12,7 @@ import Markdown from './Markdown';
|
|||
import { cn } from '~/utils';
|
||||
import Image from './Image';
|
||||
|
||||
const ErrorMessage = ({ text }: TText) => {
|
||||
export const ErrorMessage = ({ text }: TText) => {
|
||||
const { logout } = useAuthContext();
|
||||
|
||||
if (text.includes('ban')) {
|
||||
|
|
@ -29,16 +30,24 @@ const ErrorMessage = ({ text }: TText) => {
|
|||
|
||||
// Display Message Component
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
||||
const files: TFile[] = [];
|
||||
const imageFiles = message?.files
|
||||
? message.files.filter((file) => file.type && file.type.startsWith('image/'))
|
||||
? message.files.filter((file) => {
|
||||
if (file.type && file.type.startsWith('image/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
files.push(file);
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<Container>
|
||||
{files.length > 0 && files.map((file) => <FileContainer key={file.file_id} file={file} />)}
|
||||
{imageFiles &&
|
||||
imageFiles.map((file) => (
|
||||
<Image
|
||||
key={file.file_id}
|
||||
imagePath={file.preview ?? file.filepath ?? ''}
|
||||
imagePath={file?.preview ?? file.filepath ?? ''}
|
||||
height={file.height ?? 1920}
|
||||
width={file.width ?? 1080}
|
||||
altText={file.filename ?? 'Uploaded Image'}
|
||||
|
|
@ -63,8 +72,8 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay
|
|||
};
|
||||
|
||||
// Unfinished Message Component
|
||||
const UnfinishedMessage = () => (
|
||||
<ErrorMessage text="The response is incomplete; it's either still processing, was cancelled, or censored. Refresh or try a different prompt." />
|
||||
export const UnfinishedMessage = () => (
|
||||
<ErrorMessage text="The response is incomplete; it's either still processing, was cancelled, or censoreded. Refresh or try a different prompt." />
|
||||
);
|
||||
|
||||
// Content Component
|
||||
|
|
@ -76,7 +85,7 @@ const MessageContent = ({
|
|||
isSubmitting,
|
||||
isLast,
|
||||
...props
|
||||
}: TMessageContent) => {
|
||||
}: TMessageContentProps) => {
|
||||
if (error) {
|
||||
return <ErrorMessage text={text} />;
|
||||
} else if (edit) {
|
||||
|
|
|
|||
124
client/src/components/Chat/Messages/Content/Part.tsx
Normal file
124
client/src/components/Chat/Messages/Content/Part.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { ToolCallTypes, ContentTypes, imageGenTools } from 'librechat-data-provider';
|
||||
import type { TMessageContentParts, TMessage } from 'librechat-data-provider';
|
||||
import type { TDisplayProps } from '~/common';
|
||||
import RetrievalCall from './RetrievalCall';
|
||||
import CodeAnalyze from './CodeAnalyze';
|
||||
import Container from './Container';
|
||||
import ToolCall from './ToolCall';
|
||||
import Markdown from './Markdown';
|
||||
import ImageGen from './ImageGen';
|
||||
import Image from './Image';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
// import EditMessage from './EditMessage';
|
||||
|
||||
// Display Message Component
|
||||
const DisplayMessage = ({ text, isCreatedByUser = false, message, showCursor }: TDisplayProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'markdown prose dark:prose-invert light w-full break-words',
|
||||
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
|
||||
)}
|
||||
>
|
||||
{!isCreatedByUser ? (
|
||||
<Markdown content={text} message={message} showCursor={showCursor} />
|
||||
) : (
|
||||
<>{text}</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Part({
|
||||
part,
|
||||
showCursor,
|
||||
isSubmitting,
|
||||
message,
|
||||
}: {
|
||||
part: TMessageContentParts;
|
||||
isSubmitting: boolean;
|
||||
showCursor: boolean;
|
||||
message: TMessage;
|
||||
}) {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
if (part.type === ContentTypes.TEXT) {
|
||||
// Access the value property
|
||||
return (
|
||||
<Container>
|
||||
<div className="markdown prose dark:prose-invert light my-1 w-full break-words dark:text-gray-70">
|
||||
<DisplayMessage
|
||||
text={part[ContentTypes.TEXT].value}
|
||||
isCreatedByUser={message.isCreatedByUser}
|
||||
message={message}
|
||||
showCursor={showCursor}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
} else if (
|
||||
part.type === ContentTypes.TOOL_CALL &&
|
||||
part[ContentTypes.TOOL_CALL].type === ToolCallTypes.CODE_INTERPRETER
|
||||
) {
|
||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
|
||||
return (
|
||||
<CodeAnalyze
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
code={code_interpreter.input}
|
||||
outputs={code_interpreter.outputs ?? []}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
part.type === ContentTypes.TOOL_CALL &&
|
||||
part[ContentTypes.TOOL_CALL].type === ToolCallTypes.RETRIEVAL
|
||||
) {
|
||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||
return <RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />;
|
||||
} else if (
|
||||
part.type === ContentTypes.TOOL_CALL &&
|
||||
part[ContentTypes.TOOL_CALL].type === ToolCallTypes.FUNCTION &&
|
||||
imageGenTools.has(part[ContentTypes.TOOL_CALL].function.name)
|
||||
) {
|
||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||
return (
|
||||
<ImageGen initialProgress={toolCall.progress ?? 0.1} args={toolCall.function.arguments} />
|
||||
);
|
||||
} else if (
|
||||
part.type === ContentTypes.TOOL_CALL &&
|
||||
part[ContentTypes.TOOL_CALL].type === ToolCallTypes.FUNCTION
|
||||
) {
|
||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||
return (
|
||||
<ToolCall
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
args={toolCall.function.arguments}
|
||||
name={toolCall.function.name}
|
||||
output={toolCall.function.output}
|
||||
/>
|
||||
);
|
||||
} else if (part.type === ContentTypes.IMAGE_FILE) {
|
||||
const imageFile = part[ContentTypes.IMAGE_FILE];
|
||||
const height = imageFile.height ?? 1920;
|
||||
const width = imageFile.width ?? 1080;
|
||||
return (
|
||||
<Image
|
||||
imagePath={imageFile.filepath}
|
||||
height={height}
|
||||
width={width}
|
||||
altText={imageFile.filename ?? 'Uploaded Image'}
|
||||
placeholderDimensions={{
|
||||
height: height + 'px',
|
||||
width: width + 'px',
|
||||
}}
|
||||
// n={imageFiles.length}
|
||||
// i={i}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
export default function ProgressCircle({
|
||||
radius,
|
||||
circumference,
|
||||
offset,
|
||||
}: {
|
||||
radius: number;
|
||||
circumference: number;
|
||||
offset: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 120 120"
|
||||
className="absolute left-1/2 top-1/2 h-[23px] w-[23px] -translate-x-1/2 -translate-y-1/2 text-brand-purple"
|
||||
>
|
||||
<circle
|
||||
className="origin-[50%_50%] -rotate-90 stroke-brand-purple/25 dark:stroke-brand-purple/50"
|
||||
strokeWidth="7.826086956521739"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="60"
|
||||
cy="60"
|
||||
/>
|
||||
<circle
|
||||
className="origin-[50%_50%] -rotate-90 transition-[stroke-dashoffset]"
|
||||
stroke="currentColor"
|
||||
strokeWidth="7.826086956521739"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="60"
|
||||
cy="60"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
71
client/src/components/Chat/Messages/Content/ProgressText.tsx
Normal file
71
client/src/components/Chat/Messages/Content/ProgressText.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Wrapper = ({ popover, children }: { popover: boolean; children: React.ReactNode }) => {
|
||||
if (popover) {
|
||||
return (
|
||||
<div className="text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5">
|
||||
<Popover.Trigger asChild>
|
||||
<div
|
||||
className="absolute left-0 top-0 line-clamp-1 overflow-visible"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="78"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5">
|
||||
<div
|
||||
className="absolute left-0 top-0 line-clamp-1 overflow-visible"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="78"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ProgressText({
|
||||
progress,
|
||||
onClick,
|
||||
inProgressText,
|
||||
finishedText,
|
||||
hasInput = true,
|
||||
popover = false,
|
||||
}: {
|
||||
progress: number;
|
||||
onClick: () => void;
|
||||
inProgressText: string;
|
||||
finishedText: string;
|
||||
hasInput?: boolean;
|
||||
popover?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Wrapper popover={popover}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex items-center gap-1', hasInput ? '' : 'pointer-events-none')}
|
||||
disabled={!hasInput}
|
||||
onClick={onClick}
|
||||
>
|
||||
{progress < 1 ? inProgressText : finishedText}
|
||||
<svg width="16" height="17" viewBox="0 0 16 17" fill="none">
|
||||
<path
|
||||
className={hasInput ? '' : 'stroke-transparent'}
|
||||
d="M11.3346 7.83203L8.00131 11.1654L4.66797 7.83203"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import ProgressCircle from './ProgressCircle';
|
||||
import InProgressCall from './InProgressCall';
|
||||
import RetrievalIcon from './RetrievalIcon';
|
||||
import CancelledIcon from './CancelledIcon';
|
||||
import ProgressText from './ProgressText';
|
||||
import FinishedIcon from './FinishedIcon';
|
||||
import { useProgress } from '~/hooks';
|
||||
|
||||
export default function RetrievalCall({
|
||||
initialProgress = 0.1,
|
||||
isSubmitting,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const progress = useProgress(initialProgress);
|
||||
const radius = 56.08695652173913;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - progress * circumference;
|
||||
const error = progress >= 2;
|
||||
|
||||
return (
|
||||
<div className="my-2.5 flex items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">
|
||||
{progress < 1 ? (
|
||||
<InProgressCall progress={progress} isSubmitting={isSubmitting} error={error}>
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
>
|
||||
<div>
|
||||
<RetrievalIcon />
|
||||
</div>
|
||||
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
|
||||
</div>
|
||||
</InProgressCall>
|
||||
) : error ? (
|
||||
<CancelledIcon />
|
||||
) : (
|
||||
<FinishedIcon />
|
||||
)}
|
||||
</div>
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => ({})}
|
||||
inProgressText={'Searching my knowledge'}
|
||||
finishedText={'Used Retrieval'}
|
||||
hasInput={false}
|
||||
popover={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
export default function RetrievalIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
height="20"
|
||||
style={{ width: '100%', height: '100%', transform: 'translate3d(0px, 0px, 0px)' }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="__lottie_element_258">
|
||||
<rect width="20" height="20" x="0" y="0" />
|
||||
</clipPath>
|
||||
<clipPath id="__lottie_element_263">
|
||||
<path d="M0,0 L20000,0 L20000,20000 L0,20000z" />
|
||||
</clipPath>
|
||||
<clipPath id="__lottie_element_270">
|
||||
<path d="M0,0 L20000,0 L20000,20000 L0,20000z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#__lottie_element_258)">
|
||||
<g
|
||||
clipPath="url(#__lottie_element_263)"
|
||||
style={{ display: 'block' }}
|
||||
transform="matrix(0.9999999403953552,0,0,0.9999999403953552,-10000,-10000)"
|
||||
opacity="1"
|
||||
>
|
||||
<g
|
||||
clipPath="url(#__lottie_element_270)"
|
||||
style={{ display: 'block' }}
|
||||
transform="matrix(0.9999988675117493,0,0,0.9999988675117493,10.01171875,10.01171875)"
|
||||
opacity="1"
|
||||
>
|
||||
<g style={{ display: 'block' }} transform="matrix(1,0,0,1,10000,10000)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,-3.25,-2.125)">
|
||||
<path
|
||||
fill="rgb(177,97,253)"
|
||||
fillOpacity="1"
|
||||
d=" M0,0.75 C0,0.3357900083065033 0.3357900083065033,8.881784197001252e-16 0.75,8.881784197001252e-16 C2.4166667461395264,8.881784197001252e-16 4.083333492279053,8.881784197001252e-16 5.75,8.881784197001252e-16 C6.1641998291015625,8.881784197001252e-16 6.5,0.3357900083065033 6.5,0.75 C6.5,1.1642099618911743 6.1641998291015625,1.5 5.75,1.5 C4.083333492279053,1.5 2.4166667461395264,1.5 0.75,1.5 C0.3357900083065033,1.5 0,1.1642099618911743 0,0.75 C0,0.75 0,0.75 0,0.75 C0,0.75 0,0.75 0,0.75"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block' }} transform="matrix(1,0,0,1,10000,10000)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,-3.25,0.625)">
|
||||
<path
|
||||
fill="rgb(177,97,253)"
|
||||
fillOpacity="1"
|
||||
d=" M0,0.75 C0,0.3357999920845032 0.3357900083065033,0 0.75,0 C1.9166666269302368,0 3.0833332538604736,0 4.25,0 C4.6641998291015625,0 5,0.3357999920845032 5,0.75 C5,1.164199948310852 4.6641998291015625,1.5 4.25,1.5 C3.0833332538604736,1.5 1.9166666269302368,1.5 0.75,1.5 C0.3357900083065033,1.5 0,1.164199948310852 0,0.75 C0,0.75 0,0.75 0,0.75 C0,0.75 0,0.75 0,0.75"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block' }} transform="matrix(1,0,0,1,10000,10000)" opacity="1">
|
||||
<g opacity="1" transform="matrix(1,0,0,1,10,10)">
|
||||
<path
|
||||
fill="rgb(177,97,253)"
|
||||
fillOpacity="1"
|
||||
d=" M-3.13,-5.25 C-3.09,-5.25 -3.04,-5.25 -3,-5.25 C-2.59,-5.25 -2.25,-4.91 -2.25,-4.5 C-2.25,-4.09 -2.59,-3.75 -3,-3.75 C-3.03,-3.75 -3.07,-3.75 -3.1,-3.75 C-3.53,-3.75 -3.81,-3.75 -4.02,-3.73 C-4.23,-3.72 -4.3,-3.69 -4.34,-3.67 C-4.48,-3.6 -4.6,-3.48 -4.67,-3.34 C-4.69,-3.3 -4.72,-3.23 -4.73,-3.02 C-4.75,-2.81 -4.75,-2.53 -4.75,-2.1 C-4.75,-2.07 -4.75,-2.03 -4.75,-2 C-4.75,-1.59 -5.09,-1.25 -5.5,-1.25 C-5.91,-1.25 -6.25,-1.59 -6.25,-2 C-6.25,-2.04 -6.25,-2.09 -6.25,-2.13 C-6.25,-2.52 -6.25,-2.87 -6.23,-3.15 C-6.2,-3.44 -6.15,-3.74 -6,-4.02 C-5.79,-4.44 -5.44,-4.79 -5.02,-5 C-4.74,-5.15 -4.44,-5.2 -4.15,-5.23 C-3.87,-5.25 -3.52,-5.25 -3.13,-5.25 C-3.13,-5.25 -3.13,-5.25 -3.13,-5.25 C-3.13,-5.25 -3.13,-5.25 -3.13,-5.25 M6.25,-2.13 C6.25,-2.09 6.25,-2.04 6.25,-2 C6.25,-1.59 5.91,-1.25 5.5,-1.25 C5.09,-1.25 4.75,-1.59 4.75,-2 C4.75,-2.03 4.75,-2.07 4.75,-2.1 C4.75,-2.53 4.75,-2.81 4.73,-3.02 C4.72,-3.23 4.69,-3.3 4.67,-3.34 C4.6,-3.48 4.48,-3.6 4.34,-3.67 C4.3,-3.69 4.23,-3.72 4.02,-3.73 C3.81,-3.75 3.53,-3.75 3.1,-3.75 C3.07,-3.75 3.03,-3.75 3,-3.75 C2.59,-3.75 2.25,-4.09 2.25,-4.5 C2.25,-4.91 2.59,-5.25 3,-5.25 C3.04,-5.25 3.09,-5.25 3.13,-5.25 C3.52,-5.25 3.87,-5.25 4.15,-5.23 C4.44,-5.2 4.74,-5.15 5.02,-5 C5.44,-4.79 5.79,-4.44 6,-4.02 C6.15,-3.74 6.2,-3.44 6.23,-3.15 C6.25,-2.87 6.25,-2.52 6.25,-2.13 C6.25,-2.13 6.25,-2.13 6.25,-2.13 C6.25,-2.13 6.25,-2.13 6.25,-2.13 M-3.13,5.25 C-3.09,5.25 -3.04,5.25 -3,5.25 C-2.59,5.25 -2.25,4.91 -2.25,4.5 C-2.25,4.09 -2.59,3.75 -3,3.75 C-3.03,3.75 -3.07,3.75 -3.1,3.75 C-3.53,3.75 -3.81,3.75 -4.02,3.73 C-4.23,3.72 -4.3,3.69 -4.34,3.67 C-4.48,3.6 -4.6,3.48 -4.67,3.34 C-4.69,3.3 -4.72,3.23 -4.73,3.02 C-4.75,2.81 -4.75,2.53 -4.75,2.1 C-4.75,2.07 -4.75,2.03 -4.75,2 C-4.75,1.59 -5.09,1.25 -5.5,1.25 C-5.91,1.25 -6.25,1.59 -6.25,2 C-6.25,2.04 -6.25,2.09 -6.25,2.13 C-6.25,2.52 -6.25,2.87 -6.23,3.15 C-6.2,3.44 -6.15,3.74 -6,4.02 C-5.79,4.44 -5.44,4.79 -5.02,5 C-4.74,5.15 -4.44,5.2 -4.15,5.23 C-3.87,5.25 -3.52,5.25 -3.13,5.25 C-3.13,5.25 -3.13,5.25 -3.13,5.25 C-3.13,5.25 -3.13,5.25 -3.13,5.25 M6.25,2.13 C6.25,2.09 6.25,2.04 6.25,2 C6.25,1.59 5.91,1.25 5.5,1.25 C5.09,1.25 4.75,1.59 4.75,2 C4.75,2.03 4.75,2.07 4.75,2.1 C4.75,2.53 4.75,2.81 4.73,3.02 C4.72,3.23 4.69,3.3 4.67,3.34 C4.6,3.48 4.48,3.6 4.34,3.67 C4.3,3.69 4.23,3.72 4.02,3.73 C3.81,3.75 3.53,3.75 3.1,3.75 C3.07,3.75 3.03,3.75 3,3.75 C2.59,3.75 2.25,4.09 2.25,4.5 C2.25,4.91 2.59,5.25 3,5.25 C3.04,5.25 3.09,5.25 3.13,5.25 C3.52,5.25 3.87,5.25 4.15,5.23 C4.44,5.2 4.74,5.15 5.02,5 C5.44,4.79 5.79,4.44 6,4.02 C6.15,3.74 6.2,3.44 6.23,3.15 C6.25,2.87 6.25,2.52 6.25,2.13 C6.25,2.13 6.25,2.13 6.25,2.13 C6.25,2.13 6.25,2.13 6.25,2.13"
|
||||
/>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,-6.250000476837158,-5.250000476837158)">
|
||||
<path fill="rgb(177,97,253)" fillOpacity="1" d="M0 0" />
|
||||
</g>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,2.25,-5.250000476837158)">
|
||||
<path fill="rgb(177,97,253)" fillOpacity="1" d="M0 0" />
|
||||
</g>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,-6.250000476837158,1.25)">
|
||||
<path fill="rgb(177,97,253)" fillOpacity="1" d="M0 0" />
|
||||
</g>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,2.25,1.25)">
|
||||
<path fill="rgb(177,97,253)" fillOpacity="1" d="M0 0" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
72
client/src/components/Chat/Messages/Content/ToolCall.tsx
Normal file
72
client/src/components/Chat/Messages/Content/ToolCall.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// import { useState, useEffect } from 'react';
|
||||
import { actionDelimiter } from 'librechat-data-provider';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import ProgressCircle from './ProgressCircle';
|
||||
import InProgressCall from './InProgressCall';
|
||||
import CancelledIcon from './CancelledIcon';
|
||||
import ProgressText from './ProgressText';
|
||||
import FinishedIcon from './FinishedIcon';
|
||||
import ToolPopover from './ToolPopover';
|
||||
// import ActionIcon from './ActionIcon';
|
||||
import WrenchIcon from './WrenchIcon';
|
||||
import { useProgress } from '~/hooks';
|
||||
|
||||
export default function ToolCall({
|
||||
initialProgress = 0.1,
|
||||
isSubmitting,
|
||||
name,
|
||||
args = '',
|
||||
output,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isSubmitting: boolean;
|
||||
name: string;
|
||||
args: string;
|
||||
output?: string | null;
|
||||
}) {
|
||||
const progress = useProgress(initialProgress);
|
||||
const radius = 56.08695652173913;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
const [function_name, domain] = name.split(actionDelimiter);
|
||||
const error = output?.toLowerCase()?.includes('error processing tool');
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<div className="my-2.5 flex items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">
|
||||
{progress < 1 ? (
|
||||
<InProgressCall progress={progress} isSubmitting={isSubmitting} error={error}>
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="849"
|
||||
>
|
||||
<div>
|
||||
<WrenchIcon />
|
||||
</div>
|
||||
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
|
||||
</div>
|
||||
</InProgressCall>
|
||||
) : error ? (
|
||||
<CancelledIcon />
|
||||
) : (
|
||||
<FinishedIcon />
|
||||
)}
|
||||
</div>
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => ({})}
|
||||
inProgressText={'Running action'}
|
||||
finishedText={domain ? `Talked to ${domain}` : `Ran ${function_name}`}
|
||||
hasInput={!!args?.length}
|
||||
popover={true}
|
||||
/>
|
||||
{!!args?.length && (
|
||||
<ToolPopover input={args} output={output} domain={domain} function_name={function_name} />
|
||||
)}
|
||||
</div>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
56
client/src/components/Chat/Messages/Content/ToolPopover.tsx
Normal file
56
client/src/components/Chat/Messages/Content/ToolPopover.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import * as Popover from '@radix-ui/react-popover';
|
||||
|
||||
export default function ToolPopover({
|
||||
input,
|
||||
output,
|
||||
function_name,
|
||||
domain,
|
||||
}: {
|
||||
input: string;
|
||||
function_name: string;
|
||||
output?: string | null;
|
||||
domain?: string;
|
||||
}) {
|
||||
const formatText = (text: string) => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
alignOffset={-5}
|
||||
className="w-18 min-w-[180px] max-w-sm rounded-lg bg-white dark:bg-gray-900"
|
||||
>
|
||||
<div tabIndex={-1}>
|
||||
<div className="bg-token-surface-primary max-w-sm rounded-md p-2 shadow-[0_0_24px_0_rgba(0,0,0,0.05),inset_0_0.5px_0_0_rgba(0,0,0,0.05),0_2px_8px_0_rgba(0,0,0,0.05)]">
|
||||
<div className="mb-2 text-sm font-medium dark:text-gray-100">
|
||||
{domain ? 'Assistant sent this info to ' + domain : `Assistant used ${function_name}`}
|
||||
</div>
|
||||
<div className="bg-token-surface-secondary text-token-text-primary dark rounded-md text-xs">
|
||||
<div className="max-h-32 overflow-y-auto rounded-md p-2 dark:bg-gray-700">
|
||||
<code className="!whitespace-pre-wrap ">{formatText(input)}</code>
|
||||
</div>
|
||||
</div>
|
||||
{output && (
|
||||
<>
|
||||
<div className="mb-2 mt-2 text-sm font-medium dark:text-gray-100">Result</div>
|
||||
<div className="bg-token-surface-secondary text-token-text-primary dark rounded-md text-xs">
|
||||
<div className="max-h-32 overflow-y-auto rounded-md p-2 dark:bg-gray-700">
|
||||
<code className="!whitespace-pre-wrap ">{formatText(output)}</code>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
);
|
||||
}
|
||||
77
client/src/components/Chat/Messages/Content/WrenchIcon.tsx
Normal file
77
client/src/components/Chat/Messages/Content/WrenchIcon.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export default function WrenchIcon() {
|
||||
const [rotate, setRotate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setRotate((r) => !r);
|
||||
}, 2000); // Change 2000 to the duration you want for each pause
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={rotate ? 'rotate' : ''}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
style={{ width: '100%', height: '100%', transform: 'rotate(30deg)' }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="__lottie_element_28">
|
||||
<rect width="24" height="24" x="0" y="0"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#__lottie_element_28)">
|
||||
<g style={{ display: 'block', transform: 'matrix(1,0,0,1,0,0)' }} opacity="1">
|
||||
<g opacity="0.25" transform="matrix(-1,0,0,-1,12,12)">
|
||||
<path
|
||||
fill="rgb(178,98,254)"
|
||||
fillOpacity="1"
|
||||
d=" M0,-12 C6.622799873352051,-12 12,-6.622799873352051 12,0 C12,6.622799873352051 6.622799873352051,12 0,12 C-6.622799873352051,12 -12,6.622799873352051 -12,0 C-12,-6.622799873352051 -6.622799873352051,-12 0,-12z"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block', transform: 'matrix(1,0,0,1,0,0)' }} opacity="1">
|
||||
<g opacity="1" transform="matrix(0,1,-1,0,12,12)">
|
||||
<path
|
||||
fill="rgb(52,53,64)"
|
||||
fillOpacity="1"
|
||||
d=" M0,-10 C5.522847652435303,-10 10,-5.522847652435303 10,0 C10,5.522847652435303 5.522847652435303,10 0,10 C-5.522847652435303,10 -10,5.522847652435303 -10,0 C-10,-5.522847652435303 -5.522847652435303,-10 0,-10"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block', transform: 'matrix(1,0,0,1,0,0)' }} opacity="1">
|
||||
<g
|
||||
opacity="1"
|
||||
transform="matrix(0.8995603322982788,-0.8114914894104004,0.8114914894104004,0.8995603322982788,1.0572385787963867,13.542327880859375)"
|
||||
>
|
||||
<path
|
||||
fill="rgb(178,98,254)"
|
||||
fillOpacity="1"
|
||||
d=" M8.648597717285156,0.11783526837825775 C9.091397285461426,0.2144152671098709 9.200997352600098,0.7544552683830261 8.880497932434082,1.0749353170394897 C8.137197494506836,1.8182452917099 7.393897533416748,2.5615553855895996 6.65059757232666,3.3048653602600098 C5.901897430419922,4.053555488586426 5.901897430419922,5.267405033111572 6.65059757232666,6.0161051750183105 C7.399197578430176,6.764805316925049 8.613097190856934,6.764805316925049 9.361797332763672,6.0161051750183105 C10.105097770690918,5.2727952003479 10.848397254943848,4.52948522567749 11.591697692871094,3.78617525100708 C11.91219711303711,3.465695381164551 12.452197074890137,3.5752952098846436 12.548797607421875,4.018115043640137 C12.907397270202637,5.6623053550720215 12.44759750366211,7.449105262756348 11.169297218322754,8.727405548095703 C9.65219783782959,10.244504928588867 7.418797492980957,10.608805656433105 5.557697296142578,9.820205688476562 C4.796051025390625,10.581838607788086 4.034404277801514,11.34347152709961 3.2727575302124023,12.10510540008545 C2.5240674018859863,12.853805541992188 1.310207486152649,12.853805541992188 0.5615174770355225,12.10510540008545 C-0.18717250227928162,11.356505393981934 -0.18717250227928162,10.14260482788086 0.5615174770355225,9.393905639648438 C1.3231642246246338,8.632271766662598 2.084810733795166,7.870638370513916 2.8464574813842773,7.109005451202393 C2.0579075813293457,5.247905254364014 2.4221975803375244,3.014495372772217 3.939307451248169,1.497375249862671 C5.217597484588623,0.21908527612686157 7.004397392272949,-0.24075473845005035 8.648597717285156,0.11783526837825775 C8.648597717285156,0.11783526837825775 8.648597717285156,0.11783526837825775 8.648597717285156,0.11783526837825775 C8.648597717285156,0.11783526837825775 8.648597717285156,0.11783526837825775 8.648597717285156,0.11783526837825775"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
<g style={{ display: 'block', transform: 'matrix(1,0,0,1,0,0)' }} opacity="1">
|
||||
<g opacity="1" transform="matrix(-1,0,0,-1,12,12)">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(52,53,64)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="2"
|
||||
d=" M0,-11 C6.070899963378906,-11 11,-6.070899963378906 11,0 C11,6.070899963378906 6.070899963378906,11 0,11 C-6.070899963378906,11 -11,6.070899963378906 -11,0 C-11,-6.070899963378906 -6.070899963378906,-11 0,-11z"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ export default function Message(props: TMessageProps) {
|
|||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
||||
<div>
|
||||
<div className="pt-0.5">
|
||||
<div className="gizmo-shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
|
||||
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
|
||||
) : (
|
||||
|
|
|
|||
123
client/src/components/Chat/Messages/MessageParts.tsx
Normal file
123
client/src/components/Chat/Messages/MessageParts.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import ContentParts from './Content/ContentParts';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import SiblingSwitch from './SiblingSwitch';
|
||||
import { useMessageHelpers } from '~/hooks';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import MultiMessage from './MultiMessage';
|
||||
import HoverButtons from './HoverButtons';
|
||||
import SubRow from './SubRow';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
|
||||
props;
|
||||
|
||||
const {
|
||||
ask,
|
||||
icon,
|
||||
edit,
|
||||
isLast,
|
||||
enterEdit,
|
||||
assistant,
|
||||
handleScroll,
|
||||
conversation,
|
||||
isSubmitting,
|
||||
latestMessage,
|
||||
handleContinue,
|
||||
copyToClipboard,
|
||||
regenerateMessage,
|
||||
} = useMessageHelpers(props);
|
||||
|
||||
const { content, children, messageId = null, isCreatedByUser, error, unfinished } = message ?? {};
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
|
||||
onWheel={handleScroll}
|
||||
onTouchMove={handleScroll}
|
||||
>
|
||||
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 ">
|
||||
<div className="} group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
||||
<div>
|
||||
<div className="pt-0.5">
|
||||
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
|
||||
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn('relative flex w-full flex-col', isCreatedByUser ? '' : 'agent-turn')}
|
||||
>
|
||||
<div className="select-none font-semibold">
|
||||
{isCreatedByUser ? 'You' : (assistant && assistant?.name) ?? 'Assistant'}
|
||||
</div>
|
||||
<div className="flex-col gap-1 md:gap-3">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
<ContentParts
|
||||
ask={ask}
|
||||
edit={edit}
|
||||
isLast={isLast}
|
||||
content={content ?? []}
|
||||
message={message}
|
||||
messageId={messageId}
|
||||
enterEdit={enterEdit}
|
||||
error={!!error}
|
||||
isSubmitting={isSubmitting}
|
||||
unfinished={unfinished ?? false}
|
||||
isCreatedByUser={isCreatedByUser ?? true}
|
||||
siblingIdx={siblingIdx ?? 0}
|
||||
setSiblingIdx={
|
||||
setSiblingIdx ??
|
||||
(() => {
|
||||
return;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isLast && isSubmitting ? null : (
|
||||
<SubRow classes="text-xs">
|
||||
<SiblingSwitch
|
||||
siblingIdx={siblingIdx}
|
||||
siblingCount={siblingCount}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
/>
|
||||
<HoverButtons
|
||||
isEditing={edit}
|
||||
message={message}
|
||||
enterEdit={enterEdit}
|
||||
isSubmitting={isSubmitting}
|
||||
conversation={conversation ?? null}
|
||||
regenerate={() => regenerateMessage()}
|
||||
copyToClipboard={copyToClipboard}
|
||||
handleContinue={handleContinue}
|
||||
latestMessage={latestMessage}
|
||||
/>
|
||||
</SubRow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiMessage
|
||||
key={messageId}
|
||||
messageId={messageId}
|
||||
conversation={conversation}
|
||||
messagesTree={children ?? []}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import { useRecoilState } from 'recoil';
|
|||
import type { TMessageProps } from '~/common';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import Message from './Message';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import MessageParts from './MessageParts';
|
||||
import store from '~/store';
|
||||
|
||||
export default function MultiMessage({
|
||||
|
|
@ -40,6 +42,20 @@ export default function MultiMessage({
|
|||
return null;
|
||||
}
|
||||
|
||||
if (message.content) {
|
||||
return (
|
||||
<MessageParts
|
||||
key={message.messageId}
|
||||
message={message}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
siblingIdx={messagesTree.length - siblingIdx - 1}
|
||||
siblingCount={messagesTree.length}
|
||||
setSiblingIdx={setSiblingIdxRev}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Message
|
||||
key={message.messageId}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { useDragHelpers, useSetFilesToDelete } from '~/hooks';
|
||||
import DragDropOverlay from './Input/Files/DragDropOverlay';
|
||||
import { useDeleteFilesMutation } from '~/data-provider';
|
||||
import { SidePanel } from '~/components/SidePanel';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Presentation({ children }: { children: React.ReactNode }) {
|
||||
export default function Presentation({
|
||||
children,
|
||||
useSidePanel = false,
|
||||
panel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
panel?: React.ReactNode;
|
||||
useSidePanel?: boolean;
|
||||
}) {
|
||||
const hideSidePanel = useRecoilValue(store.hideSidePanel);
|
||||
const { isOver, canDrop, drop } = useDragHelpers();
|
||||
const setFilesToDelete = useSetFilesToDelete();
|
||||
const { mutateAsync } = useDeleteFilesMutation({
|
||||
|
|
@ -36,14 +48,41 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
|||
}, [mutateAsync]);
|
||||
|
||||
const isActive = canDrop && isOver;
|
||||
return (
|
||||
<div ref={drop} className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800">
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-0 dark:bg-gray-800">
|
||||
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
|
||||
{children}
|
||||
{isActive && <DragDropOverlay />}
|
||||
</div>
|
||||
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
|
||||
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
|
||||
|
||||
const defaultLayout = resizableLayout ? JSON.parse(resizableLayout) : undefined;
|
||||
const defaultCollapsed = collapsedPanels ? JSON.parse(collapsedPanels) : undefined;
|
||||
|
||||
const layout = () => (
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-0 dark:bg-gray-800">
|
||||
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
|
||||
{children}
|
||||
{isActive && <DragDropOverlay />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (useSidePanel && !hideSidePanel) {
|
||||
return (
|
||||
<div
|
||||
ref={drop}
|
||||
className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800"
|
||||
>
|
||||
<SidePanel defaultLayout={defaultLayout} defaultCollapsed={defaultCollapsed}>
|
||||
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
|
||||
{children}
|
||||
{isActive && <DragDropOverlay />}
|
||||
</div>
|
||||
</SidePanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={drop} className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800">
|
||||
{layout()}
|
||||
{panel && panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
import { memo } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
import OptionsBar from './Input/OptionsBar';
|
||||
import CreationPanel from './CreationPanel';
|
||||
import { ChatContext } from '~/Providers';
|
||||
import { useChatHelpers } from '~/hooks';
|
||||
import ChatForm from './Input/ChatForm';
|
||||
import Landing from './Landing';
|
||||
import Header from './Header';
|
||||
|
||||
function ChatView({
|
||||
messagesTree,
|
||||
index = 0,
|
||||
}: {
|
||||
messagesTree?: TMessage[] | null;
|
||||
index?: number;
|
||||
}) {
|
||||
return (
|
||||
<ChatContext.Provider value={useChatHelpers(index)}>
|
||||
<div className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800">
|
||||
<CreationPanel index={index} />
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-10 dark:bg-gray-800 md:pt-0">
|
||||
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
|
||||
{messagesTree && messagesTree.length !== 0 ? (
|
||||
<MessagesView messagesTree={messagesTree} Header={<Header />} />
|
||||
) : (
|
||||
<Landing />
|
||||
)}
|
||||
<OptionsBar messagesTree={messagesTree} />
|
||||
<div className="gizmo:border-t-0 gizmo:pl-0 gizmo:md:pl-0 w-full border-t pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-2 md:pt-0 md:dark:border-transparent">
|
||||
<ChatForm index={index} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ChatView);
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
import { useParams } from 'react-router-dom';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useLocalize, useConversations, useConversation } from '~/hooks';
|
||||
import { useDeleteConversationMutation } from '~/data-provider';
|
||||
import { Dialog, DialogTrigger, Label } from '~/components/ui';
|
||||
|
|
@ -7,17 +10,21 @@ import { TrashIcon, CrossIcon } from '~/components/svg';
|
|||
|
||||
export default function DeleteButton({ conversationId, renaming, retainView, title }) {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { newConversation } = useConversation();
|
||||
const { refreshConversations } = useConversations();
|
||||
const { conversationId: currentConvoId } = useParams();
|
||||
const deleteConvoMutation = useDeleteConversationMutation(conversationId);
|
||||
const deleteConvoMutation = useDeleteConversationMutation();
|
||||
|
||||
const confirmDelete = () => {
|
||||
const messages = queryClient.getQueryData<TMessage[]>([QueryKeys.messages, conversationId]);
|
||||
const thread_id = messages?.[messages?.length - 1]?.thread_id;
|
||||
|
||||
deleteConvoMutation.mutate(
|
||||
{ conversationId, source: 'button' },
|
||||
{ conversationId, thread_id, source: 'button' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (currentConvoId == conversationId) {
|
||||
if (currentConvoId === conversationId) {
|
||||
newConversation();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { useParams } from 'react-router-dom';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useLocalize, useConversations, useNewConvo } from '~/hooks';
|
||||
import { useDeleteConversationMutation } from '~/data-provider';
|
||||
import { Dialog, DialogTrigger, Label } from '~/components/ui';
|
||||
|
|
@ -7,15 +10,19 @@ import { TrashIcon, CrossIcon } from '~/components/svg';
|
|||
|
||||
export default function DeleteButton({ conversationId, renaming, retainView, title }) {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
// TODO: useNewConvo uses indices so we need to update global index state on every switch to Convo
|
||||
const { newConversation } = useNewConvo();
|
||||
const { refreshConversations } = useConversations();
|
||||
const { conversationId: currentConvoId } = useParams();
|
||||
const deleteConvoMutation = useDeleteConversationMutation(conversationId);
|
||||
const deleteConvoMutation = useDeleteConversationMutation();
|
||||
|
||||
const confirmDelete = () => {
|
||||
const messages = queryClient.getQueryData<TMessage[]>([QueryKeys.messages, conversationId]);
|
||||
const thread_id = messages?.[messages?.length - 1]?.thread_id;
|
||||
|
||||
deleteConvoMutation.mutate(
|
||||
{ conversationId, source: 'button' },
|
||||
{ conversationId, thread_id, source: 'button' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (currentConvoId == conversationId) {
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ const EditPresetDialog = ({ open, onOpenChange, preset: _preset, title }: TEditP
|
|||
onClick={submitPreset}
|
||||
className="dark:hover:gray-400 ml-2 border-gray-700 bg-green-600 text-white hover:bg-green-700 dark:hover:bg-green-800"
|
||||
>
|
||||
{localize('com_endpoint_save')}
|
||||
{localize('com_ui_save')}
|
||||
</DialogClose>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import UnknownIcon from '~/components/Chat/Menus/Endpoints/UnknownIcon';
|
|||
import {
|
||||
Plugin,
|
||||
GPTIcon,
|
||||
AnthropicIcon,
|
||||
AzureMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
PaLMIcon,
|
||||
CodeyIcon,
|
||||
GeminiIcon,
|
||||
AssistantIcon,
|
||||
AnthropicIcon,
|
||||
AzureMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
} from '~/components/svg';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { IconProps } from '~/common';
|
||||
|
|
@ -16,7 +17,15 @@ import { cn } from '~/utils';
|
|||
|
||||
const Icon: React.FC<IconProps> = (props) => {
|
||||
const { user } = useAuthContext();
|
||||
const { size = 30, isCreatedByUser, button, model = '', endpoint, jailbreak } = props;
|
||||
const {
|
||||
size = 30,
|
||||
isCreatedByUser,
|
||||
button,
|
||||
model = '',
|
||||
endpoint,
|
||||
jailbreak,
|
||||
assistantName,
|
||||
} = props;
|
||||
|
||||
if (isCreatedByUser) {
|
||||
const username = user?.name || 'User';
|
||||
|
|
@ -42,6 +51,27 @@ const Icon: React.FC<IconProps> = (props) => {
|
|||
);
|
||||
} else {
|
||||
const endpointIcons = {
|
||||
[EModelEndpoint.assistants]: {
|
||||
icon: props.iconURL ? (
|
||||
<div
|
||||
title={assistantName}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={cn('relative flex items-center justify-center', props.className ?? '')}
|
||||
>
|
||||
<img className="rounded-sm" src={props.iconURL} alt={assistantName} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-6 w-6">
|
||||
<div className="relative flex h-full items-center justify-center rounded-full bg-white text-black">
|
||||
<AssistantIcon />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
name: endpoint,
|
||||
},
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
icon: <AzureMinimalIcon size={size * 0.5555555555555556} />,
|
||||
bg: 'linear-gradient(0.375turn, #61bde2, #4389d0)',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
GoogleMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
AnthropicIcon,
|
||||
Sparkles,
|
||||
} from '~/components/svg';
|
||||
import { cn } from '~/utils';
|
||||
import { IconProps } from '~/common';
|
||||
|
|
@ -40,6 +41,7 @@ const MinimalIcon: React.FC<IconProps> = (props) => {
|
|||
},
|
||||
[EModelEndpoint.bingAI]: { icon: <BingAIMinimalIcon />, name: 'BingAI' },
|
||||
[EModelEndpoint.chatGPTBrowser]: { icon: <LightningIcon />, name: 'ChatGPT' },
|
||||
[EModelEndpoint.assistants]: { icon: <Sparkles className="icon-sm" />, name: 'Assistant' },
|
||||
default: {
|
||||
icon: (
|
||||
<UnknownIcon
|
||||
|
|
|
|||
178
client/src/components/Endpoints/Settings/Assistants.tsx
Normal file
178
client/src/components/Endpoints/Settings/Assistants.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useState, useMemo, useEffect } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { TPreset, defaultOrderQuery } from 'librechat-data-provider';
|
||||
import type { TModelSelectProps, Option } from '~/common';
|
||||
import { Label, HoverCard, SelectDropDown, HoverCardTrigger } from '~/components/ui';
|
||||
import { cn, defaultTextProps, removeFocusOutlines, mapAssistants } from '~/utils';
|
||||
import { useLocalize, useDebouncedInput } from '~/hooks';
|
||||
import { useListAssistantsQuery } from '~/data-provider';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
export default function Settings({ conversation, setOption, models, readonly }: TModelSelectProps) {
|
||||
const localize = useLocalize();
|
||||
const defaultOption = useMemo(
|
||||
() => ({ label: localize('com_endpoint_use_active_assistant'), value: '' }),
|
||||
[localize],
|
||||
);
|
||||
|
||||
const { data: assistants = [] } = useListAssistantsQuery(defaultOrderQuery, {
|
||||
select: (res) =>
|
||||
[
|
||||
defaultOption,
|
||||
...res.data.map(({ id, name }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
})),
|
||||
].filter(Boolean),
|
||||
});
|
||||
|
||||
const { data: assistantMap = {} } = useListAssistantsQuery(defaultOrderQuery, {
|
||||
select: (res) => mapAssistants(res.data),
|
||||
});
|
||||
|
||||
const { model, endpoint, assistant_id, endpointType, promptPrefix, instructions } =
|
||||
conversation ?? {};
|
||||
const [onPromptPrefixChange, promptPrefixValue] = useDebouncedInput(
|
||||
setOption,
|
||||
'promptPrefix',
|
||||
promptPrefix,
|
||||
);
|
||||
const [onInstructionsChange, instructionsValue] = useDebouncedInput(
|
||||
setOption,
|
||||
'instructions',
|
||||
instructions,
|
||||
);
|
||||
|
||||
const activeAssistant = useMemo(() => {
|
||||
if (assistant_id) {
|
||||
return assistantMap[assistant_id];
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [assistant_id, assistantMap]);
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
return models.map((model) => ({
|
||||
label:
|
||||
model === activeAssistant?.model
|
||||
? `${model} (${localize('com_endpoint_assistant_model')})`
|
||||
: model,
|
||||
value: model,
|
||||
}));
|
||||
}, [models, activeAssistant, localize]);
|
||||
|
||||
const [assistantValue, setAssistantValue] = useState<Option>(
|
||||
activeAssistant ? { label: activeAssistant.name, value: activeAssistant.id } : defaultOption,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (assistantValue && assistantValue.value === '') {
|
||||
setOption('presetOverride')({
|
||||
assistant_id: assistantValue.value,
|
||||
} as Partial<TPreset>);
|
||||
}
|
||||
|
||||
// Reason: `setOption` causes a re-render on every update
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assistantValue]);
|
||||
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setModel = setOption('model');
|
||||
const setAssistant = (value: string) => {
|
||||
if (!value) {
|
||||
setAssistantValue(defaultOption);
|
||||
return;
|
||||
}
|
||||
|
||||
const assistant = assistantMap[value];
|
||||
if (!assistant) {
|
||||
setAssistantValue(defaultOption);
|
||||
return;
|
||||
}
|
||||
|
||||
setAssistantValue({
|
||||
label: assistant.name ?? '',
|
||||
value: assistant.id ?? '',
|
||||
});
|
||||
setOption('assistant_id')(assistant.id);
|
||||
};
|
||||
|
||||
const optionEndpoint = endpointType ?? endpoint;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-6 gap-6">
|
||||
<div className="col-span-6 flex flex-col items-center justify-start gap-6 sm:col-span-3">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<SelectDropDown
|
||||
value={model ?? ''}
|
||||
setValue={setModel}
|
||||
availableValues={modelOptions}
|
||||
disabled={readonly}
|
||||
className={cn(defaultTextProps, 'flex w-full resize-none', removeFocusOutlines)}
|
||||
containerClassName="flex w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-6 flex flex-col items-center justify-start gap-6 px-3 sm:col-span-3">
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<SelectDropDown
|
||||
title={localize('com_endpoint_assistant')}
|
||||
value={assistantValue}
|
||||
setValue={setAssistant}
|
||||
availableValues={assistants as Option[]}
|
||||
disabled={readonly}
|
||||
className={cn(defaultTextProps, 'flex w-full resize-none', removeFocusOutlines)}
|
||||
containerClassName="flex w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="temp" side={ESide.Left} />
|
||||
</HoverCard>
|
||||
</div>
|
||||
<div className="col-span-6 flex flex-col items-center justify-start gap-6">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="promptPrefix" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_prompt_prefix_assistants')}{' '}
|
||||
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
|
||||
</Label>
|
||||
<TextareaAutosize
|
||||
id="promptPrefix"
|
||||
disabled={readonly}
|
||||
value={promptPrefixValue as string | undefined}
|
||||
onChange={onPromptPrefixChange}
|
||||
placeholder={localize('com_endpoint_prompt_prefix_assistants_placeholder')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'dark:bg-gray-700 dark:hover:bg-gray-700/60 dark:focus:bg-gray-700',
|
||||
'flex max-h-[240px] min-h-[80px] w-full resize-none px-3 py-2 ',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="instructions" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_instructions_assistants')}{' '}
|
||||
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
|
||||
</Label>
|
||||
<TextareaAutosize
|
||||
id="instructions"
|
||||
disabled={readonly}
|
||||
value={instructionsValue as string | undefined}
|
||||
onChange={onInstructionsChange}
|
||||
placeholder={localize('com_endpoint_instructions_assistants_placeholder')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'dark:bg-gray-700 dark:hover:bg-gray-700/60 dark:focus:bg-gray-700',
|
||||
'flex max-h-[240px] min-h-[80px] w-full resize-none px-3 py-2 ',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { default as AssistantsSettings } from './Assistants';
|
||||
export { default as OpenAISettings } from './OpenAI';
|
||||
export { default as BingAISettings } from './BingAI';
|
||||
export { default as GoogleSettings } from './Google';
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import { EModelEndpoint } from 'librechat-data-provider';
|
|||
import type { FC } from 'react';
|
||||
import type { TModelSelectProps, TBaseSettingsProps, TModels } from '~/common';
|
||||
import { Google, Plugins, GoogleSettings, PluginSettings } from './MultiView';
|
||||
import AssistantsSettings from './Assistants';
|
||||
import AnthropicSettings from './Anthropic';
|
||||
import BingAISettings from './BingAI';
|
||||
import OpenAISettings from './OpenAI';
|
||||
|
||||
const settings: { [key: string]: FC<TModelSelectProps> } = {
|
||||
[EModelEndpoint.assistants]: AssistantsSettings,
|
||||
[EModelEndpoint.openAI]: OpenAISettings,
|
||||
[EModelEndpoint.custom]: OpenAISettings,
|
||||
[EModelEndpoint.azureOpenAI]: OpenAISettings,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Fragment } from 'react';
|
||||
import type { TResPlugin } from 'librechat-data-provider';
|
||||
import type { TMessageContent, TText, TDisplayProps } from '~/common';
|
||||
import type { TMessageContentProps, TText, TDisplayProps } from '~/common';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import EditMessage from './EditMessage';
|
||||
|
|
@ -57,7 +57,7 @@ const MessageContent = ({
|
|||
isSubmitting,
|
||||
isLast,
|
||||
...props
|
||||
}: TMessageContent) => {
|
||||
}: TMessageContentProps) => {
|
||||
if (error) {
|
||||
return <ErrorMessage text={text} />;
|
||||
} else if (edit) {
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ export default function Nav({ navVisible, setNavVisible }) {
|
|||
setIsHovering={setIsToggleHovering}
|
||||
onToggle={toggleNavVisible}
|
||||
navVisible={navVisible}
|
||||
className="fixed left-0 top-1/2 z-40"
|
||||
/>
|
||||
<div className={`nav-mask${navVisible ? ' active' : ''}`} onClick={toggleNavVisible} />
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { Download } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Fragment, useState, memo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Fragment, useState, memo } from 'react';
|
||||
import { Download, FileText } from 'lucide-react';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { useRecoilValue, useRecoilState } from 'recoil';
|
||||
import { useGetUserBalance, useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import FilesView from '~/components/Chat/Input/Files/FilesView';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { ExportModal } from './ExportConversation';
|
||||
import { LinkIcon, GearIcon } from '~/components';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Settings from './Settings';
|
||||
import NavLink from './NavLink';
|
||||
|
|
@ -25,6 +26,7 @@ function NavLinks() {
|
|||
});
|
||||
const [showExports, setShowExports] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showFiles, setShowFiles] = useRecoilState(store.showFiles);
|
||||
|
||||
let conversation;
|
||||
const activeConvo = useRecoilValue(store.conversationByIndex(0));
|
||||
|
|
@ -108,6 +110,14 @@ function NavLinks() {
|
|||
/>
|
||||
</Menu.Item>
|
||||
<div className="my-1 h-px bg-white/20" role="none" />
|
||||
<Menu.Item as="div">
|
||||
<NavLink
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-none px-3 py-3 text-sm text-white transition-colors duration-200 hover:bg-gray-700"
|
||||
svg={() => <FileText className="icon-md" />}
|
||||
text="My Files"
|
||||
clickHandler={() => setShowFiles(true)}
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item as="div">
|
||||
<NavLink
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-none px-3 py-3 text-sm text-white transition-colors duration-200 hover:bg-gray-700"
|
||||
|
|
@ -136,6 +146,7 @@ function NavLinks() {
|
|||
{showExports && (
|
||||
<ExportModal open={showExports} onOpenChange={setShowExports} conversation={conversation} />
|
||||
)}
|
||||
{showFiles && <FilesView open={showFiles} onOpenChange={setShowFiles} />}
|
||||
{showSettings && <Settings open={showSettings} onOpenChange={setShowSettings} />}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,18 +2,33 @@ import { TooltipTrigger, TooltipContent } from '~/components/ui';
|
|||
import { useLocalize, useLocalStorage } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function NavToggle({ onToggle, navVisible, isHovering, setIsHovering }) {
|
||||
export default function NavToggle({
|
||||
onToggle,
|
||||
navVisible,
|
||||
isHovering,
|
||||
setIsHovering,
|
||||
side = 'left',
|
||||
className = '',
|
||||
translateX = true,
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const transition = {
|
||||
transition: 'transform 0.3s ease, opacity 0.2s ease',
|
||||
};
|
||||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||
const [newUser] = useLocalStorage('newUser', true);
|
||||
|
||||
const rotationDegree = 15;
|
||||
const rotation = isHovering || !navVisible ? `${rotationDegree}deg` : '0deg';
|
||||
const topBarRotation = side === 'right' ? `-${rotation}` : rotation;
|
||||
const bottomBarRotation = side === 'right' ? rotation : `-${rotation}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed left-0 top-1/2 z-40 -translate-y-1/2 transition-transform',
|
||||
navVisible ? 'translate-x-[260px] rotate-0' : 'translate-x-0 rotate-180',
|
||||
className,
|
||||
'-translate-y-1/2 transition-transform',
|
||||
navVisible ? 'rotate-0' : 'rotate-180',
|
||||
navVisible && translateX ? 'translate-x-[260px]' : 'translate-x-0 ',
|
||||
)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
|
|
@ -26,27 +41,29 @@ export default function NavToggle({ onToggle, navVisible, isHovering, setIsHover
|
|||
style={{ ...transition, opacity: isHovering ? 1 : 0.25 }}
|
||||
>
|
||||
<div className="flex h-6 w-6 flex-col items-center">
|
||||
{/* Top bar */}
|
||||
<div
|
||||
className="h-3 w-1 rounded-full bg-black dark:bg-white"
|
||||
style={{
|
||||
...transition,
|
||||
transform: `translateY(0.15rem) rotate(${
|
||||
isHovering || !navVisible ? '15' : '0'
|
||||
}deg) translateZ(0px)`,
|
||||
transform: `translateY(0.15rem) rotate(${topBarRotation}) translateZ(0px)`,
|
||||
}}
|
||||
/>
|
||||
{/* Bottom bar */}
|
||||
<div
|
||||
className="h-3 w-1 rounded-full bg-black dark:bg-white"
|
||||
style={{
|
||||
...transition,
|
||||
transform: `translateY(-0.15rem) rotate(-${
|
||||
isHovering || !navVisible ? '15' : '0'
|
||||
}deg) translateZ(0px)`,
|
||||
transform: `translateY(-0.15rem) rotate(${bottomBarRotation}) translateZ(0px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipContent forceMount={newUser ? true : undefined} side="right" sideOffset={4}>
|
||||
<TooltipContent
|
||||
forceMount={newUser ? true : undefined}
|
||||
side={side === 'right' ? 'left' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
{navVisible ? localize('com_nav_close_sidebar') : localize('com_nav_open_sidebar')}
|
||||
</TooltipContent>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
import { FileImage } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
|
||||
import { useUploadAvatarMutation } from '~/data-provider';
|
||||
import { useUploadAvatarMutation, useGetFileConfig } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
const sizeLimit = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
function Avatar() {
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const [input, setinput] = useState<File | null>(null);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
|
@ -49,7 +51,7 @@ function Avatar() {
|
|||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (file && file.size <= sizeLimit) {
|
||||
if (fileConfig.avatarSizeLimit && file && file.size <= fileConfig.avatarSizeLimit) {
|
||||
setinput(file);
|
||||
setDialogOpen(true);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
useLocalStorage,
|
||||
} from '~/hooks';
|
||||
import type { TDangerButtonProps } from '~/common';
|
||||
import HideSidePanelSwitch from './HideSidePanelSwitch';
|
||||
import AutoScrollSwitch from './AutoScrollSwitch';
|
||||
import { Dropdown } from '~/components/ui';
|
||||
import DangerButton from '../DangerButton';
|
||||
|
|
@ -190,6 +191,9 @@ function General() {
|
|||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<AutoScrollSwitch />
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<HideSidePanelSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function HideSidePanelSwitch({
|
||||
onCheckedChange,
|
||||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const [hideSidePanel, setHideSidePanel] = useRecoilState<boolean>(store.hideSidePanel);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setHideSidePanel(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div> {localize('com_nav_hide_panel')} </div>
|
||||
<Switch
|
||||
id="hideSidePanel"
|
||||
checked={hideSidePanel}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4 mt-2"
|
||||
data-testid="hideSidePanel"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,9 +7,10 @@ import PluginTooltip from './PluginTooltip';
|
|||
type TPluginAuthFormProps = {
|
||||
plugin: TPlugin | undefined;
|
||||
onSubmit: (installActionData: TPluginAction) => void;
|
||||
isAssistantTool?: boolean;
|
||||
};
|
||||
|
||||
function PluginAuthForm({ plugin, onSubmit }: TPluginAuthFormProps) {
|
||||
function PluginAuthForm({ plugin, onSubmit, isAssistantTool }: TPluginAuthFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
|
|
@ -23,7 +24,12 @@ function PluginAuthForm({ plugin, onSubmit }: TPluginAuthFormProps) {
|
|||
className="col-span-1 flex w-full flex-col items-start justify-start gap-2"
|
||||
method="POST"
|
||||
onSubmit={handleSubmit((auth) =>
|
||||
onSubmit({ pluginKey: plugin?.pluginKey ?? '', action: 'install', auth }),
|
||||
onSubmit({
|
||||
pluginKey: plugin?.pluginKey ?? '',
|
||||
action: 'install',
|
||||
auth,
|
||||
isAssistantTool,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{plugin?.authConfig?.map((config: TPluginAuthConfig, i: number) => {
|
||||
|
|
|
|||
|
|
@ -1,44 +1,48 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Dialog } from '@headlessui/react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { tConversationSchema } from 'librechat-data-provider';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
useAvailablePluginsQuery,
|
||||
useUpdateUserPluginsMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
import type { TError, TPlugin, TPluginAction } from 'librechat-data-provider';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import type { TError, TPluginAction } from 'librechat-data-provider';
|
||||
import type { TPluginStoreDialogProps } from '~/common/types';
|
||||
import { useLocalize, usePluginDialogHelpers, useSetIndexOptions, useAuthContext } from '~/hooks';
|
||||
import PluginPagination from './PluginPagination';
|
||||
import PluginStoreItem from './PluginStoreItem';
|
||||
import PluginAuthForm from './PluginAuthForm';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
type TPluginStoreDialogProps = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { data: availablePlugins } = useAvailablePluginsQuery();
|
||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||
const { setTools } = useSetIndexOptions();
|
||||
|
||||
const [conversation, setConversation] = useRecoilState(store.conversation) ?? {};
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<TPlugin | undefined>(undefined);
|
||||
|
||||
const [maxPage, setMaxPage] = useState(1);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(1);
|
||||
const [userPlugins, setUserPlugins] = useState<string[]>([]);
|
||||
const [searchChanged, setSearchChanged] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [showPluginAuthForm, setShowPluginAuthForm] = useState<boolean>(false);
|
||||
const {
|
||||
maxPage,
|
||||
setMaxPage,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
searchChanged,
|
||||
setSearchChanged,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
gridRef,
|
||||
handleSearch,
|
||||
handleChangePage,
|
||||
error,
|
||||
setError,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
showPluginAuthForm,
|
||||
setShowPluginAuthForm,
|
||||
selectedPlugin,
|
||||
setSelectedPlugin,
|
||||
} = usePluginDialogHelpers();
|
||||
|
||||
const handleInstallError = (error: TError) => {
|
||||
setError(true);
|
||||
|
|
@ -68,18 +72,7 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
|
|||
handleInstallError(error as TError);
|
||||
},
|
||||
onSuccess: () => {
|
||||
//@ts-ignore - can't set a default convo or it will break routing
|
||||
let { tools } = conversation;
|
||||
tools = tools.filter((t: TPlugin) => {
|
||||
return t.pluginKey !== plugin;
|
||||
});
|
||||
localStorage.setItem('lastSelectedTools', JSON.stringify(tools));
|
||||
setConversation((prevState) =>
|
||||
tConversationSchema.parse({
|
||||
...prevState,
|
||||
tools,
|
||||
}),
|
||||
);
|
||||
setTools(plugin, true);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -98,44 +91,10 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const calculateColumns = (node) => {
|
||||
const width = node.offsetWidth;
|
||||
let columns;
|
||||
if (width < 501) {
|
||||
setItemsPerPage(8);
|
||||
return;
|
||||
} else if (width < 640) {
|
||||
columns = 2;
|
||||
} else if (width < 1024) {
|
||||
columns = 3;
|
||||
} else {
|
||||
columns = 4;
|
||||
}
|
||||
setItemsPerPage(columns * 2); // 2 rows
|
||||
};
|
||||
|
||||
const gridRef = useCallback(
|
||||
(node) => {
|
||||
if (node !== null) {
|
||||
if (itemsPerPage === 1) {
|
||||
calculateColumns(node);
|
||||
}
|
||||
const resizeObserver = new ResizeObserver(() => calculateColumns(node));
|
||||
resizeObserver.observe(node);
|
||||
}
|
||||
},
|
||||
[itemsPerPage],
|
||||
);
|
||||
|
||||
const filteredPlugins = availablePlugins?.filter((plugin) =>
|
||||
plugin.name.toLowerCase().includes(searchValue.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
setSearchChanged(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.plugins) {
|
||||
setUserPlugins(user.plugins);
|
||||
|
|
@ -148,11 +107,10 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
|
|||
setSearchChanged(false);
|
||||
}
|
||||
}
|
||||
}, [availablePlugins, itemsPerPage, user, searchValue, filteredPlugins, searchChanged]);
|
||||
|
||||
const handleChangePage = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
// Disabled due to state setters erroneously being flagged as dependencies
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [availablePlugins, itemsPerPage, user, searchValue, filteredPlugins, searchChanged]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
|
@ -169,10 +127,10 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
|
|||
{/* Full-screen container to center the panel */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel
|
||||
className="relative w-full transform overflow-hidden overflow-y-auto rounded-lg bg-white text-left shadow-xl transition-all dark:bg-gray-900 max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
|
||||
className="relative w-full transform overflow-hidden overflow-y-auto rounded-lg bg-white text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl dark:bg-gray-900"
|
||||
style={{ minHeight: '610px' }}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b-[1px] border-black/10 px-4 pb-4 pt-5 dark:border-white/10 sm:p-6">
|
||||
<div className="flex items-center justify-between border-b-[1px] border-black/10 px-4 pb-4 pt-5 sm:p-6 dark:border-white/10">
|
||||
<div className="flex items-center">
|
||||
<div className="text-center sm:text-left">
|
||||
<Dialog.Title className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { TPlugin } from 'librechat-data-provider';
|
||||
import { XCircle, DownloadCloud } from 'lucide-react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type TPluginStoreItemProps = {
|
||||
plugin: TPlugin;
|
||||
|
|
@ -9,6 +10,7 @@ type TPluginStoreItemProps = {
|
|||
};
|
||||
|
||||
function PluginStoreItem({ plugin, onInstall, onUninstall, isInstalled }: TPluginStoreItemProps) {
|
||||
const localize = useLocalize();
|
||||
const handleClick = () => {
|
||||
if (isInstalled) {
|
||||
onUninstall();
|
||||
|
|
@ -38,11 +40,11 @@ function PluginStoreItem({ plugin, onInstall, onUninstall, isInstalled }: TPlugi
|
|||
{!isInstalled ? (
|
||||
<button
|
||||
className="btn btn-primary relative"
|
||||
aria-label={`Install ${plugin.name}`}
|
||||
aria-label={`${localize('com_nav_plugin_install')} ${plugin.name}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
Install
|
||||
{localize('com_nav_plugin_install')}
|
||||
<DownloadCloud className="flex h-4 w-4 items-center stroke-2" />
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -50,10 +52,10 @@ function PluginStoreItem({ plugin, onInstall, onUninstall, isInstalled }: TPlugi
|
|||
<button
|
||||
className="btn relative bg-gray-300 hover:bg-gray-400 dark:bg-gray-50 dark:hover:bg-gray-200"
|
||||
onClick={handleClick}
|
||||
aria-label={`Uninstall ${plugin.name}`}
|
||||
aria-label={`${localize('com_nav_plugin_uninstall')} ${plugin.name}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
Uninstall
|
||||
{localize('com_nav_plugin_uninstall')}
|
||||
<XCircle className="flex h-4 w-4 items-center stroke-2" />
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'test/matchMedia.mock';
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen } from 'test/layout-test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import PluginPagination from '../PluginPagination';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'test/matchMedia.mock';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen } from 'test/layout-test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TPlugin } from 'librechat-data-provider';
|
||||
import PluginStoreItem from '../PluginStoreItem';
|
||||
|
|
|
|||
296
client/src/components/SidePanel/Builder/ActionsAuth.tsx
Normal file
296
client/src/components/SidePanel/Builder/ActionsAuth.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import * as RadioGroup from '@radix-ui/react-radio-group';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import {
|
||||
AuthTypeEnum,
|
||||
AuthorizationTypeEnum,
|
||||
TokenExchangeMethodEnum,
|
||||
} from 'librechat-data-provider';
|
||||
import { DialogContent } from '~/components/ui/';
|
||||
|
||||
export default function ActionsAuth({
|
||||
setOpenAuthDialog,
|
||||
}: {
|
||||
setOpenAuthDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const { watch, setValue, trigger } = useFormContext();
|
||||
const type = watch('type');
|
||||
return (
|
||||
<DialogContent
|
||||
role="dialog"
|
||||
id="radix-:rf5:"
|
||||
aria-describedby="radix-:rf7:"
|
||||
aria-labelledby="radix-:rf6:"
|
||||
data-state="open"
|
||||
className="left-1/2 col-auto col-start-2 row-auto row-start-2 w-full max-w-md -translate-x-1/2 rounded-xl bg-white pb-0 text-left shadow-xl transition-all dark:bg-gray-900 dark:text-gray-100"
|
||||
tabIndex={-1}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-black/10 px-4 pb-4 pt-5 dark:border-white/10 sm:p-6">
|
||||
<div className="flex">
|
||||
<div className="flex items-center">
|
||||
<div className="flex grow flex-col gap-1">
|
||||
<h2
|
||||
id="radix-:rf6:"
|
||||
className="text-token-text-primary text-lg font-medium leading-6"
|
||||
>
|
||||
Authentication
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 sm:pt-0">
|
||||
<div className="mb-4">
|
||||
<label className="mb-1 block text-sm font-medium">Authentication Type</label>
|
||||
<RadioGroup.Root
|
||||
defaultValue={AuthTypeEnum.None}
|
||||
onValueChange={(value) => setValue('type', value)}
|
||||
value={type}
|
||||
role="radiogroup"
|
||||
aria-required="false"
|
||||
dir="ltr"
|
||||
className="flex gap-4"
|
||||
tabIndex={0}
|
||||
style={{ outline: 'none' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor=":rf8:" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthTypeEnum.None}
|
||||
id=":rf8:"
|
||||
className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-600 dark:bg-gray-700"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-gray-950 dark:bg-white"></RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
None
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor=":rfa:" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthTypeEnum.ServiceHttp}
|
||||
id=":rfa:"
|
||||
className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-600 dark:bg-gray-700"
|
||||
tabIndex={0}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-gray-950 dark:bg-white"></RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
API Key
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<label htmlFor=":rfc:" className="flex cursor-not-allowed items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
disabled={true}
|
||||
value={AuthTypeEnum.OAuth}
|
||||
id=":rfc:"
|
||||
className="mr-1 flex h-5 w-5 cursor-not-allowed items-center justify-center rounded-full border border-gray-500 bg-gray-300 dark:border-gray-600 dark:bg-gray-900"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-gray-950 dark:bg-white"></RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
OAuth
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
{type === 'none' ? null : type === 'service_http' ? <ApiKey /> : <OAuth />}
|
||||
{/* Cancel/Save */}
|
||||
<div className="mt-5 flex flex-col gap-3 sm:mt-4 sm:flex-row-reverse">
|
||||
<button
|
||||
className="btn btn-dark relative"
|
||||
onClick={async () => {
|
||||
const result = await trigger(undefined, { shouldFocus: true });
|
||||
setValue('saved_auth_fields', result);
|
||||
setOpenAuthDialog(!result);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">Save</div>
|
||||
</button>
|
||||
<DialogPrimitive.Close className="btn btn-neutral relative">
|
||||
<div className="flex w-full items-center justify-center gap-2">Cancel</div>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
const ApiKey = () => {
|
||||
const { register, watch, setValue } = useFormContext();
|
||||
const authorization_type = watch('authorization_type');
|
||||
const type = watch('type');
|
||||
return (
|
||||
<>
|
||||
<label className="mb-1 block text-sm font-medium">API Key</label>
|
||||
<input
|
||||
placeholder="<HIDDEN>"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className="border-token-border-medium mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-800"
|
||||
{...register('api_key', { required: type === AuthTypeEnum.ServiceHttp })}
|
||||
/>
|
||||
<label className="mb-1 block text-sm font-medium">Auth Type</label>
|
||||
<RadioGroup.Root
|
||||
defaultValue={AuthorizationTypeEnum.Basic}
|
||||
onValueChange={(value) => setValue('authorization_type', value)}
|
||||
value={authorization_type}
|
||||
role="radiogroup"
|
||||
aria-required="true"
|
||||
dir="ltr"
|
||||
className="mb-2 flex gap-6 overflow-hidden rounded-lg"
|
||||
tabIndex={0}
|
||||
style={{ outline: 'none' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor=":rfu:" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthorizationTypeEnum.Basic}
|
||||
id=":rfu:"
|
||||
className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-600 dark:bg-gray-700"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-gray-950 dark:bg-white"></RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
Basic
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor=":rg0:" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthorizationTypeEnum.Bearer}
|
||||
id=":rg0:"
|
||||
className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-600 dark:bg-gray-700"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-gray-950 dark:bg-white"></RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
Bearer
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor=":rg2:" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={AuthorizationTypeEnum.Custom}
|
||||
id=":rg2:"
|
||||
className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-600 dark:bg-gray-700"
|
||||
tabIndex={0}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-gray-950 dark:bg-white"></RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
Custom
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
{authorization_type === AuthorizationTypeEnum.Custom && (
|
||||
<div className="mt-2">
|
||||
<label className="mb-1 block text-sm font-medium">Custom Header Name</label>
|
||||
<input
|
||||
className="border-token-border-medium mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-800"
|
||||
placeholder="X-Api-Key"
|
||||
{...register('custom_auth_header', {
|
||||
required: authorization_type === AuthorizationTypeEnum.Custom,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OAuth = () => {
|
||||
const { register, watch, setValue } = useFormContext();
|
||||
const token_exchange_method = watch('token_exchange_method');
|
||||
const type = watch('type');
|
||||
return (
|
||||
<>
|
||||
<label className="mb-1 block text-sm font-medium">Client ID</label>
|
||||
<input
|
||||
placeholder="<HIDDEN>"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className="border-token-border-medium mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-800"
|
||||
{...register('oauth_client_id', { required: type === AuthTypeEnum.OAuth })}
|
||||
/>
|
||||
<label className="mb-1 block text-sm font-medium">Client Secret</label>
|
||||
<input
|
||||
placeholder="<HIDDEN>"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className="border-token-border-medium mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-800"
|
||||
{...register('oauth_client_secret', { required: type === AuthTypeEnum.OAuth })}
|
||||
/>
|
||||
<label className="mb-1 block text-sm font-medium">Authorization URL</label>
|
||||
<input
|
||||
className="border-token-border-medium mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-800"
|
||||
{...register('authorization_url', { required: type === AuthTypeEnum.OAuth })}
|
||||
/>
|
||||
<label className="mb-1 block text-sm font-medium">Token URL</label>
|
||||
<input
|
||||
className="border-token-border-medium mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-800"
|
||||
{...register('client_url', { required: type === AuthTypeEnum.OAuth })}
|
||||
/>
|
||||
<label className="mb-1 block text-sm font-medium">Scope</label>
|
||||
<input
|
||||
className="border-token-border-medium mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-800"
|
||||
{...register('scope', { required: type === AuthTypeEnum.OAuth })}
|
||||
/>
|
||||
<label className="mb-1 block text-sm font-medium">Token Exchange Method</label>
|
||||
<RadioGroup.Root
|
||||
defaultValue={AuthorizationTypeEnum.Basic}
|
||||
onValueChange={(value) => setValue('token_exchange_method', value)}
|
||||
value={token_exchange_method}
|
||||
role="radiogroup"
|
||||
aria-required="true"
|
||||
dir="ltr"
|
||||
tabIndex={0}
|
||||
style={{ outline: 'none' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor=":rj1:" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={TokenExchangeMethodEnum.DefaultPost}
|
||||
id=":rj1:"
|
||||
className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-600 dark:bg-gray-700"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-gray-950 dark:bg-white"></RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
Default (POST request)
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor=":rj3:" className="flex cursor-pointer items-center gap-1">
|
||||
<RadioGroup.Item
|
||||
type="button"
|
||||
role="radio"
|
||||
value={TokenExchangeMethodEnum.BasicAuthHeader}
|
||||
id=":rj3:"
|
||||
className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-600 dark:bg-gray-700"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-gray-950 dark:bg-white"></RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
Basic authorization header
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
267
client/src/components/SidePanel/Builder/ActionsInput.tsx
Normal file
267
client/src/components/SidePanel/Builder/ActionsInput.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
validateAndParseOpenAPISpec,
|
||||
openapiToFunction,
|
||||
AuthTypeEnum,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
ValidationResult,
|
||||
Action,
|
||||
FunctionTool,
|
||||
ActionMetadata,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ActionAuthForm } from '~/common';
|
||||
import type { Spec } from './ActionsTable';
|
||||
import { ActionsTable, columns } from './ActionsTable';
|
||||
import { useUpdateAction } from '~/data-provider';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { Spinner } from '~/components/svg';
|
||||
|
||||
const debouncedValidation = debounce(
|
||||
(input: string, callback: (result: ValidationResult) => void) => {
|
||||
const result = validateAndParseOpenAPISpec(input);
|
||||
callback(result);
|
||||
},
|
||||
800,
|
||||
);
|
||||
|
||||
export default function ActionsInput({
|
||||
action,
|
||||
assistant_id,
|
||||
setAction,
|
||||
}: {
|
||||
action?: Action;
|
||||
assistant_id?: string;
|
||||
setAction: React.Dispatch<React.SetStateAction<Action | undefined>>;
|
||||
}) {
|
||||
const handleResult = (result: ValidationResult) => {
|
||||
if (!result.status) {
|
||||
setData(null);
|
||||
setFunctions(null);
|
||||
}
|
||||
setValidationResult(result);
|
||||
};
|
||||
|
||||
const { handleSubmit, reset } = useFormContext<ActionAuthForm>();
|
||||
const [validationResult, setValidationResult] = useState<null | ValidationResult>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const [data, setData] = useState<Spec[] | null>(null);
|
||||
const [functions, setFunctions] = useState<FunctionTool[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!action?.metadata?.raw_spec) {
|
||||
return;
|
||||
}
|
||||
setInputValue(action.metadata.raw_spec);
|
||||
debouncedValidation(action.metadata.raw_spec, handleResult);
|
||||
}, [action?.metadata?.raw_spec]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validationResult || !validationResult.status || !validationResult.spec) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { functionSignatures, requestBuilders } = openapiToFunction(validationResult.spec);
|
||||
const specs = Object.entries(requestBuilders).map(([name, props]) => {
|
||||
return {
|
||||
name,
|
||||
method: props.method,
|
||||
path: props.path,
|
||||
domain: props.domain,
|
||||
};
|
||||
});
|
||||
|
||||
setData(specs);
|
||||
setValidationResult(null);
|
||||
setFunctions(functionSignatures.map((f) => f.toObjectTool()));
|
||||
}, [validationResult]);
|
||||
|
||||
const updateAction = useUpdateAction({
|
||||
onSuccess(data) {
|
||||
reset();
|
||||
setAction(data[2]);
|
||||
},
|
||||
});
|
||||
|
||||
const saveAction = handleSubmit((authFormData) => {
|
||||
console.log('authFormData', authFormData);
|
||||
if (!assistant_id) {
|
||||
// alert user?
|
||||
return;
|
||||
}
|
||||
|
||||
if (!functions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
let { metadata = {} } = action ?? {};
|
||||
const action_id = action?.action_id;
|
||||
metadata.raw_spec = inputValue;
|
||||
const parsedUrl = new URL(data[0].domain);
|
||||
const domain = parsedUrl.hostname;
|
||||
if (!domain) {
|
||||
// alert user?
|
||||
return;
|
||||
}
|
||||
metadata.domain = domain;
|
||||
|
||||
const { type, saved_auth_fields } = authFormData;
|
||||
|
||||
const removeSensitiveFields = (obj: ActionMetadata) => {
|
||||
delete obj.auth;
|
||||
delete obj.api_key;
|
||||
delete obj.oauth_client_id;
|
||||
delete obj.oauth_client_secret;
|
||||
};
|
||||
|
||||
if (saved_auth_fields && type === AuthTypeEnum.ServiceHttp) {
|
||||
metadata = {
|
||||
...metadata,
|
||||
api_key: authFormData.api_key,
|
||||
auth: {
|
||||
type,
|
||||
authorization_type: authFormData.authorization_type,
|
||||
custom_auth_header: authFormData.custom_auth_header,
|
||||
},
|
||||
};
|
||||
} else if (saved_auth_fields && type === AuthTypeEnum.OAuth) {
|
||||
metadata = {
|
||||
...metadata,
|
||||
auth: {
|
||||
type,
|
||||
authorization_url: authFormData.authorization_url,
|
||||
client_url: authFormData.client_url,
|
||||
scope: authFormData.scope,
|
||||
token_exchange_method: authFormData.token_exchange_method,
|
||||
},
|
||||
oauth_client_id: authFormData.oauth_client_id,
|
||||
oauth_client_secret: authFormData.oauth_client_secret,
|
||||
};
|
||||
} else if (saved_auth_fields) {
|
||||
removeSensitiveFields(metadata);
|
||||
metadata.auth = {
|
||||
type,
|
||||
};
|
||||
} else {
|
||||
removeSensitiveFields(metadata);
|
||||
}
|
||||
|
||||
updateAction.mutate({
|
||||
action_id,
|
||||
metadata,
|
||||
functions,
|
||||
assistant_id,
|
||||
});
|
||||
});
|
||||
|
||||
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = (event) => {
|
||||
const newValue = event.target.value;
|
||||
setInputValue(newValue);
|
||||
if (!newValue) {
|
||||
setData(null);
|
||||
setFunctions(null);
|
||||
return setValidationResult(null);
|
||||
}
|
||||
debouncedValidation(newValue, handleResult);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="">
|
||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-4">
|
||||
<label className="text-token-text-primary whitespace-nowrap font-medium">Schema</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* <button className="btn btn-neutral border-token-border-light relative h-8 min-w-[100px] rounded-lg font-medium">
|
||||
<div className="flex w-full items-center justify-center text-xs">Import from URL</div>
|
||||
</button> */}
|
||||
<select
|
||||
onChange={(e) => console.log(e.target.value)}
|
||||
className="border-token-border-medium h-8 min-w-[100px] rounded-lg border bg-transparent px-2 py-0 text-sm"
|
||||
>
|
||||
<option value="label">Examples</option>
|
||||
<option value="0">Weather (JSON)</option>
|
||||
<option value="1">Pet Store (YAML)</option>
|
||||
<option value="2">Blank Template</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-token-border-light mb-4 overflow-hidden rounded-lg border">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
spellCheck="false"
|
||||
placeholder="Enter your OpenAPI schema here"
|
||||
className={cn(
|
||||
'text-token-text-primary block h-96 w-full border-none bg-transparent p-2 font-mono text-xs',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
/>
|
||||
{/* TODO: format input button */}
|
||||
</div>
|
||||
{validationResult && validationResult.message !== 'OpenAPI spec is valid.' && (
|
||||
<div className="border-token-border-light border-t p-2 text-red-500">
|
||||
{validationResult.message.split('\n').map((line: string, i: number) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!!data && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center">
|
||||
<label className="text-token-text-primary block font-medium">Available actions</label>
|
||||
</div>
|
||||
<ActionsTable columns={columns} data={data} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex items-center">
|
||||
<span className="" data-state="closed">
|
||||
<label className="text-token-text-primary block font-medium">Privacy policy</label>
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border border-gray-300 px-3 py-2 shadow-none focus-within:border-gray-800 focus-within:ring-1 focus-within:ring-gray-800 dark:bg-gray-700 dark:focus-within:border-white dark:focus-within:ring-white">
|
||||
<label
|
||||
htmlFor="privacyPolicyUrl"
|
||||
className="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="privacyPolicyUrl"
|
||||
id="privacyPolicyUrl"
|
||||
className="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 shadow-none outline-none focus-within:shadow-none focus-within:outline-none focus-within:ring-0 focus:border-none focus:ring-0 dark:bg-gray-700 dark:text-gray-100 sm:text-sm"
|
||||
placeholder="https://api.example-weather-app.com/privacy"
|
||||
// value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
disabled={!functions || !functions.length}
|
||||
onClick={saveAction}
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
|
||||
type="button"
|
||||
>
|
||||
{/* TODO: Add localization */}
|
||||
{updateAction.isLoading ? (
|
||||
<Spinner className="icon-md" />
|
||||
) : action?.action_id ? (
|
||||
'Update'
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
178
client/src/components/SidePanel/Builder/ActionsPanel.tsx
Normal file
178
client/src/components/SidePanel/Builder/ActionsPanel.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import {
|
||||
AuthTypeEnum,
|
||||
AuthorizationTypeEnum,
|
||||
TokenExchangeMethodEnum,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AssistantPanelProps, ActionAuthForm } from '~/common';
|
||||
import { Dialog, DialogTrigger } from '~/components/ui';
|
||||
import { useDeleteAction } from '~/data-provider';
|
||||
import { NewTrashIcon } from '~/components/svg';
|
||||
import ActionsInput from './ActionsInput';
|
||||
import ActionsAuth from './ActionsAuth';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function ActionsPanel({
|
||||
// activePanel,
|
||||
action,
|
||||
setAction,
|
||||
setActivePanel,
|
||||
assistant_id,
|
||||
}: AssistantPanelProps) {
|
||||
const [openAuthDialog, setOpenAuthDialog] = useState(false);
|
||||
const deleteAction = useDeleteAction({
|
||||
onSuccess: () => {
|
||||
setActivePanel(Panel.builder);
|
||||
setAction(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
const methods = useForm<ActionAuthForm>({
|
||||
defaultValues: {
|
||||
/* General */
|
||||
type: AuthTypeEnum.None,
|
||||
saved_auth_fields: false,
|
||||
/* API key */
|
||||
api_key: '',
|
||||
authorization_type: AuthorizationTypeEnum.Basic,
|
||||
custom_auth_header: '',
|
||||
/* OAuth */
|
||||
oauth_client_id: '',
|
||||
oauth_client_secret: '',
|
||||
authorization_url: '',
|
||||
client_url: '',
|
||||
scope: '',
|
||||
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
|
||||
},
|
||||
});
|
||||
|
||||
const { reset, watch } = methods;
|
||||
const type = watch('type');
|
||||
|
||||
useEffect(() => {
|
||||
if (action?.metadata?.auth) {
|
||||
reset({
|
||||
type: action.metadata.auth.type || AuthTypeEnum.None,
|
||||
saved_auth_fields: false,
|
||||
api_key: action.metadata.api_key ?? '',
|
||||
authorization_type: action.metadata.auth.authorization_type || AuthorizationTypeEnum.Basic,
|
||||
oauth_client_id: action.metadata.oauth_client_id ?? '',
|
||||
oauth_client_secret: action.metadata.oauth_client_secret ?? '',
|
||||
authorization_url: action.metadata.auth.authorization_url ?? '',
|
||||
client_url: action.metadata.auth.client_url ?? '',
|
||||
scope: action.metadata.auth.scope ?? '',
|
||||
token_exchange_method:
|
||||
action.metadata.auth.token_exchange_method ?? TokenExchangeMethodEnum.DefaultPost,
|
||||
});
|
||||
}
|
||||
}, [action, reset]);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className="h-full grow overflow-hidden">
|
||||
<div className="h-full overflow-auto px-2 pb-12 text-sm">
|
||||
<div className="relative flex flex-col items-center px-16 py-6 text-center">
|
||||
<div className="absolute left-0 top-6">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-neutral relative"
|
||||
onClick={() => {
|
||||
setActivePanel(Panel.builder);
|
||||
setAction(undefined);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md"
|
||||
>
|
||||
<path
|
||||
d="M15 5L8 12L15 19"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{!!action && (
|
||||
<div className="absolute right-0 top-6">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!assistant_id || !action.action_id}
|
||||
className="btn btn-neutral relative text-red-500"
|
||||
onClick={() => {
|
||||
if (!assistant_id) {
|
||||
return prompt('No assistant_id found, is the assistant created?');
|
||||
}
|
||||
const confirmed = confirm('Are you sure you want to delete this action?');
|
||||
if (confirmed) {
|
||||
deleteAction.mutate({
|
||||
action_id: action.action_id,
|
||||
assistant_id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<NewTrashIcon className="icon-md text-red-500" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xl font-medium">{(action ? 'Edit' : 'Add') + ' ' + 'actions'}</div>
|
||||
<div className="text-token-text-tertiary text-sm">
|
||||
{/* TODO: use App title */}
|
||||
Let your Assistant retrieve information or take actions outside of LibreChat.
|
||||
</div>
|
||||
{/* <div className="text-sm text-token-text-tertiary">
|
||||
<a href="https://help.openai.com/en/articles/8554397-creating-a-gpt" target="_blank" rel="noreferrer" className="font-medium">Learn more.</a>
|
||||
</div> */}
|
||||
</div>
|
||||
<Dialog open={openAuthDialog} onOpenChange={setOpenAuthDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<div className="relative mb-6">
|
||||
<div className="mb-1.5 flex items-center">
|
||||
<label className="text-token-text-primary block font-medium">
|
||||
Authentication
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-token-border-medium flex rounded-lg border text-sm hover:cursor-pointer">
|
||||
<div className="h-9 grow px-3 py-2">{type}</div>
|
||||
<div className="bg-token-border-medium w-px"></div>
|
||||
<button type="button" color="neutral" className="flex items-center gap-2 px-3">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-sm"
|
||||
>
|
||||
<path
|
||||
d="M11.6439 3C10.9352 3 10.2794 3.37508 9.92002 3.98596L9.49644 4.70605C8.96184 5.61487 7.98938 6.17632 6.93501 6.18489L6.09967 6.19168C5.39096 6.19744 4.73823 6.57783 4.38386 7.19161L4.02776 7.80841C3.67339 8.42219 3.67032 9.17767 4.01969 9.7943L4.43151 10.5212C4.95127 11.4386 4.95127 12.5615 4.43151 13.4788L4.01969 14.2057C3.67032 14.8224 3.67339 15.5778 4.02776 16.1916L4.38386 16.8084C4.73823 17.4222 5.39096 17.8026 6.09966 17.8083L6.93502 17.8151C7.98939 17.8237 8.96185 18.3851 9.49645 19.294L9.92002 20.014C10.2794 20.6249 10.9352 21 11.6439 21H12.3561C13.0648 21 13.7206 20.6249 14.08 20.014L14.5035 19.294C15.0381 18.3851 16.0106 17.8237 17.065 17.8151L17.9004 17.8083C18.6091 17.8026 19.2618 17.4222 19.6162 16.8084L19.9723 16.1916C20.3267 15.5778 20.3298 14.8224 19.9804 14.2057L19.5686 13.4788C19.0488 12.5615 19.0488 11.4386 19.5686 10.5212L19.9804 9.7943C20.3298 9.17767 20.3267 8.42219 19.9723 7.80841L19.6162 7.19161C19.2618 6.57783 18.6091 6.19744 17.9004 6.19168L17.065 6.18489C16.0106 6.17632 15.0382 5.61487 14.5036 4.70605L14.08 3.98596C13.7206 3.37508 13.0648 3 12.3561 3H11.6439Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="2.5" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<ActionsAuth setOpenAuthDialog={setOpenAuthDialog} />
|
||||
</Dialog>
|
||||
<ActionsInput action={action} assistant_id={assistant_id} setAction={setAction} />
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
export type Spec = {
|
||||
name: string;
|
||||
method: string;
|
||||
path: string;
|
||||
domain: string;
|
||||
};
|
||||
|
||||
export const fakeData: Spec[] = [
|
||||
{
|
||||
name: 'listPets',
|
||||
method: 'get',
|
||||
path: '/pets',
|
||||
domain: 'petstore.swagger.io',
|
||||
},
|
||||
{
|
||||
name: 'createPets',
|
||||
method: 'post',
|
||||
path: '/pets',
|
||||
domain: 'petstore.swagger.io',
|
||||
},
|
||||
{
|
||||
name: 'showPetById',
|
||||
method: 'get',
|
||||
path: '/pets/{petId}',
|
||||
domain: 'petstore.swagger.io',
|
||||
},
|
||||
];
|
||||
|
||||
export const columns: ColumnDef<Spec>[] = [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Method',
|
||||
accessorKey: 'method',
|
||||
},
|
||||
{
|
||||
header: 'Path',
|
||||
accessorKey: 'path',
|
||||
},
|
||||
// {
|
||||
// header: '',
|
||||
// accessorKey: 'action',
|
||||
// // eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// cell: ({ row: _row }) => (
|
||||
// <button className="btn relative btn-neutral h-8 rounded-lg border-token-border-light font-medium">
|
||||
// <div className="flex w-full gap-2 items-center justify-center">Test</div>
|
||||
// </button>
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { useReactTable, flexRender, getCoreRowModel } from '@tanstack/react-table';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-token-border-light text-token-text-tertiary border-b text-left text-xs"
|
||||
>
|
||||
{headerGroup.headers.map((header, j) => (
|
||||
<th key={j} className="py-1 font-normal">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row, i) => (
|
||||
<tr key={i} className="border-token-border-light border-b">
|
||||
{row.getVisibleCells().map((cell, j) => (
|
||||
<td key={j} className="py-2">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ActionsTable } from './Table';
|
||||
export * from './Columns';
|
||||
33
client/src/components/SidePanel/Builder/AssistantAction.tsx
Normal file
33
client/src/components/SidePanel/Builder/AssistantAction.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Action } from 'librechat-data-provider';
|
||||
import GearIcon from '~/components/svg/GearIcon';
|
||||
|
||||
export default function AssistantAction({
|
||||
action,
|
||||
onClick,
|
||||
}: {
|
||||
action: Action;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="border-token-border-medium flex w-full rounded-lg border text-sm hover:cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="h-9 grow px-3 py-2"
|
||||
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
||||
>
|
||||
{action.metadata.domain}
|
||||
</div>
|
||||
<div className="w-px bg-gray-300 dark:bg-gray-600" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 min-w-9 items-center justify-center rounded-lg rounded-l-none"
|
||||
>
|
||||
<GearIcon className="icon-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
client/src/components/SidePanel/Builder/AssistantAvatar.tsx
Normal file
204
client/src/components/SidePanel/Builder/AssistantAvatar.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fileConfig as defaultFileConfig,
|
||||
QueryKeys,
|
||||
defaultOrderQuery,
|
||||
mergeFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type {
|
||||
Metadata,
|
||||
AssistantListResponse,
|
||||
Assistant,
|
||||
AssistantCreateParams,
|
||||
} from 'librechat-data-provider';
|
||||
import { useUploadAssistantAvatarMutation, useGetFileConfig } from '~/data-provider';
|
||||
import { AssistantAvatar, NoImage, AvatarMenu } from './Images';
|
||||
import { useToastContext } from '~/Providers';
|
||||
// import { Spinner } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
// import { cn } from '~/utils/';
|
||||
|
||||
function Avatar({
|
||||
assistant_id,
|
||||
metadata,
|
||||
createMutation,
|
||||
}: {
|
||||
assistant_id: string | null;
|
||||
metadata: null | Metadata;
|
||||
createMutation: UseMutationResult<Assistant, Error, AssistantCreateParams>;
|
||||
}) {
|
||||
// console.log('Avatar', assistant_id, metadata, createMutation);
|
||||
const queryClient = useQueryClient();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [progress, setProgress] = useState<number>(1);
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const lastSeenCreatedId = useRef<string | null>(null);
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const { mutate: uploadAvatar } = useUploadAssistantAvatarMutation({
|
||||
onMutate: () => {
|
||||
setProgress(0.4);
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
if (!vars.postCreation) {
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
} else if (lastSeenCreatedId.current !== createMutation.data?.id) {
|
||||
lastSeenCreatedId.current = createMutation.data?.id ?? '';
|
||||
}
|
||||
|
||||
setInput(null);
|
||||
setPreviewUrl(data.metadata?.avatar as string | null);
|
||||
|
||||
const res = queryClient.getQueryData<AssistantListResponse>([
|
||||
QueryKeys.assistants,
|
||||
defaultOrderQuery,
|
||||
]);
|
||||
|
||||
if (!res?.data || !res) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assistants =
|
||||
res.data.map((assistant) => {
|
||||
if (assistant.id === assistant_id) {
|
||||
return {
|
||||
...assistant,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
return assistant;
|
||||
}) ?? [];
|
||||
|
||||
queryClient.setQueryData<AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], {
|
||||
...res,
|
||||
data: assistants,
|
||||
});
|
||||
|
||||
setProgress(1);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error:', error);
|
||||
setInput(null);
|
||||
setPreviewUrl(null);
|
||||
showToast({ message: localize('com_ui_upload_error'), status: 'error' });
|
||||
setProgress(1);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (input) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreviewUrl(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(input);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewUrl((metadata?.avatar as string | undefined) ?? null);
|
||||
}, [metadata]);
|
||||
|
||||
useEffect(() => {
|
||||
/** Experimental: Condition to prime avatar upload before Assistant Creation
|
||||
* - If the createMutation state Id was last seen (current) and the createMutation is successful
|
||||
* we can assume that the avatar upload has already been initiated and we can skip the upload
|
||||
*
|
||||
* The mutation state is not reset until the user deliberately selects a new assistant or an assistant is deleted
|
||||
*
|
||||
* This prevents the avatar from being uploaded multiple times before the user selects a new assistant
|
||||
* while allowing the user to upload to prime the avatar and other values before the assistant is created.
|
||||
*/
|
||||
const sharedUploadCondition = !!(
|
||||
createMutation.isSuccess &&
|
||||
input &&
|
||||
previewUrl &&
|
||||
previewUrl?.includes('base64')
|
||||
);
|
||||
if (sharedUploadCondition && lastSeenCreatedId.current === createMutation.data?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sharedUploadCondition && createMutation.data.id) {
|
||||
console.log('[AssistantAvatar] Uploading Avatar after Assistant Creation');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', input, input.name);
|
||||
formData.append('assistant_id', createMutation.data.id);
|
||||
|
||||
if (typeof createMutation.data?.metadata === 'object') {
|
||||
formData.append('metadata', JSON.stringify(createMutation.data?.metadata));
|
||||
}
|
||||
|
||||
uploadAvatar({
|
||||
assistant_id: createMutation.data.id,
|
||||
postCreation: true,
|
||||
formData,
|
||||
});
|
||||
}
|
||||
}, [createMutation.data, createMutation.isSuccess, input, previewUrl, uploadAvatar]);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (fileConfig.avatarSizeLimit && file && file.size <= fileConfig.avatarSizeLimit) {
|
||||
if (!file) {
|
||||
console.error('No file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setInput(file);
|
||||
setMenuOpen(false);
|
||||
|
||||
if (!assistant_id) {
|
||||
// wait for successful form submission before uploading avatar
|
||||
console.log('[AssistantAvatar] No assistant_id, will wait until form submission + upload');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
formData.append('assistant_id', assistant_id);
|
||||
|
||||
if (typeof metadata === 'object') {
|
||||
formData.append('metadata', JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
uploadAvatar({
|
||||
assistant_id,
|
||||
formData,
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
message: localize('com_ui_upload_invalid'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Root open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<div className="flex w-full items-center justify-center gap-4">
|
||||
<Popover.Trigger asChild>
|
||||
<button type="button" className="h-20 w-20">
|
||||
{previewUrl ? <AssistantAvatar url={previewUrl} progress={progress} /> : <NoImage />}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
</div>
|
||||
{<AvatarMenu handleFileChange={handleFileChange} />}
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default Avatar;
|
||||
464
client/src/components/SidePanel/Builder/AssistantPanel.tsx
Normal file
464
client/src/components/SidePanel/Builder/AssistantPanel.tsx
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import { useForm, FormProvider, Controller, useWatch } from 'react-hook-form';
|
||||
import {
|
||||
Tools,
|
||||
QueryKeys,
|
||||
EModelEndpoint,
|
||||
actionDelimiter,
|
||||
supportsRetrieval,
|
||||
defaultAssistantFormValues,
|
||||
} from 'librechat-data-provider';
|
||||
import type { FunctionTool, TPlugin } from 'librechat-data-provider';
|
||||
import type { AssistantForm, AssistantPanelProps } from '~/common';
|
||||
import { useCreateAssistantMutation, useUpdateAssistantMutation } from '~/data-provider';
|
||||
import { SelectDropDown, Checkbox, QuestionMark } from '~/components/ui';
|
||||
import { useAssistantsMapContext, useToastContext } from '~/Providers';
|
||||
import { useSelectAssistant, useLocalize } from '~/hooks';
|
||||
import { ToolSelectDialog } from '~/components/Tools';
|
||||
import AssistantAvatar from './AssistantAvatar';
|
||||
import AssistantSelect from './AssistantSelect';
|
||||
import AssistantAction from './AssistantAction';
|
||||
import ContextButton from './ContextButton';
|
||||
import AssistantTool from './AssistantTool';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
import Knowledge from './Knowledge';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
const labelClass = 'mb-2 block text-xs font-bold text-gray-700 dark:text-gray-400';
|
||||
const inputClass =
|
||||
'focus:shadow-outline w-full appearance-none rounded-md border px-3 py-2 text-sm leading-tight text-gray-700 dark:text-white shadow focus:border-green-500 focus:outline-none focus:ring-0 dark:bg-gray-800 dark:border-gray-700/80';
|
||||
|
||||
export default function AssistantPanel({
|
||||
// index = 0,
|
||||
setAction,
|
||||
actions = [],
|
||||
setActivePanel,
|
||||
assistant_id: current_assistant_id,
|
||||
setCurrentAssistantId,
|
||||
}: AssistantPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const modelsQuery = useGetModelsQuery();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const [showToolDialog, setShowToolDialog] = useState(false);
|
||||
const allTools = queryClient.getQueryData<TPlugin[]>([QueryKeys.tools]) ?? [];
|
||||
const { onSelect: onSelectAssistant } = useSelectAssistant();
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
const methods = useForm<AssistantForm>({
|
||||
defaultValues: defaultAssistantFormValues,
|
||||
});
|
||||
const { control, handleSubmit, reset, setValue, getValues } = methods;
|
||||
const assistant_id = useWatch({ control, name: 'id' });
|
||||
const assistant = useWatch({ control, name: 'assistant' });
|
||||
const functions = useWatch({ control, name: 'functions' });
|
||||
const model = useWatch({ control, name: 'model' });
|
||||
|
||||
useEffect(() => {
|
||||
if (model && !supportsRetrieval.has(model)) {
|
||||
setValue('retrieval', false);
|
||||
}
|
||||
}, [model, setValue]);
|
||||
|
||||
/* Mutations */
|
||||
const update = useUpdateAssistantMutation({
|
||||
onSuccess: (data) => {
|
||||
showToast({
|
||||
message: `${localize('com_assistants_update_success')} ${
|
||||
data.name ?? localize('com_ui_assistant')
|
||||
}`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
showToast({
|
||||
message: `${localize('com_assistants_update_error')}${
|
||||
error?.message ? ` ${localize('com_ui_error')}: ${error?.message}` : ''
|
||||
}`,
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
const create = useCreateAssistantMutation({
|
||||
onSuccess: (data) => {
|
||||
setCurrentAssistantId(data.id);
|
||||
showToast({
|
||||
message: `${localize('com_assistants_create_success')} ${
|
||||
data.name ?? localize('com_ui_assistant')
|
||||
}`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
showToast({
|
||||
message: `${localize('com_assistants_create_error')}${
|
||||
error?.message ? ` ${localize('com_ui_error')}: ${error?.message}` : ''
|
||||
}`,
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const files = useMemo(() => {
|
||||
if (typeof assistant === 'string') {
|
||||
return [];
|
||||
}
|
||||
return assistant.files;
|
||||
}, [assistant]);
|
||||
|
||||
const onSubmit = (data: AssistantForm) => {
|
||||
const tools: Array<FunctionTool | string> = [...functions].map((functionName) => {
|
||||
if (!functionName.includes(actionDelimiter)) {
|
||||
return functionName;
|
||||
} else {
|
||||
const assistant = assistantMap?.[assistant_id];
|
||||
const tool = assistant?.tools?.find((tool) => tool.function?.name === functionName);
|
||||
if (assistant && tool) {
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
|
||||
return functionName;
|
||||
});
|
||||
|
||||
console.log(data);
|
||||
if (data.code_interpreter) {
|
||||
tools.push({ type: Tools.code_interpreter });
|
||||
}
|
||||
if (data.retrieval) {
|
||||
tools.push({ type: Tools.retrieval });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
model,
|
||||
// file_ids, // TODO: add file handling here
|
||||
} = data;
|
||||
|
||||
if (assistant_id) {
|
||||
update.mutate({
|
||||
assistant_id,
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
model,
|
||||
tools,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
create.mutate({
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
model,
|
||||
tools,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="h-auto w-full flex-shrink-0 overflow-x-hidden"
|
||||
>
|
||||
<div className="flex w-full flex-wrap">
|
||||
<Controller
|
||||
name="assistant"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AssistantSelect
|
||||
reset={reset}
|
||||
value={field.value}
|
||||
setCurrentAssistantId={setCurrentAssistantId}
|
||||
selectedAssistant={current_assistant_id ?? null}
|
||||
createMutation={create}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* Select Button */}
|
||||
{assistant_id && (
|
||||
<button
|
||||
className="btn btn-primary focus:shadow-outline mx-2 mt-1 h-[40px] rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
type="button"
|
||||
disabled={!assistant_id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSelectAssistant(assistant_id);
|
||||
}}
|
||||
>
|
||||
{localize('com_ui_select')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-auto bg-white px-4 pb-8 pt-3 dark:bg-transparent">
|
||||
{/* Avatar & Name */}
|
||||
<div className="mb-4">
|
||||
<AssistantAvatar
|
||||
createMutation={create}
|
||||
assistant_id={assistant_id ?? null}
|
||||
metadata={assistant?.['metadata'] ?? null}
|
||||
/>
|
||||
<label className={labelClass} htmlFor="name">
|
||||
{localize('com_ui_name')}
|
||||
</label>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
{...{ max: 256 }}
|
||||
className={inputClass}
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder={localize('com_assistants_name_placeholder')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<p className="h-3 text-xs italic text-gray-600">{field.value ?? ''}</p>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div className="mb-4">
|
||||
<label className={labelClass} htmlFor="description">
|
||||
{localize('com_ui_description')}
|
||||
</label>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
{...{ max: 512 }}
|
||||
className={inputClass}
|
||||
id="description"
|
||||
type="text"
|
||||
placeholder={localize('com_assistants_description_placeholder')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mb-6">
|
||||
<label className={labelClass} htmlFor="instructions">
|
||||
{localize('com_ui_instructions')}
|
||||
</label>
|
||||
<Controller
|
||||
name="instructions"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<textarea
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
{...{ max: 32768 }}
|
||||
className="focus:shadow-outline min-h-[150px] w-full resize-none resize-y appearance-none rounded-md border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-700/80 dark:bg-gray-800 dark:text-white"
|
||||
id="instructions"
|
||||
placeholder={localize('com_assistants_instructions_placeholder')}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Model */}
|
||||
<div className="mb-6">
|
||||
<label className={labelClass} htmlFor="model">
|
||||
{localize('com_ui_model')}
|
||||
</label>
|
||||
<Controller
|
||||
name="model"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SelectDropDown
|
||||
emptyTitle={true}
|
||||
value={field.value}
|
||||
setValue={field.onChange}
|
||||
availableValues={modelsQuery.data?.[EModelEndpoint.assistants] ?? []}
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
className={cn(
|
||||
cardStyle,
|
||||
'flex h-[40px] w-full flex-none items-center justify-center px-4 hover:cursor-pointer',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Knowledge */}
|
||||
<Knowledge assistant_id={assistant_id} files={files} />
|
||||
{/* Capabilities */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-1.5 flex items-center">
|
||||
<span>
|
||||
<label className="text-token-text-primary block font-medium">Capabilities</label>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
name={'code_interpreter'}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={field?.value?.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label text-token-text-primary w-full cursor-pointer"
|
||||
htmlFor="code_interpreter"
|
||||
onClick={() =>
|
||||
setValue('code_interpreter', !getValues('code_interpreter'), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{localize('com_assistants_code_interpreter')}
|
||||
<QuestionMark />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
name={'retrieval'}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
checked={field.value}
|
||||
disabled={!supportsRetrieval.has(model)}
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={field?.value?.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label
|
||||
className={cn(
|
||||
'form-check-label text-token-text-primary w-full',
|
||||
!supportsRetrieval.has(model) ? 'cursor-no-drop opacity-50' : 'cursor-pointer',
|
||||
)}
|
||||
htmlFor="retrieval"
|
||||
onClick={() =>
|
||||
supportsRetrieval.has(model) &&
|
||||
setValue('retrieval', !getValues('retrieval'), { shouldDirty: true })
|
||||
}
|
||||
>
|
||||
{localize('com_assistants_retrieval')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tools */}
|
||||
<div className="mb-6">
|
||||
<label className={labelClass}>{localize('com_assistants_tools_section')}</label>
|
||||
<div className="space-y-1">
|
||||
{functions.map((func) => (
|
||||
<AssistantTool
|
||||
key={func}
|
||||
tool={func}
|
||||
allTools={allTools}
|
||||
assistant_id={assistant_id}
|
||||
/>
|
||||
))}
|
||||
{actions
|
||||
.filter((action) => action.assistant_id === assistant_id)
|
||||
.map((action, i) => {
|
||||
return (
|
||||
<AssistantAction key={i} action={action} onClick={() => setAction(action)} />
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToolDialog(true)}
|
||||
className="btn btn-neutral border-token-border-light relative mx-1 mt-2 h-8 rounded-lg font-medium"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
{localize('com_assistants_add_tools')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!assistant_id}
|
||||
onClick={() => {
|
||||
if (!assistant_id) {
|
||||
return showToast({
|
||||
message: localize('com_assistants_actions_disabled'),
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
setActivePanel(Panel.actions);
|
||||
}}
|
||||
className="btn btn-neutral border-token-border-light relative mt-2 h-8 rounded-lg font-medium"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
{localize('com_assistants_add_actions')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Context Button */}
|
||||
<ContextButton
|
||||
assistant_id={assistant_id}
|
||||
setCurrentAssistantId={setCurrentAssistantId}
|
||||
createMutation={create}
|
||||
/>
|
||||
{/* Secondary Select Button */}
|
||||
{assistant_id && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
type="button"
|
||||
disabled={!assistant_id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSelectAssistant(assistant_id);
|
||||
}}
|
||||
>
|
||||
{localize('com_ui_select')}
|
||||
</button>
|
||||
)}
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
className="btn btn-primary focus:shadow-outline flex w-[90px] items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500"
|
||||
type="submit"
|
||||
>
|
||||
{create.isLoading || update.isLoading ? (
|
||||
<Spinner className="icon-md" />
|
||||
) : assistant_id ? (
|
||||
localize('com_ui_save')
|
||||
) : (
|
||||
localize('com_ui_create')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolSelectDialog
|
||||
isOpen={showToolDialog}
|
||||
setIsOpen={setShowToolDialog}
|
||||
assistant_id={assistant_id}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
190
client/src/components/SidePanel/Builder/AssistantSelect.tsx
Normal file
190
client/src/components/SidePanel/Builder/AssistantSelect.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
defaultAssistantFormValues,
|
||||
defaultOrderQuery,
|
||||
FileSources,
|
||||
} from 'librechat-data-provider';
|
||||
import type { UseFormReset } from 'react-hook-form';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type { Assistant, AssistantCreateParams } from 'librechat-data-provider';
|
||||
import type { AssistantForm, Actions, TAssistantOption, ExtendedFile } from '~/common';
|
||||
import SelectDropDown from '~/components/ui/SelectDropDown';
|
||||
import { useListAssistantsQuery } from '~/data-provider';
|
||||
import { useFileMapContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
const keys = new Set(['name', 'id', 'description', 'instructions', 'model']);
|
||||
|
||||
export default function AssistantSelect({
|
||||
reset,
|
||||
value,
|
||||
selectedAssistant,
|
||||
setCurrentAssistantId,
|
||||
createMutation,
|
||||
}: {
|
||||
reset: UseFormReset<AssistantForm>;
|
||||
value: TAssistantOption;
|
||||
selectedAssistant: string | null;
|
||||
setCurrentAssistantId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
createMutation: UseMutationResult<Assistant, Error, AssistantCreateParams>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const fileMap = useFileMapContext();
|
||||
const lastSelectedAssistant = useRef<string | null>(null);
|
||||
|
||||
const assistants = useListAssistantsQuery(defaultOrderQuery, {
|
||||
select: (res) =>
|
||||
res.data.map((_assistant) => {
|
||||
const assistant = {
|
||||
..._assistant,
|
||||
label: _assistant?.name ?? '',
|
||||
value: _assistant.id,
|
||||
files: _assistant?.file_ids ? ([] as Array<[string, ExtendedFile]>) : undefined,
|
||||
};
|
||||
|
||||
if (assistant.files && _assistant.file_ids) {
|
||||
_assistant.file_ids.forEach((file_id) => {
|
||||
const file = fileMap?.[file_id];
|
||||
if (file) {
|
||||
assistant.files?.push([
|
||||
file_id,
|
||||
{
|
||||
file_id: file.file_id,
|
||||
type: file.type,
|
||||
filepath: file.filepath,
|
||||
filename: file.filename,
|
||||
width: file.width,
|
||||
height: file.height,
|
||||
size: file.bytes,
|
||||
preview: file.filepath,
|
||||
progress: 1,
|
||||
source: FileSources.openai,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
return assistant;
|
||||
}),
|
||||
});
|
||||
|
||||
const onSelect = useCallback(
|
||||
(value: string) => {
|
||||
const assistant = assistants.data?.find((assistant) => assistant.id === value);
|
||||
|
||||
createMutation.reset();
|
||||
if (!assistant) {
|
||||
setCurrentAssistantId(undefined);
|
||||
return reset(defaultAssistantFormValues);
|
||||
}
|
||||
|
||||
const update = {
|
||||
...assistant,
|
||||
label: assistant?.name ?? '',
|
||||
value: assistant?.id ?? '',
|
||||
};
|
||||
|
||||
const actions: Actions = {
|
||||
code_interpreter: false,
|
||||
retrieval: false,
|
||||
};
|
||||
|
||||
assistant?.tools
|
||||
?.filter((tool) => tool.type !== 'function')
|
||||
?.map((tool) => tool.type)
|
||||
.forEach((tool) => {
|
||||
actions[tool] = true;
|
||||
});
|
||||
|
||||
const functions =
|
||||
assistant?.tools
|
||||
?.filter((tool) => tool.type === 'function')
|
||||
?.map((tool) => tool.function?.name ?? '') ?? [];
|
||||
|
||||
const formValues: Partial<AssistantForm & Actions> = {
|
||||
functions,
|
||||
...actions,
|
||||
assistant: update,
|
||||
};
|
||||
|
||||
Object.entries(assistant).forEach(([name, value]) => {
|
||||
if (typeof value === 'number') {
|
||||
return;
|
||||
} else if (typeof value === 'object') {
|
||||
return;
|
||||
}
|
||||
if (keys.has(name)) {
|
||||
formValues[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
reset(formValues);
|
||||
setCurrentAssistantId(assistant?.id);
|
||||
},
|
||||
[assistants.data, reset, setCurrentAssistantId, createMutation],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let timerId: NodeJS.Timeout | null = null;
|
||||
|
||||
if (selectedAssistant === lastSelectedAssistant.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAssistant && assistants.data) {
|
||||
timerId = setTimeout(() => {
|
||||
lastSelectedAssistant.current = selectedAssistant;
|
||||
onSelect(selectedAssistant);
|
||||
}, 5);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
};
|
||||
}, [selectedAssistant, assistants.data, onSelect]);
|
||||
|
||||
const createAssistant = localize('com_ui_create') + ' ' + localize('com_ui_assistant');
|
||||
return (
|
||||
<SelectDropDown
|
||||
value={!value ? createAssistant : value}
|
||||
setValue={onSelect}
|
||||
availableValues={
|
||||
assistants.data ?? [
|
||||
{
|
||||
label: 'Loading...',
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
iconSide="left"
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
emptyTitle={true}
|
||||
containerClassName="flex-grow"
|
||||
optionsClass="hover:bg-gray-20/50 dark:border-gray-700"
|
||||
optionsListClass="rounded-lg shadow-lg dark:bg-black dark:border-gray-700 dark:last:border"
|
||||
currentValueClass={cn(
|
||||
'text-md font-semibold text-gray-900 dark:text-white',
|
||||
value === '' ? 'text-gray-500' : '',
|
||||
)}
|
||||
className={cn(
|
||||
'mt-1 rounded-md dark:border-gray-700 dark:bg-black',
|
||||
'z-50 flex h-[40px] w-full flex-none items-center justify-center px-4 hover:cursor-pointer hover:border-green-500 focus:border-green-500',
|
||||
)}
|
||||
renderOption={() => (
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-2 text-gray-800 dark:text-gray-100">
|
||||
<Plus className="w-[16px]" />
|
||||
</span>
|
||||
<span className={cn('ml-4 flex h-6 items-center gap-1 text-gray-800 dark:text-gray-100')}>
|
||||
{createAssistant}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
52
client/src/components/SidePanel/Builder/AssistantTool.tsx
Normal file
52
client/src/components/SidePanel/Builder/AssistantTool.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import GearIcon from '~/components/svg/GearIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function AssistantTool({
|
||||
tool,
|
||||
allTools,
|
||||
assistant_id,
|
||||
}: {
|
||||
tool: string;
|
||||
allTools: TPlugin[];
|
||||
assistant_id?: string;
|
||||
}) {
|
||||
const currentTool = allTools.find((t) => t.pluginKey === tool);
|
||||
|
||||
if (!currentTool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'border-token-border-medium flex w-full rounded-lg border text-sm hover:cursor-pointer',
|
||||
!assistant_id ? 'opacity-40' : '',
|
||||
)}
|
||||
>
|
||||
{currentTool.icon && (
|
||||
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
|
||||
style={{ backgroundImage: `url(${currentTool.icon})`, backgroundSize: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="h-9 grow px-3 py-2"
|
||||
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
||||
>
|
||||
{currentTool.name}
|
||||
</div>
|
||||
<div className="w-px bg-gray-300 dark:bg-gray-600" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 min-w-9 items-center justify-center rounded-lg rounded-l-none"
|
||||
>
|
||||
<GearIcon className="icon-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
client/src/components/SidePanel/Builder/ContextButton.tsx
Normal file
147
client/src/components/SidePanel/Builder/ContextButton.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import * as Popover from '@radix-ui/react-popover';
|
||||
import type { Assistant, AssistantCreateParams } from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import { Dialog, DialogTrigger, Label } from '~/components/ui';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { useDeleteAssistantMutation } from '~/data-provider';
|
||||
import { useLocalize, useSetIndexOptions } from '~/hooks';
|
||||
import { cn, removeFocusOutlines } from '~/utils/';
|
||||
import { NewTrashIcon } from '~/components/svg';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
||||
export default function ContextButton({
|
||||
assistant_id,
|
||||
setCurrentAssistantId,
|
||||
createMutation,
|
||||
}: {
|
||||
assistant_id: string;
|
||||
setCurrentAssistantId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
createMutation: UseMutationResult<Assistant, Error, AssistantCreateParams>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { conversation } = useChatContext();
|
||||
const { setOption } = useSetIndexOptions();
|
||||
|
||||
const deleteAssistant = useDeleteAssistantMutation({
|
||||
onSuccess: (_, vars, context) => {
|
||||
const updatedList = context as Assistant[] | undefined;
|
||||
if (!updatedList) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (createMutation.data?.id) {
|
||||
console.log('[deleteAssistant] resetting createMutation');
|
||||
createMutation.reset();
|
||||
}
|
||||
|
||||
const firstAssistant = updatedList[0] as Assistant | undefined;
|
||||
if (!firstAssistant) {
|
||||
return setOption('assistant_id')('');
|
||||
}
|
||||
|
||||
if (vars.assistant_id === conversation?.assistant_id) {
|
||||
return setOption('assistant_id')(firstAssistant.id);
|
||||
}
|
||||
|
||||
const currentAssistant = updatedList?.find(
|
||||
(assistant) => assistant.id === conversation?.assistant_id,
|
||||
);
|
||||
|
||||
if (currentAssistant) {
|
||||
setCurrentAssistantId(currentAssistant.id);
|
||||
}
|
||||
|
||||
setCurrentAssistantId(firstAssistant.id);
|
||||
},
|
||||
});
|
||||
|
||||
if (!assistant_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3 12C3 10.8954 3.89543 10 5 10C6.10457 10 7 10.8954 7 12C7 13.1046 6.10457 14 5 14C3.89543 14 3 13.1046 3 12ZM10 12C10 10.8954 10.8954 10 12 10C13.1046 10 14 10.8954 14 12C14 13.1046 13.1046 14 12 14C10.8954 14 10 13.1046 10 12ZM17 12C17 10.8954 17.8954 10 19 10C20.1046 10 21 10.8954 21 12C21 13.1046 20.1046 14 19 14C17.8954 14 17 13.1046 17 12Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: ' 0px',
|
||||
top: ' 0px',
|
||||
transform: 'translate(1772.8px, 49.6px)',
|
||||
minWidth: 'max-content',
|
||||
zIndex: 'auto',
|
||||
}}
|
||||
dir="ltr"
|
||||
>
|
||||
<Popover.Content
|
||||
side="top"
|
||||
role="menu"
|
||||
className="bg-token-surface-primary min-w-[180px] max-w-xs rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-black"
|
||||
style={{ outline: 'none', pointerEvents: 'auto' }}
|
||||
sideOffset={8}
|
||||
tabIndex={-1}
|
||||
align="end"
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Popover.Close
|
||||
role="menuitem"
|
||||
className="group m-1.5 flex w-full cursor-pointer gap-2 rounded p-2.5 text-sm text-red-500 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<NewTrashIcon />
|
||||
{localize('com_ui_delete') + ' ' + localize('com_ui_assistant')}
|
||||
</Popover.Close>
|
||||
</DialogTrigger>
|
||||
</Popover.Content>
|
||||
</div>
|
||||
<DialogTemplate
|
||||
title={localize('com_ui_delete') + ' ' + localize('com_ui_assistant')}
|
||||
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="delete-assistant" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_assistant_confirm')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: () => deleteAssistant.mutate({ assistant_id }),
|
||||
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</Popover.Root>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
133
client/src/components/SidePanel/Builder/Images.tsx
Normal file
133
client/src/components/SidePanel/Builder/Images.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { useRef } from 'react';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
|
||||
export function NoImage() {
|
||||
return (
|
||||
<div className="border-token-border-medium flex h-full w-full items-center justify-center rounded-full border-2 border-dashed border-black">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-4xl"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AssistantAvatar = ({
|
||||
url,
|
||||
progress = 1,
|
||||
}: {
|
||||
url?: string;
|
||||
progress: number; // between 0 and 1
|
||||
}) => {
|
||||
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>
|
||||
<div className="relative overflow-hidden rounded-full">
|
||||
<img
|
||||
src={url}
|
||||
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full"
|
||||
alt="GPT"
|
||||
width="80"
|
||||
height="80"
|
||||
style={{ opacity: progress < 1 ? 0.4 : 1 }}
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function AvatarMenu({
|
||||
handleFileChange,
|
||||
}: {
|
||||
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onItemClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="flex min-w-[100px] max-w-xs flex-col rounded-xl border border-gray-400 bg-white shadow-lg dark:border-gray-700 dark:bg-black dark:text-white"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div
|
||||
role="menuitem"
|
||||
className="group m-1.5 flex cursor-pointer gap-2 rounded p-2.5 text-sm hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
|
||||
tabIndex={-1}
|
||||
data-orientation="vertical"
|
||||
onClick={onItemClick}
|
||||
>
|
||||
Upload Photo
|
||||
</div>
|
||||
{/* <Popover.Close
|
||||
role="menuitem"
|
||||
className="group m-1.5 flex cursor-pointer gap-2 rounded p-2.5 text-sm hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
|
||||
tabIndex={-1}
|
||||
data-orientation="vertical"
|
||||
>
|
||||
Use DALL·E
|
||||
</Popover.Close> */}
|
||||
<input
|
||||
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
|
||||
multiple={false}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
ref={fileInputRef}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
);
|
||||
}
|
||||
128
client/src/components/SidePanel/Builder/Knowledge.tsx
Normal file
128
client/src/components/SidePanel/Builder/Knowledge.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
EModelEndpoint,
|
||||
retrievalMimeTypes,
|
||||
fileConfig as defaultFileConfig,
|
||||
mergeFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import FileRow from '~/components/Chat/Input/Files/FileRow';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { useFileHandling } from '~/hooks/Files';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
||||
const CodeInterpreterFiles = ({ children }: { children: React.ReactNode }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<div>
|
||||
<div className="text-token-text-tertiary mb-2 text-xs">
|
||||
{localize('com_assistants_code_interpreter_files')}
|
||||
</div>
|
||||
{/* Files available to Code Interpreter only */}
|
||||
<div className="flex flex-wrap gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Knowledge({
|
||||
assistant_id,
|
||||
files: _files,
|
||||
}: {
|
||||
assistant_id: string;
|
||||
files?: [string, ExtendedFile][];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { setFilesLoading } = useChatContext();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.assistants,
|
||||
additionalMetadata: { assistant_id },
|
||||
fileSetter: setFiles,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (_files) {
|
||||
setFiles(new Map(_files));
|
||||
}
|
||||
}, [_files]);
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.assistants];
|
||||
|
||||
if (endpointFileConfig?.disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleButtonClick = () => {
|
||||
// necessary to reset the input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-1.5 flex items-center">
|
||||
<span>
|
||||
<label className="text-token-text-primary block font-medium">
|
||||
{assistant_id
|
||||
? localize('com_assistants_knowledge')
|
||||
: localize('com_assistants_knowledge_disabled')}
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-token-text-tertiary rounded-lg">
|
||||
{assistant_id ? localize('com_assistants_knowledge_info') : ''}
|
||||
</div>
|
||||
{/* Files available to both tools */}
|
||||
<FileRow
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
setFilesLoading={setFilesLoading}
|
||||
assistant_id={assistant_id}
|
||||
fileFilter={(file: ExtendedFile) =>
|
||||
retrievalMimeTypes.some((regex) => regex.test(file.type ?? ''))
|
||||
}
|
||||
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
|
||||
/>
|
||||
<FileRow
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
setFilesLoading={setFilesLoading}
|
||||
assistant_id={assistant_id}
|
||||
fileFilter={(file: ExtendedFile) =>
|
||||
!retrievalMimeTypes.some((regex) => regex.test(file.type ?? ''))
|
||||
}
|
||||
Wrapper={CodeInterpreterFiles}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!assistant_id}
|
||||
className="btn btn-neutral border-token-border-light relative h-8 rounded-lg font-medium"
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<input
|
||||
multiple={true}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
tabIndex={-1}
|
||||
ref={fileInputRef}
|
||||
disabled={!assistant_id}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{localize('com_ui_upload_files')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
client/src/components/SidePanel/Builder/PanelSwitch.tsx
Normal file
51
client/src/components/SidePanel/Builder/PanelSwitch.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import type { Action } from 'librechat-data-provider';
|
||||
import { useGetActionsQuery } from '~/data-provider';
|
||||
import AssistantPanel from './AssistantPanel';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import ActionsPanel from './ActionsPanel';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function PanelSwitch() {
|
||||
const { conversation, index } = useChatContext();
|
||||
const [activePanel, setActivePanel] = useState(Panel.builder);
|
||||
const [currentAssistantId, setCurrentAssistantId] = useState<string | undefined>(
|
||||
conversation?.assistant_id,
|
||||
);
|
||||
const [action, setAction] = useState<Action | undefined>(undefined);
|
||||
const { data: actions = [] } = useGetActionsQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (conversation?.assistant_id) {
|
||||
setCurrentAssistantId(conversation?.assistant_id);
|
||||
}
|
||||
}, [conversation?.assistant_id]);
|
||||
|
||||
if (activePanel === Panel.actions || action) {
|
||||
return (
|
||||
<ActionsPanel
|
||||
index={index}
|
||||
action={action}
|
||||
actions={actions}
|
||||
setAction={setAction}
|
||||
activePanel={activePanel}
|
||||
setActivePanel={setActivePanel}
|
||||
assistant_id={currentAssistantId}
|
||||
setCurrentAssistantId={setCurrentAssistantId}
|
||||
/>
|
||||
);
|
||||
} else if (activePanel === Panel.builder) {
|
||||
return (
|
||||
<AssistantPanel
|
||||
index={index}
|
||||
activePanel={activePanel}
|
||||
action={action}
|
||||
actions={actions}
|
||||
setAction={setAction}
|
||||
setActivePanel={setActivePanel}
|
||||
assistant_id={currentAssistantId}
|
||||
setCurrentAssistantId={setCurrentAssistantId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
14
client/src/components/SidePanel/Files/Panel.tsx
Normal file
14
client/src/components/SidePanel/Files/Panel.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { TFile } from 'librechat-data-provider';
|
||||
import { useGetFiles } from '~/data-provider';
|
||||
import { columns } from './PanelColumns';
|
||||
import DataTable from './PanelTable';
|
||||
|
||||
export default function FilesPanel() {
|
||||
const { data: files = [] } = useGetFiles<TFile[]>();
|
||||
|
||||
return (
|
||||
<div className="h-auto max-w-full overflow-x-hidden">
|
||||
<DataTable columns={columns} data={files} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
client/src/components/SidePanel/Files/PanelColumns.tsx
Normal file
47
client/src/components/SidePanel/Files/PanelColumns.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { ArrowUpDown } from 'lucide-react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import PanelFileCell from './PanelFileCell';
|
||||
import { Button } from '~/components/ui';
|
||||
import { formatDate } from '~/utils';
|
||||
|
||||
export const columns: ColumnDef<TFile>[] = [
|
||||
{
|
||||
accessorKey: 'filename',
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
size: '150px',
|
||||
},
|
||||
cell: ({ row }) => <PanelFileCell row={row} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'updatedAt',
|
||||
meta: {
|
||||
size: '10%',
|
||||
},
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
Date
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<span className="flex justify-end text-xs">{formatDate(row.original.updatedAt)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
101
client/src/components/SidePanel/Files/PanelFileCell.tsx
Normal file
101
client/src/components/SidePanel/Files/PanelFileCell.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useCallback } from 'react';
|
||||
import {
|
||||
fileConfig as defaultFileConfig,
|
||||
mergeFileConfig,
|
||||
megabyte,
|
||||
} from 'librechat-data-provider';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import { useFileMapContext, useChatContext, useToastContext } from '~/Providers';
|
||||
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
|
||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { useUpdateFiles, useLocalize } from '~/hooks';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { getFileType } from '~/utils';
|
||||
|
||||
export default function PanelFileCell({ row }: { row: Row<TFile> }) {
|
||||
const localize = useLocalize();
|
||||
const fileMap = useFileMapContext();
|
||||
const { showToast } = useToastContext();
|
||||
const { setFiles, conversation } = useChatContext();
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
const { addFile } = useUpdateFiles(setFiles);
|
||||
|
||||
const handleFileClick = useCallback(() => {
|
||||
const file = row.original;
|
||||
const endpoint = conversation?.endpoint;
|
||||
const fileData = fileMap?.[file.file_id];
|
||||
|
||||
if (!fileData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!endpoint) {
|
||||
return showToast({ message: localize('com_ui_attach_error'), status: 'error' });
|
||||
}
|
||||
|
||||
const { fileSizeLimit, supportedMimeTypes } =
|
||||
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
|
||||
|
||||
if (fileData.bytes > fileSizeLimit) {
|
||||
return showToast({
|
||||
message: `${localize('com_ui_attach_error_size')} ${
|
||||
fileSizeLimit / megabyte
|
||||
} MB (${endpoint})`,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
const isSupportedMimeType = defaultFileConfig.checkType(file.type, supportedMimeTypes);
|
||||
|
||||
if (!isSupportedMimeType) {
|
||||
return showToast({
|
||||
message: `${localize('com_ui_attach_error_type')} ${file.type} (${endpoint})`,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
addFile({
|
||||
progress: 1,
|
||||
attached: true,
|
||||
file_id: fileData.file_id,
|
||||
filepath: fileData.filepath,
|
||||
preview: fileData.filepath,
|
||||
type: fileData.type,
|
||||
height: fileData.height,
|
||||
width: fileData.width,
|
||||
filename: fileData.filename,
|
||||
source: fileData.source,
|
||||
size: fileData.bytes,
|
||||
});
|
||||
}, [addFile, fileMap, row.original, conversation, localize, showToast, fileConfig.endpoints]);
|
||||
|
||||
const file = row.original;
|
||||
if (file.type?.startsWith('image')) {
|
||||
return (
|
||||
<div
|
||||
onClick={handleFileClick}
|
||||
className="flex cursor-pointer gap-2 rounded-md dark:hover:bg-gray-900"
|
||||
>
|
||||
<ImagePreview
|
||||
url={file.filepath}
|
||||
className="h-10 w-10 shrink-0 overflow-hidden rounded-md"
|
||||
/>
|
||||
<span className="self-center truncate text-xs">{file.filename}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileType = getFileType(file.type);
|
||||
return (
|
||||
<div
|
||||
onClick={handleFileClick}
|
||||
className="flex cursor-pointer gap-2 rounded-md dark:hover:bg-gray-900"
|
||||
>
|
||||
{fileType && <FilePreview fileType={fileType} />}
|
||||
<span className="self-center truncate">{file.filename}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
client/src/components/SidePanel/Files/PanelTable.tsx
Normal file
165
client/src/components/SidePanel/Files/PanelTable.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { useState } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { LucideArrowUpLeft } 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 {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '~/components/ui';
|
||||
import store from '~/store';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [paginationState, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||
const setShowFiles = useSetRecoilState(store.showFiles);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
pagination: paginationState,
|
||||
},
|
||||
defaultColumn: {
|
||||
minSize: 0,
|
||||
size: 10,
|
||||
maxSize: 10,
|
||||
enableResizing: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4 px-2 py-4">
|
||||
<Input
|
||||
placeholder="Filter files..."
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
className="max-w-xs dark:border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto rounded-md border border-black/10 dark:border-white/10 ">
|
||||
<Table className="border-separate border-spacing-0 ">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup, index) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: index === 0 ? '75%' : '25%' }}
|
||||
className="sticky top-0 h-auto border-b border-black/10 bg-white py-1 text-left font-medium text-gray-700 dark:border-white/10 dark:bg-black dark:text-gray-100"
|
||||
>
|
||||
{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) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="p-2 px-4 [tr[data-disabled=true]_&]:opacity-50"
|
||||
style={{
|
||||
maxWidth: (cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta
|
||||
.size,
|
||||
}}
|
||||
>
|
||||
{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="flex items-center justify-around space-x-2 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFiles(true)}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<LucideArrowUpLeft className="icon-sm" />
|
||||
Manage Files
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
113
client/src/components/SidePanel/Nav.tsx
Normal file
113
client/src/components/SidePanel/Nav.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useState } from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import type { NavLink, NavProps } from '~/common';
|
||||
import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/Tooltip';
|
||||
import { buttonVariants } from '~/components/ui/Button';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) {
|
||||
const localize = useLocalize();
|
||||
const [active, _setActive] = useState<string | undefined>(defaultActive);
|
||||
const getVariant = (link: NavLink) => (link.id === active ? 'default' : 'ghost');
|
||||
|
||||
const setActive = (id: string) => {
|
||||
localStorage.setItem('side:active-panel', id + '');
|
||||
_setActive(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-collapsed={isCollapsed}
|
||||
className="bg-token-sidebar-surface-primary group flex-shrink-0 overflow-x-hidden py-2 data-[collapsed=true]:py-2"
|
||||
>
|
||||
<div className="h-full">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col opacity-100 transition-opacity">
|
||||
<div className="scrollbar-trigger relative h-full w-full flex-1 items-start border-white/20">
|
||||
<nav className="flex h-full w-full flex-col gap-1 px-2 px-3 pb-3.5 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
|
||||
{links.map((link, index) => {
|
||||
const variant = getVariant(link);
|
||||
return isCollapsed ? (
|
||||
<Tooltip key={index} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({ variant, size: 'icon' }),
|
||||
removeFocusOutlines,
|
||||
'h-9 w-9',
|
||||
variant === 'default'
|
||||
? 'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
|
||||
: '',
|
||||
)}
|
||||
onClick={() => {
|
||||
setActive(link.id);
|
||||
resize && resize(25);
|
||||
}}
|
||||
>
|
||||
<link.icon className="h-4 w-4" />
|
||||
<span className="sr-only">{link.title}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-4">
|
||||
{localize(link.title)}
|
||||
{link.label && (
|
||||
<span className="text-muted-foreground ml-auto">{link.label}</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Accordion
|
||||
key={index}
|
||||
type="single"
|
||||
value={active}
|
||||
onValueChange={setActive}
|
||||
collapsible
|
||||
>
|
||||
<AccordionItem value={link.id} className="w-full border-none">
|
||||
<AccordionPrimitive.Header asChild>
|
||||
<AccordionPrimitive.Trigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({ variant, size: 'sm' }),
|
||||
removeFocusOutlines,
|
||||
variant === 'default'
|
||||
? 'dark:bg-muted dark:hover:bg-muted dark:text-white dark:hover:text-white'
|
||||
: '',
|
||||
'data-[state=open]:bg-gray-900 data-[state=open]:text-white dark:data-[state=open]:bg-gray-800',
|
||||
'w-full justify-start rounded-md border dark:border-gray-600',
|
||||
)}
|
||||
>
|
||||
<link.icon className="mr-2 h-4 w-4" />
|
||||
{localize(link.title)}
|
||||
{link.label && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto transition-all duration-300 ease-in-out',
|
||||
variant === 'default' ? 'text-background dark:text-white' : '',
|
||||
isCollapsed ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
|
||||
<AccordionContent className="w-full dark:text-white">
|
||||
{link.Component && <link.Component />}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
client/src/components/SidePanel/SidePanel.tsx
Normal file
214
client/src/components/SidePanel/SidePanel.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import throttle from 'lodash/throttle';
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useGetEndpointsQuery, useUserKeyQuery } from 'librechat-data-provider/react-query';
|
||||
import type { ImperativePanelHandle } from 'react-resizable-panels';
|
||||
import { EModelEndpoint, type TEndpointsConfig } from 'librechat-data-provider';
|
||||
import type { NavLink } from '~/common';
|
||||
import { ResizableHandleAlt, ResizablePanel, ResizablePanelGroup } from '~/components/ui/Resizable';
|
||||
import { TooltipProvider, Tooltip } from '~/components/ui/Tooltip';
|
||||
import { Blocks, AttachmentIcon } from '~/components/svg';
|
||||
import { useMediaQuery, useLocalStorage } from '~/hooks';
|
||||
import { Separator } from '~/components/ui/Separator';
|
||||
import NavToggle from '~/components/Nav/NavToggle';
|
||||
import PanelSwitch from './Builder/PanelSwitch';
|
||||
import FilesPanel from './Files/Panel';
|
||||
import Switcher from './Switcher';
|
||||
import { cn } from '~/utils';
|
||||
import Nav from './Nav';
|
||||
|
||||
interface SidePanelProps {
|
||||
defaultLayout?: number[] | undefined;
|
||||
defaultCollapsed?: boolean;
|
||||
navCollapsedSize?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const defaultMinSize = 20;
|
||||
|
||||
export default function SidePanel({
|
||||
defaultLayout = [97, 3],
|
||||
defaultCollapsed = false,
|
||||
navCollapsedSize = 3,
|
||||
children,
|
||||
}: SidePanelProps) {
|
||||
const [minSize, setMinSize] = useState(defaultMinSize);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
|
||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(EModelEndpoint.assistants);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||
|
||||
const panelRef = useRef<ImperativePanelHandle>(null);
|
||||
|
||||
const activePanel = localStorage.getItem('side:active-panel');
|
||||
const defaultActive = activePanel ? activePanel : undefined;
|
||||
|
||||
const Links = useMemo(() => {
|
||||
const links: NavLink[] = [];
|
||||
const assistants = endpointsConfig?.[EModelEndpoint.assistants];
|
||||
const userProvidesKey = !!assistants?.userProvide;
|
||||
const keyProvided = userProvidesKey ? !!keyExpiry?.expiresAt : true;
|
||||
if (assistants && assistants.disableBuilder !== true && keyProvided) {
|
||||
links.push({
|
||||
title: 'com_sidepanel_assistant_builder',
|
||||
label: '',
|
||||
icon: Blocks,
|
||||
id: 'assistants',
|
||||
Component: PanelSwitch,
|
||||
});
|
||||
}
|
||||
|
||||
links.push({
|
||||
title: 'com_sidepanel_attach_files',
|
||||
label: '',
|
||||
icon: AttachmentIcon,
|
||||
id: 'files',
|
||||
Component: FilesPanel,
|
||||
});
|
||||
|
||||
return links;
|
||||
}, [endpointsConfig, keyExpiry?.expiresAt]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const throttledSaveLayout = useCallback(
|
||||
throttle((sizes: number[]) => {
|
||||
localStorage.setItem('react-resizable-panels:layout', JSON.stringify(sizes));
|
||||
}, 350),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
setIsCollapsed(true);
|
||||
setMinSize(0);
|
||||
setCollapsedSize(0);
|
||||
panelRef.current?.collapse();
|
||||
return;
|
||||
}
|
||||
}, [isSmallScreen]);
|
||||
|
||||
const toggleNavVisible = () => {
|
||||
if (newUser) {
|
||||
setNewUser(false);
|
||||
}
|
||||
setIsCollapsed((prev: boolean) => {
|
||||
if (!prev) {
|
||||
setMinSize(0);
|
||||
setCollapsedSize(0);
|
||||
} else {
|
||||
setMinSize(defaultMinSize);
|
||||
setCollapsedSize(3);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
if (!isCollapsed) {
|
||||
panelRef.current?.collapse();
|
||||
} else {
|
||||
panelRef.current?.expand();
|
||||
}
|
||||
};
|
||||
|
||||
const assistants = endpointsConfig?.[EModelEndpoint.assistants];
|
||||
const userProvidesKey = !!assistants?.userProvide;
|
||||
const keyProvided = userProvidesKey ? !!keyExpiry?.expiresAt : true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
onLayout={(sizes: number[]) => throttledSaveLayout(sizes)}
|
||||
className="transition-width relative h-full w-full flex-1 overflow-auto bg-white dark:bg-gray-800"
|
||||
>
|
||||
<ResizablePanel defaultSize={defaultLayout[0]} minSize={30}>
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<Tooltip>
|
||||
<div
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className="relative flex w-px items-center justify-center"
|
||||
>
|
||||
<NavToggle
|
||||
navVisible={!isCollapsed}
|
||||
isHovering={isHovering}
|
||||
onToggle={toggleNavVisible}
|
||||
setIsHovering={setIsHovering}
|
||||
className={cn(
|
||||
'fixed top-1/2',
|
||||
isCollapsed && (minSize === 0 || collapsedSize === 0) ? 'mr-9' : 'mr-16',
|
||||
)}
|
||||
translateX={false}
|
||||
side="right"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{(!isCollapsed || minSize > 0) && (
|
||||
<ResizableHandleAlt withHandle className="bg-transparent dark:text-white" />
|
||||
)}
|
||||
<ResizablePanel
|
||||
collapsedSize={collapsedSize}
|
||||
defaultSize={defaultLayout[1]}
|
||||
collapsible={true}
|
||||
minSize={minSize}
|
||||
maxSize={40}
|
||||
ref={panelRef}
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
visibility:
|
||||
isCollapsed && (minSize === 0 || collapsedSize === 0) ? 'hidden' : 'visible',
|
||||
transition: 'width 0.2s ease',
|
||||
}}
|
||||
onExpand={() => {
|
||||
setIsCollapsed(false);
|
||||
localStorage.setItem('react-resizable-panels:collapsed', 'false');
|
||||
}}
|
||||
onCollapse={() => {
|
||||
setIsCollapsed(true);
|
||||
localStorage.setItem('react-resizable-panels:collapsed', 'true');
|
||||
}}
|
||||
className={cn(
|
||||
'sidenav border-l border-gray-200 bg-white dark:border-gray-800/50 dark:bg-black',
|
||||
isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]',
|
||||
minSize === 0 ? 'min-w-0' : '',
|
||||
)}
|
||||
>
|
||||
{keyProvided && (
|
||||
<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-black',
|
||||
isCollapsed ? 'h-[52px]' : 'px-2',
|
||||
)}
|
||||
>
|
||||
<Switcher isCollapsed={isCollapsed} />
|
||||
<Separator className="bg-gray-100/50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Nav
|
||||
resize={panelRef.current?.resize}
|
||||
isCollapsed={isCollapsed}
|
||||
defaultActive={defaultActive}
|
||||
links={Links}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</TooltipProvider>
|
||||
<div
|
||||
className={`nav-mask${!isCollapsed ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setIsCollapsed(() => {
|
||||
setCollapsedSize(0);
|
||||
setMinSize(0);
|
||||
return false;
|
||||
});
|
||||
panelRef.current?.collapse();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
client/src/components/SidePanel/Switcher.tsx
Normal file
100
client/src/components/SidePanel/Switcher.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useEffect } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '~/components/ui/Select';
|
||||
import { EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider';
|
||||
import { useSetIndexOptions, useSelectAssistant, useLocalize } from '~/hooks';
|
||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
||||
import { useListAssistantsQuery } from '~/data-provider';
|
||||
import Icon from '~/components/Endpoints/Icon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface SwitcherProps {
|
||||
isCollapsed: boolean;
|
||||
}
|
||||
|
||||
export default function Switcher({ isCollapsed }: SwitcherProps) {
|
||||
const localize = useLocalize();
|
||||
const { setOption } = useSetIndexOptions();
|
||||
const { index, conversation } = useChatContext();
|
||||
|
||||
/* `selectedAssistant` must be defined with `null` to cause re-render on update */
|
||||
const { assistant_id: selectedAssistant = null, endpoint } = conversation ?? {};
|
||||
|
||||
const { data: assistants = [] } = useListAssistantsQuery(defaultOrderQuery, {
|
||||
select: (res) => res.data.map(({ id, name, metadata }) => ({ id, name, metadata })),
|
||||
});
|
||||
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const { onSelect } = useSelectAssistant();
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAssistant && assistants && assistants.length && assistantMap) {
|
||||
const assistant_id =
|
||||
localStorage.getItem(`assistant_id__${index}`) ?? assistants[0]?.id ?? '';
|
||||
const assistant = assistantMap?.[assistant_id];
|
||||
if (!assistant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (endpoint !== EModelEndpoint.assistants) {
|
||||
return;
|
||||
}
|
||||
setOption('model')(assistant.model);
|
||||
setOption('assistant_id')(assistant_id);
|
||||
}
|
||||
}, [index, assistants, selectedAssistant, assistantMap, endpoint, setOption]);
|
||||
|
||||
const currentAssistant = assistantMap?.[selectedAssistant ?? ''];
|
||||
|
||||
return (
|
||||
<Select defaultValue={selectedAssistant as string | undefined} onValueChange={onSelect}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
|
||||
isCollapsed
|
||||
? 'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden'
|
||||
: '',
|
||||
'bg-white',
|
||||
)}
|
||||
aria-label={localize('com_sidepanel_select_assistant')}
|
||||
>
|
||||
<SelectValue placeholder={localize('com_sidepanel_select_assistant')}>
|
||||
<div className="assistant-item flex items-center justify-center overflow-hidden rounded-full">
|
||||
<Icon
|
||||
isCreatedByUser={false}
|
||||
endpoint={EModelEndpoint.assistants}
|
||||
assistantName={currentAssistant?.name ?? ''}
|
||||
iconURL={(currentAssistant?.metadata?.avatar as string) ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn('ml-2', isCollapsed ? 'hidden' : '')}>
|
||||
{assistants.find((assistant) => assistant.id === selectedAssistant)?.name ??
|
||||
localize('com_sidepanel_select_assistant')}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white">
|
||||
{assistants.map((assistant) => (
|
||||
<SelectItem key={assistant.id} value={assistant.id}>
|
||||
<div className="[&_svg]:text-foreground flex items-center justify-center gap-3 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 ">
|
||||
<div className="assistant-item overflow-hidden rounded-full ">
|
||||
<Icon
|
||||
isCreatedByUser={false}
|
||||
endpoint={EModelEndpoint.assistants}
|
||||
assistantName={assistant.name ?? ''}
|
||||
iconURL={(assistant.metadata?.avatar as string) ?? ''}
|
||||
/>
|
||||
</div>
|
||||
{assistant.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue