mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 08:12:00 +02:00
🎨 feat: UI Refresh for Enhanced UX (#6346)
* ✨ feat: Add Expand Chat functionality and improve UI components * ✨ feat: Introduce Chat Badges feature with editing capabilities and UI enhancements * ✨ feat: re-implement file attachment functionality with new components and improved UI * ✨ feat: Enhance BadgeRow component with drag-and-drop functionality and add animations for better user experience * ✨ feat: Add useChatBadges hook and enhance Badge component with animations and toggle functionality * feat: Improve Add/Delete Badges + style and bug fixes * ✨ feat: Refactor EditBadges component and optimize useChatBadges hook for improved performance and readability * ✨ feat: Add type definition for LucideIcon in EditBadges component * refactor: Clean up BadgeRow component by removing outdated comment and improving code readability * refactor: Rename app-icon class to badge-icon for consistency and improve badge styling * feat: Add Center Chat Input toggle and update related components for improved UI/UX * refactor: Simplify ChatView and MessagesView components for improved readability and performance * refactor: Improve layout and positioning of scroll button in MessagesView component * refactor: Adjust scroll button position in MessagesView component for better visibility * refactor: Remove redundant background class from Badge component for cleaner styling * feat: disable chat badges * refactor: adjust positioning of scroll button and popover for improved layout * refactor: simplify class names in ChatForm and RemoveFile components for cleaner code * refactor: move Switcher to HeaderOptions from SidePanel * fix(Landing): duplicate description * feat: add SplitText component for animated text display and update Landing component to use it * feat(Chat): add ConversationStarters component and integrate it into ChatView; remove ConvoStarter component * feat(Chat): enhance Message component layout and styling for improved readability * feat(ControlCombobox, Select): enhance styling and add animation for improved UI experience * feat(Chat): update Header and HeaderNewChat components for improved layout and styling * feat(Chat): add ModelDropdown (now includes both endpoint and model) and refactor Menu components for improved UI * feat(ModelDropdown): add Agent Select; removed old AgentSwitcher components * feat(ModelDropdown): add settings button for user key configuration * fix(ModelDropdown): the model dropdown wasn't opening automatically when opening the endpoint one * refactor(Chat): remove unused EndpointsMenu and related components to streamline codebase * feat: enhance greeting message and improve accessibility fro ModelDropdown * refactor(Endpoints): add new hooks and components for endpoint management * feat(Endpoint): add support for modelSpecs * feat(Endpoints): add mobile support * fix: type issues * fix(modelSpec): type issue * fix(EndpointMenuDropdown): double overflow scroller in mobile model list * fix: search model on mobile * refactor: Endpoint/Model/modelSpec dropdown * refactor: reorganize imports in Endpoint components * refactor: remove unused translation keys from English locale * BREAKING: moving to ariakit with new CustomMenu * refactor: remove unnecessary comments * refactor: remove EndpointItem, ModelDropdownButton, SpecIcon, and SpecItem components * 🔧 fix: AI Icon bump when regenerating message * wip: chat UI refactoring, fix issues * chore: add recent update to useAutoSave * feat: add access control for agent permissions in useMentions hook * refactor: streamline ModelSelector by removing unused endpoints logic * refactor: enhance ModelSelector and context by integrating endpointsConfig and improving type usage * feat: update ModelSelectorContext to utilize conversation data for initial state * feat: add selector effects for synced endpoint handling * feat: add guard clause for conversation endpoint in useSelectorEffects hook * fix: safely call onSelectMention and add autofocus to mention input * chore: typing * refactor: ModelSelector to streamline key dialog handling and improve endpoint rendering * refactor: extract SettingsButton component for cleaner endpoint item rendering * wip: first pass, expand set api key * wip: first pass, expanding set key * refactor: update EndpointItem styles for improved layout and hover effects * refactor: adjust padding in EndpointItem for improved layout consistency * refactor: update preset structure in useSelectMention to include spec as null * refactor: rename setKeyDialogOpen to onOpenChange for clarity and consistency, bring focus back to button that opened dialog * feat: add SpecIcon component for dynamic model spec icons in menu, adjust icon styling * refactor: update getSelectedIcon to accept additional parameters and improve icon rendering logic * fix: adjust padding in MessageRender for improved layout * refactor: remove inline style for menu width in CustomMenu component * refactor: enhance layout and styling in ModelSpecItem component for better responsiveness * refactor: update getDefaultModelSpec to accept startupConfig and improve model spec retrieval logic * refactor: improve key management and default values in ModelSelector and related components * refactor: adjust menu width and improve responsiveness in CustomMenu and EndpointItem components * refactor: enhance focus styles and responsiveness in EndpointItem component * refactor: improve layout and spacing in Header and ModelSelector components for better responsiveness * refactor: adjust button styles for consistency and improved layout in AddMultiConvo and PresetsMenu components * fix: initial fix of assistant names * fix: assistants handling * chore: update version of librechat-data-provider to 0.7.75 and add 'spec' to excludedKeys * fix: improve endpoint filtering logic based on interface configuration and access rights * fix: remove unused HeaderOptions import and set spec to null in presets and mentions * fix: ensure currentExample is always an object when updating examples * fix: update interfaceConfig checks to ensure modelSelect is considered for rendering components * fix: update model selection logic to consider interface configuration when prioritizing model specs * fix: add missing localizations * fix: remove unused agent and assistant selection translations * fix: implement debounced state updates for selected values in useSelectorEffects * style: minor style changes related to the ModelSelector * fix: adjust maximum height for popover and set fixed height for model item * fix: update placeholders for model and endpoint search inputs * fix: refactor MessageRender and ContentRender components to better match each other * fix: remove convo fallback for iconURL in MessageRender and ContentRender components * fix: update handling of spec, iconURL, and modelLabel in conversation presets, to allow better interchangeability * fix: replace chatGptLabel with modelLabel in OpenAI settings configuration (fully deprecate chatGptLabel) * fix: remove console log for assistantNames in useEndpoints hook * refactor: add cleanInput and cleanOutput options to default conversation handling * chore: update bun.lockb * fix: set default value for showIconInHeader in getSelectedIcon function * refactor: enhance error handling in message processing when latest message has existing content blocks * chore: allow import/no-cycle for messages * fix: adjust flex properties in BookmarkMenu for better layout * feat: support both 'prompt' and 'q' as query parameters in useQueryParams hook * feat: re-enable Badges components * refactor: disable edit badge component * chore: rename assistantMap to assistantsMap for consistency * chore: rename assistantMap to assistantsMap for consistency in Mention component * feat: set staleTime for various queries to improve data freshness * feat: add spec field to tQueryParamsSchema for model specification * feat: enhance useQueryParams to handle model specs --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
c4fea9cd79
commit
7f29f2f676
127 changed files with 4507 additions and 2163 deletions
|
@ -879,13 +879,14 @@ class BaseClient {
|
||||||
: await getConvo(this.options.req?.user?.id, message.conversationId);
|
: await getConvo(this.options.req?.user?.id, message.conversationId);
|
||||||
|
|
||||||
const unsetFields = {};
|
const unsetFields = {};
|
||||||
|
const exceptions = new Set(['spec', 'iconURL']);
|
||||||
if (existingConvo != null) {
|
if (existingConvo != null) {
|
||||||
this.fetchedConvo = true;
|
this.fetchedConvo = true;
|
||||||
for (const key in existingConvo) {
|
for (const key in existingConvo) {
|
||||||
if (!key) {
|
if (!key) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (excludedKeys.has(key)) {
|
if (excludedKeys.has(key) && !exceptions.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -52,6 +52,7 @@
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.3",
|
"@radix-ui/react-tabs": "^1.0.3",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@react-spring/web": "^9.7.5",
|
||||||
"@tanstack/react-query": "^4.28.0",
|
"@tanstack/react-query": "^4.28.0",
|
||||||
"@tanstack/react-table": "^8.11.7",
|
"@tanstack/react-table": "^8.11.7",
|
||||||
"class-variance-authority": "^0.6.0",
|
"class-variance-authority": "^0.6.0",
|
||||||
|
@ -141,8 +142,8 @@
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"vite-plugin-node-polyfills": "^0.17.0",
|
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
|
"vite-plugin-node-polyfills": "^0.17.0",
|
||||||
"vite-plugin-pwa": "^0.21.1"
|
"vite-plugin-pwa": "^0.21.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,5 +3,6 @@ export * from './artifacts';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './menus';
|
export * from './menus';
|
||||||
export * from './tools';
|
export * from './tools';
|
||||||
|
export * from './selector';
|
||||||
export * from './assistants-types';
|
export * from './assistants-types';
|
||||||
export * from './agents-types';
|
export * from './agents-types';
|
||||||
|
|
24
client/src/common/selector.ts
Normal file
24
client/src/common/selector.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { TModelSpec, TInterfaceConfig } from 'librechat-data-provider';
|
||||||
|
|
||||||
|
export interface Endpoint {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
hasModels: boolean;
|
||||||
|
models?: string[];
|
||||||
|
icon: React.ReactNode;
|
||||||
|
agentNames?: Record<string, string>;
|
||||||
|
assistantNames?: Record<string, string>;
|
||||||
|
modelIcons?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedValues {
|
||||||
|
endpoint: string | null;
|
||||||
|
model: string | null;
|
||||||
|
modelSpec: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelSelectorProps {
|
||||||
|
interfaceConfig: TInterfaceConfig;
|
||||||
|
modelSpecs: TModelSpec[];
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import { RefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
import { FileSources } from 'librechat-data-provider';
|
import { FileSources, EModelEndpoint } from 'librechat-data-provider';
|
||||||
import type * as InputNumberPrimitive from 'rc-input-number';
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import type { SetterOrUpdater } from 'recoil';
|
|
||||||
import type * as t from 'librechat-data-provider';
|
|
||||||
import type { UseMutationResult } from '@tanstack/react-query';
|
import type { UseMutationResult } from '@tanstack/react-query';
|
||||||
|
import type * as InputNumberPrimitive from 'rc-input-number';
|
||||||
|
import type { SetterOrUpdater, RecoilState } from 'recoil';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import type * as t from 'librechat-data-provider';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import type { TranslationKeys } from '~/hooks';
|
import type { TranslationKeys } from '~/hooks';
|
||||||
|
|
||||||
|
@ -48,6 +48,14 @@ export type AudioChunk = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BadgeItem = {
|
||||||
|
id: string;
|
||||||
|
icon: React.ComponentType<any>;
|
||||||
|
label: string;
|
||||||
|
atom: RecoilState<boolean>;
|
||||||
|
isAvailable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type AssistantListItem = {
|
export type AssistantListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -488,6 +496,27 @@ export interface ExtendedFile {
|
||||||
metadata?: t.TFile['metadata'];
|
metadata?: t.TFile['metadata'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExtendedEndpoint {
|
||||||
|
value: EModelEndpoint;
|
||||||
|
label: string;
|
||||||
|
hasModels: boolean;
|
||||||
|
icon: JSX.Element | null;
|
||||||
|
models?: string[];
|
||||||
|
agentNames?: Record<string, string>;
|
||||||
|
assistantNames?: Record<string, string>;
|
||||||
|
modelIcons?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelItemProps {
|
||||||
|
modelName: string;
|
||||||
|
endpoint: EModelEndpoint;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onNavigateBack: () => void;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ContextType = { navVisible: boolean; setNavVisible: (visible: boolean) => void };
|
export type ContextType = { navVisible: boolean; setNavVisible: (visible: boolean) => void };
|
||||||
|
|
||||||
export interface SwitcherProps {
|
export interface SwitcherProps {
|
||||||
|
|
|
@ -12,7 +12,7 @@ function AddMultiConvo() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
const clickHandler = () => {
|
const clickHandler = () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
|
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
|
||||||
setAddedConvo({
|
setAddedConvo({
|
||||||
...convo,
|
...convo,
|
||||||
|
@ -42,7 +42,7 @@ function AddMultiConvo() {
|
||||||
role="button"
|
role="button"
|
||||||
onClick={clickHandler}
|
onClick={clickHandler}
|
||||||
data-testid="parameters-button"
|
data-testid="parameters-button"
|
||||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||||
>
|
>
|
||||||
<PlusCircle size={16} aria-label="Plus Icon" />
|
<PlusCircle size={16} aria-label="Plus Icon" />
|
||||||
</TooltipAnchor>
|
</TooltipAnchor>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import type { TMessage } from 'librechat-data-provider';
|
||||||
import type { ChatFormValues } from '~/common';
|
import type { ChatFormValues } from '~/common';
|
||||||
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
|
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
|
||||||
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
|
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
|
||||||
|
import ConversationStarters from './Input/ConversationStarters';
|
||||||
import MessagesView from './Messages/MessagesView';
|
import MessagesView from './Messages/MessagesView';
|
||||||
import { Spinner } from '~/components/svg';
|
import { Spinner } from '~/components/svg';
|
||||||
import Presentation from './Presentation';
|
import Presentation from './Presentation';
|
||||||
|
@ -21,6 +22,7 @@ function ChatView({ index = 0 }: { index?: number }) {
|
||||||
const { conversationId } = useParams();
|
const { conversationId } = useParams();
|
||||||
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
|
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
|
||||||
const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1));
|
const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1));
|
||||||
|
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
|
||||||
|
|
||||||
const fileMap = useFileMapContext();
|
const fileMap = useFileMapContext();
|
||||||
|
|
||||||
|
@ -46,16 +48,18 @@ function ChatView({ index = 0 }: { index?: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
let content: JSX.Element | null | undefined;
|
let content: JSX.Element | null | undefined;
|
||||||
|
const isLandingPage = !messagesTree || messagesTree.length === 0;
|
||||||
|
|
||||||
if (isLoading && conversationId !== 'new') {
|
if (isLoading && conversationId !== 'new') {
|
||||||
content = (
|
content = (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<div className="flex h-screen items-center justify-center">
|
||||||
<Spinner className="opacity-0" />
|
<Spinner className="text-text-primary" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (messagesTree && messagesTree.length !== 0) {
|
} else if (!isLandingPage) {
|
||||||
content = <MessagesView messagesTree={messagesTree} Header={<Header />} />;
|
content = <MessagesView messagesTree={messagesTree} />;
|
||||||
} else {
|
} else {
|
||||||
content = <Landing Header={<Header />} />;
|
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -63,10 +67,27 @@ function ChatView({ index = 0 }: { index?: number }) {
|
||||||
<ChatContext.Provider value={chatHelpers}>
|
<ChatContext.Provider value={chatHelpers}>
|
||||||
<AddedChatContext.Provider value={addedChatHelpers}>
|
<AddedChatContext.Provider value={addedChatHelpers}>
|
||||||
<Presentation>
|
<Presentation>
|
||||||
{content}
|
<div className="flex h-full w-full flex-col">
|
||||||
<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">
|
{!isLoading && <Header />}
|
||||||
<ChatForm index={index} />
|
|
||||||
<Footer />
|
{isLandingPage ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-end sm:justify-center">
|
||||||
|
{content}
|
||||||
|
<div className="w-full max-w-3xl transition-all duration-200 xl:max-w-4xl">
|
||||||
|
<ChatForm index={index} />
|
||||||
|
<ConversationStarters />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col overflow-y-auto">
|
||||||
|
{content}
|
||||||
|
<div className="w-full">
|
||||||
|
<ChatForm index={index} />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Presentation>
|
</Presentation>
|
||||||
</AddedChatContext.Provider>
|
</AddedChatContext.Provider>
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
interface ConvoStarterProps {
|
|
||||||
text: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConvoStarter({ text, onClick }: ConvoStarterProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className="relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border border-border-medium px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-surface-tertiary"
|
|
||||||
>
|
|
||||||
<p className="break-word line-clamp-3 overflow-hidden text-balance break-all text-text-secondary">
|
|
||||||
{text}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -56,7 +56,7 @@ export default function Footer({ className }: { className?: string }) {
|
||||||
<React.Fragment key={`main-content-part-${index}`}>
|
<React.Fragment key={`main-content-part-${index}`}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
components={{
|
components={{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
a: ({ node: _n, href, children, ...otherProps }) => {
|
a: ({ node: _n, href, children, ...otherProps }) => {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
@ -70,7 +70,7 @@ export default function Footer({ className }: { className?: string }) {
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
p: ({ node: _n, ...props }) => <span {...props} />,
|
p: ({ node: _n, ...props }) => <span {...props} />,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -87,7 +87,7 @@ export default function Footer({ className }: { className?: string }) {
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
className ??
|
className ??
|
||||||
'relative flex items-center justify-center gap-2 px-2 py-2 text-center text-xs text-text-primary md:px-[60px]'
|
'relative hidden items-center justify-center gap-2 px-2 py-2 text-center text-xs text-text-primary sm:flex md:px-[60px]'
|
||||||
}
|
}
|
||||||
role="contentinfo"
|
role="contentinfo"
|
||||||
>
|
>
|
||||||
|
|
|
@ -2,11 +2,11 @@ import { useMemo } from 'react';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
import type { ContextType } from '~/common';
|
import type { ContextType } from '~/common';
|
||||||
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
|
import ModelSelector from './Menus/Endpoints/ModelSelector';
|
||||||
|
import { PresetsMenu, HeaderNewChat } from './Menus';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
import ExportAndShareMenu from './ExportAndShareMenu';
|
import ExportAndShareMenu from './ExportAndShareMenu';
|
||||||
import { useMediaQuery, useHasAccess } from '~/hooks';
|
import { useMediaQuery, useHasAccess } from '~/hooks';
|
||||||
import HeaderOptions from './Input/HeaderOptions';
|
|
||||||
import BookmarkMenu from './Menus/BookmarkMenu';
|
import BookmarkMenu from './Menus/BookmarkMenu';
|
||||||
import AddMultiConvo from './AddMultiConvo';
|
import AddMultiConvo from './AddMultiConvo';
|
||||||
|
|
||||||
|
@ -34,14 +34,12 @@ export default function Header() {
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold dark:bg-gray-800 dark:text-white">
|
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
|
||||||
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
|
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
|
||||||
<div className="flex items-center gap-2">
|
<div className="mx-2 flex items-center gap-2">
|
||||||
{!navVisible && <HeaderNewChat />}
|
{!navVisible && <HeaderNewChat />}
|
||||||
{interfaceConfig.endpointsMenu === true && <EndpointsMenu />}
|
{<ModelSelector interfaceConfig={interfaceConfig} modelSpecs={modelSpecs} />}
|
||||||
{modelSpecs.length > 0 && <ModelSpecsMenu modelSpecs={modelSpecs} />}
|
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
|
||||||
{<HeaderOptions interfaceConfig={interfaceConfig} />}
|
|
||||||
{interfaceConfig.presets === true && <PresetsMenu />}
|
|
||||||
{hasAccessToBookmarks === true && <BookmarkMenu />}
|
{hasAccessToBookmarks === true && <BookmarkMenu />}
|
||||||
{hasAccessToMultiConvo === true && <AddMultiConvo />}
|
{hasAccessToMultiConvo === true && <AddMultiConvo />}
|
||||||
{isSmallScreen && (
|
{isSmallScreen && (
|
||||||
|
|
|
@ -7,14 +7,12 @@ import { globalAudioId } from '~/common';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
export default function AudioRecorder({
|
export default function AudioRecorder({
|
||||||
isRTL,
|
|
||||||
disabled,
|
disabled,
|
||||||
ask,
|
ask,
|
||||||
methods,
|
methods,
|
||||||
textAreaRef,
|
textAreaRef,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
}: {
|
}: {
|
||||||
isRTL: boolean;
|
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
ask: (data: { text: string }) => void;
|
ask: (data: { text: string }) => void;
|
||||||
methods: ReturnType<typeof useChatFormContext>;
|
methods: ReturnType<typeof useChatFormContext>;
|
||||||
|
@ -90,9 +88,7 @@ export default function AudioRecorder({
|
||||||
onClick={isListening === true ? handleStopRecording : handleStartRecording}
|
onClick={isListening === true ? handleStopRecording : handleStartRecording}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
|
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
|
||||||
isRTL ? 'bottom-2 left-2' : 'bottom-2 right-2',
|
|
||||||
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
|
||||||
)}
|
)}
|
||||||
title={localize('com_ui_use_micrphone')}
|
title={localize('com_ui_use_micrphone')}
|
||||||
aria-pressed={isListening}
|
aria-pressed={isListening}
|
||||||
|
|
357
client/src/components/Chat/Input/BadgeRow.tsx
Normal file
357
client/src/components/Chat/Input/BadgeRow.tsx
Normal file
|
@ -0,0 +1,357 @@
|
||||||
|
import React, {
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
forwardRef,
|
||||||
|
useReducer,
|
||||||
|
} from 'react';
|
||||||
|
import { useRecoilValue, useRecoilCallback } from 'recoil';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { useChatBadges } from '~/hooks';
|
||||||
|
import { Badge } from '~/components/ui';
|
||||||
|
import { BadgeItem } from '~/common';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
|
interface BadgeRowProps {
|
||||||
|
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
|
||||||
|
onToggle?: (badgeId: string, currentActive: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BadgeWrapperProps {
|
||||||
|
badge: BadgeItem;
|
||||||
|
isEditing: boolean;
|
||||||
|
onToggle: (badge: BadgeItem) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onMouseDown: (e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => void;
|
||||||
|
badgeRefs: React.MutableRefObject<Record<string, HTMLDivElement>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BadgeWrapper = React.memo(
|
||||||
|
forwardRef<HTMLDivElement, BadgeWrapperProps>(
|
||||||
|
({ badge, isEditing, onToggle, onDelete, onMouseDown, badgeRefs }, ref) => {
|
||||||
|
const isActive = badge.atom ? useRecoilValue(badge.atom) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
badgeRefs.current[badge.id] = el;
|
||||||
|
}
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(el);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = el;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => onMouseDown(e, badge, isActive)}
|
||||||
|
className={isEditing ? 'ios-wiggle badge-icon h-full' : 'badge-icon h-full'}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
icon={badge.icon as LucideIcon}
|
||||||
|
label={badge.label}
|
||||||
|
isActive={isActive}
|
||||||
|
isEditing={isEditing}
|
||||||
|
isAvailable={badge.isAvailable}
|
||||||
|
onToggle={() => onToggle(badge)}
|
||||||
|
onBadgeAction={() => onDelete(badge.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
prevProps.badge.id === nextProps.badge.id &&
|
||||||
|
prevProps.isEditing === nextProps.isEditing &&
|
||||||
|
prevProps.onToggle === nextProps.onToggle &&
|
||||||
|
prevProps.onDelete === nextProps.onDelete &&
|
||||||
|
prevProps.onMouseDown === nextProps.onMouseDown &&
|
||||||
|
prevProps.badgeRefs === nextProps.badgeRefs,
|
||||||
|
);
|
||||||
|
|
||||||
|
BadgeWrapper.displayName = 'BadgeWrapper';
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
draggedBadge: BadgeItem | null;
|
||||||
|
mouseX: number;
|
||||||
|
offsetX: number;
|
||||||
|
insertIndex: number | null;
|
||||||
|
draggedBadgeActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DragAction =
|
||||||
|
| {
|
||||||
|
type: 'START_DRAG';
|
||||||
|
badge: BadgeItem;
|
||||||
|
mouseX: number;
|
||||||
|
offsetX: number;
|
||||||
|
insertIndex: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
| { type: 'UPDATE_POSITION'; mouseX: number; insertIndex: number }
|
||||||
|
| { type: 'END_DRAG' };
|
||||||
|
|
||||||
|
const dragReducer = (state: DragState, action: DragAction): DragState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'START_DRAG':
|
||||||
|
return {
|
||||||
|
draggedBadge: action.badge,
|
||||||
|
mouseX: action.mouseX,
|
||||||
|
offsetX: action.offsetX,
|
||||||
|
insertIndex: action.insertIndex,
|
||||||
|
draggedBadgeActive: action.isActive,
|
||||||
|
};
|
||||||
|
case 'UPDATE_POSITION':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
mouseX: action.mouseX,
|
||||||
|
insertIndex: action.insertIndex,
|
||||||
|
};
|
||||||
|
case 'END_DRAG':
|
||||||
|
return {
|
||||||
|
draggedBadge: null,
|
||||||
|
mouseX: 0,
|
||||||
|
offsetX: 0,
|
||||||
|
insertIndex: null,
|
||||||
|
draggedBadgeActive: false,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BadgeRow({ onChange, onToggle }: BadgeRowProps) {
|
||||||
|
const [orderedBadges, setOrderedBadges] = useState<BadgeItem[]>([]);
|
||||||
|
const [dragState, dispatch] = useReducer(dragReducer, {
|
||||||
|
draggedBadge: null,
|
||||||
|
mouseX: 0,
|
||||||
|
offsetX: 0,
|
||||||
|
insertIndex: null,
|
||||||
|
draggedBadgeActive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const badgeRefs = useRef<Record<string, HTMLDivElement>>({});
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const animationFrame = useRef<number | null>(null);
|
||||||
|
const containerRectRef = useRef<DOMRect | null>(null);
|
||||||
|
|
||||||
|
const allBadges = useChatBadges() || [];
|
||||||
|
const isEditing = useRecoilValue(store.isEditingBadges);
|
||||||
|
|
||||||
|
const badges = useMemo(
|
||||||
|
() => allBadges.filter((badge) => badge.isAvailable !== false),
|
||||||
|
[allBadges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleBadge = useRecoilCallback(
|
||||||
|
({ snapshot, set }) =>
|
||||||
|
async (badgeAtom: any) => {
|
||||||
|
const current = await snapshot.getPromise(badgeAtom);
|
||||||
|
set(badgeAtom, !current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrderedBadges((prev) => {
|
||||||
|
const currentIds = new Set(prev.map((b) => b.id));
|
||||||
|
const newBadges = badges.filter((b) => !currentIds.has(b.id));
|
||||||
|
return newBadges.length > 0 ? [...prev, ...newBadges] : prev;
|
||||||
|
});
|
||||||
|
}, [badges]);
|
||||||
|
|
||||||
|
const tempBadges = dragState.draggedBadge
|
||||||
|
? orderedBadges.filter((b) => b.id !== dragState.draggedBadge?.id)
|
||||||
|
: orderedBadges;
|
||||||
|
const ghostBadge = dragState.draggedBadge || null;
|
||||||
|
|
||||||
|
const calculateInsertIndex = useCallback(
|
||||||
|
(currentMouseX: number): number => {
|
||||||
|
if (!dragState.draggedBadge || !containerRef.current || !containerRectRef.current) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const relativeMouseX = currentMouseX - containerRectRef.current.left;
|
||||||
|
const refs = tempBadges.map((b) => badgeRefs.current[b.id]).filter(Boolean);
|
||||||
|
if (refs.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let idx = 0;
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
const rect = refs[i].getBoundingClientRect();
|
||||||
|
const relativeLeft = rect.left - containerRectRef.current.left;
|
||||||
|
const relativeCenter = relativeLeft + rect.width / 2;
|
||||||
|
if (relativeMouseX < relativeCenter) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
idx = i + 1;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
},
|
||||||
|
[dragState.draggedBadge, tempBadges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => {
|
||||||
|
if (!isEditing || !containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = badgeRefs.current[badge.id];
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const offsetX = e.clientX - rect.left;
|
||||||
|
const mouseX = e.clientX;
|
||||||
|
const initialIndex = orderedBadges.findIndex((b) => b.id === badge.id);
|
||||||
|
containerRectRef.current = containerRef.current.getBoundingClientRect();
|
||||||
|
dispatch({
|
||||||
|
type: 'START_DRAG',
|
||||||
|
badge,
|
||||||
|
mouseX,
|
||||||
|
offsetX,
|
||||||
|
insertIndex: initialIndex,
|
||||||
|
isActive,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isEditing, orderedBadges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (!dragState.draggedBadge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (animationFrame.current) {
|
||||||
|
cancelAnimationFrame(animationFrame.current);
|
||||||
|
}
|
||||||
|
animationFrame.current = requestAnimationFrame(() => {
|
||||||
|
const newMouseX = e.clientX;
|
||||||
|
const newInsertIndex = calculateInsertIndex(newMouseX);
|
||||||
|
if (newInsertIndex !== dragState.insertIndex) {
|
||||||
|
dispatch({ type: 'UPDATE_POSITION', mouseX: newMouseX, insertIndex: newInsertIndex });
|
||||||
|
} else {
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_POSITION',
|
||||||
|
mouseX: newMouseX,
|
||||||
|
insertIndex: dragState.insertIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[dragState.draggedBadge, dragState.insertIndex, calculateInsertIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (dragState.draggedBadge && dragState.insertIndex !== null) {
|
||||||
|
const otherBadges = orderedBadges.filter((b) => b.id !== dragState.draggedBadge?.id);
|
||||||
|
const newBadges = [
|
||||||
|
...otherBadges.slice(0, dragState.insertIndex),
|
||||||
|
dragState.draggedBadge,
|
||||||
|
...otherBadges.slice(dragState.insertIndex),
|
||||||
|
];
|
||||||
|
setOrderedBadges(newBadges);
|
||||||
|
onChange(newBadges.map((badge) => ({ id: badge.id })));
|
||||||
|
}
|
||||||
|
dispatch({ type: 'END_DRAG' });
|
||||||
|
containerRectRef.current = null;
|
||||||
|
}, [dragState.draggedBadge, dragState.insertIndex, orderedBadges, onChange]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(badgeId: string) => {
|
||||||
|
const newBadges = orderedBadges.filter((b) => b.id !== badgeId);
|
||||||
|
setOrderedBadges(newBadges);
|
||||||
|
onChange(newBadges.map((badge) => ({ id: badge.id })));
|
||||||
|
},
|
||||||
|
[orderedBadges, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBadgeToggle = useCallback(
|
||||||
|
(badge: BadgeItem) => {
|
||||||
|
if (badge.atom) {
|
||||||
|
toggleBadge(badge.atom);
|
||||||
|
}
|
||||||
|
if (onToggle) {
|
||||||
|
onToggle(badge.id, !!badge.atom);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toggleBadge, onToggle],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragState.draggedBadge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
if (animationFrame.current) {
|
||||||
|
cancelAnimationFrame(animationFrame.current);
|
||||||
|
animationFrame.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [dragState.draggedBadge, handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
|
||||||
|
{tempBadges.map((badge, index) => (
|
||||||
|
<React.Fragment key={badge.id}>
|
||||||
|
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
|
||||||
|
<div className="badge-icon h-full">
|
||||||
|
<Badge
|
||||||
|
icon={ghostBadge.icon as LucideIcon}
|
||||||
|
label={ghostBadge.label}
|
||||||
|
isActive={dragState.draggedBadgeActive}
|
||||||
|
isEditing={isEditing}
|
||||||
|
isAvailable={ghostBadge.isAvailable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<BadgeWrapper
|
||||||
|
badge={badge}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onToggle={handleBadgeToggle}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
badgeRefs={badgeRefs}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
|
||||||
|
<div className="badge-icon h-full">
|
||||||
|
<Badge
|
||||||
|
icon={ghostBadge.icon as LucideIcon}
|
||||||
|
label={ghostBadge.label}
|
||||||
|
isActive={dragState.draggedBadgeActive}
|
||||||
|
isEditing={isEditing}
|
||||||
|
isAvailable={ghostBadge.isAvailable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ghostBadge && (
|
||||||
|
<div
|
||||||
|
className="ghost-badge h-full"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
transform: `translateX(${dragState.mouseX - dragState.offsetX - (containerRectRef.current?.left || 0)}px)`,
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
icon={ghostBadge.icon as LucideIcon}
|
||||||
|
label={ghostBadge.label}
|
||||||
|
isActive={dragState.draggedBadgeActive}
|
||||||
|
isAvailable={ghostBadge.isAvailable}
|
||||||
|
isEditing
|
||||||
|
isDragging
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,11 +1,7 @@
|
||||||
import { memo, useRef, useMemo, useEffect, useState } from 'react';
|
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useWatch } from 'react-hook-form';
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||||
import {
|
import { isAssistantsEndpoint } from 'librechat-data-provider';
|
||||||
supportsFiles,
|
|
||||||
mergeFileConfig,
|
|
||||||
isAssistantsEndpoint,
|
|
||||||
fileConfig as defaultFileConfig,
|
|
||||||
} from 'librechat-data-provider';
|
|
||||||
import {
|
import {
|
||||||
useChatContext,
|
useChatContext,
|
||||||
useChatFormContext,
|
useChatFormContext,
|
||||||
|
@ -20,47 +16,105 @@ import {
|
||||||
useQueryParams,
|
useQueryParams,
|
||||||
useSubmitMessage,
|
useSubmitMessage,
|
||||||
} from '~/hooks';
|
} from '~/hooks';
|
||||||
import { cn, removeFocusRings, checkIfScrollable } from '~/utils';
|
import { mainTextareaId, BadgeItem } from '~/common';
|
||||||
import FileFormWrapper from './Files/FileFormWrapper';
|
import AttachFileChat from './Files/AttachFileChat';
|
||||||
import { TextareaAutosize } from '~/components/ui';
|
import FileFormChat from './Files/FileFormChat';
|
||||||
import { useGetFileConfig } from '~/data-provider';
|
import { TextareaAutosize } from '~/components';
|
||||||
import { TemporaryChat } from './TemporaryChat';
|
import { cn, removeFocusRings } from '~/utils';
|
||||||
import TextareaHeader from './TextareaHeader';
|
import TextareaHeader from './TextareaHeader';
|
||||||
import PromptsCommand from './PromptsCommand';
|
import PromptsCommand from './PromptsCommand';
|
||||||
import AudioRecorder from './AudioRecorder';
|
import AudioRecorder from './AudioRecorder';
|
||||||
import { mainTextareaId } from '~/common';
|
|
||||||
import CollapseChat from './CollapseChat';
|
import CollapseChat from './CollapseChat';
|
||||||
import StreamAudio from './StreamAudio';
|
import StreamAudio from './StreamAudio';
|
||||||
import StopButton from './StopButton';
|
import StopButton from './StopButton';
|
||||||
import SendButton from './SendButton';
|
import SendButton from './SendButton';
|
||||||
|
import { BadgeRow } from './BadgeRow';
|
||||||
|
import EditBadges from './EditBadges';
|
||||||
import Mention from './Mention';
|
import Mention from './Mention';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
const ChatForm = ({ index = 0 }) => {
|
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
useQueryParams({ textAreaRef });
|
|
||||||
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [isScrollable, setIsScrollable] = useState(false);
|
const [, setIsScrollable] = useState(false);
|
||||||
|
const [visualRowCount, setVisualRowCount] = useState(1);
|
||||||
|
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
|
||||||
|
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
|
||||||
|
|
||||||
const SpeechToText = useRecoilValue(store.speechToText);
|
const SpeechToText = useRecoilValue(store.speechToText);
|
||||||
const TextToSpeech = useRecoilValue(store.textToSpeech);
|
const TextToSpeech = useRecoilValue(store.textToSpeech);
|
||||||
const automaticPlayback = useRecoilValue(store.automaticPlayback);
|
const automaticPlayback = useRecoilValue(store.automaticPlayback);
|
||||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||||
const [isTemporaryChat, setIsTemporaryChat] = useRecoilState<boolean>(store.isTemporary);
|
const chatDirection = useRecoilValue(store.chatDirection);
|
||||||
|
|
||||||
const isSearching = useRecoilValue(store.isSearching);
|
const isSearching = useRecoilValue(store.isSearching);
|
||||||
|
|
||||||
|
const [badges, setBadges] = useRecoilState(store.chatBadges);
|
||||||
|
const [isEditingBadges, setIsEditingBadges] = useRecoilState(store.isEditingBadges);
|
||||||
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
|
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
|
||||||
const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index));
|
const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index));
|
||||||
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
|
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
|
||||||
store.showMentionPopoverFamily(index),
|
store.showMentionPopoverFamily(index),
|
||||||
);
|
);
|
||||||
|
|
||||||
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
|
||||||
const isRTL = chatDirection === 'rtl';
|
|
||||||
|
|
||||||
const { requiresKey } = useRequiresKey();
|
const { requiresKey } = useRequiresKey();
|
||||||
|
const methods = useChatFormContext();
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
setFiles,
|
||||||
|
conversation,
|
||||||
|
isSubmitting,
|
||||||
|
filesLoading,
|
||||||
|
newConversation,
|
||||||
|
handleStopGenerating,
|
||||||
|
} = useChatContext();
|
||||||
|
const {
|
||||||
|
addedIndex,
|
||||||
|
generateConversation,
|
||||||
|
conversation: addedConvo,
|
||||||
|
setConversation: setAddedConvo,
|
||||||
|
isSubmitting: isSubmittingAdded,
|
||||||
|
} = useAddedChatContext();
|
||||||
|
const assistantMap = useAssistantsMapContext();
|
||||||
|
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
|
||||||
|
|
||||||
|
const endpoint = useMemo(
|
||||||
|
() => conversation?.endpointType ?? conversation?.endpoint,
|
||||||
|
[conversation?.endpointType, conversation?.endpoint],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRTL = useMemo(() => chatDirection === 'rtl', [chatDirection.toLowerCase()]);
|
||||||
|
const invalidAssistant = useMemo(
|
||||||
|
() =>
|
||||||
|
isAssistantsEndpoint(endpoint) &&
|
||||||
|
(!(conversation?.assistant_id ?? '') ||
|
||||||
|
!assistantMap?.[endpoint ?? '']?.[conversation?.assistant_id ?? '']),
|
||||||
|
[conversation?.assistant_id, endpoint, assistantMap],
|
||||||
|
);
|
||||||
|
const disableInputs = useMemo(
|
||||||
|
() => requiresKey || invalidAssistant,
|
||||||
|
[requiresKey, invalidAssistant],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContainerClick = useCallback(() => {
|
||||||
|
textAreaRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFocusOrClick = useCallback(() => {
|
||||||
|
if (isCollapsed) {
|
||||||
|
setIsCollapsed(false);
|
||||||
|
}
|
||||||
|
}, [isCollapsed]);
|
||||||
|
|
||||||
|
useAutoSave({
|
||||||
|
conversationId: conversation?.conversationId,
|
||||||
|
textAreaRef,
|
||||||
|
files,
|
||||||
|
setFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { submitMessage, submitPrompt } = useSubmitMessage();
|
||||||
const handleKeyUp = useHandleKeyUp({
|
const handleKeyUp = useHandleKeyUp({
|
||||||
index,
|
index,
|
||||||
textAreaRef,
|
textAreaRef,
|
||||||
|
@ -71,65 +125,22 @@ const ChatForm = ({ index = 0 }) => {
|
||||||
textAreaRef,
|
textAreaRef,
|
||||||
submitButtonRef,
|
submitButtonRef,
|
||||||
setIsScrollable,
|
setIsScrollable,
|
||||||
disabled: !!(requiresKey ?? false),
|
disabled: disableInputs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
useQueryParams({ textAreaRef });
|
||||||
files,
|
|
||||||
setFiles,
|
|
||||||
conversation,
|
|
||||||
isSubmitting,
|
|
||||||
filesLoading,
|
|
||||||
newConversation,
|
|
||||||
handleStopGenerating,
|
|
||||||
} = useChatContext();
|
|
||||||
const methods = useChatFormContext();
|
|
||||||
const {
|
|
||||||
addedIndex,
|
|
||||||
generateConversation,
|
|
||||||
conversation: addedConvo,
|
|
||||||
setConversation: setAddedConvo,
|
|
||||||
isSubmitting: isSubmittingAdded,
|
|
||||||
} = useAddedChatContext();
|
|
||||||
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
|
|
||||||
|
|
||||||
useAutoSave({
|
|
||||||
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
|
|
||||||
textAreaRef,
|
|
||||||
files,
|
|
||||||
setFiles,
|
|
||||||
});
|
|
||||||
|
|
||||||
const assistantMap = useAssistantsMapContext();
|
|
||||||
const { submitMessage, submitPrompt } = useSubmitMessage();
|
|
||||||
|
|
||||||
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
|
||||||
const endpoint = endpointType ?? _endpoint;
|
|
||||||
|
|
||||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
|
||||||
select: (data) => mergeFileConfig(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
|
|
||||||
const invalidAssistant = useMemo(
|
|
||||||
() =>
|
|
||||||
isAssistantsEndpoint(conversation?.endpoint) &&
|
|
||||||
(!(conversation?.assistant_id ?? '') ||
|
|
||||||
!assistantMap?.[conversation?.endpoint ?? ''][conversation?.assistant_id ?? '']),
|
|
||||||
[conversation?.assistant_id, conversation?.endpoint, assistantMap],
|
|
||||||
);
|
|
||||||
const disableInputs = useMemo(
|
|
||||||
() => !!((requiresKey ?? false) || invalidAssistant),
|
|
||||||
[requiresKey, invalidAssistant],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { ref, ...registerProps } = methods.register('text', {
|
const { ref, ...registerProps } = methods.register('text', {
|
||||||
required: true,
|
required: true,
|
||||||
onChange: (e) => {
|
onChange: useCallback(
|
||||||
methods.setValue('text', e.target.value, { shouldValidate: true });
|
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||||
},
|
methods.setValue('text', e.target.value, { shouldValidate: true }),
|
||||||
|
[methods],
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const textValue = useWatch({ control: methods.control, name: 'text' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSearching && textAreaRef.current && !disableInputs) {
|
if (!isSearching && textAreaRef.current && !disableInputs) {
|
||||||
textAreaRef.current.focus();
|
textAreaRef.current.focus();
|
||||||
|
@ -138,33 +149,53 @@ const ChatForm = ({ index = 0 }) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textAreaRef.current) {
|
if (textAreaRef.current) {
|
||||||
checkIfScrollable(textAreaRef.current);
|
const style = window.getComputedStyle(textAreaRef.current);
|
||||||
|
const lineHeight = parseFloat(style.lineHeight);
|
||||||
|
setVisualRowCount(Math.floor(textAreaRef.current.scrollHeight / lineHeight));
|
||||||
}
|
}
|
||||||
|
}, [textValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditingBadges && backupBadges.length === 0) {
|
||||||
|
setBackupBadges([...badges]);
|
||||||
|
}
|
||||||
|
}, [isEditingBadges, badges, backupBadges.length]);
|
||||||
|
|
||||||
|
const handleSaveBadges = useCallback(() => {
|
||||||
|
setIsEditingBadges(false);
|
||||||
|
setBackupBadges([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
|
const handleCancelBadges = useCallback(() => {
|
||||||
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
|
if (backupBadges.length > 0) {
|
||||||
|
setBadges([...backupBadges]);
|
||||||
|
}
|
||||||
|
setIsEditingBadges(false);
|
||||||
|
setBackupBadges([]);
|
||||||
|
}, [backupBadges, setBadges]);
|
||||||
|
|
||||||
const baseClasses = cn(
|
const isMoreThanThreeRows = visualRowCount > 3;
|
||||||
'md:py-3.5 m-0 w-full resize-none py-[13px] bg-surface-tertiary placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
|
|
||||||
isCollapsed ? 'max-h-[52px]' : 'max-h-[65vh] md:max-h-[75vh]',
|
const baseClasses = useMemo(
|
||||||
|
() =>
|
||||||
|
cn(
|
||||||
|
'md:py-3.5 m-0 w-full resize-none py-[13px] bg-surface-chat placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
|
||||||
|
isCollapsed ? 'max-h-[52px]' : 'max-h-[45vh] md:max-h-[55vh]',
|
||||||
|
isMoreThanThreeRows ? 'pl-5' : 'px-5',
|
||||||
|
),
|
||||||
|
[isCollapsed, isMoreThanThreeRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadActive = endpointSupportsFiles && !isUploadDisabled;
|
|
||||||
const speechClass = isRTL
|
|
||||||
? `pr-${uploadActive ? '12' : '4'} pl-12`
|
|
||||||
: `pl-${uploadActive ? '12' : '4'} pr-12`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={methods.handleSubmit((data) => submitMessage(data))}
|
onSubmit={methods.handleSubmit(submitMessage)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'mx-auto flex flex-row gap-3 pl-2 transition-all duration-200 last:mb-2',
|
'mx-auto flex flex-row gap-3 transition-all duration-200 sm:mb-2 sm:px-2',
|
||||||
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-2xl xl:max-w-3xl',
|
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
|
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
|
||||||
<div className="flex w-full items-center">
|
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
|
||||||
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
|
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
|
||||||
<Mention
|
<Mention
|
||||||
setShowMentionPopover={setShowPlusPopover}
|
setShowMentionPopover={setShowPlusPopover}
|
||||||
|
@ -183,90 +214,101 @@ const ChatForm = ({ index = 0 }) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
|
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
|
||||||
<div className="transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl bg-surface-tertiary text-text-primary duration-200">
|
<div
|
||||||
<TemporaryChat
|
onClick={handleContainerClick}
|
||||||
isTemporaryChat={isTemporaryChat}
|
className={cn(
|
||||||
setIsTemporaryChat={setIsTemporaryChat}
|
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border border-border-light bg-surface-chat text-text-primary transition-all duration-200 sm:rounded-3xl',
|
||||||
/>
|
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||||
<FileFormWrapper disableInputs={disableInputs}>
|
<EditBadges
|
||||||
{endpoint && (
|
isEditingChatBadges={isEditingBadges}
|
||||||
<>
|
handleCancelBadges={handleCancelBadges}
|
||||||
|
handleSaveBadges={handleSaveBadges}
|
||||||
|
setBadges={setBadges}
|
||||||
|
/>
|
||||||
|
<FileFormChat disableInputs={disableInputs} />
|
||||||
|
{endpoint && (
|
||||||
|
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
|
||||||
|
<TextareaAutosize
|
||||||
|
{...registerProps}
|
||||||
|
ref={(e) => {
|
||||||
|
ref(e);
|
||||||
|
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
|
||||||
|
}}
|
||||||
|
disabled={disableInputs}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onCompositionStart={handleCompositionStart}
|
||||||
|
onCompositionEnd={handleCompositionEnd}
|
||||||
|
id={mainTextareaId}
|
||||||
|
tabIndex={0}
|
||||||
|
data-testid="text-input"
|
||||||
|
rows={1}
|
||||||
|
onFocus={() => {
|
||||||
|
handleFocusOrClick();
|
||||||
|
setIsTextAreaFocused(true);
|
||||||
|
}}
|
||||||
|
onBlur={setIsTextAreaFocused.bind(null, false)}
|
||||||
|
onClick={handleFocusOrClick}
|
||||||
|
style={{ height: 44, overflowY: 'auto' }}
|
||||||
|
className={cn(
|
||||||
|
baseClasses,
|
||||||
|
removeFocusRings,
|
||||||
|
'transition-[max-height] duration-200',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-start justify-start pt-1.5">
|
||||||
<CollapseChat
|
<CollapseChat
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
isScrollable={isScrollable}
|
isScrollable={isMoreThanThreeRows}
|
||||||
setIsCollapsed={setIsCollapsed}
|
setIsCollapsed={setIsCollapsed}
|
||||||
/>
|
/>
|
||||||
<TextareaAutosize
|
</div>
|
||||||
{...registerProps}
|
</div>
|
||||||
ref={(e) => {
|
)}
|
||||||
ref(e);
|
<div
|
||||||
textAreaRef.current = e;
|
className={cn(
|
||||||
}}
|
'items-between flex gap-2 pb-2',
|
||||||
disabled={disableInputs}
|
isRTL ? 'flex-row-reverse' : 'flex-row',
|
||||||
onPaste={handlePaste}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
onHeightChange={() => {
|
|
||||||
if (textAreaRef.current) {
|
|
||||||
const scrollable = checkIfScrollable(textAreaRef.current);
|
|
||||||
setIsScrollable(scrollable);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCompositionStart={handleCompositionStart}
|
|
||||||
onCompositionEnd={handleCompositionEnd}
|
|
||||||
id={mainTextareaId}
|
|
||||||
tabIndex={0}
|
|
||||||
data-testid="text-input"
|
|
||||||
rows={1}
|
|
||||||
onFocus={() => isCollapsed && setIsCollapsed(false)}
|
|
||||||
onClick={() => isCollapsed && setIsCollapsed(false)}
|
|
||||||
style={{ height: 44, overflowY: 'auto' }}
|
|
||||||
className={cn(
|
|
||||||
baseClasses,
|
|
||||||
speechClass,
|
|
||||||
removeFocusRings,
|
|
||||||
'transition-[max-height] duration-200',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</FileFormWrapper>
|
>
|
||||||
{SpeechToText && (
|
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
|
||||||
<AudioRecorder
|
<AttachFileChat disableInputs={disableInputs} />
|
||||||
isRTL={isRTL}
|
</div>
|
||||||
methods={methods}
|
<BadgeRow onChange={(newBadges) => setBadges(newBadges)} />
|
||||||
ask={submitMessage}
|
<div className="mx-auto flex" />
|
||||||
textAreaRef={textAreaRef}
|
{SpeechToText && (
|
||||||
disabled={!!disableInputs}
|
<AudioRecorder
|
||||||
isSubmitting={isSubmitting}
|
methods={methods}
|
||||||
/>
|
ask={submitMessage}
|
||||||
)}
|
textAreaRef={textAreaRef}
|
||||||
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
|
disabled={disableInputs}
|
||||||
</div>
|
isSubmitting={isSubmitting}
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'mb-[5px] ml-[8px] flex flex-col items-end justify-end',
|
|
||||||
isRTL && 'order-first mr-[8px]',
|
|
||||||
)}
|
|
||||||
style={{ alignSelf: 'flex-end' }}
|
|
||||||
>
|
|
||||||
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
|
|
||||||
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
|
|
||||||
) : (
|
|
||||||
endpoint && (
|
|
||||||
<SendButton
|
|
||||||
ref={submitButtonRef}
|
|
||||||
control={methods.control}
|
|
||||||
disabled={!!(filesLoading || isSubmitting || disableInputs)}
|
|
||||||
/>
|
/>
|
||||||
)
|
)}
|
||||||
)}
|
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
|
||||||
|
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
|
||||||
|
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
|
||||||
|
) : (
|
||||||
|
endpoint && (
|
||||||
|
<SendButton
|
||||||
|
ref={submitButtonRef}
|
||||||
|
control={methods.control}
|
||||||
|
disabled={filesLoading || isSubmitting || disableInputs}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default memo(ChatForm);
|
export default ChatForm;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Minimize2 } from 'lucide-react';
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { TooltipAnchor } from '~/components/ui';
|
import { TooltipAnchor } from '~/components/ui';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
@ -18,23 +18,37 @@ const CollapseChat = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCollapsed) {
|
const description = isCollapsed
|
||||||
return null;
|
? localize('com_ui_expand_chat')
|
||||||
}
|
: localize('com_ui_collapse_chat');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipAnchor
|
<div className="relative ml-auto items-end justify-end">
|
||||||
role="button"
|
<TooltipAnchor
|
||||||
description={localize('com_ui_collapse_chat')}
|
description={description}
|
||||||
aria-label={localize('com_ui_collapse_chat')}
|
render={
|
||||||
onClick={() => setIsCollapsed(true)}
|
<button
|
||||||
className={cn(
|
aria-label={description}
|
||||||
'absolute right-2 top-2 z-10 size-[35px] rounded-full p-2 transition-colors',
|
onClick={(event) => {
|
||||||
'hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
event.preventDefault();
|
||||||
)}
|
event.stopPropagation();
|
||||||
>
|
setIsCollapsed((prev) => !prev);
|
||||||
<Minimize2 className="h-full w-full" />
|
}}
|
||||||
</TooltipAnchor>
|
className={cn(
|
||||||
|
// 'absolute right-1.5 top-1.5',
|
||||||
|
'z-10 size-5 rounded-full transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronDown className="h-full w-full" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="h-full w-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
85
client/src/components/Chat/Input/ConversationStarters.tsx
Normal file
85
client/src/components/Chat/Input/ConversationStarters.tsx
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
import { EModelEndpoint, Constants } from 'librechat-data-provider';
|
||||||
|
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
|
||||||
|
import { useGetAssistantDocsQuery, useGetEndpointsQuery } from '~/data-provider';
|
||||||
|
import { getIconEndpoint, getEntity } from '~/utils';
|
||||||
|
import { useSubmitMessage } from '~/hooks';
|
||||||
|
|
||||||
|
const ConversationStarters = () => {
|
||||||
|
const { conversation } = useChatContext();
|
||||||
|
const agentsMap = useAgentsMapContext();
|
||||||
|
const assistantMap = useAssistantsMapContext();
|
||||||
|
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||||
|
|
||||||
|
const endpointType = useMemo(() => {
|
||||||
|
let ep = conversation?.endpoint ?? '';
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
EModelEndpoint.chatGPTBrowser,
|
||||||
|
EModelEndpoint.azureOpenAI,
|
||||||
|
EModelEndpoint.gptPlugins,
|
||||||
|
].includes(ep as EModelEndpoint)
|
||||||
|
) {
|
||||||
|
ep = EModelEndpoint.openAI;
|
||||||
|
}
|
||||||
|
return getIconEndpoint({
|
||||||
|
endpointsConfig,
|
||||||
|
iconURL: conversation?.iconURL,
|
||||||
|
endpoint: ep,
|
||||||
|
});
|
||||||
|
}, [conversation?.endpoint, conversation?.iconURL, endpointsConfig]);
|
||||||
|
|
||||||
|
const { data: documentsMap = new Map() } = useGetAssistantDocsQuery(endpointType, {
|
||||||
|
select: (data) => new Map(data.map((dbA) => [dbA.assistant_id, dbA])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { entity, isAgent } = getEntity({
|
||||||
|
endpoint: endpointType,
|
||||||
|
agentsMap,
|
||||||
|
assistantMap,
|
||||||
|
agent_id: conversation?.agent_id,
|
||||||
|
assistant_id: conversation?.assistant_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversation_starters = useMemo(() => {
|
||||||
|
if (entity?.conversation_starters?.length) {
|
||||||
|
return entity.conversation_starters;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAgent) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentsMap.get(entity?.id ?? '')?.conversation_starters ?? [];
|
||||||
|
}, [documentsMap, isAgent, entity]);
|
||||||
|
|
||||||
|
const { submitMessage } = useSubmitMessage();
|
||||||
|
const sendConversationStarter = useCallback(
|
||||||
|
(text: string) => submitMessage({ text }),
|
||||||
|
[submitMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!conversation_starters.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex flex-wrap justify-center gap-3 px-4">
|
||||||
|
{conversation_starters
|
||||||
|
.slice(0, Constants.MAX_CONVO_STARTERS)
|
||||||
|
.map((text: string, index: number) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => sendConversationStarter(text)}
|
||||||
|
className="relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border border-border-medium px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-surface-tertiary"
|
||||||
|
>
|
||||||
|
<p className="break-word line-clamp-3 overflow-hidden text-balance break-all text-text-secondary">
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConversationStarters;
|
87
client/src/components/Chat/Input/EditBadges.tsx
Normal file
87
client/src/components/Chat/Input/EditBadges.tsx
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Edit3, Check, X } from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import type { BadgeItem } from '~/common';
|
||||||
|
import { useChatBadges, useLocalize } from '~/hooks';
|
||||||
|
import { Button, Badge } from '~/components/ui';
|
||||||
|
|
||||||
|
interface EditBadgesProps {
|
||||||
|
isEditingChatBadges: boolean;
|
||||||
|
handleCancelBadges: () => void;
|
||||||
|
handleSaveBadges: () => void;
|
||||||
|
setBadges: React.Dispatch<React.SetStateAction<Pick<BadgeItem, 'id'>[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditBadgesComponent = ({
|
||||||
|
isEditingChatBadges,
|
||||||
|
handleCancelBadges,
|
||||||
|
handleSaveBadges,
|
||||||
|
setBadges,
|
||||||
|
}: EditBadgesProps) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const allBadges = useChatBadges() || [];
|
||||||
|
const unavailableBadges = allBadges.filter((badge) => !badge.isAvailable);
|
||||||
|
|
||||||
|
const handleRestoreBadge = useCallback(
|
||||||
|
(badgeId: string) => {
|
||||||
|
setBadges((prev: Pick<BadgeItem, 'id'>[]) => [...prev, { id: badgeId }]);
|
||||||
|
},
|
||||||
|
[setBadges],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isEditingChatBadges) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="m-1.5 flex flex-col overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
|
||||||
|
<div className="flex items-center gap-4 py-2 pl-3 pr-1.5 text-sm">
|
||||||
|
<span className="mt-0 flex size-6 flex-shrink-0 items-center justify-center">
|
||||||
|
<div className="icon-md">
|
||||||
|
<Edit3 className="icon-md" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span className="text-token-text-secondary line-clamp-3 flex-1 py-0.5 font-semibold">
|
||||||
|
{localize('com_ui_save_badge_changes')}
|
||||||
|
</span>
|
||||||
|
<div className="flex h-8 gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
aria-label="Cancel"
|
||||||
|
onClick={handleCancelBadges}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<X className="icon-md" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="submit"
|
||||||
|
aria-label="Save changes"
|
||||||
|
onClick={handleSaveBadges}
|
||||||
|
className="h-8 rounded-b-lg rounded-tr-xl"
|
||||||
|
>
|
||||||
|
<Check className="icon-md" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{unavailableBadges && unavailableBadges.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 p-2">
|
||||||
|
{unavailableBadges.map((badge) => (
|
||||||
|
<div key={badge.id} className="badge-icon">
|
||||||
|
<Badge
|
||||||
|
icon={badge.icon as unknown as LucideIcon}
|
||||||
|
label={badge.label}
|
||||||
|
isAvailable={false}
|
||||||
|
isEditing={true}
|
||||||
|
onBadgeAction={() => handleRestoreBadge(badge.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(EditBadgesComponent);
|
|
@ -1,55 +1,51 @@
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { FileUpload, TooltipAnchor } from '~/components/ui';
|
import { FileUpload, TooltipAnchor, AttachmentIcon } from '~/components';
|
||||||
import { AttachmentIcon } from '~/components/svg';
|
import { useLocalize, useFileHandling } from '~/hooks';
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
const AttachFile = ({
|
const AttachFile = ({ disabled }: { disabled?: boolean | null }) => {
|
||||||
isRTL,
|
|
||||||
disabled,
|
|
||||||
handleFileChange,
|
|
||||||
}: {
|
|
||||||
isRTL: boolean;
|
|
||||||
disabled?: boolean | null;
|
|
||||||
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
}) => {
|
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const isUploadDisabled = disabled ?? false;
|
const isUploadDisabled = disabled ?? false;
|
||||||
|
|
||||||
|
const { handleFileChange } = useFileHandling();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
|
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
role="button"
|
|
||||||
id="attach-file"
|
|
||||||
aria-label={localize('com_sidepanel_attach_files')}
|
|
||||||
disabled={isUploadDisabled}
|
|
||||||
className={cn(
|
|
||||||
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
|
||||||
isRTL ? 'bottom-2 right-2' : 'bottom-2 left-2',
|
|
||||||
)}
|
|
||||||
description={localize('com_sidepanel_attach_files')}
|
description={localize('com_sidepanel_attach_files')}
|
||||||
onKeyDownCapture={(e) => {
|
id="attach-file"
|
||||||
if (!inputRef.current) {
|
disabled={isUploadDisabled}
|
||||||
return;
|
render={
|
||||||
}
|
<button
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
aria-label={localize('com_sidepanel_attach_files')}
|
||||||
inputRef.current.value = '';
|
disabled={isUploadDisabled}
|
||||||
inputRef.current.click();
|
className={cn(
|
||||||
}
|
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||||
}}
|
)}
|
||||||
onClick={() => {
|
onKeyDownCapture={(e) => {
|
||||||
if (!inputRef.current) {
|
if (!inputRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
inputRef.current.value = '';
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
inputRef.current.click();
|
inputRef.current.value = '';
|
||||||
}}
|
inputRef.current.click();
|
||||||
>
|
}
|
||||||
<div className="flex w-full items-center justify-center gap-2">
|
}}
|
||||||
<AttachmentIcon />
|
onClick={() => {
|
||||||
</div>
|
if (!inputRef.current) {
|
||||||
</TooltipAnchor>
|
return;
|
||||||
|
}
|
||||||
|
inputRef.current.value = '';
|
||||||
|
inputRef.current.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-center gap-2">
|
||||||
|
<AttachmentIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</FileUpload>
|
</FileUpload>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
44
client/src/components/Chat/Input/Files/AttachFileChat.tsx
Normal file
44
client/src/components/Chat/Input/Files/AttachFileChat.tsx
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import {
|
||||||
|
supportsFiles,
|
||||||
|
mergeFileConfig,
|
||||||
|
isAgentsEndpoint,
|
||||||
|
EndpointFileConfig,
|
||||||
|
fileConfig as defaultFileConfig,
|
||||||
|
} from 'librechat-data-provider';
|
||||||
|
import { useChatContext } from '~/Providers';
|
||||||
|
import { useGetFileConfig } from '~/data-provider';
|
||||||
|
import AttachFileMenu from './AttachFileMenu';
|
||||||
|
import AttachFile from './AttachFile';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
|
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
|
||||||
|
const { conversation } = useChatContext();
|
||||||
|
|
||||||
|
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||||
|
|
||||||
|
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
|
||||||
|
|
||||||
|
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||||
|
select: (data) => mergeFileConfig(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
|
||||||
|
| EndpointFileConfig
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
|
||||||
|
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
|
||||||
|
|
||||||
|
if (isAgents) {
|
||||||
|
return <AttachFileMenu disabled={disableInputs} />;
|
||||||
|
}
|
||||||
|
if (endpointSupportsFiles && !isUploadDisabled) {
|
||||||
|
return <AttachFile disabled={disableInputs} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(AttachFileChat);
|
|
@ -2,25 +2,23 @@ import * as Ariakit from '@ariakit/react';
|
||||||
import React, { useRef, useState, useMemo } from 'react';
|
import React, { useRef, useState, useMemo } from 'react';
|
||||||
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
|
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
|
||||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||||
import { FileUpload, TooltipAnchor, DropdownPopup } from '~/components/ui';
|
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
import { useGetEndpointsQuery } from '~/data-provider';
|
||||||
import { AttachmentIcon } from '~/components/svg';
|
import { useLocalize, useFileHandling } from '~/hooks';
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
interface AttachFileProps {
|
interface AttachFileProps {
|
||||||
isRTL: boolean;
|
|
||||||
disabled?: boolean | null;
|
disabled?: boolean | null;
|
||||||
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>, toolResource?: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
|
const AttachFile = ({ disabled }: AttachFileProps) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const isUploadDisabled = disabled ?? false;
|
const isUploadDisabled = disabled ?? false;
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||||
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
|
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
|
||||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||||
|
const { handleFileChange } = useFileHandling();
|
||||||
|
|
||||||
const capabilities = useMemo(
|
const capabilities = useMemo(
|
||||||
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
|
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
|
||||||
|
@ -93,8 +91,7 @@ const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
|
||||||
id="attach-file-menu-button"
|
id="attach-file-menu-button"
|
||||||
aria-label="Attach File Options"
|
aria-label="Attach File Options"
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||||
isRTL ? 'bottom-2 right-2' : 'bottom-2 left-1 md:left-2',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-center gap-2">
|
<div className="flex w-full items-center justify-center gap-2">
|
||||||
|
@ -115,17 +112,15 @@ const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
|
||||||
handleFileChange(e, toolResource);
|
handleFileChange(e, toolResource);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative select-none">
|
<DropdownPopup
|
||||||
<DropdownPopup
|
menuId="attach-file-menu"
|
||||||
menuId="attach-file-menu"
|
isOpen={isPopoverActive}
|
||||||
isOpen={isPopoverActive}
|
setIsOpen={setIsPopoverActive}
|
||||||
setIsOpen={setIsPopoverActive}
|
modal={true}
|
||||||
modal={true}
|
trigger={menuTrigger}
|
||||||
trigger={menuTrigger}
|
items={dropdownItems}
|
||||||
items={dropdownItems}
|
iconClassName="mr-0"
|
||||||
iconClassName="mr-0"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FileUpload>
|
</FileUpload>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
30
client/src/components/Chat/Input/Files/FileFormChat.tsx
Normal file
30
client/src/components/Chat/Input/Files/FileFormChat.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import { useChatContext } from '~/Providers';
|
||||||
|
import { useFileHandling } from '~/hooks';
|
||||||
|
import FileRow from './FileRow';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
|
function FileFormChat({ disableInputs }: { disableInputs: boolean }) {
|
||||||
|
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
||||||
|
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
|
||||||
|
const { endpoint: _endpoint } = conversation ?? { endpoint: null };
|
||||||
|
const { abortUpload } = useFileHandling();
|
||||||
|
|
||||||
|
const isRTL = chatDirection === 'rtl';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FileRow
|
||||||
|
files={files}
|
||||||
|
setFiles={setFiles}
|
||||||
|
abortUpload={abortUpload}
|
||||||
|
setFilesLoading={setFilesLoading}
|
||||||
|
isRTL={isRTL}
|
||||||
|
Wrapper={({ children }) => <div className="mx-2 mt-2 flex flex-wrap gap-2">{children}</div>}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(FileFormChat);
|
|
@ -1,80 +0,0 @@
|
||||||
import { memo, useMemo } from 'react';
|
|
||||||
import { useRecoilValue } from 'recoil';
|
|
||||||
import {
|
|
||||||
supportsFiles,
|
|
||||||
mergeFileConfig,
|
|
||||||
isAgentsEndpoint,
|
|
||||||
EndpointFileConfig,
|
|
||||||
fileConfig as defaultFileConfig,
|
|
||||||
} from 'librechat-data-provider';
|
|
||||||
import { useGetFileConfig } from '~/data-provider';
|
|
||||||
import AttachFileMenu from './AttachFileMenu';
|
|
||||||
import { useChatContext } from '~/Providers';
|
|
||||||
import { useFileHandling } from '~/hooks';
|
|
||||||
import AttachFile from './AttachFile';
|
|
||||||
import FileRow from './FileRow';
|
|
||||||
import store from '~/store';
|
|
||||||
|
|
||||||
function FileFormWrapper({
|
|
||||||
children,
|
|
||||||
disableInputs,
|
|
||||||
}: {
|
|
||||||
disableInputs: boolean;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
|
||||||
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
|
|
||||||
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
|
||||||
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
|
|
||||||
|
|
||||||
const { handleFileChange, abortUpload } = useFileHandling();
|
|
||||||
|
|
||||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
|
||||||
select: (data) => mergeFileConfig(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
const isRTL = chatDirection === 'rtl';
|
|
||||||
|
|
||||||
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
|
|
||||||
| EndpointFileConfig
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
|
|
||||||
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
|
|
||||||
|
|
||||||
const renderAttachFile = () => {
|
|
||||||
if (isAgents) {
|
|
||||||
return (
|
|
||||||
<AttachFileMenu
|
|
||||||
isRTL={isRTL}
|
|
||||||
disabled={disableInputs}
|
|
||||||
handleFileChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (endpointSupportsFiles && !isUploadDisabled) {
|
|
||||||
return (
|
|
||||||
<AttachFile isRTL={isRTL} disabled={disableInputs} handleFileChange={handleFileChange} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FileRow
|
|
||||||
files={files}
|
|
||||||
setFiles={setFiles}
|
|
||||||
abortUpload={abortUpload}
|
|
||||||
setFilesLoading={setFilesLoading}
|
|
||||||
isRTL={isRTL}
|
|
||||||
Wrapper={({ children }) => <div className="mx-2 mt-2 flex flex-wrap gap-2">{children}</div>}
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
{renderAttachFile()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(FileFormWrapper);
|
|
|
@ -2,7 +2,7 @@ export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full bg-surface-secondary p-0.5 transition-colors duration-200 hover:bg-surface-primary z-50"
|
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full bg-surface-secondary p-0.5 transition-colors duration-200 hover:bg-surface-primary"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
|
|
|
@ -2,17 +2,12 @@ import { useRecoilState } from 'recoil';
|
||||||
import { Settings2 } from 'lucide-react';
|
import { Settings2 } from 'lucide-react';
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Root, Anchor } from '@radix-ui/react-popover';
|
import { Root, Anchor } from '@radix-ui/react-popover';
|
||||||
import {
|
import { EModelEndpoint, isParamEndpoint, tConvoUpdateSchema } from 'librechat-data-provider';
|
||||||
EModelEndpoint,
|
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
|
||||||
isParamEndpoint,
|
|
||||||
isAgentsEndpoint,
|
|
||||||
tConvoUpdateSchema,
|
|
||||||
} from 'librechat-data-provider';
|
|
||||||
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
|
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
|
||||||
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
|
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
|
||||||
|
import { useSetIndexOptions, useMediaQuery, useLocalize } from '~/hooks';
|
||||||
import { PluginStoreDialog, TooltipAnchor } from '~/components';
|
import { PluginStoreDialog, TooltipAnchor } from '~/components';
|
||||||
import { ModelSelect } from '~/components/Input/ModelSelect';
|
|
||||||
import { useSetIndexOptions, useLocalize } from '~/hooks';
|
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
import { useGetEndpointsQuery } from '~/data-provider';
|
||||||
import OptionsPopover from './OptionsPopover';
|
import OptionsPopover from './OptionsPopover';
|
||||||
import PopoverButtons from './PopoverButtons';
|
import PopoverButtons from './PopoverButtons';
|
||||||
|
@ -26,6 +21,7 @@ export default function HeaderOptions({
|
||||||
interfaceConfig?: Partial<TInterfaceConfig>;
|
interfaceConfig?: Partial<TInterfaceConfig>;
|
||||||
}) {
|
}) {
|
||||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||||
|
|
||||||
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
|
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
|
||||||
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
|
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
|
||||||
store.showPluginStoreDialog,
|
store.showPluginStoreDialog,
|
||||||
|
@ -35,6 +31,15 @@ export default function HeaderOptions({
|
||||||
const { showPopover, conversation, setShowPopover } = useChatContext();
|
const { showPopover, conversation, setShowPopover } = useChatContext();
|
||||||
const { setOption } = useSetIndexOptions();
|
const { setOption } = useSetIndexOptions();
|
||||||
const { endpoint, conversationId } = conversation ?? {};
|
const { endpoint, conversationId } = conversation ?? {};
|
||||||
|
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? '');
|
||||||
|
const userProvidesKey = useMemo(
|
||||||
|
() => !!(endpointsConfig?.[endpoint ?? '']?.userProvide ?? false),
|
||||||
|
[endpointsConfig, endpoint],
|
||||||
|
);
|
||||||
|
const keyProvided = useMemo(
|
||||||
|
() => (userProvidesKey ? !!(keyExpiry.expiresAt ?? '') : true),
|
||||||
|
[keyExpiry.expiresAt, userProvidesKey],
|
||||||
|
);
|
||||||
|
|
||||||
const noSettings = useMemo<{ [key: string]: boolean }>(
|
const noSettings = useMemo<{ [key: string]: boolean }>(
|
||||||
() => ({
|
() => ({
|
||||||
|
@ -71,14 +76,6 @@ export default function HeaderOptions({
|
||||||
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
|
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
|
||||||
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
|
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
|
||||||
<div className="z-[61] flex w-full items-center justify-center gap-2">
|
<div className="z-[61] flex w-full items-center justify-center gap-2">
|
||||||
{interfaceConfig?.modelSelect === true && !isAgentsEndpoint(endpoint) && (
|
|
||||||
<ModelSelect
|
|
||||||
conversation={conversation}
|
|
||||||
setOption={setOption}
|
|
||||||
showAbove={false}
|
|
||||||
popover={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!noSettings[endpoint] &&
|
{!noSettings[endpoint] &&
|
||||||
interfaceConfig?.parameters === true &&
|
interfaceConfig?.parameters === true &&
|
||||||
paramEndpoint === false && (
|
paramEndpoint === false && (
|
||||||
|
|
|
@ -28,7 +28,7 @@ export default function Mention({
|
||||||
includeAssistants?: boolean;
|
includeAssistants?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const assistantMap = useAssistantsMapContext();
|
const assistantsMap = useAssistantsMapContext();
|
||||||
const {
|
const {
|
||||||
options,
|
options,
|
||||||
presets,
|
presets,
|
||||||
|
@ -37,11 +37,11 @@ export default function Mention({
|
||||||
modelsConfig,
|
modelsConfig,
|
||||||
endpointsConfig,
|
endpointsConfig,
|
||||||
assistantListMap,
|
assistantListMap,
|
||||||
} = useMentions({ assistantMap: assistantMap || {}, includeAssistants });
|
} = useMentions({ assistantMap: assistantsMap || {}, includeAssistants });
|
||||||
const { onSelectMention } = useSelectMention({
|
const { onSelectMention } = useSelectMention({
|
||||||
presets,
|
presets,
|
||||||
modelSpecs,
|
modelSpecs,
|
||||||
assistantMap,
|
assistantsMap,
|
||||||
endpointsConfig,
|
endpointsConfig,
|
||||||
newConversation,
|
newConversation,
|
||||||
});
|
});
|
||||||
|
@ -65,7 +65,7 @@ export default function Mention({
|
||||||
setSearchValue('');
|
setSearchValue('');
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setShowMentionPopover(false);
|
setShowMentionPopover(false);
|
||||||
onSelectMention(mention);
|
onSelectMention?.(mention);
|
||||||
|
|
||||||
if (textAreaRef.current) {
|
if (textAreaRef.current) {
|
||||||
removeCharIfLast(textAreaRef.current, commandChar);
|
removeCharIfLast(textAreaRef.current, commandChar);
|
||||||
|
@ -158,11 +158,11 @@ export default function Mention({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-14 z-10 w-full space-y-2">
|
<div className="absolute bottom-28 z-10 w-full space-y-2">
|
||||||
<div className="popover border-token-border-light rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700">
|
<div className="popover border-token-border-light rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700">
|
||||||
<input
|
<input
|
||||||
// The user expects focus to transition to the input field when the popover is opened
|
// The user expects focus to transition to the input field when the popover is opened
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||||
autoFocus
|
autoFocus
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={localize(placeholder)}
|
placeholder={localize(placeholder)}
|
||||||
|
|
|
@ -69,7 +69,9 @@ function PromptsCommand({
|
||||||
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
|
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
|
||||||
group.name
|
group.name
|
||||||
}: ${
|
}: ${
|
||||||
(group.oneliner?.length ?? 0) > 0 ? group.oneliner : group.productionPrompt?.prompt ?? ''
|
(group.oneliner?.length ?? 0) > 0
|
||||||
|
? group.oneliner
|
||||||
|
: (group.productionPrompt?.prompt ?? '')
|
||||||
}`,
|
}`,
|
||||||
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
|
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
|
||||||
}));
|
}));
|
||||||
|
@ -195,11 +197,11 @@ function PromptsCommand({
|
||||||
variableGroup={variableGroup}
|
variableGroup={variableGroup}
|
||||||
setVariableDialogOpen={setVariableDialogOpen}
|
setVariableDialogOpen={setVariableDialogOpen}
|
||||||
>
|
>
|
||||||
<div className="absolute bottom-14 z-10 w-full space-y-2">
|
<div className="absolute bottom-28 z-10 w-full space-y-2">
|
||||||
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
|
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
|
||||||
<input
|
<input
|
||||||
// The user expects focus to transition to the input field when the popover is opened
|
// The user expects focus to transition to the input field when the popover is opened
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
|
||||||
autoFocus
|
autoFocus
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={localize('com_ui_command_usage_placeholder')}
|
placeholder={localize('com_ui_command_usage_placeholder')}
|
||||||
|
|
|
@ -24,7 +24,7 @@ const SubmitButton = React.memo(
|
||||||
id="send-button"
|
id="send-button"
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full bg-text-primary p-2 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
|
'rounded-full bg-text-primary p-1.5 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
|
||||||
)}
|
)}
|
||||||
data-testid="send-button"
|
data-testid="send-button"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
@ -34,7 +34,7 @@ const SubmitButton = React.memo(
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
></TooltipAnchor>
|
/>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
@ -12,7 +12,7 @@ export default function StopButton({ stop, setShowStopButton }) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full bg-text-primary p-2 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
|
'rounded-full bg-text-primary p-1.5 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
|
||||||
)}
|
)}
|
||||||
aria-label={localize('com_nav_stop_generating')}
|
aria-label={localize('com_nav_stop_generating')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
import { MessageCircleDashed, X } from 'lucide-react';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
|
|
||||||
interface TemporaryChatProps {
|
|
||||||
isTemporaryChat: boolean;
|
|
||||||
setIsTemporaryChat: (value: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TemporaryChat = ({ isTemporaryChat, setIsTemporaryChat }: TemporaryChatProps) => {
|
|
||||||
const localize = useLocalize();
|
|
||||||
|
|
||||||
if (!isTemporaryChat) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
|
|
||||||
<div className="flex items-start gap-4 py-2.5 pl-3 pr-1.5 text-sm">
|
|
||||||
<span className="mt-0 flex h-6 w-6 flex-shrink-0 items-center justify-center">
|
|
||||||
<div className="icon-md">
|
|
||||||
<MessageCircleDashed className="icon-md" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
<span className="text-token-text-secondary line-clamp-3 flex-1 py-0.5 font-semibold">
|
|
||||||
{localize('com_ui_temporary_chat')}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="text-token-text-secondary flex-shrink-0"
|
|
||||||
type="button"
|
|
||||||
aria-label="Close temporary chat"
|
|
||||||
onClick={() => setIsTemporaryChat(false)}
|
|
||||||
>
|
|
||||||
<X className="pr-1" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -13,7 +13,7 @@ export default function TextareaHeader({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
|
<div className="m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
|
||||||
<AddedConvo addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
<AddedConvo addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,47 +1,45 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useCallback, useState, useEffect } from 'react';
|
||||||
import { EModelEndpoint, Constants } from 'librechat-data-provider';
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
import type * as t from 'librechat-data-provider';
|
import type * as t from 'librechat-data-provider';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
|
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
|
||||||
import {
|
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
|
||||||
useGetAssistantDocsQuery,
|
import { BirthdayIcon, TooltipAnchor, SplitText } from '~/components';
|
||||||
useGetEndpointsQuery,
|
|
||||||
useGetStartupConfig,
|
|
||||||
} from '~/data-provider';
|
|
||||||
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
|
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
|
||||||
import { getIconEndpoint, getEntity, cn } from '~/utils';
|
import { useLocalize, useAuthContext } from '~/hooks';
|
||||||
import { useLocalize, useSubmitMessage } from '~/hooks';
|
import { getIconEndpoint, getEntity } from '~/utils';
|
||||||
import { TooltipAnchor } from '~/components/ui';
|
|
||||||
import { BirthdayIcon } from '~/components/svg';
|
|
||||||
import ConvoStarter from './ConvoStarter';
|
|
||||||
|
|
||||||
export default function Landing({ Header }: { Header?: ReactNode }) {
|
const containerClassName =
|
||||||
|
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
|
||||||
|
|
||||||
|
export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: boolean }) {
|
||||||
const { conversation } = useChatContext();
|
const { conversation } = useChatContext();
|
||||||
const agentsMap = useAgentsMapContext();
|
const agentsMap = useAgentsMapContext();
|
||||||
const assistantMap = useAssistantsMapContext();
|
const assistantMap = useAssistantsMapContext();
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||||
|
const { user } = useAuthContext();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
let { endpoint = '' } = conversation ?? {};
|
const endpointType = useMemo(() => {
|
||||||
|
let ep = conversation?.endpoint ?? '';
|
||||||
if (
|
if (
|
||||||
endpoint === EModelEndpoint.chatGPTBrowser ||
|
[
|
||||||
endpoint === EModelEndpoint.azureOpenAI ||
|
EModelEndpoint.chatGPTBrowser,
|
||||||
endpoint === EModelEndpoint.gptPlugins
|
EModelEndpoint.azureOpenAI,
|
||||||
) {
|
EModelEndpoint.gptPlugins,
|
||||||
endpoint = EModelEndpoint.openAI;
|
].includes(ep as EModelEndpoint)
|
||||||
}
|
) {
|
||||||
|
ep = EModelEndpoint.openAI;
|
||||||
const iconURL = conversation?.iconURL;
|
}
|
||||||
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
|
return getIconEndpoint({
|
||||||
const { data: documentsMap = new Map() } = useGetAssistantDocsQuery(endpoint, {
|
endpointsConfig,
|
||||||
select: (data) => new Map(data.map((dbA) => [dbA.assistant_id, dbA])),
|
iconURL: conversation?.iconURL,
|
||||||
});
|
endpoint: ep,
|
||||||
|
});
|
||||||
|
}, [conversation?.endpoint, conversation?.iconURL, endpointsConfig]);
|
||||||
|
|
||||||
const { entity, isAgent, isAssistant } = getEntity({
|
const { entity, isAgent, isAssistant } = getEntity({
|
||||||
endpoint,
|
endpoint: endpointType,
|
||||||
agentsMap,
|
agentsMap,
|
||||||
assistantMap,
|
assistantMap,
|
||||||
agent_id: conversation?.agent_id,
|
agent_id: conversation?.agent_id,
|
||||||
|
@ -50,102 +48,106 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
|
||||||
|
|
||||||
const name = entity?.name ?? '';
|
const name = entity?.name ?? '';
|
||||||
const description = entity?.description ?? '';
|
const description = entity?.description ?? '';
|
||||||
const avatar = isAgent
|
|
||||||
? (entity as t.Agent | undefined)?.avatar?.filepath ?? ''
|
const getGreeting = useCallback(() => {
|
||||||
: ((entity as t.Assistant | undefined)?.metadata?.avatar as string | undefined) ?? '';
|
if (typeof startupConfig?.interface?.customWelcome === 'string') {
|
||||||
const conversation_starters = useMemo(() => {
|
return startupConfig.interface.customWelcome;
|
||||||
/* The user made updates, use client-side cache, or they exist in an Agent */
|
|
||||||
if (entity && (entity.conversation_starters?.length ?? 0) > 0) {
|
|
||||||
return entity.conversation_starters;
|
|
||||||
}
|
|
||||||
if (isAgent) {
|
|
||||||
return entity?.conversation_starters ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* If none in cache, we use the latest assistant docs */
|
const now = new Date();
|
||||||
const entityDocs = documentsMap.get(entity?.id ?? '');
|
const hours = now.getHours();
|
||||||
return entityDocs?.conversation_starters ?? [];
|
|
||||||
}, [documentsMap, isAgent, entity]);
|
|
||||||
|
|
||||||
const containerClassName =
|
const dayOfWeek = now.getDay();
|
||||||
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||||
|
|
||||||
const { submitMessage } = useSubmitMessage();
|
// Early morning (midnight to 4:59 AM)
|
||||||
const sendConversationStarter = (text: string) => submitMessage({ text });
|
if (hours >= 0 && hours < 5) {
|
||||||
|
return localize('com_ui_late_night');
|
||||||
const getWelcomeMessage = () => {
|
|
||||||
const greeting = conversation?.greeting ?? '';
|
|
||||||
if (greeting) {
|
|
||||||
return greeting;
|
|
||||||
}
|
}
|
||||||
|
// Morning (6 AM to 11:59 AM)
|
||||||
if (isAssistant) {
|
else if (hours < 12) {
|
||||||
return localize('com_nav_welcome_assistant');
|
if (isWeekend) {
|
||||||
|
return localize('com_ui_weekend_morning');
|
||||||
|
}
|
||||||
|
return localize('com_ui_good_morning');
|
||||||
}
|
}
|
||||||
|
// Afternoon (12 PM to 4:59 PM)
|
||||||
if (isAgent) {
|
else if (hours < 17) {
|
||||||
return localize('com_nav_welcome_agent');
|
return localize('com_ui_good_afternoon');
|
||||||
}
|
}
|
||||||
|
// Evening (5 PM to 8:59 PM)
|
||||||
return typeof startupConfig?.interface?.customWelcome === 'string'
|
else {
|
||||||
? startupConfig?.interface?.customWelcome
|
return localize('com_ui_good_evening');
|
||||||
: localize('com_nav_welcome_message');
|
}
|
||||||
};
|
}, [localize, startupConfig?.interface?.customWelcome]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full">
|
<div
|
||||||
<div className="absolute left-0 right-0">{Header != null ? Header : null}</div>
|
className={`flex h-full transform-gpu flex-col items-center justify-center pb-16 transition-all duration-200 ${centerFormOnLanding ? 'max-h-full sm:max-h-0' : 'max-h-full'}`}
|
||||||
<div className="flex h-full flex-col items-center justify-center">
|
>
|
||||||
<div className={cn('relative h-12 w-12', name && avatar ? 'mb-0' : 'mb-3')}>
|
<div className="flex flex-col items-center gap-0 p-2">
|
||||||
<ConvoIcon
|
<div className="flex flex-col items-center justify-center gap-4 md:flex-row">
|
||||||
agentsMap={agentsMap}
|
<div className="relative size-10 justify-center">
|
||||||
assistantMap={assistantMap}
|
<ConvoIcon
|
||||||
conversation={conversation}
|
agentsMap={agentsMap}
|
||||||
endpointsConfig={endpointsConfig}
|
assistantMap={assistantMap}
|
||||||
containerClassName={containerClassName}
|
conversation={conversation}
|
||||||
context="landing"
|
endpointsConfig={endpointsConfig}
|
||||||
className="h-2/3 w-2/3"
|
containerClassName={containerClassName}
|
||||||
size={41}
|
context="landing"
|
||||||
/>
|
className="h-2/3 w-2/3"
|
||||||
{startupConfig?.showBirthdayIcon === true ? (
|
size={41}
|
||||||
<TooltipAnchor
|
/>
|
||||||
className="absolute bottom-8 right-2.5"
|
{startupConfig?.showBirthdayIcon && (
|
||||||
description={localize('com_ui_happy_birthday')}
|
<TooltipAnchor
|
||||||
>
|
className="absolute bottom-[27px] right-2"
|
||||||
<BirthdayIcon />
|
description={localize('com_ui_happy_birthday')}
|
||||||
</TooltipAnchor>
|
>
|
||||||
) : null}
|
<BirthdayIcon />
|
||||||
</div>
|
</TooltipAnchor>
|
||||||
{name ? (
|
)}
|
||||||
<div className="flex flex-col items-center gap-0 p-2">
|
</div>
|
||||||
<div className="text-center text-2xl font-medium dark:text-white">{name}</div>
|
{((isAgent || isAssistant) && name) || name ? (
|
||||||
<div className="max-w-md text-center text-sm font-normal text-text-primary ">
|
<div className="flex flex-col items-center gap-0 p-2">
|
||||||
{description ||
|
<SplitText
|
||||||
(typeof startupConfig?.interface?.customWelcome === 'string'
|
key={`split-text-${name}`}
|
||||||
? startupConfig?.interface?.customWelcome
|
text={name}
|
||||||
: localize('com_nav_welcome_message'))}
|
className="text-4xl font-medium text-text-primary"
|
||||||
|
delay={50}
|
||||||
|
textAlign="center"
|
||||||
|
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
|
||||||
|
animationTo={{ opacity: 1, transform: 'translate3d(0,0,0)' }}
|
||||||
|
easing="easeOutCubic"
|
||||||
|
threshold={0}
|
||||||
|
rootMargin="0px"
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
<SplitText
|
||||||
</div> */}
|
key={`split-text-${getGreeting()}${user?.username || ''}`}
|
||||||
|
text={getGreeting() + (user?.username ? ', ' + user.username : '')}
|
||||||
|
className="text-4xl font-medium text-text-primary"
|
||||||
|
delay={50}
|
||||||
|
textAlign="center"
|
||||||
|
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
|
||||||
|
animationTo={{ opacity: 1, transform: 'translate3d(0,0,0)' }}
|
||||||
|
easing="easeOutCubic"
|
||||||
|
threshold={0}
|
||||||
|
rootMargin="0px"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(isAgent || isAssistant) && description ? (
|
||||||
|
<div className="animate-fadeIn mt-2 max-w-md text-center text-sm font-normal text-text-primary">
|
||||||
|
{description}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<h2 className="mb-5 max-w-[75vh] px-12 text-center text-lg font-medium dark:text-white md:px-0 md:text-2xl">
|
typeof startupConfig?.interface?.customWelcome === 'string' && (
|
||||||
{getWelcomeMessage()}
|
<div className="animate-fadeIn mt-2 max-w-md text-center text-sm font-normal text-text-primary">
|
||||||
</h2>
|
{startupConfig?.interface?.customWelcome}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<div className="mt-8 flex flex-wrap justify-center gap-3 px-4">
|
|
||||||
{conversation_starters.length > 0 &&
|
|
||||||
conversation_starters
|
|
||||||
.slice(0, Constants.MAX_CONVO_STARTERS)
|
|
||||||
.map((text: string, index: number) => (
|
|
||||||
<ConvoStarter
|
|
||||||
key={index}
|
|
||||||
text={text}
|
|
||||||
onClick={() => sendConversationStarter(text)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -170,7 +170,7 @@ const BookmarkMenu: FC = () => {
|
||||||
id="bookmark-menu-button"
|
id="bookmark-menu-button"
|
||||||
aria-label={localize('com_ui_bookmarks_add')}
|
aria-label={localize('com_ui_bookmarks_add')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-text-sm flex size-10 items-center justify-center gap-2 rounded-lg border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
|
'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-lg border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
|
||||||
isMenuOpen ? 'bg-surface-hover' : '',
|
isMenuOpen ? 'bg-surface-hover' : '',
|
||||||
)}
|
)}
|
||||||
data-testid="bookmark-menu"
|
data-testid="bookmark-menu"
|
||||||
|
|
246
client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx
Normal file
246
client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as Ariakit from '@ariakit/react';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
export interface CustomMenuProps extends Ariakit.MenuButtonProps<'div'> {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
values?: Record<string, any>;
|
||||||
|
onValuesChange?: (values: Record<string, any>) => void;
|
||||||
|
searchValue?: string;
|
||||||
|
onSearch?: (value: string) => void;
|
||||||
|
combobox?: Ariakit.ComboboxProps['render'];
|
||||||
|
trigger?: Ariakit.MenuButtonProps['render'];
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(function CustomMenu(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
values,
|
||||||
|
onValuesChange,
|
||||||
|
searchValue,
|
||||||
|
onSearch,
|
||||||
|
combobox,
|
||||||
|
trigger,
|
||||||
|
defaultOpen,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const parent = Ariakit.useMenuContext();
|
||||||
|
const searchable = searchValue != null || !!onSearch || !!combobox;
|
||||||
|
|
||||||
|
const menuStore = Ariakit.useMenuStore({
|
||||||
|
showTimeout: 100,
|
||||||
|
placement: parent ? 'right' : 'left',
|
||||||
|
defaultOpen: defaultOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const element = (
|
||||||
|
<Ariakit.MenuProvider store={menuStore} values={values} setValues={onValuesChange}>
|
||||||
|
<Ariakit.MenuButton
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
!parent &&
|
||||||
|
'flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-border-light px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white',
|
||||||
|
menuStore.useState('open')
|
||||||
|
? 'bg-surface-tertiary hover:bg-surface-tertiary'
|
||||||
|
: 'bg-surface-secondary hover:bg-surface-tertiary',
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
render={parent ? <CustomMenuItem render={trigger} /> : trigger}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{label}</span>
|
||||||
|
<Ariakit.MenuButtonArrow className="stroke-1 text-base opacity-75" />
|
||||||
|
</Ariakit.MenuButton>
|
||||||
|
<Ariakit.Menu
|
||||||
|
open={menuStore.useState('open')}
|
||||||
|
portal
|
||||||
|
overlap
|
||||||
|
unmountOnHide
|
||||||
|
gutter={parent ? -4 : 4}
|
||||||
|
className={cn(
|
||||||
|
`${parent ? 'animate-popover-left ml-3' : 'animate-popover'} outline-none! z-50 flex max-h-[min(450px,var(--popover-available-height))] w-full`,
|
||||||
|
'w-[var(--menu-width,auto)] min-w-[300px] flex-col overflow-auto rounded-xl border border-border-light',
|
||||||
|
'bg-surface-secondary px-3 py-2 text-sm text-text-primary shadow-lg',
|
||||||
|
'max-w-[calc(100vw-4rem)] sm:max-h-[calc(65vh)] sm:max-w-[400px]',
|
||||||
|
searchable && 'p-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SearchableContext.Provider value={searchable}>
|
||||||
|
{searchable ? (
|
||||||
|
<>
|
||||||
|
<div className="sticky top-0 z-10 bg-inherit p-1">
|
||||||
|
<Ariakit.Combobox
|
||||||
|
autoSelect
|
||||||
|
render={combobox}
|
||||||
|
className={cn(
|
||||||
|
'h-10 w-full rounded border-none bg-transparent px-2 text-base',
|
||||||
|
'sm:h-8 sm:text-sm',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Ariakit.ComboboxList className="p-0.5 pt-0">{children}</Ariakit.ComboboxList>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</SearchableContext.Provider>
|
||||||
|
</Ariakit.Menu>
|
||||||
|
</Ariakit.MenuProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (searchable) {
|
||||||
|
return (
|
||||||
|
<Ariakit.ComboboxProvider
|
||||||
|
resetValueOnHide
|
||||||
|
includesBaseElement={false}
|
||||||
|
value={searchValue}
|
||||||
|
setValue={onSearch}
|
||||||
|
>
|
||||||
|
{element}
|
||||||
|
</Ariakit.ComboboxProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CustomMenuSeparator = React.forwardRef<HTMLHRElement, Ariakit.MenuSeparatorProps>(
|
||||||
|
function CustomMenuSeparator(props, ref) {
|
||||||
|
return (
|
||||||
|
<Ariakit.MenuSeparator
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
'my-0.5 h-0 w-full border-t border-slate-200 dark:border-slate-700',
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface CustomMenuGroupProps extends Ariakit.MenuGroupProps {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMenuGroup = React.forwardRef<HTMLDivElement, CustomMenuGroupProps>(
|
||||||
|
function CustomMenuGroup({ label, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<Ariakit.MenuGroup ref={ref} {...props} className={cn('', props.className)}>
|
||||||
|
{label && (
|
||||||
|
<Ariakit.MenuGroupLabel className="cursor-default p-2 text-sm font-medium opacity-60 sm:py-1 sm:text-xs">
|
||||||
|
{label}
|
||||||
|
</Ariakit.MenuGroupLabel>
|
||||||
|
)}
|
||||||
|
{props.children}
|
||||||
|
</Ariakit.MenuGroup>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const SearchableContext = React.createContext(false);
|
||||||
|
|
||||||
|
export interface CustomMenuItemProps extends Omit<Ariakit.ComboboxItemProps, 'store'> {
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemProps>(
|
||||||
|
function CustomMenuItem({ name, value, ...props }, ref) {
|
||||||
|
const menu = Ariakit.useMenuContext();
|
||||||
|
const searchable = React.useContext(SearchableContext);
|
||||||
|
const defaultProps: CustomMenuItemProps = {
|
||||||
|
ref,
|
||||||
|
focusOnHover: true,
|
||||||
|
blurOnHoverEnd: false,
|
||||||
|
...props,
|
||||||
|
className: cn(
|
||||||
|
'flex cursor-default items-center gap-2 rounded-lg p-2 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:py-1 sm:text-sm min-w-0 w-full',
|
||||||
|
props.className,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkable = Ariakit.useStoreState(menu, (state) => {
|
||||||
|
if (!name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return state?.values[name] != null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const checked = Ariakit.useStoreState(menu, (state) => {
|
||||||
|
if (!name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return state?.values[name] === value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the item is checkable, we render a checkmark icon next to the label.
|
||||||
|
if (checkable) {
|
||||||
|
defaultProps.children = (
|
||||||
|
<React.Fragment>
|
||||||
|
<span className="flex-1">{defaultProps.children}</span>
|
||||||
|
<Ariakit.MenuItemCheck checked={checked} />
|
||||||
|
{searchable && (
|
||||||
|
// When an item is displayed in a search menu as a role=option
|
||||||
|
// element instead of a role=menuitemradio, we can't depend on the
|
||||||
|
// aria-checked attribute. Although NVDA and JAWS announce it
|
||||||
|
// accurately, VoiceOver doesn't. TalkBack does announce the checked
|
||||||
|
// state, but misleadingly implies that a double tap will change the
|
||||||
|
// state, which isn't the case. Therefore, we use a visually hidden
|
||||||
|
// element to indicate whether the item is checked or not, ensuring
|
||||||
|
// cross-browser/AT compatibility.
|
||||||
|
<Ariakit.VisuallyHidden>{checked ? 'checked' : 'not checked'}</Ariakit.VisuallyHidden>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the item is not rendered in a search menu (listbox), we can render it
|
||||||
|
// as a MenuItem/MenuItemRadio.
|
||||||
|
if (!searchable) {
|
||||||
|
if (name != null && value != null) {
|
||||||
|
const radioProps = { ...defaultProps, name, value, hideOnClick: true };
|
||||||
|
return <Ariakit.MenuItemRadio {...radioProps} />;
|
||||||
|
}
|
||||||
|
return <Ariakit.MenuItem {...defaultProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Ariakit.ComboboxItem
|
||||||
|
{...defaultProps}
|
||||||
|
setValueOnClick={false}
|
||||||
|
value={checkable ? value : undefined}
|
||||||
|
selectValueOnClick={() => {
|
||||||
|
if (name == null || value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// By default, clicking on a ComboboxItem will update the
|
||||||
|
// selectedValue state of the combobox. However, since we're sharing
|
||||||
|
// state between combobox and menu, we also need to update the menu's
|
||||||
|
// values state.
|
||||||
|
menu?.setValue(name, value);
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
hideOnClick={(event) => {
|
||||||
|
// Make sure that clicking on a combobox item that opens a nested
|
||||||
|
// menu/dialog does not close the menu.
|
||||||
|
const expandable = event.currentTarget.hasAttribute('aria-expanded');
|
||||||
|
if (expandable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// By default, clicking on a ComboboxItem only closes its own popover.
|
||||||
|
// However, since we're in a menu context, we also close all parent
|
||||||
|
// menus.
|
||||||
|
menu?.hideAll();
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
34
client/src/components/Chat/Menus/Endpoints/DialogManager.tsx
Normal file
34
client/src/components/Chat/Menus/Endpoints/DialogManager.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
||||||
|
import { getEndpointField } from '~/utils';
|
||||||
|
|
||||||
|
interface DialogManagerProps {
|
||||||
|
keyDialogOpen: boolean;
|
||||||
|
keyDialogEndpoint?: EModelEndpoint;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
endpointsConfig: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogManager = ({
|
||||||
|
keyDialogOpen,
|
||||||
|
keyDialogEndpoint,
|
||||||
|
onOpenChange,
|
||||||
|
endpointsConfig,
|
||||||
|
}: DialogManagerProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{keyDialogEndpoint && (
|
||||||
|
<SetKeyDialog
|
||||||
|
open={keyDialogOpen}
|
||||||
|
endpoint={keyDialogEndpoint}
|
||||||
|
endpointType={getEndpointField(endpointsConfig, keyDialogEndpoint, 'type')}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
userProvideURL={getEndpointField(endpointsConfig, keyDialogEndpoint, 'userProvideURL')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DialogManager;
|
|
@ -1,221 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Settings } from 'lucide-react';
|
|
||||||
import { useRecoilValue } from 'recoil';
|
|
||||||
import { EModelEndpoint } from 'librechat-data-provider';
|
|
||||||
import type { TConversation } from 'librechat-data-provider';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import { cn, getConvoSwitchLogic, getEndpointField, getIconKey } from '~/utils';
|
|
||||||
import { useLocalize, useUserKey, useDefaultConvo } from '~/hooks';
|
|
||||||
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
|
||||||
import { useChatContext } from '~/Providers';
|
|
||||||
import { icons } from './Icons';
|
|
||||||
import store from '~/store';
|
|
||||||
|
|
||||||
type MenuItemProps = {
|
|
||||||
title: string;
|
|
||||||
value: EModelEndpoint;
|
|
||||||
selected: boolean;
|
|
||||||
description?: string;
|
|
||||||
userProvidesKey: boolean;
|
|
||||||
// iconPath: string;
|
|
||||||
// hoverContent?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MenuItem: FC<MenuItemProps> = ({
|
|
||||||
title,
|
|
||||||
value: endpoint,
|
|
||||||
description,
|
|
||||||
selected,
|
|
||||||
userProvidesKey,
|
|
||||||
...rest
|
|
||||||
}) => {
|
|
||||||
const modularChat = useRecoilValue(store.modularChat);
|
|
||||||
const [isDialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
|
||||||
const { conversation, newConversation } = useChatContext();
|
|
||||||
const getDefaultConversation = useDefaultConvo();
|
|
||||||
|
|
||||||
const { getExpiry } = useUserKey(endpoint);
|
|
||||||
const localize = useLocalize();
|
|
||||||
const expiryTime = getExpiry() ?? '';
|
|
||||||
|
|
||||||
const onSelectEndpoint = (newEndpoint?: EModelEndpoint) => {
|
|
||||||
if (!newEndpoint) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!expiryTime) {
|
|
||||||
setDialogOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
template,
|
|
||||||
shouldSwitch,
|
|
||||||
isNewModular,
|
|
||||||
newEndpointType,
|
|
||||||
isCurrentModular,
|
|
||||||
isExistingConversation,
|
|
||||||
} = getConvoSwitchLogic({
|
|
||||||
newEndpoint,
|
|
||||||
modularChat,
|
|
||||||
conversation,
|
|
||||||
endpointsConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isModular = isCurrentModular && isNewModular && shouldSwitch;
|
|
||||||
if (isExistingConversation && isModular) {
|
|
||||||
template.endpointType = newEndpointType;
|
|
||||||
|
|
||||||
const currentConvo = getDefaultConversation({
|
|
||||||
/* target endpointType is necessary to avoid endpoint mixing */
|
|
||||||
conversation: { ...(conversation ?? {}), endpointType: template.endpointType },
|
|
||||||
preset: template,
|
|
||||||
});
|
|
||||||
|
|
||||||
/* We don't reset the latest message, only when changing settings mid-converstion */
|
|
||||||
newConversation({
|
|
||||||
template: currentConvo,
|
|
||||||
preset: currentConvo,
|
|
||||||
keepLatestMessage: true,
|
|
||||||
keepAddedConvos: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
newConversation({
|
|
||||||
template: { ...(template as Partial<TConversation>) },
|
|
||||||
keepAddedConvos: isModular,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
|
||||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType });
|
|
||||||
const Icon = icons[iconKey];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
role="option"
|
|
||||||
aria-selected={selected}
|
|
||||||
className={cn(
|
|
||||||
'group m-1.5 flex max-h-[40px] cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-surface-hover',
|
|
||||||
'radix-disabled:pointer-events-none radix-disabled:opacity-50',
|
|
||||||
)}
|
|
||||||
tabIndex={0}
|
|
||||||
{...rest}
|
|
||||||
onClick={() => onSelectEndpoint(endpoint)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
onSelectEndpoint(endpoint);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex grow items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{Icon != null && (
|
|
||||||
<Icon
|
|
||||||
size={18}
|
|
||||||
endpoint={endpoint}
|
|
||||||
context={'menu-item'}
|
|
||||||
className="icon-md shrink-0 dark:text-white"
|
|
||||||
iconURL={getEndpointField(endpointsConfig, endpoint, 'iconURL')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
{title}
|
|
||||||
<div className="text-token-text-tertiary">{description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{userProvidesKey ? (
|
|
||||||
<div className="text-token-text-primary" key={`set-key-${endpoint}`}>
|
|
||||||
<button
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`${localize('com_endpoint_config_key')} for ${title}`}
|
|
||||||
className={cn(
|
|
||||||
'invisible flex gap-x-1 group-focus-within:visible group-hover:visible',
|
|
||||||
selected ? 'visible' : '',
|
|
||||||
expiryTime ? 'text-token-text-primary w-full rounded-lg p-2' : '',
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDialogOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'invisible group-focus-within:visible group-hover:visible',
|
|
||||||
expiryTime ? 'text-xs' : '',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{localize('com_endpoint_config_key')}
|
|
||||||
</div>
|
|
||||||
<Settings className={cn(expiryTime ? 'icon-sm' : 'icon-md stroke-1')} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{selected && (
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="icon-md block group-hover:hidden"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{(!userProvidesKey || expiryTime) && (
|
|
||||||
<div className="text-token-text-primary hidden gap-x-1 group-hover:flex ">
|
|
||||||
{!userProvidesKey && <div className="">{localize('com_ui_new_chat')}</div>}
|
|
||||||
<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="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{userProvidesKey && (
|
|
||||||
<SetKeyDialog
|
|
||||||
open={isDialogOpen}
|
|
||||||
endpoint={endpoint}
|
|
||||||
endpointType={endpointType}
|
|
||||||
onOpenChange={setDialogOpen}
|
|
||||||
userProvideURL={getEndpointField(endpointsConfig, endpoint, 'userProvideURL')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MenuItem;
|
|
|
@ -1,59 +0,0 @@
|
||||||
import type { FC } from 'react';
|
|
||||||
import { Close } from '@radix-ui/react-popover';
|
|
||||||
import {
|
|
||||||
EModelEndpoint,
|
|
||||||
alternateName,
|
|
||||||
PermissionTypes,
|
|
||||||
Permissions,
|
|
||||||
} from 'librechat-data-provider';
|
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
|
||||||
import MenuSeparator from '../UI/MenuSeparator';
|
|
||||||
import { getEndpointField } from '~/utils';
|
|
||||||
import { useHasAccess } from '~/hooks';
|
|
||||||
import MenuItem from './MenuItem';
|
|
||||||
|
|
||||||
const EndpointItems: FC<{
|
|
||||||
endpoints: Array<EModelEndpoint | undefined>;
|
|
||||||
selected: EModelEndpoint | '';
|
|
||||||
}> = ({ endpoints = [], selected }) => {
|
|
||||||
const hasAccessToAgents = useHasAccess({
|
|
||||||
permissionType: PermissionTypes.AGENTS,
|
|
||||||
permission: Permissions.USE,
|
|
||||||
});
|
|
||||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{endpoints.map((endpoint, i) => {
|
|
||||||
if (!endpoint) {
|
|
||||||
return null;
|
|
||||||
} else if (!endpointsConfig?.[endpoint]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endpoint === EModelEndpoint.agents && !hasAccessToAgents) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const userProvidesKey: boolean | null | undefined =
|
|
||||||
getEndpointField(endpointsConfig, endpoint, 'userProvide') ?? false;
|
|
||||||
return (
|
|
||||||
<Close asChild key={`endpoint-${endpoint}`}>
|
|
||||||
<div key={`endpoint-${endpoint}`}>
|
|
||||||
<MenuItem
|
|
||||||
key={`endpoint-item-${endpoint}`}
|
|
||||||
title={alternateName[endpoint] || endpoint}
|
|
||||||
value={endpoint}
|
|
||||||
selected={selected === endpoint}
|
|
||||||
data-testid={`endpoint-item-${endpoint}`}
|
|
||||||
userProvidesKey={!!userProvidesKey}
|
|
||||||
// description="With DALL·E, browsing and analysis"
|
|
||||||
/>
|
|
||||||
{i !== endpoints.length - 1 && <MenuSeparator />}
|
|
||||||
</div>
|
|
||||||
</Close>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EndpointItems;
|
|
95
client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx
Normal file
95
client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import React from 'react';
|
||||||
|
import type { ModelSelectorProps } from '~/common';
|
||||||
|
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
|
||||||
|
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components';
|
||||||
|
import { CustomMenu as Menu } from './CustomMenu';
|
||||||
|
import DialogManager from './DialogManager';
|
||||||
|
import { getSelectedIcon } from './utils';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
function ModelSelectorContent() {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
const {
|
||||||
|
// LibreChat
|
||||||
|
modelSpecs,
|
||||||
|
mappedEndpoints,
|
||||||
|
endpointsConfig,
|
||||||
|
// State
|
||||||
|
searchValue,
|
||||||
|
searchResults,
|
||||||
|
selectedValues,
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
setSearchValue,
|
||||||
|
getDisplayValue,
|
||||||
|
setSelectedValues,
|
||||||
|
// Dialog
|
||||||
|
keyDialogOpen,
|
||||||
|
onOpenChange,
|
||||||
|
keyDialogEndpoint,
|
||||||
|
} = useModelSelectorContext();
|
||||||
|
|
||||||
|
const selectedIcon = getSelectedIcon({
|
||||||
|
mappedEndpoints: mappedEndpoints ?? [],
|
||||||
|
selectedValues,
|
||||||
|
modelSpecs,
|
||||||
|
endpointsConfig,
|
||||||
|
});
|
||||||
|
const selectedDisplayValue = getDisplayValue();
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<button
|
||||||
|
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||||
|
aria-label={localize('com_endpoint_select_model')}
|
||||||
|
>
|
||||||
|
{selectedIcon && React.isValidElement(selectedIcon) && (
|
||||||
|
<div className="flex flex-shrink-0 items-center justify-center overflow-hidden">
|
||||||
|
{selectedIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="flex-grow truncate text-left">{selectedDisplayValue}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex w-full max-w-md flex-col items-center gap-2">
|
||||||
|
<Menu
|
||||||
|
values={selectedValues}
|
||||||
|
onValuesChange={(values: Record<string, any>) => {
|
||||||
|
setSelectedValues({
|
||||||
|
endpoint: values.endpoint || '',
|
||||||
|
model: values.model || '',
|
||||||
|
modelSpec: values.modelSpec || '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSearch={(value) => setSearchValue(value)}
|
||||||
|
combobox={<input placeholder={localize('com_endpoint_search_models')} />}
|
||||||
|
trigger={trigger}
|
||||||
|
>
|
||||||
|
{searchResults ? (
|
||||||
|
renderSearchResults(searchResults, localize, searchValue)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{renderModelSpecs(modelSpecs, selectedValues.modelSpec || '')}
|
||||||
|
{renderEndpoints(mappedEndpoints ?? [])}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
<DialogManager
|
||||||
|
keyDialogOpen={keyDialogOpen}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
endpointsConfig={endpointsConfig || {}}
|
||||||
|
keyDialogEndpoint={keyDialogEndpoint || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModelSelector({ interfaceConfig, modelSpecs }: ModelSelectorProps) {
|
||||||
|
return (
|
||||||
|
<ModelSelectorProvider modelSpecs={modelSpecs} interfaceConfig={interfaceConfig}>
|
||||||
|
<ModelSelectorContent />
|
||||||
|
</ModelSelectorProvider>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,224 @@
|
||||||
|
import React, { startTransition, createContext, useContext, useState, useMemo } from 'react';
|
||||||
|
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||||
|
import type * as t from 'librechat-data-provider';
|
||||||
|
import type { Endpoint, SelectedValues } from '~/common';
|
||||||
|
import { useAgentsMapContext, useAssistantsMapContext, useChatContext } from '~/Providers';
|
||||||
|
import { useEndpoints, useSelectorEffects, useKeyDialog, useLocalize } from '~/hooks';
|
||||||
|
import useSelectMention from '~/hooks/Input/useSelectMention';
|
||||||
|
import { useGetEndpointsQuery } from '~/data-provider';
|
||||||
|
import { filterItems } from './utils';
|
||||||
|
|
||||||
|
type ModelSelectorContextType = {
|
||||||
|
// State
|
||||||
|
searchValue: string;
|
||||||
|
selectedValues: SelectedValues;
|
||||||
|
endpointSearchValues: Record<string, string>;
|
||||||
|
searchResults: (t.TModelSpec | Endpoint)[] | null;
|
||||||
|
// LibreChat
|
||||||
|
modelSpecs: t.TModelSpec[];
|
||||||
|
mappedEndpoints: Endpoint[];
|
||||||
|
agentsMap: t.TAgentsMap | undefined;
|
||||||
|
assistantsMap: t.TAssistantsMap | undefined;
|
||||||
|
endpointsConfig: t.TEndpointsConfig;
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
getDisplayValue: () => string;
|
||||||
|
endpointRequiresUserKey: (endpoint: string) => boolean;
|
||||||
|
setSelectedValues: React.Dispatch<React.SetStateAction<SelectedValues>>;
|
||||||
|
setSearchValue: (value: string) => void;
|
||||||
|
setEndpointSearchValue: (endpoint: string, value: string) => void;
|
||||||
|
handleSelectSpec: (spec: t.TModelSpec) => void;
|
||||||
|
handleSelectEndpoint: (endpoint: Endpoint) => void;
|
||||||
|
handleSelectModel: (endpoint: Endpoint, model: string) => void;
|
||||||
|
} & ReturnType<typeof useKeyDialog>;
|
||||||
|
|
||||||
|
const ModelSelectorContext = createContext<ModelSelectorContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function useModelSelectorContext() {
|
||||||
|
const context = useContext(ModelSelectorContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useModelSelectorContext must be used within a ModelSelectorProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelSelectorProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
modelSpecs: t.TModelSpec[];
|
||||||
|
interfaceConfig: t.TInterfaceConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelectorProvider({
|
||||||
|
children,
|
||||||
|
modelSpecs,
|
||||||
|
interfaceConfig,
|
||||||
|
}: ModelSelectorProviderProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const agentsMap = useAgentsMapContext();
|
||||||
|
const assistantsMap = useAssistantsMapContext();
|
||||||
|
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||||
|
const { conversation, newConversation } = useChatContext();
|
||||||
|
const { mappedEndpoints, endpointRequiresUserKey } = useEndpoints({
|
||||||
|
agentsMap,
|
||||||
|
assistantsMap,
|
||||||
|
endpointsConfig,
|
||||||
|
interfaceConfig,
|
||||||
|
});
|
||||||
|
const { onSelectEndpoint, onSelectSpec } = useSelectMention({
|
||||||
|
// presets,
|
||||||
|
modelSpecs,
|
||||||
|
assistantsMap,
|
||||||
|
endpointsConfig,
|
||||||
|
newConversation,
|
||||||
|
returnHandlers: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [selectedValues, setSelectedValues] = useState<SelectedValues>({
|
||||||
|
endpoint: conversation?.endpoint || '',
|
||||||
|
model: conversation?.model || '',
|
||||||
|
modelSpec: conversation?.spec || '',
|
||||||
|
});
|
||||||
|
useSelectorEffects({
|
||||||
|
agentsMap,
|
||||||
|
conversation,
|
||||||
|
assistantsMap,
|
||||||
|
setSelectedValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [searchValue, setSearchValueState] = useState('');
|
||||||
|
const [endpointSearchValues, setEndpointSearchValues] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const keyProps = useKeyDialog();
|
||||||
|
|
||||||
|
// Memoized search results
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
if (!searchValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const allItems = [...modelSpecs, ...mappedEndpoints];
|
||||||
|
return filterItems(allItems, searchValue, agentsMap, assistantsMap || {});
|
||||||
|
}, [searchValue, modelSpecs, mappedEndpoints, agentsMap, assistantsMap]);
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
const setSearchValue = (value: string) => {
|
||||||
|
startTransition(() => setSearchValueState(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setEndpointSearchValue = (endpoint: string, value: string) => {
|
||||||
|
setEndpointSearchValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[endpoint]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSpec = (spec: t.TModelSpec) => {
|
||||||
|
onSelectSpec?.(spec);
|
||||||
|
setSelectedValues({
|
||||||
|
endpoint: spec.preset.endpoint,
|
||||||
|
model: spec.preset.model ?? null,
|
||||||
|
modelSpec: spec.name,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectEndpoint = (endpoint: Endpoint) => {
|
||||||
|
if (!endpoint.hasModels) {
|
||||||
|
if (endpoint.value) {
|
||||||
|
onSelectEndpoint?.(endpoint.value);
|
||||||
|
}
|
||||||
|
setSelectedValues({
|
||||||
|
endpoint: endpoint.value,
|
||||||
|
model: '',
|
||||||
|
modelSpec: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectModel = (endpoint: Endpoint, model: string) => {
|
||||||
|
if (isAgentsEndpoint(endpoint.value)) {
|
||||||
|
onSelectEndpoint?.(endpoint.value, {
|
||||||
|
agent_id: model,
|
||||||
|
});
|
||||||
|
} else if (isAssistantsEndpoint(endpoint.value)) {
|
||||||
|
onSelectEndpoint?.(endpoint.value, {
|
||||||
|
assistant_id: model,
|
||||||
|
model: assistantsMap?.[endpoint.value]?.[model]?.model ?? '',
|
||||||
|
});
|
||||||
|
} else if (endpoint.value) {
|
||||||
|
onSelectEndpoint?.(endpoint.value, { model });
|
||||||
|
}
|
||||||
|
setSelectedValues({
|
||||||
|
endpoint: endpoint.value,
|
||||||
|
model: model,
|
||||||
|
modelSpec: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayValue = () => {
|
||||||
|
if (selectedValues.modelSpec) {
|
||||||
|
const spec = modelSpecs.find((s) => s.name === selectedValues.modelSpec);
|
||||||
|
return spec?.label || localize('com_endpoint_select_model');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedValues.model && selectedValues.endpoint) {
|
||||||
|
const endpoint = mappedEndpoints.find((e) => e.value === selectedValues.endpoint);
|
||||||
|
if (!endpoint) {
|
||||||
|
return localize('com_endpoint_select_model');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
endpoint.value === EModelEndpoint.agents &&
|
||||||
|
endpoint.agentNames &&
|
||||||
|
endpoint.agentNames[selectedValues.model]
|
||||||
|
) {
|
||||||
|
return endpoint.agentNames[selectedValues.model];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(endpoint.value === EModelEndpoint.assistants ||
|
||||||
|
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||||
|
endpoint.assistantNames &&
|
||||||
|
endpoint.assistantNames[selectedValues.model]
|
||||||
|
) {
|
||||||
|
return endpoint.assistantNames[selectedValues.model];
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedValues.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedValues.endpoint) {
|
||||||
|
const endpoint = mappedEndpoints.find((e) => e.value === selectedValues.endpoint);
|
||||||
|
return endpoint?.label || localize('com_endpoint_select_model');
|
||||||
|
}
|
||||||
|
|
||||||
|
return localize('com_endpoint_select_model');
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
// State
|
||||||
|
searchValue,
|
||||||
|
searchResults,
|
||||||
|
selectedValues,
|
||||||
|
endpointSearchValues,
|
||||||
|
// LibreChat
|
||||||
|
agentsMap,
|
||||||
|
modelSpecs,
|
||||||
|
assistantsMap,
|
||||||
|
mappedEndpoints,
|
||||||
|
endpointsConfig,
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
setSearchValue,
|
||||||
|
getDisplayValue,
|
||||||
|
handleSelectSpec,
|
||||||
|
handleSelectModel,
|
||||||
|
setSelectedValues,
|
||||||
|
handleSelectEndpoint,
|
||||||
|
setEndpointSearchValue,
|
||||||
|
endpointRequiresUserKey,
|
||||||
|
// Dialog
|
||||||
|
...keyProps,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ModelSelectorContext.Provider value={value}>{children}</ModelSelectorContext.Provider>;
|
||||||
|
}
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { SettingsIcon } from 'lucide-react';
|
||||||
|
import { Spinner } from '~/components';
|
||||||
|
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||||
|
import type { Endpoint } from '~/common';
|
||||||
|
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||||
|
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||||
|
import { renderEndpointModels } from './EndpointModelItem';
|
||||||
|
import { filterModels } from '../utils';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface EndpointItemProps {
|
||||||
|
endpoint: Endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsButton = ({
|
||||||
|
endpoint,
|
||||||
|
className,
|
||||||
|
handleOpenKeyDialog,
|
||||||
|
}: {
|
||||||
|
endpoint: Endpoint;
|
||||||
|
className?: string;
|
||||||
|
handleOpenKeyDialog: (endpoint: EModelEndpoint, e: React.MouseEvent) => void;
|
||||||
|
}) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const text = localize('com_endpoint_config_key');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
id={`endpoint-${endpoint.value}-settings`}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!endpoint.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center overflow-visible text-text-primary transition-all duration-300 ease-in-out',
|
||||||
|
'group/button rounded-md px-1 hover:bg-surface-secondary focus:bg-surface-secondary',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={`${text} ${endpoint.label}`}
|
||||||
|
>
|
||||||
|
<div className="flex w-[28px] items-center gap-1 whitespace-nowrap transition-all duration-300 ease-in-out group-hover:w-auto group-focus/button:w-auto">
|
||||||
|
<SettingsIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-300 ease-in-out group-hover:max-w-[100px] group-hover:opacity-100 group-focus/button:max-w-[100px] group-focus/button:opacity-100">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EndpointItem({ endpoint }: EndpointItemProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const {
|
||||||
|
agentsMap,
|
||||||
|
assistantsMap,
|
||||||
|
selectedValues,
|
||||||
|
handleOpenKeyDialog,
|
||||||
|
handleSelectEndpoint,
|
||||||
|
endpointSearchValues,
|
||||||
|
setEndpointSearchValue,
|
||||||
|
endpointRequiresUserKey,
|
||||||
|
} = useModelSelectorContext();
|
||||||
|
const { model: selectedModel, endpoint: selectedEndpoint } = selectedValues;
|
||||||
|
|
||||||
|
const searchValue = endpointSearchValues[endpoint.value] || '';
|
||||||
|
const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]);
|
||||||
|
|
||||||
|
const renderIconLabel = () => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{endpoint.icon && (
|
||||||
|
<div className="flex flex-shrink-0 items-center justify-center overflow-hidden">
|
||||||
|
{endpoint.icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'truncate text-left',
|
||||||
|
isUserProvided ? 'group-hover:w-24 group-focus:w-24' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{endpoint.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (endpoint.hasModels) {
|
||||||
|
const filteredModels = searchValue
|
||||||
|
? filterModels(endpoint, endpoint.models || [], searchValue, agentsMap, assistantsMap)
|
||||||
|
: null;
|
||||||
|
const placeholder =
|
||||||
|
isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)
|
||||||
|
? localize('com_endpoint_search_var', { 0: endpoint.label })
|
||||||
|
: localize('com_endpoint_search_endpoint_models', { 0: endpoint.label });
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
id={`endpoint-${endpoint.value}-menu`}
|
||||||
|
key={`endpoint-${endpoint.value}-item`}
|
||||||
|
className="transition-opacity duration-200 ease-in-out"
|
||||||
|
defaultOpen={endpoint.value === selectedEndpoint}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearch={(value) => setEndpointSearchValue(endpoint.value, value)}
|
||||||
|
combobox={<input placeholder={placeholder} />}
|
||||||
|
label={
|
||||||
|
<div
|
||||||
|
onClick={() => handleSelectEndpoint(endpoint)}
|
||||||
|
className="group flex w-full flex-shrink cursor-pointer items-center justify-between rounded-xl px-1 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{renderIconLabel()}
|
||||||
|
{isUserProvided && (
|
||||||
|
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(endpoint.value === EModelEndpoint.assistants ||
|
||||||
|
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||||
|
endpoint.models === undefined ? (
|
||||||
|
<div className="flex items-center justify-center p-2">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : filteredModels ? (
|
||||||
|
renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels)
|
||||||
|
) : (
|
||||||
|
endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
id={`endpoint-${endpoint.value}-menu`}
|
||||||
|
key={`endpoint-${endpoint.value}-item`}
|
||||||
|
onClick={() => handleSelectEndpoint(endpoint)}
|
||||||
|
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-xl px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="group flex w-full min-w-0 items-center justify-between">
|
||||||
|
{renderIconLabel()}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{endpointRequiresUserKey(endpoint.value) && (
|
||||||
|
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
||||||
|
)}
|
||||||
|
{selectedEndpoint === endpoint.value && (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEndpoints(mappedEndpoints: Endpoint[]) {
|
||||||
|
return mappedEndpoints.map((endpoint) => (
|
||||||
|
<EndpointItem endpoint={endpoint} key={`endpoint-${endpoint.value}-item`} />
|
||||||
|
));
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
import type { Endpoint } from '~/common';
|
||||||
|
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||||
|
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||||
|
|
||||||
|
interface EndpointModelItemProps {
|
||||||
|
modelId: string | null;
|
||||||
|
endpoint: Endpoint;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) {
|
||||||
|
const { handleSelectModel } = useModelSelectorContext();
|
||||||
|
let modelName = modelId;
|
||||||
|
const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null;
|
||||||
|
|
||||||
|
// Use custom names if available
|
||||||
|
if (
|
||||||
|
endpoint &&
|
||||||
|
modelId &&
|
||||||
|
endpoint.value === EModelEndpoint.agents &&
|
||||||
|
endpoint.agentNames?.[modelId]
|
||||||
|
) {
|
||||||
|
modelName = endpoint.agentNames[modelId];
|
||||||
|
} else if (
|
||||||
|
endpoint &&
|
||||||
|
modelId &&
|
||||||
|
(endpoint.value === EModelEndpoint.assistants ||
|
||||||
|
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||||
|
endpoint.assistantNames?.[modelId]
|
||||||
|
) {
|
||||||
|
modelName = endpoint.assistantNames[modelId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={modelId}
|
||||||
|
onClick={() => handleSelectModel(endpoint, modelId ?? '')}
|
||||||
|
className="flex h-8 w-full cursor-pointer items-center justify-start rounded-lg px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{avatarUrl ? (
|
||||||
|
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||||
|
<img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : (endpoint.value === EModelEndpoint.agents ||
|
||||||
|
endpoint.value === EModelEndpoint.assistants ||
|
||||||
|
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||||
|
endpoint.icon ? (
|
||||||
|
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||||
|
{endpoint.icon}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<span>{modelName}</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="ml-auto block"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEndpointModels(
|
||||||
|
endpoint: Endpoint | null,
|
||||||
|
models: string[],
|
||||||
|
selectedModel: string | null,
|
||||||
|
filteredModels?: string[],
|
||||||
|
) {
|
||||||
|
const modelsToRender = filteredModels || models;
|
||||||
|
|
||||||
|
return modelsToRender.map(
|
||||||
|
(modelId) =>
|
||||||
|
endpoint && (
|
||||||
|
<EndpointModelItem
|
||||||
|
key={modelId}
|
||||||
|
modelId={modelId}
|
||||||
|
endpoint={endpoint}
|
||||||
|
isSelected={selectedModel === modelId}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
import React from 'react';
|
||||||
|
import type { TModelSpec } from 'librechat-data-provider';
|
||||||
|
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||||
|
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||||
|
import SpecIcon from './SpecIcon';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface ModelSpecItemProps {
|
||||||
|
spec: TModelSpec;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSpecItem({ spec, isSelected }: ModelSpecItemProps) {
|
||||||
|
const { handleSelectSpec, endpointsConfig } = useModelSelectorContext();
|
||||||
|
const { showIconInMenu = true } = spec;
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={spec.name}
|
||||||
|
onClick={() => handleSelectSpec(spec)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full cursor-pointer justify-between rounded-lg px-2 text-sm',
|
||||||
|
spec.description ? 'items-start' : 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-full min-w-0 gap-2 px-1 py-1',
|
||||||
|
spec.description ? 'items-start' : 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showIconInMenu && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="truncate text-left">{spec.label}</span>
|
||||||
|
{spec.description && (
|
||||||
|
<span className="break-words text-xs font-normal">{spec.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className={cn('flex-shrink-0', spec.description ? 'pt-1' : '')}>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderModelSpecs(specs: TModelSpec[], selectedSpec: string) {
|
||||||
|
if (!specs || specs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return specs.map((spec) => (
|
||||||
|
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
|
||||||
|
));
|
||||||
|
}
|
|
@ -0,0 +1,253 @@
|
||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
import type { TModelSpec } from 'librechat-data-provider';
|
||||||
|
import type { Endpoint } from '~/common';
|
||||||
|
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||||
|
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||||
|
import SpecIcon from './SpecIcon';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface SearchResultsProps {
|
||||||
|
results: (TModelSpec | Endpoint)[] | null;
|
||||||
|
localize: (phraseKey: any, options?: any) => string;
|
||||||
|
searchValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchResults({ results, localize, searchValue }: SearchResultsProps) {
|
||||||
|
const {
|
||||||
|
selectedValues,
|
||||||
|
handleSelectSpec,
|
||||||
|
handleSelectModel,
|
||||||
|
handleSelectEndpoint,
|
||||||
|
endpointsConfig,
|
||||||
|
agentsMap,
|
||||||
|
assistantsMap,
|
||||||
|
} = useModelSelectorContext();
|
||||||
|
|
||||||
|
const {
|
||||||
|
modelSpec: selectedSpec,
|
||||||
|
endpoint: selectedEndpoint,
|
||||||
|
model: selectedModel,
|
||||||
|
} = selectedValues;
|
||||||
|
|
||||||
|
if (!results) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!results.length) {
|
||||||
|
return (
|
||||||
|
<div className="cursor-default p-2 sm:py-1 sm:text-sm">
|
||||||
|
{localize('com_files_no_results')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{results.map((item, i) => {
|
||||||
|
if ('name' in item && 'label' in item) {
|
||||||
|
// Render model spec
|
||||||
|
const spec = item as TModelSpec;
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={spec.name}
|
||||||
|
onClick={() => handleSelectSpec(spec)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full cursor-pointer justify-between rounded-lg px-2 text-sm',
|
||||||
|
spec.description ? 'items-start' : 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-full min-w-0 gap-2 px-1 py-1',
|
||||||
|
spec.description ? 'items-start' : 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(spec.showIconInMenu ?? true) && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="truncate text-left">{spec.label}</span>
|
||||||
|
{spec.description && (
|
||||||
|
<span className="break-words text-xs font-normal">{spec.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedSpec === spec.name && (
|
||||||
|
<div className={cn('flex-shrink-0', spec.description ? 'pt-1' : '')}>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// For an endpoint item
|
||||||
|
const endpoint = item as Endpoint;
|
||||||
|
if (endpoint.hasModels && endpoint.models && endpoint.models.length > 0) {
|
||||||
|
const lowerQuery = searchValue.toLowerCase();
|
||||||
|
const filteredModels = endpoint.label.toLowerCase().includes(lowerQuery)
|
||||||
|
? endpoint.models
|
||||||
|
: endpoint.models.filter((modelId) => {
|
||||||
|
let modelName = modelId;
|
||||||
|
if (
|
||||||
|
endpoint.value === EModelEndpoint.agents &&
|
||||||
|
endpoint.agentNames &&
|
||||||
|
endpoint.agentNames[modelId]
|
||||||
|
) {
|
||||||
|
modelName = endpoint.agentNames[modelId];
|
||||||
|
} else if (
|
||||||
|
(endpoint.value === EModelEndpoint.assistants ||
|
||||||
|
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||||
|
endpoint.assistantNames &&
|
||||||
|
endpoint.assistantNames[modelId]
|
||||||
|
) {
|
||||||
|
modelName = endpoint.assistantNames[modelId];
|
||||||
|
}
|
||||||
|
return modelName.toLowerCase().includes(lowerQuery);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filteredModels.length) {
|
||||||
|
return null; // skip if no models match
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={`endpoint-${endpoint.value}-search-${i}`}>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 text-sm font-medium">
|
||||||
|
{endpoint.icon && (
|
||||||
|
<div className="flex items-center justify-center overflow-hidden rounded-full p-1">
|
||||||
|
{endpoint.icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endpoint.label}
|
||||||
|
</div>
|
||||||
|
{filteredModels.map((modelId) => {
|
||||||
|
let modelName = modelId;
|
||||||
|
if (
|
||||||
|
endpoint.value === EModelEndpoint.agents &&
|
||||||
|
endpoint.agentNames &&
|
||||||
|
endpoint.agentNames[modelId]
|
||||||
|
) {
|
||||||
|
modelName = endpoint.agentNames[modelId];
|
||||||
|
} else if (
|
||||||
|
(endpoint.value === EModelEndpoint.assistants ||
|
||||||
|
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||||
|
endpoint.assistantNames &&
|
||||||
|
endpoint.assistantNames[modelId]
|
||||||
|
) {
|
||||||
|
modelName = endpoint.assistantNames[modelId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={`${endpoint.value}-${modelId}-search-${i}`}
|
||||||
|
onClick={() => handleSelectModel(endpoint, modelId)}
|
||||||
|
className="flex w-full cursor-pointer items-center justify-start rounded-lg px-3 py-2 pl-6 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{endpoint.modelIcons?.[modelId] && (
|
||||||
|
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||||
|
<img
|
||||||
|
src={endpoint.modelIcons[modelId]}
|
||||||
|
alt={modelName}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span>{modelName}</span>
|
||||||
|
</div>
|
||||||
|
{selectedEndpoint === endpoint.value && selectedModel === modelId && (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="ml-auto block"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Endpoints with no models
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={`endpoint-${endpoint.value}-search-item`}
|
||||||
|
onClick={() => handleSelectEndpoint(endpoint)}
|
||||||
|
className="flex w-full cursor-pointer items-center justify-between rounded-xl px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{endpoint.icon && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center overflow-hidden rounded-full border border-gray-200 p-1 dark:border-gray-700"
|
||||||
|
style={{ borderRadius: '50%' }}
|
||||||
|
>
|
||||||
|
{endpoint.icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span>{endpoint.label}</span>
|
||||||
|
</div>
|
||||||
|
{selectedEndpoint === endpoint.value && (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSearchResults(
|
||||||
|
results: (TModelSpec | Endpoint)[] | null,
|
||||||
|
localize: (phraseKey: any, options?: any) => string,
|
||||||
|
searchValue: string,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<SearchResults
|
||||||
|
key={`search-results-${searchValue}`}
|
||||||
|
results={results}
|
||||||
|
localize={localize}
|
||||||
|
searchValue={searchValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,27 +2,29 @@ import React, { memo } from 'react';
|
||||||
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
||||||
import type { IconMapProps } from '~/common';
|
import type { IconMapProps } from '~/common';
|
||||||
import { getModelSpecIconURL, getIconKey, getEndpointField } from '~/utils';
|
import { getModelSpecIconURL, getIconKey, getEndpointField } from '~/utils';
|
||||||
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
|
|
||||||
import { URLIcon } from '~/components/Endpoints/URLIcon';
|
import { URLIcon } from '~/components/Endpoints/URLIcon';
|
||||||
|
import { icons } from '~/hooks/Endpoint/Icons';
|
||||||
|
|
||||||
interface SpecIconProps {
|
interface SpecIconProps {
|
||||||
currentSpec: TModelSpec;
|
currentSpec: TModelSpec;
|
||||||
endpointsConfig: TEndpointsConfig;
|
endpointsConfig: TEndpointsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IconType = (props: IconMapProps) => React.JSX.Element;
|
||||||
|
|
||||||
const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) => {
|
const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) => {
|
||||||
const iconURL = getModelSpecIconURL(currentSpec);
|
const iconURL = getModelSpecIconURL(currentSpec);
|
||||||
const { endpoint } = currentSpec.preset;
|
const { endpoint } = currentSpec.preset;
|
||||||
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
||||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });
|
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });
|
||||||
let Icon: (props: IconMapProps) => React.JSX.Element;
|
let Icon: IconType;
|
||||||
|
|
||||||
if (!iconURL.includes('http')) {
|
if (!iconURL.includes('http')) {
|
||||||
Icon = icons[iconKey] ?? icons.unknown;
|
Icon = (icons[iconKey] ?? icons.unknown) as IconType;
|
||||||
} else if (iconURL) {
|
} else if (iconURL) {
|
||||||
return <URLIcon iconURL={iconURL} altName={currentSpec.name} />;
|
return <URLIcon iconURL={iconURL} altName={currentSpec.name} />;
|
||||||
} else {
|
} else {
|
||||||
Icon = icons[endpoint ?? ''] ?? icons.unknown;
|
Icon = (icons[endpoint ?? ''] ?? icons.unknown) as IconType;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -31,7 +33,7 @@ const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) =>
|
||||||
endpoint={endpoint}
|
endpoint={endpoint}
|
||||||
context="menu-item"
|
context="menu-item"
|
||||||
iconURL={endpointIconURL}
|
iconURL={endpointIconURL}
|
||||||
className="icon-lg mr-1 shrink-0 text-text-primary"
|
className="icon-md shrink-0 text-text-primary"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './ModelSpecItem';
|
||||||
|
export * from './EndpointModelItem';
|
||||||
|
export * from './EndpointItem';
|
||||||
|
export * from './SearchResults';
|
162
client/src/components/Chat/Menus/Endpoints/utils.ts
Normal file
162
client/src/components/Chat/Menus/Endpoints/utils.ts
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Bot } from 'lucide-react';
|
||||||
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
import type {
|
||||||
|
TAgentsMap,
|
||||||
|
TAssistantsMap,
|
||||||
|
TEndpointsConfig,
|
||||||
|
TModelSpec,
|
||||||
|
} from 'librechat-data-provider';
|
||||||
|
import SpecIcon from '~/components/Chat/Menus/Endpoints/components/SpecIcon';
|
||||||
|
import { Endpoint, SelectedValues } from '~/common';
|
||||||
|
|
||||||
|
export function filterItems<
|
||||||
|
T extends { label: string; name?: string; value?: string; models?: string[] },
|
||||||
|
>(
|
||||||
|
items: T[],
|
||||||
|
searchValue: string,
|
||||||
|
agentsMap: TAgentsMap | undefined,
|
||||||
|
assistantsMap: TAssistantsMap | undefined,
|
||||||
|
): T[] | null {
|
||||||
|
const searchTermLower = searchValue.trim().toLowerCase();
|
||||||
|
if (!searchTermLower) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter((item) => {
|
||||||
|
const itemMatches =
|
||||||
|
item.label.toLowerCase().includes(searchTermLower) ||
|
||||||
|
(item.name && item.name.toLowerCase().includes(searchTermLower)) ||
|
||||||
|
(item.value && item.value.toLowerCase().includes(searchTermLower));
|
||||||
|
|
||||||
|
if (itemMatches) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.models && item.models.length > 0) {
|
||||||
|
return item.models.some((modelId) => {
|
||||||
|
if (modelId.toLowerCase().includes(searchTermLower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.value === EModelEndpoint.agents && agentsMap && modelId in agentsMap) {
|
||||||
|
const agentName = agentsMap[modelId]?.name;
|
||||||
|
return typeof agentName === 'string' && agentName.toLowerCase().includes(searchTermLower);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(item.value === EModelEndpoint.assistants ||
|
||||||
|
item.value === EModelEndpoint.azureAssistants) &&
|
||||||
|
assistantsMap
|
||||||
|
) {
|
||||||
|
const endpoint = item.value;
|
||||||
|
const assistant = assistantsMap[endpoint][modelId];
|
||||||
|
if (assistant && typeof assistant.name === 'string') {
|
||||||
|
return assistant.name.toLowerCase().includes(searchTermLower);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterModels(
|
||||||
|
endpoint: Endpoint,
|
||||||
|
models: string[],
|
||||||
|
searchValue: string,
|
||||||
|
agentsMap: TAgentsMap | undefined,
|
||||||
|
assistantsMap: TAssistantsMap | undefined,
|
||||||
|
): string[] {
|
||||||
|
const searchTermLower = searchValue.trim().toLowerCase();
|
||||||
|
if (!searchTermLower) {
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.filter((modelId) => {
|
||||||
|
let modelName = modelId;
|
||||||
|
|
||||||
|
if (endpoint.value === EModelEndpoint.agents && agentsMap && agentsMap[modelId]) {
|
||||||
|
modelName = agentsMap[modelId].name || modelId;
|
||||||
|
} else if (
|
||||||
|
(endpoint.value === EModelEndpoint.assistants ||
|
||||||
|
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||||
|
assistantsMap &&
|
||||||
|
assistantsMap[endpoint.value]
|
||||||
|
) {
|
||||||
|
const assistant = assistantsMap[endpoint.value][modelId];
|
||||||
|
modelName =
|
||||||
|
typeof assistant.name === 'string' && assistant.name ? (assistant.name as string) : modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return modelName.toLowerCase().includes(searchTermLower);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedIcon({
|
||||||
|
mappedEndpoints,
|
||||||
|
selectedValues,
|
||||||
|
modelSpecs,
|
||||||
|
endpointsConfig,
|
||||||
|
}: {
|
||||||
|
mappedEndpoints: Endpoint[];
|
||||||
|
selectedValues: SelectedValues;
|
||||||
|
modelSpecs: TModelSpec[];
|
||||||
|
endpointsConfig: TEndpointsConfig;
|
||||||
|
}): React.ReactNode | null {
|
||||||
|
const { endpoint, model, modelSpec } = selectedValues;
|
||||||
|
|
||||||
|
if (modelSpec) {
|
||||||
|
const spec = modelSpecs.find((s) => s.name === modelSpec);
|
||||||
|
if (!spec) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { showIconInHeader = true } = spec;
|
||||||
|
if (!showIconInHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return React.createElement(SpecIcon, {
|
||||||
|
currentSpec: spec,
|
||||||
|
endpointsConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint && model) {
|
||||||
|
const selectedEndpoint = mappedEndpoints.find((e) => e.value === endpoint);
|
||||||
|
if (!selectedEndpoint) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEndpoint.modelIcons?.[model]) {
|
||||||
|
const iconUrl = selectedEndpoint.modelIcons[model];
|
||||||
|
return React.createElement(
|
||||||
|
'div',
|
||||||
|
{ className: 'h-5 w-5 overflow-hidden rounded-full' },
|
||||||
|
React.createElement('img', {
|
||||||
|
src: iconUrl,
|
||||||
|
alt: model,
|
||||||
|
className: 'h-full w-full object-cover',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedEndpoint.icon ||
|
||||||
|
React.createElement(Bot, {
|
||||||
|
size: 20,
|
||||||
|
className: 'icon-md shrink-0 text-text-primary',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint) {
|
||||||
|
const selectedEndpoint = mappedEndpoints.find((e) => e.value === endpoint);
|
||||||
|
return selectedEndpoint?.icon || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
|
@ -1,105 +0,0 @@
|
||||||
import { useCallback, useRef } from 'react';
|
|
||||||
import { alternateName } from 'librechat-data-provider';
|
|
||||||
import { Content, Portal, Root } from '@radix-ui/react-popover';
|
|
||||||
import type { FC, KeyboardEvent } from 'react';
|
|
||||||
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
|
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
|
||||||
import { mapEndpoints, getEntity } from '~/utils';
|
|
||||||
import EndpointItems from './Endpoints/MenuItems';
|
|
||||||
import useLocalize from '~/hooks/useLocalize';
|
|
||||||
import TitleButton from './UI/TitleButton';
|
|
||||||
|
|
||||||
const EndpointsMenu: FC = () => {
|
|
||||||
const { data: endpoints = [] } = useGetEndpointsQuery({
|
|
||||||
select: mapEndpoints,
|
|
||||||
});
|
|
||||||
|
|
||||||
const localize = useLocalize();
|
|
||||||
const agentsMap = useAgentsMapContext();
|
|
||||||
const assistantMap = useAssistantsMapContext();
|
|
||||||
const { conversation } = useChatContext();
|
|
||||||
const { endpoint = '' } = conversation ?? {};
|
|
||||||
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
|
||||||
const menuItems = menuRef.current?.querySelectorAll('[role="option"]');
|
|
||||||
if (!menuItems) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!menuItems.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIndex = Array.from(menuItems).findIndex((item) => item === document.activeElement);
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
event.preventDefault();
|
|
||||||
if (currentIndex < menuItems.length - 1) {
|
|
||||||
(menuItems[currentIndex + 1] as HTMLElement).focus();
|
|
||||||
} else {
|
|
||||||
(menuItems[0] as HTMLElement).focus();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
event.preventDefault();
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
(menuItems[currentIndex - 1] as HTMLElement).focus();
|
|
||||||
} else {
|
|
||||||
(menuItems[menuItems.length - 1] as HTMLElement).focus();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!endpoint) {
|
|
||||||
console.warn('No endpoint selected');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { entity } = getEntity({
|
|
||||||
endpoint,
|
|
||||||
agentsMap,
|
|
||||||
assistantMap,
|
|
||||||
agent_id: conversation?.agent_id,
|
|
||||||
assistant_id: conversation?.assistant_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const primaryText = entity
|
|
||||||
? entity.name
|
|
||||||
: (alternateName[endpoint] as string | undefined) ?? endpoint;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Root>
|
|
||||||
<TitleButton primaryText={primaryText + ' '} />
|
|
||||||
<Portal>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
left: '0px',
|
|
||||||
top: '0px',
|
|
||||||
transform: 'translate3d(268px, 50px, 0px)',
|
|
||||||
minWidth: 'max-content',
|
|
||||||
zIndex: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Content
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
role="listbox"
|
|
||||||
id="llm-endpoint-menu"
|
|
||||||
ref={menuRef}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
aria-label={localize('com_ui_endpoints_available')}
|
|
||||||
className="mt-2 max-h-[65vh] min-w-[340px] overflow-y-auto rounded-lg border border-border-light bg-header-primary text-text-primary shadow-lg lg:max-h-[75vh]"
|
|
||||||
>
|
|
||||||
<EndpointItems endpoints={endpoints} selected={endpoint} />
|
|
||||||
</Content>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
</Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EndpointsMenu;
|
|
|
@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { QueryKeys, Constants } from 'librechat-data-provider';
|
import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||||
import type { TMessage } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
import { useMediaQuery, useLocalize } from '~/hooks';
|
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||||
import { NewChatIcon } from '~/components/svg';
|
import { Button, NewChatIcon } from '~/components';
|
||||||
import { useChatContext } from '~/Providers';
|
import { useChatContext } from '~/Providers';
|
||||||
|
|
||||||
export default function HeaderNewChat() {
|
export default function HeaderNewChat() {
|
||||||
|
@ -10,15 +10,18 @@ export default function HeaderNewChat() {
|
||||||
const { conversation, newConversation } = useChatContext();
|
const { conversation, newConversation } = useChatContext();
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
data-testid="wide-header-new-chat-button"
|
data-testid="wide-header-new-chat-button"
|
||||||
aria-label={localize('com_ui_new_chat')}
|
aria-label={localize('com_ui_new_chat')}
|
||||||
type="button"
|
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover"
|
||||||
className="btn btn-neutral btn-small border-token-border-medium focus:border-black-500 dark:focus:border-white-500 relative ml-2 flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-lg border md:flex"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
queryClient.setQueryData<TMessage[]>(
|
queryClient.setQueryData<TMessage[]>(
|
||||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||||
|
@ -27,9 +30,7 @@ export default function HeaderNewChat() {
|
||||||
newConversation();
|
newConversation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-center gap-2">
|
<NewChatIcon />
|
||||||
<NewChatIcon />
|
</Button>
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,66 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Trigger } from '@radix-ui/react-popover';
|
|
||||||
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import SpecIcon from './SpecIcon';
|
|
||||||
import { cn } from '~/utils';
|
|
||||||
|
|
||||||
export default function MenuButton({
|
|
||||||
selected,
|
|
||||||
className = '',
|
|
||||||
textClassName = '',
|
|
||||||
primaryText = '',
|
|
||||||
secondaryText = '',
|
|
||||||
endpointsConfig,
|
|
||||||
}: {
|
|
||||||
selected?: TModelSpec;
|
|
||||||
className?: string;
|
|
||||||
textClassName?: string;
|
|
||||||
primaryText?: string;
|
|
||||||
secondaryText?: string;
|
|
||||||
endpointsConfig: TEndpointsConfig;
|
|
||||||
}) {
|
|
||||||
const localize = useLocalize();
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Trigger asChild>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'group flex cursor-pointer items-center gap-1 rounded-xl px-3 py-2 text-lg font-medium hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
aria-label={localize('com_ui_llm_menu')}
|
|
||||||
role="combobox"
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
aria-controls="llm-menu"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
>
|
|
||||||
{selected && selected.showIconInHeader === true && (
|
|
||||||
<SpecIcon currentSpec={selected} endpointsConfig={endpointsConfig} />
|
|
||||||
)}
|
|
||||||
<div className={textClassName}>
|
|
||||||
{!selected ? localize('com_ui_none_selected') : primaryText}{' '}
|
|
||||||
{!!secondaryText && <span className="text-token-text-secondary">{secondaryText}</span>}
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="17"
|
|
||||||
viewBox="0 0 16 17"
|
|
||||||
fill="none"
|
|
||||||
className="text-token-text-tertiary"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M11.3346 7.83203L8.00131 11.1654L4.66797 7.83203"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</Trigger>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,153 +0,0 @@
|
||||||
import { useState, useMemo } from 'react';
|
|
||||||
import { Settings } from 'lucide-react';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
|
||||||
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
|
||||||
import { useLocalize, useUserKey } from '~/hooks';
|
|
||||||
import { cn, getEndpointField } from '~/utils';
|
|
||||||
import SpecIcon from './SpecIcon';
|
|
||||||
|
|
||||||
type MenuItemProps = {
|
|
||||||
title: string;
|
|
||||||
spec: TModelSpec;
|
|
||||||
selected: boolean;
|
|
||||||
description?: string;
|
|
||||||
userProvidesKey: boolean;
|
|
||||||
endpointsConfig: TEndpointsConfig;
|
|
||||||
onClick?: () => void;
|
|
||||||
// iconPath: string;
|
|
||||||
// hoverContent?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MenuItem: FC<MenuItemProps> = ({
|
|
||||||
title,
|
|
||||||
spec,
|
|
||||||
selected,
|
|
||||||
description,
|
|
||||||
userProvidesKey,
|
|
||||||
endpointsConfig,
|
|
||||||
onClick,
|
|
||||||
...rest
|
|
||||||
}) => {
|
|
||||||
const { endpoint } = spec.preset;
|
|
||||||
const [isDialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const { getExpiry } = useUserKey(endpoint ?? '');
|
|
||||||
const localize = useLocalize();
|
|
||||||
const expiryTime = getExpiry() ?? '';
|
|
||||||
|
|
||||||
const clickHandler = () => {
|
|
||||||
if (expiryTime) {
|
|
||||||
setDialogOpen(true);
|
|
||||||
}
|
|
||||||
if (onClick) {
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const endpointType = useMemo(
|
|
||||||
() => spec.preset.endpointType ?? getEndpointField(endpointsConfig, endpoint, 'type'),
|
|
||||||
[spec, endpointsConfig, endpoint],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { showIconInMenu = true } = spec;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
id={selected ? 'selected-llm' : undefined}
|
|
||||||
role="option"
|
|
||||||
aria-selected={selected}
|
|
||||||
className="group m-1.5 flex cursor-pointer gap-2 rounded px-1 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
|
|
||||||
tabIndex={0}
|
|
||||||
{...rest}
|
|
||||||
onClick={clickHandler}
|
|
||||||
aria-label={title}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
clickHandler();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex grow items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{showIconInMenu && <SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />}
|
|
||||||
<div>
|
|
||||||
{title}
|
|
||||||
<div className="text-text-secondary">{description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{userProvidesKey ? (
|
|
||||||
<div className="text-token-text-primary" key={`set-key-${endpoint}`}>
|
|
||||||
<button
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`${localize('com_endpoint_config_key')} for ${title}`}
|
|
||||||
className={cn(
|
|
||||||
'invisible flex gap-x-1 group-focus-within:visible group-hover:visible',
|
|
||||||
selected ? 'visible' : '',
|
|
||||||
expiryTime
|
|
||||||
? 'w-full rounded-lg p-2 hover:bg-gray-200 dark:hover:bg-gray-900'
|
|
||||||
: '',
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDialogOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'invisible group-focus-within:visible group-hover:visible',
|
|
||||||
expiryTime ? 'text-xs' : '',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{localize('com_endpoint_config_key')}
|
|
||||||
</div>
|
|
||||||
<Settings className={cn(expiryTime ? 'icon-sm' : 'icon-md stroke-1')} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{selected && (
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="icon-md block"
|
|
||||||
// className="icon-md block group-hover:hidden"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{userProvidesKey && (
|
|
||||||
<SetKeyDialog
|
|
||||||
open={isDialogOpen}
|
|
||||||
onOpenChange={setDialogOpen}
|
|
||||||
endpoint={endpoint ?? ''}
|
|
||||||
endpointType={endpointType}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MenuItem;
|
|
|
@ -1,44 +0,0 @@
|
||||||
import type { FC } from 'react';
|
|
||||||
import { Close } from '@radix-ui/react-popover';
|
|
||||||
import { AuthType } from 'librechat-data-provider';
|
|
||||||
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
|
||||||
import MenuSeparator from '~/components/Chat/Menus/UI/MenuSeparator';
|
|
||||||
import ModelSpec from './ModelSpec';
|
|
||||||
|
|
||||||
const ModelSpecs: FC<{
|
|
||||||
specs?: Array<TModelSpec | undefined>;
|
|
||||||
selected?: TModelSpec;
|
|
||||||
setSelected?: (spec: TModelSpec) => void;
|
|
||||||
endpointsConfig: TEndpointsConfig;
|
|
||||||
}> = ({ specs = [], selected, setSelected = () => ({}), endpointsConfig }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{specs.length &&
|
|
||||||
specs.map((spec, i) => {
|
|
||||||
if (!spec) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Close asChild key={`spec-${spec.name}`}>
|
|
||||||
<div key={`spec-${spec.name}`}>
|
|
||||||
<ModelSpec
|
|
||||||
spec={spec}
|
|
||||||
title={spec.label}
|
|
||||||
key={`spec-item-${spec.name}`}
|
|
||||||
description={spec.description}
|
|
||||||
onClick={() => setSelected(spec)}
|
|
||||||
data-testid={`spec-item-${spec.name}`}
|
|
||||||
selected={selected?.name === spec.name}
|
|
||||||
userProvidesKey={spec.authType === AuthType.USER_PROVIDED}
|
|
||||||
endpointsConfig={endpointsConfig}
|
|
||||||
/>
|
|
||||||
{i !== specs.length - 1 && <MenuSeparator />}
|
|
||||||
</div>
|
|
||||||
</Close>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ModelSpecs;
|
|
|
@ -1,168 +0,0 @@
|
||||||
import { useRecoilValue } from 'recoil';
|
|
||||||
import { useMemo, useCallback, useRef } from 'react';
|
|
||||||
import { Content, Portal, Root } from '@radix-ui/react-popover';
|
|
||||||
import { EModelEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
|
||||||
import type { TModelSpec, TConversation, TEndpointsConfig } from 'librechat-data-provider';
|
|
||||||
import type { KeyboardEvent } from 'react';
|
|
||||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
|
||||||
import { useDefaultConvo, useNewConvo, useLocalize } from '~/hooks';
|
|
||||||
import { getConvoSwitchLogic, getModelSpecIconURL } from '~/utils';
|
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
|
||||||
import MenuButton from './MenuButton';
|
|
||||||
import ModelSpecs from './ModelSpecs';
|
|
||||||
import store from '~/store';
|
|
||||||
|
|
||||||
export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs?: TModelSpec[] }) {
|
|
||||||
const { conversation } = useChatContext();
|
|
||||||
const { newConversation } = useNewConvo();
|
|
||||||
|
|
||||||
const localize = useLocalize();
|
|
||||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
|
||||||
const modularChat = useRecoilValue(store.modularChat);
|
|
||||||
const getDefaultConversation = useDefaultConvo();
|
|
||||||
const assistantMap = useAssistantsMapContext();
|
|
||||||
|
|
||||||
const onSelectSpec = (spec: TModelSpec) => {
|
|
||||||
const { preset } = spec;
|
|
||||||
preset.iconURL = getModelSpecIconURL(spec);
|
|
||||||
preset.spec = spec.name;
|
|
||||||
const { endpoint } = preset;
|
|
||||||
const newEndpoint = endpoint ?? '';
|
|
||||||
if (!newEndpoint) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
template,
|
|
||||||
shouldSwitch,
|
|
||||||
isNewModular,
|
|
||||||
newEndpointType,
|
|
||||||
isCurrentModular,
|
|
||||||
isExistingConversation,
|
|
||||||
} = getConvoSwitchLogic({
|
|
||||||
newEndpoint,
|
|
||||||
modularChat,
|
|
||||||
conversation,
|
|
||||||
endpointsConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newEndpointType) {
|
|
||||||
preset.endpointType = newEndpointType;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAssistantsEndpoint(newEndpoint) && preset.assistant_id != null && !(preset.model ?? '')) {
|
|
||||||
preset.model = assistantMap?.[newEndpoint]?.[preset.assistant_id]?.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isModular = isCurrentModular && isNewModular && shouldSwitch;
|
|
||||||
if (isExistingConversation && isModular) {
|
|
||||||
template.endpointType = newEndpointType as EModelEndpoint | undefined;
|
|
||||||
|
|
||||||
const currentConvo = getDefaultConversation({
|
|
||||||
/* target endpointType is necessary to avoid endpoint mixing */
|
|
||||||
conversation: { ...(conversation ?? {}), endpointType: template.endpointType },
|
|
||||||
preset: template,
|
|
||||||
});
|
|
||||||
|
|
||||||
/* We don't reset the latest message, only when changing settings mid-converstion */
|
|
||||||
newConversation({
|
|
||||||
template: currentConvo,
|
|
||||||
preset,
|
|
||||||
keepLatestMessage: true,
|
|
||||||
keepAddedConvos: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
newConversation({
|
|
||||||
template: { ...(template as Partial<TConversation>) },
|
|
||||||
preset,
|
|
||||||
keepAddedConvos: isModular,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const selected = useMemo(() => {
|
|
||||||
const spec = modelSpecs?.find((spec) => spec.name === conversation?.spec);
|
|
||||||
if (!spec) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return spec;
|
|
||||||
}, [modelSpecs, conversation?.spec]);
|
|
||||||
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
|
||||||
const menuItems = menuRef.current?.querySelectorAll('[role="option"]');
|
|
||||||
if (!menuItems) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!menuItems.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIndex = Array.from(menuItems).findIndex((item) => item === document.activeElement);
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
event.preventDefault();
|
|
||||||
if (currentIndex < menuItems.length - 1) {
|
|
||||||
(menuItems[currentIndex + 1] as HTMLElement).focus();
|
|
||||||
} else {
|
|
||||||
(menuItems[0] as HTMLElement).focus();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
event.preventDefault();
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
(menuItems[currentIndex - 1] as HTMLElement).focus();
|
|
||||||
} else {
|
|
||||||
(menuItems[menuItems.length - 1] as HTMLElement).focus();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Root>
|
|
||||||
<MenuButton
|
|
||||||
selected={selected}
|
|
||||||
className="min-h-11"
|
|
||||||
textClassName="block items-center justify-start text-xs md:text-base whitespace-nowrap max-w-64 overflow-hidden shrink-0 text-ellipsis"
|
|
||||||
primaryText={selected?.label ?? ''}
|
|
||||||
endpointsConfig={endpointsConfig}
|
|
||||||
/>
|
|
||||||
<Portal>
|
|
||||||
{modelSpecs && modelSpecs.length && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
left: '0px',
|
|
||||||
top: '0px',
|
|
||||||
transform: 'translate3d(268px, 50px, 0px)',
|
|
||||||
minWidth: 'max-content',
|
|
||||||
zIndex: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Content
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
id="llm-menu"
|
|
||||||
role="listbox"
|
|
||||||
ref={menuRef}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
aria-label={localize('com_ui_llms_available')}
|
|
||||||
className="models-scrollbar mt-2 max-h-[65vh] min-w-[340px] max-w-xs overflow-y-auto rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white lg:max-h-[75vh]"
|
|
||||||
>
|
|
||||||
<ModelSpecs
|
|
||||||
specs={modelSpecs}
|
|
||||||
selected={selected}
|
|
||||||
setSelected={onSelectSpec}
|
|
||||||
endpointsConfig={endpointsConfig}
|
|
||||||
/>
|
|
||||||
</Content>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Portal>
|
|
||||||
</Root>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -10,7 +10,7 @@ import { Dialog, DialogTrigger, Label } from '~/components/ui';
|
||||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
import { useGetEndpointsQuery } from '~/data-provider';
|
||||||
import { MenuSeparator, MenuItem } from '../UI';
|
import { MenuSeparator, MenuItem } from '../UI';
|
||||||
import { icons } from '../Endpoints/Icons';
|
import { icons } from '~/hooks/Endpoint/Icons';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
@ -39,7 +39,7 @@ const PresetItems: FC<{
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className="pointer-none group m-1.5 flex h-8 min-w-[170px] gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 md:min-w-[240px]"
|
className="pointer-none group m-1.5 flex h-8 min-w-[170px] gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 md:min-w-[240px]"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className="flex h-full grow items-center justify-end gap-2">
|
<div className="flex h-full grow items-center justify-end gap-2">
|
||||||
|
|
|
@ -30,7 +30,7 @@ const PresetsMenu: FC = () => {
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
data-testid="presets-button"
|
data-testid="presets-button"
|
||||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||||
>
|
>
|
||||||
<BookCopy size={16} aria-label="Preset Icon" />
|
<BookCopy size={16} aria-label="Preset Icon" />
|
||||||
</TooltipAnchor>
|
</TooltipAnchor>
|
||||||
|
|
|
@ -1,4 +1,2 @@
|
||||||
export { default as PresetsMenu } from './PresetsMenu';
|
export { default as PresetsMenu } from './PresetsMenu';
|
||||||
export { default as EndpointsMenu } from './EndpointsMenu';
|
|
||||||
export { default as HeaderNewChat } from './HeaderNewChat';
|
export { default as HeaderNewChat } from './HeaderNewChat';
|
||||||
export { default as ModelSpecsMenu } from './Models/ModelSpecsMenu';
|
|
||||||
|
|
|
@ -38,7 +38,13 @@ const Part = memo(
|
||||||
if (part.type === ContentTypes.ERROR) {
|
if (part.type === ContentTypes.ERROR) {
|
||||||
return (
|
return (
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
text={part[ContentTypes.ERROR] ?? part[ContentTypes.TEXT]?.value}
|
text={
|
||||||
|
part[ContentTypes.ERROR] ??
|
||||||
|
(typeof part[ContentTypes.TEXT] === 'string'
|
||||||
|
? part[ContentTypes.TEXT]
|
||||||
|
: part.text?.value) ??
|
||||||
|
''
|
||||||
|
}
|
||||||
className="my-2"
|
className="my-2"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,7 +6,7 @@ import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||||
import { useMessageHelpers, useLocalize } from '~/hooks';
|
import { useMessageHelpers, useLocalize } from '~/hooks';
|
||||||
import ContentParts from './Content/ContentParts';
|
import ContentParts from './Content/ContentParts';
|
||||||
import SiblingSwitch from './SiblingSwitch';
|
import SiblingSwitch from './SiblingSwitch';
|
||||||
// eslint-disable-next-line import/no-cycle
|
|
||||||
import MultiMessage from './MultiMessage';
|
import MultiMessage from './MultiMessage';
|
||||||
import HoverButtons from './HoverButtons';
|
import HoverButtons from './HoverButtons';
|
||||||
import SubRow from './SubRow';
|
import SubRow from './SubRow';
|
||||||
|
@ -33,8 +33,11 @@ export default function Message(props: TMessageProps) {
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
regenerateMessage,
|
regenerateMessage,
|
||||||
} = useMessageHelpers(props);
|
} = useMessageHelpers(props);
|
||||||
|
|
||||||
const fontSize = useRecoilValue(store.fontSize);
|
const fontSize = useRecoilValue(store.fontSize);
|
||||||
|
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||||
const { children, messageId = null, isCreatedByUser } = message ?? {};
|
const { children, messageId = null, isCreatedByUser } = message ?? {};
|
||||||
|
|
||||||
const name = useMemo(() => {
|
const name = useMemo(() => {
|
||||||
let result = '';
|
let result = '';
|
||||||
if (isCreatedByUser === true) {
|
if (isCreatedByUser === true) {
|
||||||
|
@ -67,71 +70,86 @@ export default function Message(props: TMessageProps) {
|
||||||
message?.isCreatedByUser,
|
message?.isCreatedByUser,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseClasses = {
|
||||||
|
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu',
|
||||||
|
chat: maximizeChatSpace
|
||||||
|
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
|
||||||
|
: 'md:max-w-[47rem] xl:max-w-[55rem]',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
|
className="w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
|
||||||
onWheel={handleScroll}
|
onWheel={handleScroll}
|
||||||
onTouchMove={handleScroll}
|
onTouchMove={handleScroll}
|
||||||
>
|
>
|
||||||
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
|
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||||
<div className="group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
<div
|
||||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
id={messageId}
|
||||||
<div>
|
aria-label={`message-${message.depth}-${messageId}`}
|
||||||
<div className="pt-0.5">
|
className={cn(baseClasses.common, baseClasses.chat, 'message-render')}
|
||||||
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
>
|
||||||
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
<div className="relative flex flex-shrink-0 flex-col items-center">
|
||||||
</div>
|
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full pt-0.5">
|
||||||
</div>
|
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex w-full flex-col',
|
'relative flex w-11/12 flex-col',
|
||||||
isCreatedByUser === true ? '' : 'agent-turn',
|
isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn('select-none font-semibold', fontSize)}>{name}</div>
|
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>
|
||||||
<div className="flex-col gap-1 md:gap-3">
|
{name}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-1 md:gap-3">
|
||||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||||
<ContentParts
|
<ContentParts
|
||||||
|
edit={edit}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
isSubmitting={isSubmitting}
|
enterEdit={enterEdit}
|
||||||
|
siblingIdx={siblingIdx}
|
||||||
messageId={message.messageId}
|
messageId={message.messageId}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
setSiblingIdx={setSiblingIdx}
|
||||||
|
attachments={message.attachments}
|
||||||
isCreatedByUser={message.isCreatedByUser}
|
isCreatedByUser={message.isCreatedByUser}
|
||||||
conversationId={conversation?.conversationId}
|
conversationId={conversation?.conversationId}
|
||||||
content={message.content as Array<TMessageContentParts | undefined>}
|
content={message.content as Array<TMessageContentParts | undefined>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isLast && isSubmitting ? (
|
||||||
|
<div className="mt-1 h-[27px] bg-transparent" />
|
||||||
|
) : (
|
||||||
|
<SubRow classes="text-xs">
|
||||||
|
<SiblingSwitch
|
||||||
|
siblingIdx={siblingIdx}
|
||||||
|
siblingCount={siblingCount}
|
||||||
|
setSiblingIdx={setSiblingIdx}
|
||||||
|
/>
|
||||||
|
<HoverButtons
|
||||||
|
index={index}
|
||||||
|
isEditing={edit}
|
||||||
|
message={message}
|
||||||
|
enterEdit={enterEdit}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
conversation={conversation ?? null}
|
||||||
|
regenerate={() => regenerateMessage()}
|
||||||
|
copyToClipboard={copyToClipboard}
|
||||||
|
handleContinue={handleContinue}
|
||||||
|
latestMessage={latestMessage}
|
||||||
|
isLast={isLast}
|
||||||
|
/>
|
||||||
|
</SubRow>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLast && isSubmitting ? (
|
|
||||||
<div className="mt-1 h-[27px] bg-transparent" />
|
|
||||||
) : (
|
|
||||||
<SubRow classes="text-xs">
|
|
||||||
<SiblingSwitch
|
|
||||||
siblingIdx={siblingIdx}
|
|
||||||
siblingCount={siblingCount}
|
|
||||||
setSiblingIdx={setSiblingIdx}
|
|
||||||
/>
|
|
||||||
<HoverButtons
|
|
||||||
index={index}
|
|
||||||
isEditing={edit}
|
|
||||||
message={message}
|
|
||||||
enterEdit={enterEdit}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
conversation={conversation ?? null}
|
|
||||||
regenerate={() => regenerateMessage()}
|
|
||||||
copyToClipboard={copyToClipboard}
|
|
||||||
handleContinue={handleContinue}
|
|
||||||
latestMessage={latestMessage}
|
|
||||||
isLast={isLast}
|
|
||||||
/>
|
|
||||||
</SubRow>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { CSSTransition } from 'react-transition-group';
|
import { CSSTransition } from 'react-transition-group';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import type { TMessage } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
|
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
|
||||||
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
||||||
|
@ -11,13 +10,12 @@ import store from '~/store';
|
||||||
|
|
||||||
export default function MessagesView({
|
export default function MessagesView({
|
||||||
messagesTree: _messagesTree,
|
messagesTree: _messagesTree,
|
||||||
Header,
|
|
||||||
}: {
|
}: {
|
||||||
messagesTree?: TMessage[] | null;
|
messagesTree?: TMessage[] | null;
|
||||||
Header?: ReactNode;
|
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
|
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
|
||||||
|
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||||
const fontSize = useRecoilValue(store.fontSize);
|
const fontSize = useRecoilValue(store.fontSize);
|
||||||
const { screenshotTargetRef } = useScreenshot();
|
const { screenshotTargetRef } = useScreenshot();
|
||||||
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
|
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
|
||||||
|
@ -34,62 +32,64 @@ export default function MessagesView({
|
||||||
const { conversationId } = conversation ?? {};
|
const { conversationId } = conversation ?? {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-hidden overflow-y-auto">
|
<>
|
||||||
<div className="relative h-full">
|
<div className="relative flex-1 overflow-hidden overflow-y-auto">
|
||||||
<div
|
<div className="relative h-full">
|
||||||
className="scrollbar-gutter-stable"
|
<div
|
||||||
onScroll={debouncedHandleScroll}
|
className="scrollbar-gutter-stable"
|
||||||
ref={scrollableRef}
|
onScroll={debouncedHandleScroll}
|
||||||
style={{
|
ref={scrollableRef}
|
||||||
height: '100%',
|
style={{
|
||||||
overflowY: 'auto',
|
height: '100%',
|
||||||
width: '100%',
|
overflowY: 'auto',
|
||||||
}}
|
width: '100%',
|
||||||
>
|
}}
|
||||||
<div className="flex flex-col pb-9 dark:bg-transparent">
|
>
|
||||||
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
|
<div className="flex flex-col pb-9 dark:bg-transparent">
|
||||||
<div
|
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
|
||||||
className={cn(
|
<div
|
||||||
'flex w-full items-center justify-center p-3 text-text-secondary',
|
className={cn(
|
||||||
fontSize,
|
'flex w-full items-center justify-center p-3 text-text-secondary',
|
||||||
)}
|
fontSize,
|
||||||
>
|
)}
|
||||||
{localize('com_ui_nothing_found')}
|
>
|
||||||
</div>
|
{localize('com_ui_nothing_found')}
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{Header != null && Header}
|
|
||||||
<div ref={screenshotTargetRef}>
|
|
||||||
<MultiMessage
|
|
||||||
key={conversationId} // avoid internal state mixture
|
|
||||||
messagesTree={_messagesTree}
|
|
||||||
messageId={conversationId ?? null}
|
|
||||||
setCurrentEditId={setCurrentEditId}
|
|
||||||
currentEditId={currentEditId ?? null}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
<div
|
<div ref={screenshotTargetRef}>
|
||||||
id="messages-end"
|
<MultiMessage
|
||||||
className="group h-0 w-full flex-shrink-0"
|
key={conversationId}
|
||||||
ref={messagesEndRef}
|
messagesTree={_messagesTree}
|
||||||
/>
|
messageId={conversationId ?? null}
|
||||||
|
setCurrentEditId={setCurrentEditId}
|
||||||
|
currentEditId={currentEditId ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
id="messages-end"
|
||||||
|
className="group h-0 w-full flex-shrink-0"
|
||||||
|
ref={messagesEndRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CSSTransition
|
||||||
|
in={showScrollButton && scrollButtonPreference}
|
||||||
|
timeout={{
|
||||||
|
enter: 550,
|
||||||
|
exit: 700,
|
||||||
|
}}
|
||||||
|
classNames="scroll-animation"
|
||||||
|
unmountOnExit={true}
|
||||||
|
appear={true}
|
||||||
|
>
|
||||||
|
<ScrollToBottom scrollHandler={handleSmoothToRef} />
|
||||||
|
</CSSTransition>
|
||||||
</div>
|
</div>
|
||||||
<CSSTransition
|
|
||||||
in={showScrollButton}
|
|
||||||
timeout={400}
|
|
||||||
classNames="scroll-down"
|
|
||||||
unmountOnExit={false}
|
|
||||||
// appear
|
|
||||||
>
|
|
||||||
{() =>
|
|
||||||
showScrollButton &&
|
|
||||||
scrollButtonPreference && <ScrollToBottom scrollHandler={handleSmoothToRef} />
|
|
||||||
}
|
|
||||||
</CSSTransition>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,6 @@ export default function MultiMessage({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
|
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
|
||||||
setSiblingIdx(0);
|
setSiblingIdx(0);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [messagesTree?.length]);
|
}, [messagesTree?.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -62,6 +61,7 @@ export default function MultiMessage({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (message.content) {
|
} else if (message.content) {
|
||||||
|
console.log('message.id with content', message.messageId);
|
||||||
return (
|
return (
|
||||||
<MessageContent
|
<MessageContent
|
||||||
key={message.messageId}
|
key={message.messageId}
|
||||||
|
|
|
@ -26,20 +26,21 @@ type MessageRenderProps = {
|
||||||
|
|
||||||
const MessageRender = memo(
|
const MessageRender = memo(
|
||||||
({
|
({
|
||||||
isCard,
|
message: msg,
|
||||||
|
isCard = false,
|
||||||
siblingIdx,
|
siblingIdx,
|
||||||
siblingCount,
|
siblingCount,
|
||||||
message: msg,
|
|
||||||
setSiblingIdx,
|
setSiblingIdx,
|
||||||
currentEditId,
|
currentEditId,
|
||||||
isMultiMessage,
|
isMultiMessage = false,
|
||||||
setCurrentEditId,
|
setCurrentEditId,
|
||||||
isSubmittingFamily,
|
isSubmittingFamily = false,
|
||||||
}: MessageRenderProps) => {
|
}: MessageRenderProps) => {
|
||||||
const {
|
const {
|
||||||
ask,
|
ask,
|
||||||
edit,
|
edit,
|
||||||
index,
|
index,
|
||||||
|
agent,
|
||||||
assistant,
|
assistant,
|
||||||
enterEdit,
|
enterEdit,
|
||||||
conversation,
|
conversation,
|
||||||
|
@ -56,28 +57,31 @@ const MessageRender = memo(
|
||||||
isMultiMessage,
|
isMultiMessage,
|
||||||
setCurrentEditId,
|
setCurrentEditId,
|
||||||
});
|
});
|
||||||
const fontSize = useRecoilValue(store.fontSize);
|
|
||||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||||
|
const fontSize = useRecoilValue(store.fontSize);
|
||||||
|
|
||||||
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
|
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
|
||||||
const { isCreatedByUser, error, unfinished } = msg ?? {};
|
|
||||||
const hasNoChildren = !(msg?.children?.length ?? 0);
|
const hasNoChildren = !(msg?.children?.length ?? 0);
|
||||||
const isLast = useMemo(
|
const isLast = useMemo(
|
||||||
() => hasNoChildren && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
|
() => hasNoChildren && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
|
||||||
[hasNoChildren, msg?.depth, latestMessage?.depth],
|
[hasNoChildren, msg?.depth, latestMessage?.depth],
|
||||||
);
|
);
|
||||||
|
const isLatestMessage = msg?.messageId === latestMessage?.messageId;
|
||||||
|
const showCardRender = isLast && !isSubmittingFamily && isCard;
|
||||||
|
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
|
||||||
|
|
||||||
const iconData: TMessageIcon = useMemo(
|
const iconData: TMessageIcon = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
endpoint: msg?.endpoint ?? conversation?.endpoint,
|
endpoint: msg?.endpoint ?? conversation?.endpoint,
|
||||||
model: msg?.model ?? conversation?.model,
|
model: msg?.model ?? conversation?.model,
|
||||||
iconURL: msg?.iconURL ?? conversation?.iconURL,
|
iconURL: msg?.iconURL,
|
||||||
modelLabel: messageLabel,
|
modelLabel: messageLabel,
|
||||||
isCreatedByUser: msg?.isCreatedByUser,
|
isCreatedByUser: msg?.isCreatedByUser,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
messageLabel,
|
messageLabel,
|
||||||
conversation?.endpoint,
|
conversation?.endpoint,
|
||||||
conversation?.iconURL,
|
|
||||||
conversation?.model,
|
conversation?.model,
|
||||||
msg?.model,
|
msg?.model,
|
||||||
msg?.iconURL,
|
msg?.iconURL,
|
||||||
|
@ -86,49 +90,47 @@ const MessageRender = memo(
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clickHandler = useMemo(
|
||||||
|
() =>
|
||||||
|
showCardRender && !isLatestMessage
|
||||||
|
? () => {
|
||||||
|
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
|
||||||
|
logger.dir(msg);
|
||||||
|
setLatestMessage(msg!);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
[showCardRender, isLatestMessage, msg, setLatestMessage],
|
||||||
|
);
|
||||||
|
|
||||||
if (!msg) {
|
if (!msg) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLatestMessage = msg.messageId === latestMessage?.messageId;
|
const baseClasses = {
|
||||||
const showCardRender = isLast && !(isSubmittingFamily === true) && isCard === true;
|
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
|
||||||
const isLatestCard = isCard === true && !(isSubmittingFamily === true) && isLatestMessage;
|
card: 'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4',
|
||||||
const clickHandler =
|
chat: maximizeChatSpace
|
||||||
showCardRender && !isLatestMessage
|
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
|
||||||
? () => {
|
: 'md:max-w-[47rem] xl:max-w-[55rem]',
|
||||||
logger.log(`Message Card click: Setting ${msg.messageId} as latest message`);
|
};
|
||||||
logger.dir(msg);
|
|
||||||
setLatestMessage(msg);
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Style classes
|
const conditionalClasses = {
|
||||||
const baseClasses =
|
latestCard: isLatestCard ? 'bg-surface-secondary' : '',
|
||||||
'final-completion group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu';
|
cardRender: showCardRender ? 'cursor-pointer transition-colors duration-300' : '',
|
||||||
let layoutClasses = '';
|
focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
|
||||||
|
};
|
||||||
if (isCard ?? false) {
|
|
||||||
layoutClasses =
|
|
||||||
'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4';
|
|
||||||
} else if (maximizeChatSpace) {
|
|
||||||
layoutClasses = 'md:max-w-full md:px-5';
|
|
||||||
} else {
|
|
||||||
layoutClasses = 'md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5';
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestCardClasses = isLatestCard ? 'bg-surface-secondary' : '';
|
|
||||||
const showRenderClasses = showCardRender ? 'cursor-pointer transition-colors duration-300' : '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={msg.messageId}
|
id={msg.messageId}
|
||||||
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
baseClasses,
|
baseClasses.common,
|
||||||
layoutClasses,
|
isCard ? baseClasses.card : baseClasses.chat,
|
||||||
latestCardClasses,
|
conditionalClasses.latestCard,
|
||||||
showRenderClasses,
|
conditionalClasses.cardRender,
|
||||||
'message-render focus:outline-none focus:ring-2 focus:ring-border-xheavy',
|
conditionalClasses.focus,
|
||||||
|
'message-render',
|
||||||
)}
|
)}
|
||||||
onClick={clickHandler}
|
onClick={clickHandler}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
@ -139,31 +141,31 @@ const MessageRender = memo(
|
||||||
role={showCardRender ? 'button' : undefined}
|
role={showCardRender ? 'button' : undefined}
|
||||||
tabIndex={showCardRender ? 0 : undefined}
|
tabIndex={showCardRender ? 0 : undefined}
|
||||||
>
|
>
|
||||||
{isLatestCard === true && (
|
{isLatestCard && (
|
||||||
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary"></div>
|
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary" />
|
||||||
)}
|
)}
|
||||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
|
||||||
<div>
|
<div className="relative flex flex-shrink-0 flex-col items-center">
|
||||||
<div className="pt-0.5">
|
<div className="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">
|
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
||||||
<MessageIcon iconData={iconData} assistant={assistant} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex w-11/12 flex-col',
|
'relative flex w-11/12 flex-col',
|
||||||
msg.isCreatedByUser === true ? '' : 'agent-turn',
|
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||||
<div className="flex-col gap-1 md:gap-3">
|
|
||||||
|
<div className="flex flex-col gap-1 md:gap-3">
|
||||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||||
<MessageContext.Provider
|
<MessageContext.Provider
|
||||||
value={{
|
value={{
|
||||||
messageId: msg.messageId,
|
messageId: msg.messageId,
|
||||||
conversationId: conversation?.conversationId,
|
conversationId: conversation?.conversationId,
|
||||||
|
isExpanded: false,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{msg.plugin && <Plugin plugin={msg.plugin} />}
|
{msg.plugin && <Plugin plugin={msg.plugin} />}
|
||||||
|
@ -174,40 +176,41 @@ const MessageRender = memo(
|
||||||
text={msg.text || ''}
|
text={msg.text || ''}
|
||||||
message={msg}
|
message={msg}
|
||||||
enterEdit={enterEdit}
|
enterEdit={enterEdit}
|
||||||
error={!!(error ?? false)}
|
error={!!(msg.error ?? false)}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
unfinished={unfinished ?? false}
|
unfinished={msg.unfinished ?? false}
|
||||||
isCreatedByUser={isCreatedByUser ?? true}
|
isCreatedByUser={msg.isCreatedByUser ?? true}
|
||||||
siblingIdx={siblingIdx ?? 0}
|
siblingIdx={siblingIdx ?? 0}
|
||||||
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
|
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
|
||||||
/>
|
/>
|
||||||
</MessageContext.Provider>
|
</MessageContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasNoChildren && (isSubmittingFamily === true || isSubmitting) ? (
|
||||||
|
<PlaceholderRow isCard={isCard} />
|
||||||
|
) : (
|
||||||
|
<SubRow classes="text-xs">
|
||||||
|
<SiblingSwitch
|
||||||
|
siblingIdx={siblingIdx}
|
||||||
|
siblingCount={siblingCount}
|
||||||
|
setSiblingIdx={setSiblingIdx}
|
||||||
|
/>
|
||||||
|
<HoverButtons
|
||||||
|
index={index}
|
||||||
|
isEditing={edit}
|
||||||
|
message={msg}
|
||||||
|
enterEdit={enterEdit}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
conversation={conversation ?? null}
|
||||||
|
regenerate={handleRegenerateMessage}
|
||||||
|
copyToClipboard={copyToClipboard}
|
||||||
|
handleContinue={handleContinue}
|
||||||
|
latestMessage={latestMessage}
|
||||||
|
isLast={isLast}
|
||||||
|
/>
|
||||||
|
</SubRow>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasNoChildren && (isSubmittingFamily === true || isSubmitting) ? (
|
|
||||||
<PlaceholderRow isCard={isCard} />
|
|
||||||
) : (
|
|
||||||
<SubRow classes="text-xs">
|
|
||||||
<SiblingSwitch
|
|
||||||
siblingIdx={siblingIdx}
|
|
||||||
siblingCount={siblingCount}
|
|
||||||
setSiblingIdx={setSiblingIdx}
|
|
||||||
/>
|
|
||||||
<HoverButtons
|
|
||||||
index={index}
|
|
||||||
isEditing={edit}
|
|
||||||
message={msg}
|
|
||||||
enterEdit={enterEdit}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
conversation={conversation ?? null}
|
|
||||||
regenerate={handleRegenerateMessage}
|
|
||||||
copyToClipboard={copyToClipboard}
|
|
||||||
handleContinue={handleContinue}
|
|
||||||
latestMessage={latestMessage}
|
|
||||||
isLast={isLast}
|
|
||||||
/>
|
|
||||||
</SubRow>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -57,14 +57,6 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
||||||
}, []);
|
}, []);
|
||||||
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
|
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
|
||||||
|
|
||||||
const layout = () => (
|
|
||||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-presentation pt-0">
|
|
||||||
<div className="flex h-full flex-col" role="presentation">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
|
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
|
||||||
<SidePanelGroup
|
<SidePanelGroup
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import type * as t from 'librechat-data-provider';
|
import type * as t from 'librechat-data-provider';
|
||||||
import { getEndpointField, getIconKey, getEntity, getIconEndpoint } from '~/utils';
|
import { getEndpointField, getIconKey, getEntity, getIconEndpoint } from '~/utils';
|
||||||
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
|
|
||||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||||
|
import { icons } from '~/hooks/Endpoint/Icons';
|
||||||
|
|
||||||
export default function ConvoIcon({
|
export default function ConvoIcon({
|
||||||
conversation,
|
conversation,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import type { IconMapProps } from '~/common';
|
import type { IconMapProps } from '~/common';
|
||||||
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
|
|
||||||
import { URLIcon } from '~/components/Endpoints/URLIcon';
|
import { URLIcon } from '~/components/Endpoints/URLIcon';
|
||||||
|
import { icons } from '~/hooks/Endpoint/Icons';
|
||||||
|
|
||||||
interface ConvoIconURLProps {
|
interface ConvoIconURLProps {
|
||||||
iconURL?: string;
|
iconURL?: string;
|
||||||
|
@ -39,12 +39,7 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
|
||||||
agentName,
|
agentName,
|
||||||
context,
|
context,
|
||||||
}) => {
|
}) => {
|
||||||
const Icon: (
|
const Icon = useMemo(() => icons[iconURL] ?? icons.unknown, [iconURL]);
|
||||||
props: IconMapProps & {
|
|
||||||
context?: string;
|
|
||||||
iconURL?: string;
|
|
||||||
},
|
|
||||||
) => React.JSX.Element = useMemo(() => icons[iconURL] ?? icons.unknown, [iconURL]);
|
|
||||||
const isURL = useMemo(
|
const isURL = useMemo(
|
||||||
() => !!(iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'))),
|
() => !!(iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'))),
|
||||||
[iconURL],
|
[iconURL],
|
||||||
|
@ -63,15 +58,17 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
|
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
|
||||||
<Icon
|
{Icon && (
|
||||||
size={41}
|
<Icon
|
||||||
context={context}
|
size={41}
|
||||||
className="h-2/3 w-2/3"
|
context={context}
|
||||||
agentName={agentName}
|
className="h-2/3 w-2/3"
|
||||||
iconURL={endpointIconURL}
|
agentName={agentName}
|
||||||
assistantName={assistantName}
|
iconURL={endpointIconURL}
|
||||||
avatar={assistantAvatar ?? agentAvatar}
|
assistantName={assistantName}
|
||||||
/>
|
avatar={assistantAvatar ?? agentAvatar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { memo } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
import type { TUser } from 'librechat-data-provider';
|
import type { TUser } from 'librechat-data-provider';
|
||||||
import type { IconProps } from '~/common';
|
import type { IconProps } from '~/common';
|
||||||
import MessageEndpointIcon from './MessageEndpointIcon';
|
import MessageEndpointIcon from './MessageEndpointIcon';
|
||||||
|
@ -16,32 +16,50 @@ type UserAvatarProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => (
|
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => {
|
||||||
<div
|
const [imageError, setImageError] = useState(false);
|
||||||
title={username}
|
|
||||||
style={{
|
const handleImageError = () => {
|
||||||
width: size,
|
setImageError(true);
|
||||||
height: size,
|
};
|
||||||
}}
|
|
||||||
className={cn('relative flex items-center justify-center', className ?? '')}
|
const renderDefaultAvatar = () => (
|
||||||
>
|
<div
|
||||||
{!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '') ? (
|
style={{
|
||||||
<div
|
backgroundColor: 'rgb(121, 137, 255)',
|
||||||
style={{
|
width: '20px',
|
||||||
backgroundColor: 'rgb(121, 137, 255)',
|
height: '20px',
|
||||||
width: '20px',
|
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||||
height: '20px',
|
}}
|
||||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
||||||
}}
|
>
|
||||||
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
<UserIcon />
|
||||||
>
|
</div>
|
||||||
<UserIcon />
|
);
|
||||||
</div>
|
|
||||||
) : (
|
return (
|
||||||
<img className="rounded-full" src={(user?.avatar ?? '') || avatarSrc} alt="avatar" />
|
<div
|
||||||
)}
|
title={username}
|
||||||
</div>
|
style={{
|
||||||
));
|
width: size,
|
||||||
|
height: size,
|
||||||
|
}}
|
||||||
|
className={cn('relative flex items-center justify-center', className ?? '')}
|
||||||
|
>
|
||||||
|
{(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) ||
|
||||||
|
imageError ? (
|
||||||
|
renderDefaultAvatar()
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className="rounded-full"
|
||||||
|
src={(user?.avatar ?? '') || avatarSrc}
|
||||||
|
alt="avatar"
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
UserAvatar.displayName = 'UserAvatar';
|
UserAvatar.displayName = 'UserAvatar';
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
AzureMinimalIcon,
|
AzureMinimalIcon,
|
||||||
CustomMinimalIcon,
|
CustomMinimalIcon,
|
||||||
} from '~/components/svg';
|
} from '~/components/svg';
|
||||||
import UnknownIcon from '~/components/Chat/Menus/Endpoints/UnknownIcon';
|
import UnknownIcon from '~/hooks/Endpoint/UnknownIcon';
|
||||||
import { IconProps } from '~/common';
|
import { IconProps } from '~/common';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
@ -186,7 +186,7 @@ const MessageEndpointIcon: React.FC<IconProps> = (props) => {
|
||||||
|
|
||||||
let { icon, bg, name } =
|
let { icon, bg, name } =
|
||||||
endpoint != null && endpoint && endpointIcons[endpoint]
|
endpoint != null && endpoint && endpointIcons[endpoint]
|
||||||
? endpointIcons[endpoint] ?? {}
|
? (endpointIcons[endpoint] ?? {})
|
||||||
: (endpointIcons.default as EndpointIcon);
|
: (endpointIcons.default as EndpointIcon);
|
||||||
|
|
||||||
if (iconURL && endpointIcons[iconURL]) {
|
if (iconURL && endpointIcons[iconURL]) {
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
BedrockIcon,
|
BedrockIcon,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from '~/components/svg';
|
} from '~/components/svg';
|
||||||
import UnknownIcon from '~/components/Chat/Menus/Endpoints/UnknownIcon';
|
import UnknownIcon from '~/hooks/Endpoint/UnknownIcon';
|
||||||
import { IconProps } from '~/common';
|
import { IconProps } from '~/common';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import React, { memo } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
|
import { icons } from '~/hooks/Endpoint/Icons';
|
||||||
|
import { AlertCircle } from 'lucide-react'; // Assuming you have lucide-react for icons
|
||||||
|
|
||||||
export const URLIcon = memo(
|
export const URLIcon = memo(
|
||||||
({
|
({
|
||||||
|
@ -7,15 +9,53 @@ export const URLIcon = memo(
|
||||||
containerStyle = { width: '20', height: '20' },
|
containerStyle = { width: '20', height: '20' },
|
||||||
imageStyle = { width: '100%', height: '100%' },
|
imageStyle = { width: '100%', height: '100%' },
|
||||||
className = 'icon-xl mr-1 shrink-0 overflow-hidden rounded-full',
|
className = 'icon-xl mr-1 shrink-0 overflow-hidden rounded-full',
|
||||||
|
endpoint,
|
||||||
}: {
|
}: {
|
||||||
iconURL: string;
|
iconURL: string;
|
||||||
altName?: string | null;
|
altName?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
containerStyle?: React.CSSProperties;
|
containerStyle?: React.CSSProperties;
|
||||||
imageStyle?: React.CSSProperties;
|
imageStyle?: React.CSSProperties;
|
||||||
}) => (
|
endpoint?: string;
|
||||||
<div className={className} style={containerStyle}>
|
}) => {
|
||||||
<img src={iconURL} alt={altName ?? ''} style={imageStyle} className="object-cover" />
|
const [imageError, setImageError] = useState(false);
|
||||||
</div>
|
|
||||||
),
|
const handleImageError = () => {
|
||||||
|
setImageError(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultIcon: React.ElementType =
|
||||||
|
endpoint && icons[endpoint] ? icons[endpoint]! : icons.unknown!;
|
||||||
|
if (imageError || !iconURL) {
|
||||||
|
return (
|
||||||
|
<div className="relative" style={{ ...containerStyle, margin: '2px' }}>
|
||||||
|
<div className={className}>
|
||||||
|
<DefaultIcon endpoint={endpoint} context="menu-item" />
|
||||||
|
</div>
|
||||||
|
{imageError && iconURL && (
|
||||||
|
<div
|
||||||
|
className="absolute flex items-center justify-center rounded-full bg-red-500"
|
||||||
|
style={{ width: '14px', height: '14px', top: 0, right: 0 }}
|
||||||
|
>
|
||||||
|
<AlertCircle size={10} className="text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={containerStyle}>
|
||||||
|
<img
|
||||||
|
src={iconURL}
|
||||||
|
alt={altName ?? ''}
|
||||||
|
style={imageStyle}
|
||||||
|
className="object-cover"
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
URLIcon.displayName = 'URLIcon';
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
||||||
import type { TModelSelectProps } from '~/common';
|
import type { TModelSelectProps } from '~/common';
|
||||||
import { cn, cardStyle } from '~/utils/';
|
import { cn, cardStyle } from '~/utils/';
|
||||||
import { TemporaryChat } from './TemporaryChat';
|
|
||||||
|
|
||||||
export default function Anthropic({
|
export default function Anthropic({
|
||||||
conversation,
|
conversation,
|
||||||
|
@ -22,7 +21,6 @@ export default function Anthropic({
|
||||||
cardStyle,
|
cardStyle,
|
||||||
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
|
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
|
||||||
)}
|
)}
|
||||||
footer={<TemporaryChat />}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
||||||
import type { TModelSelectProps } from '~/common';
|
import type { TModelSelectProps } from '~/common';
|
||||||
import { TemporaryChat } from './TemporaryChat';
|
|
||||||
import { cn, cardStyle } from '~/utils/';
|
import { cn, cardStyle } from '~/utils/';
|
||||||
|
|
||||||
export default function ChatGPT({
|
export default function ChatGPT({
|
||||||
|
@ -29,7 +28,6 @@ export default function ChatGPT({
|
||||||
cardStyle,
|
cardStyle,
|
||||||
'z-50 flex h-[40px] w-60 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
|
'z-50 flex h-[40px] w-60 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
|
||||||
)}
|
)}
|
||||||
footer={<TemporaryChat />}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
||||||
import type { TModelSelectProps } from '~/common';
|
import type { TModelSelectProps } from '~/common';
|
||||||
import { TemporaryChat } from './TemporaryChat';
|
|
||||||
import { cn, cardStyle } from '~/utils/';
|
import { cn, cardStyle } from '~/utils/';
|
||||||
|
|
||||||
export default function Google({
|
export default function Google({
|
||||||
|
@ -22,7 +21,6 @@ export default function Google({
|
||||||
cardStyle,
|
cardStyle,
|
||||||
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
|
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
|
||||||
)}
|
)}
|
||||||
footer={<TemporaryChat />}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
||||||
import type { TModelSelectProps } from '~/common';
|
import type { TModelSelectProps } from '~/common';
|
||||||
import { TemporaryChat } from './TemporaryChat';
|
|
||||||
import { cn, cardStyle } from '~/utils/';
|
import { cn, cardStyle } from '~/utils/';
|
||||||
|
|
||||||
export default function OpenAI({
|
export default function OpenAI({
|
||||||
|
@ -22,7 +21,6 @@ export default function OpenAI({
|
||||||
cardStyle,
|
cardStyle,
|
||||||
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 hover:cursor-pointer',
|
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 hover:cursor-pointer',
|
||||||
)}
|
)}
|
||||||
footer={<TemporaryChat />}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,65 +0,0 @@
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { MessageCircleDashed } from 'lucide-react';
|
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
|
||||||
import { Constants, getConfigDefaults } from 'librechat-data-provider';
|
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
|
||||||
import { Switch } from '~/components/ui';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import { cn } from '~/utils';
|
|
||||||
import store from '~/store';
|
|
||||||
|
|
||||||
export const TemporaryChat = () => {
|
|
||||||
const localize = useLocalize();
|
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
|
||||||
const defaultInterface = getConfigDefaults().interface;
|
|
||||||
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
|
|
||||||
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
|
|
||||||
const conversationId = conversation?.conversationId ?? '';
|
|
||||||
const interfaceConfig = useMemo(
|
|
||||||
() => startupConfig?.interface ?? defaultInterface,
|
|
||||||
[startupConfig],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (interfaceConfig.temporaryChat === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActiveConvo = Boolean(
|
|
||||||
conversation &&
|
|
||||||
conversationId &&
|
|
||||||
conversationId !== Constants.NEW_CONVO &&
|
|
||||||
conversationId !== 'search',
|
|
||||||
);
|
|
||||||
|
|
||||||
const onClick = () => {
|
|
||||||
if (isActiveConvo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsTemporary(!isTemporary);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sticky bottom-0 mt-auto w-full border-none bg-surface-tertiary px-6 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className={cn('flex items-center gap-2', isActiveConvo && 'opacity-40')}>
|
|
||||||
<MessageCircleDashed className="icon-sm" aria-hidden="true" />
|
|
||||||
<span className="truncate text-sm text-text-primary">
|
|
||||||
{localize('com_ui_temporary_chat')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-shrink-0 items-center">
|
|
||||||
<Switch
|
|
||||||
id="temporary-chat-switch"
|
|
||||||
checked={isTemporary}
|
|
||||||
onCheckedChange={onClick}
|
|
||||||
disabled={isActiveConvo}
|
|
||||||
className="ml-4"
|
|
||||||
aria-label="Toggle temporary chat"
|
|
||||||
data-testid="temporary-chat-switch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -119,7 +119,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
||||||
const language = isNonCode ? 'json' : lang;
|
const language = isNonCode ? 'json' : lang;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full rounded-md bg-gray-900 text-xs text-white/80">
|
<div className="w-full rounded-md bg-surface-primary text-xs text-white/80">
|
||||||
<CodeBar
|
<CodeBar
|
||||||
lang={lang}
|
lang={lang}
|
||||||
error={error}
|
error={error}
|
||||||
|
|
|
@ -24,18 +24,17 @@ type ContentRenderProps = {
|
||||||
|
|
||||||
const ContentRender = memo(
|
const ContentRender = memo(
|
||||||
({
|
({
|
||||||
isCard,
|
message: msg,
|
||||||
|
isCard = false,
|
||||||
siblingIdx,
|
siblingIdx,
|
||||||
siblingCount,
|
siblingCount,
|
||||||
message: msg,
|
|
||||||
setSiblingIdx,
|
setSiblingIdx,
|
||||||
currentEditId,
|
currentEditId,
|
||||||
isMultiMessage,
|
isMultiMessage = false,
|
||||||
setCurrentEditId,
|
setCurrentEditId,
|
||||||
isSubmittingFamily,
|
isSubmittingFamily = false,
|
||||||
}: ContentRenderProps) => {
|
}: ContentRenderProps) => {
|
||||||
const {
|
const {
|
||||||
// ask,
|
|
||||||
edit,
|
edit,
|
||||||
index,
|
index,
|
||||||
agent,
|
agent,
|
||||||
|
@ -58,26 +57,28 @@ const ContentRender = memo(
|
||||||
|
|
||||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||||
const fontSize = useRecoilValue(store.fontSize);
|
const fontSize = useRecoilValue(store.fontSize);
|
||||||
|
|
||||||
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
|
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
|
||||||
// const { isCreatedByUser, error, unfinished } = msg ?? {};
|
|
||||||
const isLast = useMemo(
|
const isLast = useMemo(
|
||||||
() =>
|
() =>
|
||||||
!(msg?.children?.length ?? 0) && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
|
!(msg?.children?.length ?? 0) && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
|
||||||
[msg?.children, msg?.depth, latestMessage?.depth],
|
[msg?.children, msg?.depth, latestMessage?.depth],
|
||||||
);
|
);
|
||||||
|
const isLatestMessage = msg?.messageId === latestMessage?.messageId;
|
||||||
|
const showCardRender = isLast && !isSubmittingFamily && isCard;
|
||||||
|
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
|
||||||
|
|
||||||
const iconData: TMessageIcon = useMemo(
|
const iconData: TMessageIcon = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
endpoint: msg?.endpoint ?? conversation?.endpoint,
|
endpoint: msg?.endpoint ?? conversation?.endpoint,
|
||||||
model: msg?.model ?? conversation?.model,
|
model: msg?.model ?? conversation?.model,
|
||||||
iconURL: msg?.iconURL ?? conversation?.iconURL,
|
iconURL: msg?.iconURL,
|
||||||
modelLabel: messageLabel,
|
modelLabel: messageLabel,
|
||||||
isCreatedByUser: msg?.isCreatedByUser,
|
isCreatedByUser: msg?.isCreatedByUser,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
messageLabel,
|
messageLabel,
|
||||||
conversation?.endpoint,
|
conversation?.endpoint,
|
||||||
conversation?.iconURL,
|
|
||||||
conversation?.model,
|
conversation?.model,
|
||||||
msg?.model,
|
msg?.model,
|
||||||
msg?.iconURL,
|
msg?.iconURL,
|
||||||
|
@ -86,31 +87,29 @@ const ContentRender = memo(
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clickHandler = useMemo(
|
||||||
|
() =>
|
||||||
|
showCardRender && !isLatestMessage
|
||||||
|
? () => {
|
||||||
|
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
|
||||||
|
logger.dir(msg);
|
||||||
|
setLatestMessage(msg!);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
[showCardRender, isLatestMessage, msg, setLatestMessage],
|
||||||
|
);
|
||||||
|
|
||||||
if (!msg) {
|
if (!msg) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLatestMessage = msg.messageId === latestMessage?.messageId;
|
const baseClasses = {
|
||||||
const showCardRender = isLast && !(isSubmittingFamily === true) && isCard === true;
|
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
|
||||||
const isLatestCard = isCard === true && !(isSubmittingFamily === true) && isLatestMessage;
|
card: 'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4',
|
||||||
const clickHandler =
|
chat: maximizeChatSpace
|
||||||
showCardRender && !isLatestMessage
|
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
|
||||||
? () => {
|
: 'md:max-w-[47rem] xl:max-w-[55rem]',
|
||||||
logger.log(`Message Card click: Setting ${msg.messageId} as latest message`);
|
};
|
||||||
logger.dir(msg);
|
|
||||||
setLatestMessage(msg);
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const baseClasses =
|
|
||||||
'final-completion group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu';
|
|
||||||
|
|
||||||
const cardClasses =
|
|
||||||
'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4';
|
|
||||||
|
|
||||||
const chatSpaceClasses = maximizeChatSpace
|
|
||||||
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
|
|
||||||
: 'md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5';
|
|
||||||
|
|
||||||
const conditionalClasses = {
|
const conditionalClasses = {
|
||||||
latestCard: isLatestCard ? 'bg-surface-secondary' : '',
|
latestCard: isLatestCard ? 'bg-surface-secondary' : '',
|
||||||
|
@ -123,8 +122,8 @@ const ContentRender = memo(
|
||||||
id={msg.messageId}
|
id={msg.messageId}
|
||||||
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
baseClasses,
|
baseClasses.common,
|
||||||
isCard === true ? cardClasses : chatSpaceClasses,
|
isCard ? baseClasses.card : baseClasses.chat,
|
||||||
conditionalClasses.latestCard,
|
conditionalClasses.latestCard,
|
||||||
conditionalClasses.cardRender,
|
conditionalClasses.cardRender,
|
||||||
conditionalClasses.focus,
|
conditionalClasses.focus,
|
||||||
|
@ -139,26 +138,25 @@ const ContentRender = memo(
|
||||||
role={showCardRender ? 'button' : undefined}
|
role={showCardRender ? 'button' : undefined}
|
||||||
tabIndex={showCardRender ? 0 : undefined}
|
tabIndex={showCardRender ? 0 : undefined}
|
||||||
>
|
>
|
||||||
{isLatestCard === true && (
|
{isLatestCard && (
|
||||||
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary" />
|
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary" />
|
||||||
)}
|
)}
|
||||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
|
||||||
<div>
|
<div className="relative flex flex-shrink-0 flex-col items-center">
|
||||||
<div className="pt-0.5">
|
<div className="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">
|
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
||||||
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex w-11/12 flex-col',
|
'relative flex w-11/12 flex-col',
|
||||||
msg.isCreatedByUser === true ? '' : 'agent-turn',
|
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||||
<div className="flex-col gap-1 md:gap-3">
|
|
||||||
|
<div className="flex flex-col gap-1 md:gap-3">
|
||||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||||
<ContentParts
|
<ContentParts
|
||||||
edit={edit}
|
edit={edit}
|
||||||
|
@ -174,31 +172,32 @@ const ContentRender = memo(
|
||||||
content={msg.content as Array<TMessageContentParts | undefined>}
|
content={msg.content as Array<TMessageContentParts | undefined>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(isSubmittingFamily || isSubmitting) && !(msg.children?.length ?? 0) ? (
|
||||||
|
<PlaceholderRow isCard={isCard} />
|
||||||
|
) : (
|
||||||
|
<SubRow classes="text-xs">
|
||||||
|
<SiblingSwitch
|
||||||
|
siblingIdx={siblingIdx}
|
||||||
|
siblingCount={siblingCount}
|
||||||
|
setSiblingIdx={setSiblingIdx}
|
||||||
|
/>
|
||||||
|
<HoverButtons
|
||||||
|
index={index}
|
||||||
|
isEditing={edit}
|
||||||
|
message={msg}
|
||||||
|
enterEdit={enterEdit}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
conversation={conversation ?? null}
|
||||||
|
regenerate={handleRegenerateMessage}
|
||||||
|
copyToClipboard={copyToClipboard}
|
||||||
|
handleContinue={handleContinue}
|
||||||
|
latestMessage={latestMessage}
|
||||||
|
isLast={isLast}
|
||||||
|
/>
|
||||||
|
</SubRow>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!(msg.children?.length ?? 0) && (isSubmittingFamily === true || isSubmitting) ? (
|
|
||||||
<PlaceholderRow isCard={isCard} />
|
|
||||||
) : (
|
|
||||||
<SubRow classes="text-xs">
|
|
||||||
<SiblingSwitch
|
|
||||||
siblingIdx={siblingIdx}
|
|
||||||
siblingCount={siblingCount}
|
|
||||||
setSiblingIdx={setSiblingIdx}
|
|
||||||
/>
|
|
||||||
<HoverButtons
|
|
||||||
index={index}
|
|
||||||
isEditing={edit}
|
|
||||||
message={msg}
|
|
||||||
enterEdit={enterEdit}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
conversation={conversation ?? null}
|
|
||||||
regenerate={handleRegenerateMessage}
|
|
||||||
copyToClipboard={copyToClipboard}
|
|
||||||
handleContinue={handleContinue}
|
|
||||||
latestMessage={latestMessage}
|
|
||||||
isLast={isLast}
|
|
||||||
/>
|
|
||||||
</SubRow>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,16 +8,10 @@ export default function ScrollToBottom({ scrollHandler }: Props) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={scrollHandler}
|
onClick={scrollHandler}
|
||||||
className="absolute bottom-5 right-1/2 cursor-pointer rounded-full border border-gray-200 bg-white bg-clip-padding text-gray-600 dark:border-white/10 dark:bg-gray-850/90 dark:text-gray-200"
|
className="premium-scroll-button absolute bottom-5 right-1/2 cursor-pointer border border-border-light bg-surface-secondary"
|
||||||
aria-label="Scroll to bottom"
|
aria-label="Scroll to bottom"
|
||||||
>
|
>
|
||||||
<svg
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-text-secondary">
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
className="m-1 text-black dark:text-white"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
d="M17 13L12 18L7 13M12 6L12 17"
|
d="M17 13L12 18L7 13M12 6L12 17"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
|
|
@ -5,10 +5,10 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { QueryKeys, Constants } from 'librechat-data-provider';
|
import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||||
import type { TConversation, TMessage } from 'librechat-data-provider';
|
import type { TConversation, TMessage } from 'librechat-data-provider';
|
||||||
import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
|
import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
|
||||||
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
|
|
||||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
import { useGetEndpointsQuery } from '~/data-provider';
|
||||||
import { useLocalize, useNewConvo } from '~/hooks';
|
import { useLocalize, useNewConvo } from '~/hooks';
|
||||||
|
import { icons } from '~/hooks/Endpoint/Icons';
|
||||||
import { NewChatIcon } from '~/components/svg';
|
import { NewChatIcon } from '~/components/svg';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import CodeArtifacts from './CodeArtifacts';
|
import CodeArtifacts from './CodeArtifacts';
|
||||||
|
import ChatBadges from './ChatBadges';
|
||||||
|
|
||||||
function Beta() {
|
function Beta() {
|
||||||
return (
|
return (
|
||||||
|
@ -7,6 +8,9 @@ function Beta() {
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<CodeArtifacts />
|
<CodeArtifacts />
|
||||||
</div>
|
</div>
|
||||||
|
{/* <div className="pb-3">
|
||||||
|
<ChatBadges />
|
||||||
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
22
client/src/components/Nav/SettingsTabs/Beta/ChatBadges.tsx
Normal file
22
client/src/components/Nav/SettingsTabs/Beta/ChatBadges.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
import { Button } from '~/components/ui';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
|
export default function ChatBadges() {
|
||||||
|
const setIsEditing = useSetRecoilState<boolean>(store.isEditingBadges);
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
const handleEditChatBadges = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>{localize('com_nav_edit_chat_badges')}</div>
|
||||||
|
<Button variant="outline" onClick={handleEditChatBadges}>
|
||||||
|
{localize('com_ui_edit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useRecoilState } from 'recoil';
|
||||||
|
import HoverCardSettings from '../HoverCardSettings';
|
||||||
|
import { Switch } from '~/components/ui/Switch';
|
||||||
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
|
export default function CenterChatInput({
|
||||||
|
onCheckedChange,
|
||||||
|
}: {
|
||||||
|
onCheckedChange?: (value: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [centerFormOnLanding, setcenterFormOnLanding] = useRecoilState(store.centerFormOnLanding);
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
const handleCheckedChange = (value: boolean) => {
|
||||||
|
setcenterFormOnLanding(value);
|
||||||
|
if (onCheckedChange) {
|
||||||
|
onCheckedChange(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>{localize('com_nav_center_chat_input')}</div>
|
||||||
|
<Switch
|
||||||
|
id="centerFormOnLanding"
|
||||||
|
checked={centerFormOnLanding}
|
||||||
|
onCheckedChange={handleCheckedChange}
|
||||||
|
className="ml-4"
|
||||||
|
data-testid="centerFormOnLanding"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { memo } from 'react';
|
||||||
import MaximizeChatSpace from './MaximizeChatSpace';
|
import MaximizeChatSpace from './MaximizeChatSpace';
|
||||||
import FontSizeSelector from './FontSizeSelector';
|
import FontSizeSelector from './FontSizeSelector';
|
||||||
import SendMessageKeyEnter from './EnterToSend';
|
import SendMessageKeyEnter from './EnterToSend';
|
||||||
|
import CenterChatInput from './CenterChatInput';
|
||||||
import ShowCodeSwitch from './ShowCodeSwitch';
|
import ShowCodeSwitch from './ShowCodeSwitch';
|
||||||
import { ForkSettings } from './ForkSettings';
|
import { ForkSettings } from './ForkSettings';
|
||||||
import ChatDirection from './ChatDirection';
|
import ChatDirection from './ChatDirection';
|
||||||
|
@ -20,6 +21,9 @@ function Chat() {
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<ChatDirection />
|
<ChatDirection />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pb-3">
|
||||||
|
<CenterChatInput />
|
||||||
|
</div>
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<SendMessageKeyEnter />
|
<SendMessageKeyEnter />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,88 +0,0 @@
|
||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { EModelEndpoint, isAgentsEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
|
||||||
import type { Agent } from 'librechat-data-provider';
|
|
||||||
import type { SwitcherProps, OptionWithIcon } from '~/common';
|
|
||||||
import { useSetIndexOptions, useSelectAgent, useLocalize } from '~/hooks';
|
|
||||||
import { useChatContext, useAgentsMapContext } from '~/Providers';
|
|
||||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
|
||||||
import Icon from '~/components/Endpoints/Icon';
|
|
||||||
|
|
||||||
export default function AgentSwitcher({ isCollapsed }: SwitcherProps) {
|
|
||||||
const localize = useLocalize();
|
|
||||||
const { setOption } = useSetIndexOptions();
|
|
||||||
const { index, conversation } = useChatContext();
|
|
||||||
const { agent_id: selectedAgentId = null, endpoint } = conversation ?? {};
|
|
||||||
|
|
||||||
const agentsMapResult = useAgentsMapContext();
|
|
||||||
|
|
||||||
const agentsMap = useMemo(() => {
|
|
||||||
return agentsMapResult ?? {};
|
|
||||||
}, [agentsMapResult]);
|
|
||||||
|
|
||||||
const { onSelect } = useSelectAgent();
|
|
||||||
|
|
||||||
const agents: Agent[] = useMemo(() => {
|
|
||||||
return Object.values(agentsMap) as Agent[];
|
|
||||||
}, [agentsMap]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedAgentId == null && agents.length > 0) {
|
|
||||||
let agent_id = localStorage.getItem(`${LocalStorageKeys.AGENT_ID_PREFIX}${index}`);
|
|
||||||
if (agent_id == null) {
|
|
||||||
agent_id = agents[0].id;
|
|
||||||
}
|
|
||||||
const agent = agentsMap[agent_id];
|
|
||||||
|
|
||||||
if (agent !== undefined && isAgentsEndpoint(endpoint as string) === true) {
|
|
||||||
setOption('model')('');
|
|
||||||
setOption('agent_id')(agent_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [index, agents, selectedAgentId, agentsMap, endpoint, setOption]);
|
|
||||||
|
|
||||||
const currentAgent = agentsMap[selectedAgentId ?? ''];
|
|
||||||
|
|
||||||
const agentOptions: OptionWithIcon[] = useMemo(
|
|
||||||
() =>
|
|
||||||
agents.map((agent: Agent) => {
|
|
||||||
return {
|
|
||||||
label: agent.name ?? '',
|
|
||||||
value: agent.id,
|
|
||||||
icon: (
|
|
||||||
<Icon
|
|
||||||
isCreatedByUser={false}
|
|
||||||
endpoint={EModelEndpoint.agents}
|
|
||||||
agentName={agent.name ?? ''}
|
|
||||||
iconURL={agent.avatar?.filepath}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
[agents],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ControlCombobox
|
|
||||||
selectedValue={currentAgent?.id ?? ''}
|
|
||||||
displayValue={
|
|
||||||
agents.find((agent: Agent) => agent.id === selectedAgentId)?.name ??
|
|
||||||
localize('com_sidepanel_select_agent')
|
|
||||||
}
|
|
||||||
selectPlaceholder={localize('com_sidepanel_select_agent')}
|
|
||||||
searchPlaceholder={localize('com_agents_search_name')}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
ariaLabel={'agent'}
|
|
||||||
setValue={onSelect}
|
|
||||||
items={agentOptions}
|
|
||||||
iconClassName="assistant-item"
|
|
||||||
SelectIcon={
|
|
||||||
<Icon
|
|
||||||
isCreatedByUser={false}
|
|
||||||
endpoint={endpoint}
|
|
||||||
agentName={currentAgent?.name ?? ''}
|
|
||||||
iconURL={currentAgent?.avatar?.filepath ?? ''}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -6,9 +6,9 @@ import type { TPlugin } from 'librechat-data-provider';
|
||||||
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
|
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
|
||||||
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
|
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
|
||||||
import { useToastContext, useFileMapContext } from '~/Providers';
|
import { useToastContext, useFileMapContext } from '~/Providers';
|
||||||
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
|
|
||||||
import Action from '~/components/SidePanel/Builder/Action';
|
import Action from '~/components/SidePanel/Builder/Action';
|
||||||
import { ToolSelectDialog } from '~/components/Tools';
|
import { ToolSelectDialog } from '~/components/Tools';
|
||||||
|
import { icons } from '~/hooks/Endpoint/Icons';
|
||||||
import { processAgentOption } from '~/utils';
|
import { processAgentOption } from '~/utils';
|
||||||
import AgentAvatar from './AgentAvatar';
|
import AgentAvatar from './AgentAvatar';
|
||||||
import FileContext from './FileContext';
|
import FileContext from './FileContext';
|
||||||
|
|
|
@ -1,92 +0,0 @@
|
||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { isAssistantsEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
|
||||||
import type { AssistantsEndpoint } from 'librechat-data-provider';
|
|
||||||
import type { SwitcherProps, AssistantListItem } from '~/common';
|
|
||||||
import { useSetIndexOptions, useSelectAssistant, useLocalize, useAssistantListMap } from '~/hooks';
|
|
||||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
|
||||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
|
||||||
import Icon from '~/components/Endpoints/Icon';
|
|
||||||
|
|
||||||
export default function AssistantSwitcher({ 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 assistantListMap = useAssistantListMap((res) =>
|
|
||||||
res.data.map(({ id, name, metadata }) => ({ id, name, metadata })),
|
|
||||||
);
|
|
||||||
const assistants: Omit<AssistantListItem, 'model'>[] = useMemo(
|
|
||||||
() => assistantListMap[endpoint ?? ''] ?? [],
|
|
||||||
[endpoint, assistantListMap],
|
|
||||||
);
|
|
||||||
const assistantMap = useAssistantsMapContext();
|
|
||||||
const { onSelect } = useSelectAssistant(endpoint as AssistantsEndpoint);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedAssistant && assistants && assistants.length && assistantMap) {
|
|
||||||
const assistant_id =
|
|
||||||
localStorage.getItem(`${LocalStorageKeys.ASST_ID_PREFIX}${index}${endpoint}`) ??
|
|
||||||
assistants[0]?.id ??
|
|
||||||
'';
|
|
||||||
const assistant = assistantMap[endpoint ?? ''][assistant_id];
|
|
||||||
|
|
||||||
if (!assistant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAssistantsEndpoint(endpoint)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOption('model')(assistant.model);
|
|
||||||
setOption('assistant_id')(assistant_id);
|
|
||||||
}
|
|
||||||
}, [index, assistants, selectedAssistant, assistantMap, endpoint, setOption]);
|
|
||||||
|
|
||||||
const currentAssistant = assistantMap?.[endpoint ?? '']?.[selectedAssistant ?? ''];
|
|
||||||
|
|
||||||
const assistantOptions = useMemo(() => {
|
|
||||||
return assistants.map((assistant) => {
|
|
||||||
return {
|
|
||||||
label: (assistant.name as string | null) ?? '',
|
|
||||||
value: assistant.id,
|
|
||||||
icon: (
|
|
||||||
<Icon
|
|
||||||
isCreatedByUser={false}
|
|
||||||
endpoint={endpoint}
|
|
||||||
assistantName={(assistant.name as string | null) ?? ''}
|
|
||||||
iconURL={assistant.metadata?.avatar ?? ''}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [assistants, endpoint]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ControlCombobox
|
|
||||||
selectedValue={currentAssistant?.id ?? ''}
|
|
||||||
displayValue={
|
|
||||||
assistants.find((assistant) => assistant.id === selectedAssistant)?.name ??
|
|
||||||
localize('com_sidepanel_select_assistant')
|
|
||||||
}
|
|
||||||
selectPlaceholder={localize('com_sidepanel_select_assistant')}
|
|
||||||
searchPlaceholder={localize('com_assistants_search_name')}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
ariaLabel={'assistant'}
|
|
||||||
setValue={onSelect}
|
|
||||||
items={assistantOptions}
|
|
||||||
iconClassName="assistant-item"
|
|
||||||
SelectIcon={
|
|
||||||
<Icon
|
|
||||||
isCreatedByUser={false}
|
|
||||||
endpoint={endpoint}
|
|
||||||
assistantName={currentAssistant?.name ?? ''}
|
|
||||||
iconURL={currentAssistant?.metadata?.avatar ?? ''}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,58 +0,0 @@
|
||||||
import { useMemo, useRef, useCallback } from 'react';
|
|
||||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
|
||||||
import type { SwitcherProps } from '~/common';
|
|
||||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
|
||||||
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
|
|
||||||
import { useSetIndexOptions, useLocalize } from '~/hooks';
|
|
||||||
import { useChatContext } from '~/Providers';
|
|
||||||
import { mainTextareaId } from '~/common';
|
|
||||||
|
|
||||||
export default function ModelSwitcher({ isCollapsed }: SwitcherProps) {
|
|
||||||
const localize = useLocalize();
|
|
||||||
const modelsQuery = useGetModelsQuery();
|
|
||||||
const { conversation } = useChatContext();
|
|
||||||
const { setOption } = useSetIndexOptions();
|
|
||||||
const timeoutIdRef = useRef<NodeJS.Timeout>();
|
|
||||||
|
|
||||||
const { endpoint, model = null } = conversation ?? {};
|
|
||||||
const models = useMemo(() => {
|
|
||||||
return (modelsQuery.data?.[endpoint ?? ''] ?? []).map((model) => ({
|
|
||||||
label: model,
|
|
||||||
value: model,
|
|
||||||
}));
|
|
||||||
}, [modelsQuery, endpoint]);
|
|
||||||
|
|
||||||
const setModel = useCallback(
|
|
||||||
(model: string) => {
|
|
||||||
setOption('model')(model);
|
|
||||||
clearTimeout(timeoutIdRef.current);
|
|
||||||
timeoutIdRef.current = setTimeout(() => {
|
|
||||||
const textarea = document.getElementById(mainTextareaId);
|
|
||||||
if (textarea) {
|
|
||||||
textarea.focus();
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
},
|
|
||||||
[setOption],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ControlCombobox
|
|
||||||
displayValue={model ?? ''}
|
|
||||||
selectPlaceholder={localize('com_ui_select_model')}
|
|
||||||
searchPlaceholder={localize('com_ui_select_search_model')}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
ariaLabel={'model'}
|
|
||||||
selectedValue={model ?? ''}
|
|
||||||
setValue={setModel}
|
|
||||||
items={models}
|
|
||||||
SelectIcon={
|
|
||||||
<MinimalIcon
|
|
||||||
isCreatedByUser={false}
|
|
||||||
endpoint={endpoint}
|
|
||||||
// iconURL={} // for future preset icons
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -479,7 +479,7 @@ const googleCol2: SettingsConfiguration = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const openAI: SettingsConfiguration = [
|
const openAI: SettingsConfiguration = [
|
||||||
openAIParams.chatGptLabel,
|
librechat.modelLabel,
|
||||||
librechat.promptPrefix,
|
librechat.promptPrefix,
|
||||||
librechat.maxContextTokens,
|
librechat.maxContextTokens,
|
||||||
openAIParams.max_tokens,
|
openAIParams.max_tokens,
|
||||||
|
@ -495,7 +495,7 @@ const openAI: SettingsConfiguration = [
|
||||||
|
|
||||||
const openAICol1: SettingsConfiguration = [
|
const openAICol1: SettingsConfiguration = [
|
||||||
baseDefinitions.model as SettingDefinition,
|
baseDefinitions.model as SettingDefinition,
|
||||||
openAIParams.chatGptLabel,
|
librechat.modelLabel,
|
||||||
librechat.promptPrefix,
|
librechat.promptPrefix,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { useGetEndpointsQuery } from '~/data-provider';
|
||||||
import NavToggle from '~/components/Nav/NavToggle';
|
import NavToggle from '~/components/Nav/NavToggle';
|
||||||
import { cn, getEndpointField } from '~/utils';
|
import { cn, getEndpointField } from '~/utils';
|
||||||
import { useChatContext } from '~/Providers';
|
import { useChatContext } from '~/Providers';
|
||||||
import Switcher from './Switcher';
|
|
||||||
import Nav from './Nav';
|
import Nav from './Nav';
|
||||||
|
|
||||||
const defaultMinSize = 20;
|
const defaultMinSize = 20;
|
||||||
|
@ -163,27 +163,13 @@ const SidePanel = ({
|
||||||
localStorage.setItem('react-resizable-panels:collapsed', 'true');
|
localStorage.setItem('react-resizable-panels:collapsed', 'true');
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'sidenav hide-scrollbar border-l border-border-light bg-background transition-opacity',
|
'sidenav hide-scrollbar border-l border-border-light bg-background py-1 transition-opacity',
|
||||||
isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]',
|
isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]',
|
||||||
(isSmallScreen && isCollapsed && (minSize === 0 || collapsedSize === 0)) || fullCollapse
|
(isSmallScreen && isCollapsed && (minSize === 0 || collapsedSize === 0)) || fullCollapse
|
||||||
? 'hidden min-w-0'
|
? 'hidden min-w-0'
|
||||||
: 'opacity-100',
|
: 'opacity-100',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{interfaceConfig.modelSelect === true && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-background',
|
|
||||||
isCollapsed ? 'h-[52px]' : 'px-2',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Switcher
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
endpointKeyProvided={keyProvided}
|
|
||||||
endpoint={endpoint}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Nav
|
<Nav
|
||||||
resize={panelRef.current?.resize}
|
resize={panelRef.current?.resize}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
|
||||||
import type { SwitcherProps } from '~/common';
|
|
||||||
import AssistantSwitcher from './AssistantSwitcher';
|
|
||||||
import AgentSwitcher from './AgentSwitcher';
|
|
||||||
import ModelSwitcher from './ModelSwitcher';
|
|
||||||
|
|
||||||
export default function Switcher(props: SwitcherProps) {
|
|
||||||
if (isAssistantsEndpoint(props.endpoint) && props.endpointKeyProvided) {
|
|
||||||
return <AssistantSwitcher {...props} />;
|
|
||||||
} else if (isAgentsEndpoint(props.endpoint) && props.endpointKeyProvided) {
|
|
||||||
return <AgentSwitcher {...props} />;
|
|
||||||
} else if (isAssistantsEndpoint(props.endpoint)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <ModelSwitcher {...props} />;
|
|
||||||
}
|
|
|
@ -8,7 +8,7 @@ export default function BirthdayIcon({ className = '' }) {
|
||||||
viewBox="0 0 233.33 290"
|
viewBox="0 0 233.33 290"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
className={cn('h-11 w-11', className)}
|
className={cn('h-9 w-9', className)}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient
|
<linearGradient
|
||||||
|
|
83
client/src/components/ui/Badge.tsx
Normal file
83
client/src/components/ui/Badge.tsx
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { X, Plus } from 'lucide-react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import type { ButtonHTMLAttributes } from 'react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface BadgeProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
isEditing?: boolean;
|
||||||
|
isDragging?: boolean;
|
||||||
|
isAvailable: boolean;
|
||||||
|
onBadgeAction?: () => void;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Badge({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
isActive = false,
|
||||||
|
isEditing = false,
|
||||||
|
isDragging = false,
|
||||||
|
isAvailable = true,
|
||||||
|
onBadgeAction,
|
||||||
|
onToggle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: BadgeProps) {
|
||||||
|
const isMoveable = isEditing && isAvailable;
|
||||||
|
|
||||||
|
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||||
|
if (!isEditing && onToggle) {
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth >= 768) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5',
|
||||||
|
'border border-border-medium text-sm font-medium transition-shadow',
|
||||||
|
isActive
|
||||||
|
? 'bg-surface-active shadow-md'
|
||||||
|
: 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md',
|
||||||
|
isMoveable && 'cursor-move',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
animate={{
|
||||||
|
scale: isDragging ? 1.1 : 1,
|
||||||
|
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
|
||||||
|
}}
|
||||||
|
whileTap={{ scale: isDragging ? 1.1 : 0.97 }}
|
||||||
|
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="relative h-4 w-4" />}
|
||||||
|
<span className="relative hidden md:inline">{label}</span>
|
||||||
|
|
||||||
|
{isEditing && !isDragging && (
|
||||||
|
<motion.button
|
||||||
|
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-surface-secondary-alt text-text-primary"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onBadgeAction?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAvailable ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
|
@ -107,7 +107,7 @@ function ControlCombobox({
|
||||||
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
|
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
|
||||||
'text-text-primary hover:bg-surface-tertiary',
|
'text-text-primary hover:bg-surface-tertiary',
|
||||||
'border border-border-light',
|
'border border-border-light',
|
||||||
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm',
|
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-xl px-3 py-2 text-sm',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -132,17 +132,19 @@ function ControlCombobox({
|
||||||
store={select}
|
store={select}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
portal
|
portal
|
||||||
className="z-50 overflow-hidden rounded-md border border-border-light bg-surface-secondary shadow-lg"
|
className={cn(
|
||||||
|
'animate-popover z-50 overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg',
|
||||||
|
)}
|
||||||
style={{ width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }}
|
style={{ width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }}
|
||||||
>
|
>
|
||||||
<div className="p-2">
|
<div className="py-1.5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
|
||||||
<Ariakit.Combobox
|
<Ariakit.Combobox
|
||||||
store={combobox}
|
store={combobox}
|
||||||
autoSelect
|
autoSelect
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
className="w-full rounded-md border border-border-light bg-surface-tertiary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none"
|
className="w-full rounded-md bg-surface-secondary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
|
import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
|
||||||
|
@ -19,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 [&>span]:line-clamp-1',
|
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-gray-200 border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 [&>span]:line-clamp-1',
|
||||||
'rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700',
|
'rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
@ -120,7 +118,7 @@ const SelectItem = React.forwardRef<
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
'rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700',
|
'rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
@ -142,7 +140,7 @@ const SelectSeparator = React.forwardRef<
|
||||||
>(({ className = '', ...props }, ref) => (
|
>(({ className = '', ...props }, ref) => (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('bg-muted -mx-1 my-1 h-px', className)}
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
102
client/src/components/ui/SplitText.tsx
Normal file
102
client/src/components/ui/SplitText.tsx
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import { useSprings, animated, SpringConfig } from '@react-spring/web';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface SplitTextProps {
|
||||||
|
text?: string;
|
||||||
|
className?: string;
|
||||||
|
delay?: number;
|
||||||
|
animationFrom?: { opacity: number; transform: string };
|
||||||
|
animationTo?: { opacity: number; transform: string };
|
||||||
|
easing?: SpringConfig['easing'];
|
||||||
|
threshold?: number;
|
||||||
|
rootMargin?: string;
|
||||||
|
textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end';
|
||||||
|
onLetterAnimationComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SplitText: React.FC<SplitTextProps> = ({
|
||||||
|
text = '',
|
||||||
|
className = '',
|
||||||
|
delay = 100,
|
||||||
|
animationFrom = { opacity: 0, transform: 'translate3d(0,40px,0)' },
|
||||||
|
animationTo = { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||||
|
easing = (t: number) => t,
|
||||||
|
threshold = 0.1,
|
||||||
|
rootMargin = '-100px',
|
||||||
|
textAlign = 'center',
|
||||||
|
onLetterAnimationComplete,
|
||||||
|
}) => {
|
||||||
|
const words = text.split(' ').map((word) => word.split(''));
|
||||||
|
const letters = words.flat();
|
||||||
|
const [inView, setInView] = useState(false);
|
||||||
|
const ref = useRef<HTMLParagraphElement>(null);
|
||||||
|
const animatedCount = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setInView(true);
|
||||||
|
if (ref.current) {
|
||||||
|
observer.unobserve(ref.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold, rootMargin },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ref.current) {
|
||||||
|
observer.observe(ref.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [threshold, rootMargin]);
|
||||||
|
|
||||||
|
const springs = useSprings(
|
||||||
|
letters.length,
|
||||||
|
letters.map((_, i) => ({
|
||||||
|
from: animationFrom,
|
||||||
|
to: inView
|
||||||
|
? async (next: (props: any) => Promise<void>) => {
|
||||||
|
await next(animationTo);
|
||||||
|
animatedCount.current += 1;
|
||||||
|
if (animatedCount.current === letters.length && onLetterAnimationComplete) {
|
||||||
|
onLetterAnimationComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: animationFrom,
|
||||||
|
delay: i * delay,
|
||||||
|
config: { easing },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={`split-parent inline overflow-hidden ${className}`}
|
||||||
|
style={{ textAlign, whiteSpace: 'normal', wordWrap: 'break-word' }}
|
||||||
|
>
|
||||||
|
{words.map((word, wordIndex) => (
|
||||||
|
<span key={wordIndex} style={{ display: 'inline-block', whiteSpace: 'nowrap' }}>
|
||||||
|
{word.map((letter, letterIndex) => {
|
||||||
|
const index =
|
||||||
|
words.slice(0, wordIndex).reduce((acc, w) => acc + w.length, 0) + letterIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<animated.span
|
||||||
|
key={index}
|
||||||
|
style={springs[index] as unknown as React.CSSProperties}
|
||||||
|
className="inline-block transform transition-opacity will-change-transform"
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</animated.span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<span style={{ display: 'inline-block', width: '0.3em' }}> </span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SplitText;
|
|
@ -26,8 +26,10 @@ export * from './Tooltip';
|
||||||
export * from './Pagination';
|
export * from './Pagination';
|
||||||
export * from './Progress';
|
export * from './Progress';
|
||||||
export * from './InputOTP';
|
export * from './InputOTP';
|
||||||
|
export { default as Badge } from './Badge';
|
||||||
export { default as Combobox } from './Combobox';
|
export { default as Combobox } from './Combobox';
|
||||||
export { default as Dropdown } from './Dropdown';
|
export { default as Dropdown } from './Dropdown';
|
||||||
|
export { default as SplitText } from './SplitText';
|
||||||
export { default as FileUpload } from './FileUpload';
|
export { default as FileUpload } from './FileUpload';
|
||||||
export { default as FormInput } from './FormInput';
|
export { default as FormInput } from './FormInput';
|
||||||
export { default as DropdownPopup } from './DropdownPopup';
|
export { default as DropdownPopup } from './DropdownPopup';
|
||||||
|
|
|
@ -42,6 +42,7 @@ export const useListAgentsQuery = <TData = t.AgentListResponse>(
|
||||||
// select: (res) => {
|
// select: (res) => {
|
||||||
// return res.data.sort((a, b) => a.created_at - b.created_at);
|
// return res.data.sort((a, b) => a.created_at - b.created_at);
|
||||||
// },
|
// },
|
||||||
|
staleTime: 1000 * 5,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
|
|
@ -31,6 +31,7 @@ export const useGetStartupConfig = (
|
||||||
[QueryKeys.startupConfig],
|
[QueryKeys.startupConfig],
|
||||||
() => dataService.getStartupConfig(),
|
() => dataService.getStartupConfig(),
|
||||||
{
|
{
|
||||||
|
staleTime: Infinity,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
|
|
@ -35,6 +35,7 @@ export const useGetPresetsQuery = (
|
||||||
config?: UseQueryOptions<TPreset[]>,
|
config?: UseQueryOptions<TPreset[]>,
|
||||||
): QueryObserverResult<TPreset[], unknown> => {
|
): QueryObserverResult<TPreset[], unknown> => {
|
||||||
return useQuery<TPreset[]>([QueryKeys.presets], () => dataService.getPresets(), {
|
return useQuery<TPreset[]>([QueryKeys.presets], () => dataService.getPresets(), {
|
||||||
|
staleTime: 1000 * 10,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
@ -236,6 +237,7 @@ export const useListAssistantsQuery = <TData = AssistantListResponse>(
|
||||||
// select: (res) => {
|
// select: (res) => {
|
||||||
// return res.data.sort((a, b) => a.created_at - b.created_at);
|
// return res.data.sort((a, b) => a.created_at - b.created_at);
|
||||||
// },
|
// },
|
||||||
|
staleTime: 1000 * 5,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { excludedKeys } from 'librechat-data-provider';
|
||||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||||
import type {
|
import type {
|
||||||
TEndpointsConfig,
|
TEndpointsConfig,
|
||||||
|
@ -8,26 +9,66 @@ import type {
|
||||||
import { getDefaultEndpoint, buildDefaultConvo } from '~/utils';
|
import { getDefaultEndpoint, buildDefaultConvo } from '~/utils';
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
import { useGetEndpointsQuery } from '~/data-provider';
|
||||||
|
|
||||||
type TDefaultConvo = { conversation: Partial<TConversation>; preset?: Partial<TPreset> | null };
|
type TDefaultConvo = {
|
||||||
|
conversation: Partial<TConversation>;
|
||||||
|
preset?: Partial<TPreset> | null;
|
||||||
|
cleanInput?: boolean;
|
||||||
|
cleanOutput?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exceptions = new Set(['spec', 'iconURL']);
|
||||||
|
|
||||||
const useDefaultConvo = () => {
|
const useDefaultConvo = () => {
|
||||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||||
const { data: modelsConfig = {} as TModelsConfig } = useGetModelsQuery();
|
const { data: modelsConfig = {} as TModelsConfig } = useGetModelsQuery();
|
||||||
|
|
||||||
const getDefaultConversation = ({ conversation, preset }: TDefaultConvo) => {
|
const getDefaultConversation = ({
|
||||||
|
conversation: _convo,
|
||||||
|
preset,
|
||||||
|
cleanInput,
|
||||||
|
cleanOutput,
|
||||||
|
}: TDefaultConvo) => {
|
||||||
const endpoint = getDefaultEndpoint({
|
const endpoint = getDefaultEndpoint({
|
||||||
convoSetup: preset as TPreset,
|
convoSetup: preset as TPreset,
|
||||||
endpointsConfig,
|
endpointsConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
const models = modelsConfig[endpoint] || [];
|
const models = modelsConfig[endpoint ?? ''] || [];
|
||||||
|
const conversation = { ..._convo };
|
||||||
|
if (cleanInput === true) {
|
||||||
|
for (const key in conversation) {
|
||||||
|
if (excludedKeys.has(key) && !exceptions.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (conversation[key] == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
conversation[key] = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return buildDefaultConvo({
|
const defaultConvo = buildDefaultConvo({
|
||||||
conversation: conversation as TConversation,
|
conversation: conversation as TConversation,
|
||||||
endpoint,
|
endpoint,
|
||||||
lastConversationSetup: preset as TConversation,
|
lastConversationSetup: preset as TConversation,
|
||||||
models,
|
models,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!cleanOutput) {
|
||||||
|
return defaultConvo;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in defaultConvo) {
|
||||||
|
if (excludedKeys.has(key) && !exceptions.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (defaultConvo[key] == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
defaultConvo[key] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultConvo;
|
||||||
};
|
};
|
||||||
|
|
||||||
return getDefaultConversation;
|
return getDefaultConversation;
|
||||||
|
|
|
@ -62,7 +62,6 @@ export default function usePresets() {
|
||||||
}
|
}
|
||||||
hasLoaded.current = true;
|
hasLoaded.current = true;
|
||||||
// dependencies are stable and only needed once
|
// dependencies are stable and only needed once
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [presetsQuery.data, user, modelsData]);
|
}, [presetsQuery.data, user, modelsData]);
|
||||||
|
|
||||||
const setPresets = useCallback(
|
const setPresets = useCallback(
|
||||||
|
@ -182,12 +181,22 @@ export default function usePresets() {
|
||||||
endpointsConfig,
|
endpointsConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
newPreset.spec = null;
|
||||||
|
newPreset.iconURL = newPreset.iconURL ?? null;
|
||||||
|
newPreset.modelLabel = newPreset.modelLabel ?? null;
|
||||||
const isModular = isCurrentModular && isNewModular && shouldSwitch;
|
const isModular = isCurrentModular && isNewModular && shouldSwitch;
|
||||||
if (isExistingConversation && isModular) {
|
if (isExistingConversation && isModular) {
|
||||||
const currentConvo = getDefaultConversation({
|
const currentConvo = getDefaultConversation({
|
||||||
/* target endpointType is necessary to avoid endpoint mixing */
|
/* target endpointType is necessary to avoid endpoint mixing */
|
||||||
conversation: { ...(conversation ?? {}), endpointType: newEndpointType },
|
conversation: {
|
||||||
|
...(conversation ?? {}),
|
||||||
|
spec: null,
|
||||||
|
iconURL: null,
|
||||||
|
modelLabel: null,
|
||||||
|
endpointType: newEndpointType,
|
||||||
|
},
|
||||||
preset: { ...newPreset, endpointType: newEndpointType },
|
preset: { ...newPreset, endpointType: newEndpointType },
|
||||||
|
cleanInput: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/* We don't reset the latest message, only when changing settings mid-converstion */
|
/* We don't reset the latest message, only when changing settings mid-converstion */
|
||||||
|
|
|
@ -42,7 +42,7 @@ const useSetIndexOptions: TUseSetOptions = (preset = false) => {
|
||||||
const setExample: TSetExample = (i, type, newValue = null) => {
|
const setExample: TSetExample = (i, type, newValue = null) => {
|
||||||
const update = {};
|
const update = {};
|
||||||
const current = conversation?.examples?.slice() || [];
|
const current = conversation?.examples?.slice() || [];
|
||||||
const currentExample = { ...current[i] } || {};
|
const currentExample = { ...current[i] };
|
||||||
currentExample[type] = { content: newValue };
|
currentExample[type] = { content: newValue };
|
||||||
current[i] = currentExample;
|
current[i] = currentExample;
|
||||||
update['examples'] = current;
|
update['examples'] = current;
|
||||||
|
|
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