mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-17 07:55:32 +01:00
Merge branch 'main' into feat/Multitenant-login-OIDC
This commit is contained in:
commit
a85e853e12
535 changed files with 28767 additions and 13591 deletions
|
|
@ -1,6 +0,0 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import useSearch from '~/hooks/Conversations/useSearch';
|
||||
type SearchContextType = ReturnType<typeof useSearch>;
|
||||
|
||||
export const SearchContext = createContext<SearchContextType>({} as SearchContextType);
|
||||
export const useSearchContext = () => useContext(SearchContext);
|
||||
|
|
@ -4,7 +4,6 @@ export { default as AgentsProvider } from './AgentsContext';
|
|||
export * from './ChatContext';
|
||||
export * from './ShareContext';
|
||||
export * from './ToastContext';
|
||||
export * from './SearchContext';
|
||||
export * from './FileMapContext';
|
||||
export * from './AddedChatContext';
|
||||
export * from './EditorContext';
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ export * from './artifacts';
|
|||
export * from './types';
|
||||
export * from './menus';
|
||||
export * from './tools';
|
||||
export * from './selector';
|
||||
export * from './assistants-types';
|
||||
export * from './agents-types';
|
||||
|
|
|
|||
23
client/src/common/selector.ts
Normal file
23
client/src/common/selector.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import { TModelSpec, TStartupConfig } from 'librechat-data-provider';
|
||||
|
||||
export interface Endpoint {
|
||||
value: string;
|
||||
label: string;
|
||||
hasModels: boolean;
|
||||
models?: Array<{ name: string; isGlobal?: boolean }>;
|
||||
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 {
|
||||
startupConfig: TStartupConfig | undefined;
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { RefObject } from 'react';
|
||||
import { FileSources } 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 { FileSources, EModelEndpoint } from 'librechat-data-provider';
|
||||
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 { TranslationKeys } from '~/hooks';
|
||||
|
||||
|
|
@ -29,7 +29,6 @@ export enum STTEndpoints {
|
|||
|
||||
export enum TTSEndpoints {
|
||||
browser = 'browser',
|
||||
edge = 'edge',
|
||||
external = 'external',
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +47,14 @@ export type AudioChunk = {
|
|||
};
|
||||
};
|
||||
|
||||
export type BadgeItem = {
|
||||
id: string;
|
||||
icon: React.ComponentType<any>;
|
||||
label: string;
|
||||
atom: RecoilState<boolean>;
|
||||
isAvailable: boolean;
|
||||
};
|
||||
|
||||
export type AssistantListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -299,11 +306,14 @@ export type TAskProps = {
|
|||
export type TOptions = {
|
||||
editedMessageId?: string | null;
|
||||
editedText?: string | null;
|
||||
resubmitFiles?: boolean;
|
||||
isRegenerate?: boolean;
|
||||
isContinued?: boolean;
|
||||
isEdited?: boolean;
|
||||
overrideMessages?: t.TMessage[];
|
||||
/** This value is only true when the user submits a message with "Save & Submit" for a user-created message */
|
||||
isResubmission?: boolean;
|
||||
/** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */
|
||||
overrideFiles?: t.TMessage['files'];
|
||||
};
|
||||
|
||||
export type TAskFunction = (props: TAskProps, options?: TOptions) => void;
|
||||
|
|
@ -488,7 +498,20 @@ export interface ExtendedFile {
|
|||
metadata?: t.TFile['metadata'];
|
||||
}
|
||||
|
||||
export type ContextType = { navVisible: boolean; setNavVisible: (visible: boolean) => void };
|
||||
export interface ModelItemProps {
|
||||
modelName: string;
|
||||
endpoint: EModelEndpoint;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onNavigateBack: () => void;
|
||||
icon?: JSX.Element;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export type ContextType = {
|
||||
navVisible: boolean;
|
||||
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export interface SwitcherProps {
|
||||
endpoint?: t.EModelEndpoint | null;
|
||||
|
|
@ -531,7 +554,8 @@ export type TResData = TBaseResData & {
|
|||
responseMessage: t.TMessage;
|
||||
};
|
||||
|
||||
export type TFinalResData = TBaseResData & {
|
||||
export type TFinalResData = Omit<TBaseResData, 'conversation'> & {
|
||||
conversation: Partial<t.TConversation> & Pick<t.TConversation, 'conversationId'>;
|
||||
requestMessage?: t.TMessage;
|
||||
responseMessage?: t.TMessage;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useCallback, useRef, useState } from 'react';
|
|||
import throttle from 'lodash/throttle';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import type { Pluggable } from 'unified';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useMessageContext, useArtifactContext } from '~/Providers';
|
||||
|
|
@ -11,7 +12,16 @@ import ArtifactButton from './ArtifactButton';
|
|||
|
||||
export const artifactPlugin: Pluggable = () => {
|
||||
return (tree) => {
|
||||
visit(tree, ['textDirective', 'leafDirective', 'containerDirective'], (node) => {
|
||||
visit(tree, ['textDirective', 'leafDirective', 'containerDirective'], (node, index, parent) => {
|
||||
if (node.type === 'textDirective') {
|
||||
const replacementText = `:${node.name}`;
|
||||
if (parent && Array.isArray(parent.children) && typeof index === 'number') {
|
||||
parent.children[index] = {
|
||||
type: 'text',
|
||||
value: replacementText,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (node.name !== 'artifact') {
|
||||
return;
|
||||
}
|
||||
|
|
@ -25,14 +35,18 @@ export const artifactPlugin: Pluggable = () => {
|
|||
};
|
||||
};
|
||||
|
||||
const defaultTitle = 'untitled';
|
||||
const defaultType = 'unknown';
|
||||
const defaultIdentifier = 'lc-no-identifier';
|
||||
|
||||
export function Artifact({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
node,
|
||||
...props
|
||||
}: Artifact & {
|
||||
children: React.ReactNode | { props: { children: React.ReactNode } };
|
||||
node: unknown;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const { messageId } = useMessageContext();
|
||||
const { getNextIndex, resetCounter } = useArtifactContext();
|
||||
const artifactIndex = useRef(getNextIndex(false)).current;
|
||||
|
|
@ -50,15 +64,18 @@ export function Artifact({
|
|||
const content = extractContent(props.children);
|
||||
logger.log('artifacts', 'updateArtifact: content.length', content.length);
|
||||
|
||||
const title = props.title ?? 'Untitled Artifact';
|
||||
const type = props.type ?? 'unknown';
|
||||
const identifier = props.identifier ?? 'no-identifier';
|
||||
const title = props.title ?? defaultTitle;
|
||||
const type = props.type ?? defaultType;
|
||||
const identifier = props.identifier ?? defaultIdentifier;
|
||||
const artifactKey = `${identifier}_${type}_${title}_${messageId}`
|
||||
.replace(/\s+/g, '_')
|
||||
.toLowerCase();
|
||||
|
||||
throttledUpdateRef.current(() => {
|
||||
const now = Date.now();
|
||||
if (artifactKey === `${defaultIdentifier}_${defaultType}_${defaultTitle}_${messageId}`) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentArtifact: Artifact = {
|
||||
id: artifactKey,
|
||||
|
|
@ -71,6 +88,10 @@ export function Artifact({
|
|||
lastUpdateTime: now,
|
||||
};
|
||||
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
return setArtifact(currentArtifact);
|
||||
}
|
||||
|
||||
setArtifacts((prevArtifacts) => {
|
||||
if (
|
||||
prevArtifacts?.[artifactKey] != null &&
|
||||
|
|
@ -95,6 +116,7 @@ export function Artifact({
|
|||
props.identifier,
|
||||
messageId,
|
||||
artifactIndex,
|
||||
location.pathname,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,52 @@
|
|||
import { useSetRecoilState } from 'recoil';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||
import type { Artifact } from '~/common';
|
||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { getFileType, logger } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { getFileType } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
const localize = useLocalize();
|
||||
const setVisible = useSetRecoilState(store.artifactsVisible);
|
||||
const setArtifactId = useSetRecoilState(store.currentArtifactId);
|
||||
const location = useLocation();
|
||||
const setVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
|
||||
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
|
||||
|
||||
const debouncedSetVisibleRef = useRef(
|
||||
debounce((artifactToSet: Artifact) => {
|
||||
logger.log(
|
||||
'artifacts_visibility',
|
||||
'Setting artifact to visible state from Artifact button',
|
||||
artifactToSet,
|
||||
);
|
||||
setVisibleArtifacts((prev) => ({
|
||||
...prev,
|
||||
[artifactToSet.id]: artifactToSet,
|
||||
}));
|
||||
}, 750),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (artifact == null || artifact?.id == null || artifact.id === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const debouncedSetVisible = debouncedSetVisibleRef.current;
|
||||
debouncedSetVisible(artifact);
|
||||
return () => {
|
||||
debouncedSetVisible.cancel();
|
||||
};
|
||||
}, [artifact, location.pathname]);
|
||||
|
||||
if (artifact === null || artifact === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -19,12 +57,21 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setArtifactId(artifact.id);
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
return;
|
||||
}
|
||||
resetCurrentArtifactId();
|
||||
setVisible(true);
|
||||
if (artifacts?.[artifact.id] == null) {
|
||||
setArtifacts(visibleArtifacts);
|
||||
}
|
||||
setTimeout(() => {
|
||||
setCurrentArtifactId(artifact.id);
|
||||
}, 15);
|
||||
}}
|
||||
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
|
||||
>
|
||||
<div className="w-fit bg-surface-tertiary p-2 ">
|
||||
<div className="w-fit bg-surface-tertiary p-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<FilePreview fileType={fileType} className="relative" />
|
||||
<div className="overflow-hidden text-left">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import {
|
|||
SandpackCodeEditor,
|
||||
SandpackProvider as StyledProvider,
|
||||
} from '@codesandbox/sandpack-react';
|
||||
import { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
|
||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { ArtifactFiles, Artifact } from '~/common';
|
||||
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
|
||||
|
|
@ -65,8 +66,11 @@ const CodeEditor = ({
|
|||
if (isMutating) {
|
||||
return;
|
||||
}
|
||||
if (artifact.index == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCode = sandpack.files['/' + fileKey].code;
|
||||
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
|
||||
|
||||
if (currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim()) {
|
||||
setCurrentCode(currentCode);
|
||||
|
|
@ -131,9 +135,9 @@ export const ArtifactCodeEditor = memo(function ({
|
|||
}
|
||||
return {
|
||||
...sharedOptions,
|
||||
bundlerURL: config.bundlerURL,
|
||||
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
||||
};
|
||||
}, [config]);
|
||||
}, [config, template]);
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -5,27 +5,27 @@ import {
|
|||
SandpackProviderProps,
|
||||
} from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import type { ArtifactFiles } from '~/common';
|
||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
|
||||
export const ArtifactPreview = memo(function ({
|
||||
files,
|
||||
fileKey,
|
||||
previewRef,
|
||||
sharedProps,
|
||||
template,
|
||||
sharedProps,
|
||||
previewRef,
|
||||
currentCode,
|
||||
startupConfig,
|
||||
}: {
|
||||
files: ArtifactFiles;
|
||||
fileKey: string;
|
||||
template: SandpackProviderProps['template'];
|
||||
sharedProps: Partial<SandpackProviderProps>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
currentCode?: string;
|
||||
startupConfig?: TStartupConfig;
|
||||
}) {
|
||||
const { currentCode } = useEditorContext();
|
||||
const { data: config } = useGetStartupConfig();
|
||||
|
||||
const artifactFiles = useMemo(() => {
|
||||
if (Object.keys(files).length === 0) {
|
||||
return files;
|
||||
|
|
@ -43,14 +43,16 @@ export const ArtifactPreview = memo(function ({
|
|||
}, [currentCode, files, fileKey]);
|
||||
|
||||
const options: typeof sharedOptions = useMemo(() => {
|
||||
if (!config) {
|
||||
if (!startupConfig) {
|
||||
return sharedOptions;
|
||||
}
|
||||
return {
|
||||
const _options: typeof sharedOptions = {
|
||||
...sharedOptions,
|
||||
bundlerURL: config.bundlerURL,
|
||||
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
return _options;
|
||||
}, [startupConfig, template]);
|
||||
|
||||
if (Object.keys(artifactFiles).length === 0) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { useRef } from 'react';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { Artifact } from '~/common';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
|
||||
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ArtifactPreview } from './ArtifactPreview';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ArtifactTabs({
|
||||
|
|
@ -21,6 +23,16 @@ export default function ArtifactTabs({
|
|||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
}) {
|
||||
const { currentCode, setCurrentCode } = useEditorContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const lastIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (artifact.id !== lastIdRef.current) {
|
||||
setCurrentCode(undefined);
|
||||
}
|
||||
lastIdRef.current = artifact.id;
|
||||
}, [setCurrentCode, artifact.id]);
|
||||
|
||||
const content = artifact.content ?? '';
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
useAutoScroll({ ref: contentRef, content, isSubmitting });
|
||||
|
|
@ -53,6 +65,8 @@ export default function ArtifactTabs({
|
|||
template={template}
|
||||
previewRef={previewRef}
|
||||
sharedProps={sharedProps}
|
||||
currentCode={currentCode}
|
||||
startupConfig={startupConfig}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useRef, useState, useEffect } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, X } from 'lucide-react';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||
import DownloadArtifact from './DownloadArtifact';
|
||||
|
|
@ -18,7 +18,7 @@ export default function Artifacts() {
|
|||
const previewRef = useRef<SandpackPreviewRef>();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisible);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
|
|
@ -48,37 +48,26 @@ export default function Artifacts() {
|
|||
setTimeout(() => setIsRefreshing(false), 750);
|
||||
};
|
||||
|
||||
const closeArtifacts = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
{/* Main Parent */}
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{/* Main Container */}
|
||||
<div
|
||||
className={`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-300 ease-in-out ${
|
||||
isVisible
|
||||
? 'translate-x-0 scale-100 opacity-100'
|
||||
: 'translate-x-full scale-95 opacity-0'
|
||||
className={`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-500 ease-in-out ${
|
||||
isVisible ? 'scale-100 opacity-100 blur-0' : 'scale-105 opacity-0 blur-sm'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="mr-2 text-text-secondary"
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />
|
||||
</svg>
|
||||
<button className="mr-2 text-text-secondary" onClick={closeArtifacts}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
|
||||
</div>
|
||||
|
|
@ -118,22 +107,8 @@ export default function Artifacts() {
|
|||
{localize('com_ui_code')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<button
|
||||
className="text-text-secondary"
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
|
||||
</svg>
|
||||
<button className="text-text-secondary" onClick={closeArtifacts}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -149,29 +124,13 @@ export default function Artifacts() {
|
|||
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
|
||||
<div className="flex items-center">
|
||||
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z" />
|
||||
</svg>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-xs">{`${currentIndex + 1} / ${
|
||||
orderedArtifactIds.length
|
||||
}`}</span>
|
||||
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z" />
|
||||
</svg>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const CodeMarkdown = memo(
|
|||
const [userScrolled, setUserScrolled] = useState(false);
|
||||
const currentContent = content;
|
||||
const rehypePlugins = [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[rehypeKatex],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessageAudio } from '~/common';
|
||||
import { useLocalize, useTTSBrowser, useTTSEdge, useTTSExternal } from '~/hooks';
|
||||
import { VolumeIcon, VolumeMuteIcon, Spinner } from '~/components/svg';
|
||||
import { useToastContext } from '~/Providers/ToastContext';
|
||||
import { useLocalize, useTTSBrowser, useTTSExternal } from '~/hooks';
|
||||
import { VolumeIcon, VolumeMuteIcon, Spinner } from '~/components';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -85,97 +84,6 @@ export function BrowserTTS({ isLast, index, messageId, content, className }: TMe
|
|||
);
|
||||
}
|
||||
|
||||
export function EdgeTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
|
||||
const localize = useLocalize();
|
||||
const playbackRate = useRecoilValue(store.playbackRate);
|
||||
const isBrowserSupported = useMemo(
|
||||
() => typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported('audio/mpeg'),
|
||||
[],
|
||||
);
|
||||
|
||||
const { showToast } = useToastContext();
|
||||
const { toggleSpeech, isSpeaking, isLoading, audioRef } = useTTSEdge({
|
||||
isLast,
|
||||
index,
|
||||
messageId,
|
||||
content,
|
||||
});
|
||||
|
||||
const renderIcon = (size: string) => {
|
||||
if (isLoading === true) {
|
||||
return <Spinner size={size} />;
|
||||
}
|
||||
|
||||
if (isSpeaking === true) {
|
||||
return <VolumeMuteIcon size={size} />;
|
||||
}
|
||||
|
||||
return <VolumeIcon size={size} />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const messageAudio = document.getElementById(`audio-${messageId}`) as HTMLAudioElement | null;
|
||||
if (!messageAudio) {
|
||||
return;
|
||||
}
|
||||
if (playbackRate != null && playbackRate > 0 && messageAudio.playbackRate !== playbackRate) {
|
||||
messageAudio.playbackRate = playbackRate;
|
||||
}
|
||||
}, [audioRef, isSpeaking, playbackRate, messageId]);
|
||||
|
||||
logger.log(
|
||||
'MessageAudio: audioRef.current?.src, audioRef.current',
|
||||
audioRef.current?.src,
|
||||
audioRef.current,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={className}
|
||||
onClickCapture={() => {
|
||||
if (!isBrowserSupported) {
|
||||
showToast({
|
||||
message: localize('com_nav_tts_unsupported_error'),
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = false;
|
||||
}
|
||||
toggleSpeech();
|
||||
}}
|
||||
type="button"
|
||||
title={isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
|
||||
>
|
||||
{renderIcon('19')}
|
||||
</button>
|
||||
{isBrowserSupported ? (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
controls
|
||||
preload="none"
|
||||
controlsList="nodownload nofullscreen noremoteplayback"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
display: 'none',
|
||||
height: '0px',
|
||||
width: '0px',
|
||||
}}
|
||||
src={audioRef.current?.src}
|
||||
onError={(error) => {
|
||||
logger.error('Error fetching audio:', error);
|
||||
}}
|
||||
id={`audio-${messageId}`}
|
||||
autoPlay
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
|
||||
const localize = useLocalize();
|
||||
const playbackRate = useRecoilValue(store.playbackRate);
|
||||
|
|
|
|||
|
|
@ -1,39 +1,11 @@
|
|||
import React from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import type { Option } from '~/common';
|
||||
import { useLocalize, useTTSBrowser, useTTSEdge, useTTSExternal } from '~/hooks';
|
||||
import { useLocalize, useTTSBrowser, useTTSExternal } from '~/hooks';
|
||||
import { Dropdown } from '~/components/ui';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export function EdgeVoiceDropdown() {
|
||||
const localize = useLocalize();
|
||||
const { voices = [] } = useTTSEdge();
|
||||
const [voice, setVoice] = useRecoilState(store.voice);
|
||||
|
||||
const handleVoiceChange = (newValue?: string | Option) => {
|
||||
logger.log('Edge Voice changed:', newValue);
|
||||
const newVoice = typeof newValue === 'string' ? newValue : newValue?.value;
|
||||
if (newVoice != null) {
|
||||
return setVoice(newVoice.toString());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_voice_select')}</div>
|
||||
<Dropdown
|
||||
key={`edge-voice-dropdown-${voices.length}`}
|
||||
value={voice ?? ''}
|
||||
options={voices}
|
||||
onChange={handleVoiceChange}
|
||||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||
testId="EdgeVoiceDropdown"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrowserVoiceDropdown() {
|
||||
const localize = useLocalize();
|
||||
const { voices = [] } = useTTSBrowser();
|
||||
|
|
@ -57,6 +29,7 @@ export function BrowserVoiceDropdown() {
|
|||
onChange={handleVoiceChange}
|
||||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||
testId="BrowserVoiceDropdown"
|
||||
className="z-50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -85,6 +58,7 @@ export function ExternalVoiceDropdown() {
|
|||
onChange={handleVoiceChange}
|
||||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||
testId="ExternalVoiceDropdown"
|
||||
className="z-50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -34,19 +34,22 @@ const BookmarkItem: FC<MenuItemProps> = ({ tag, selected, handleSubmit, icon, ..
|
|||
if (icon != null) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner className="size-4" />;
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return <BookmarkFilledIcon className="size-4" />;
|
||||
}
|
||||
|
||||
return <BookmarkIcon className="size-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
aria-label={tag as string}
|
||||
className="group flex w-full gap-2 rounded-lg p-2.5 text-sm text-text-primary transition-colors duration-200 focus:outline-none data-[focus]:bg-surface-secondary data-[focus]:ring-2 data-[focus]:ring-primary"
|
||||
className="group flex w-full gap-2 rounded-lg p-2.5 text-sm text-text-primary transition-colors duration-200 focus:outline-none data-[focus]:bg-surface-hover data-[focus-visible]:ring-2 data-[focus-visible]:ring-primary"
|
||||
{...rest}
|
||||
as="button"
|
||||
onClick={clickHandler}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ function AddMultiConvo() {
|
|||
const localize = useLocalize();
|
||||
|
||||
const clickHandler = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
|
||||
setAddedConvo({
|
||||
...convo,
|
||||
|
|
@ -42,7 +41,7 @@ function AddMultiConvo() {
|
|||
role="button"
|
||||
onClick={clickHandler}
|
||||
data-testid="parameters-button"
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<PlusCircle size={16} aria-label="Plus Icon" />
|
||||
</TooltipAnchor>
|
||||
|
|
|
|||
|
|
@ -2,25 +2,38 @@ import { memo, useCallback } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { ChatFormValues } from '~/common';
|
||||
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
|
||||
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
|
||||
import ConversationStarters from './Input/ConversationStarters';
|
||||
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import Presentation from './Presentation';
|
||||
import { buildTree, cn } from '~/utils';
|
||||
import ChatForm from './Input/ChatForm';
|
||||
import { buildTree } from '~/utils';
|
||||
import Landing from './Landing';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import store from '~/store';
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="relative flex-1 overflow-hidden overflow-y-auto">
|
||||
<div className="relative flex h-full items-center justify-center">
|
||||
<Spinner className="text-text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatView({ index = 0 }: { index?: number }) {
|
||||
const { conversationId } = useParams();
|
||||
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
|
||||
const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1));
|
||||
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
|
||||
|
||||
const fileMap = useFileMapContext();
|
||||
|
||||
|
|
@ -46,16 +59,19 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
});
|
||||
|
||||
let content: JSX.Element | null | undefined;
|
||||
if (isLoading && conversationId !== 'new') {
|
||||
content = (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Spinner className="opacity-0" />
|
||||
</div>
|
||||
);
|
||||
} else if (messagesTree && messagesTree.length !== 0) {
|
||||
content = <MessagesView messagesTree={messagesTree} Header={<Header />} />;
|
||||
const isLandingPage =
|
||||
(!messagesTree || messagesTree.length === 0) &&
|
||||
(conversationId === Constants.NEW_CONVO || !conversationId);
|
||||
const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null;
|
||||
|
||||
if (isLoading && conversationId !== Constants.NEW_CONVO) {
|
||||
content = <LoadingSpinner />;
|
||||
} else if ((isLoading || isNavigating) && !isLandingPage) {
|
||||
content = <LoadingSpinner />;
|
||||
} else if (!isLandingPage) {
|
||||
content = <MessagesView messagesTree={messagesTree} />;
|
||||
} else {
|
||||
content = <Landing Header={<Header />} />;
|
||||
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -63,10 +79,30 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
<ChatContext.Provider value={chatHelpers}>
|
||||
<AddedChatContext.Provider value={addedChatHelpers}>
|
||||
<Presentation>
|
||||
{content}
|
||||
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
|
||||
<ChatForm index={index} />
|
||||
<Footer />
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{!isLoading && <Header />}
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
isLandingPage
|
||||
? 'flex-1 items-center justify-end sm:justify-center'
|
||||
: 'h-full overflow-y-auto',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
<div
|
||||
className={cn(
|
||||
'w-full',
|
||||
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
|
||||
)}
|
||||
>
|
||||
<ChatForm index={index} />
|
||||
{isLandingPage ? <ConversationStarters /> : <Footer />}
|
||||
</div>
|
||||
</div>
|
||||
{isLandingPage && <Footer />}
|
||||
</>
|
||||
</div>
|
||||
</Presentation>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -44,15 +44,6 @@ export default function ExportAndShareMenu({
|
|||
};
|
||||
|
||||
const dropdownItems: t.MenuItemProps[] = [
|
||||
{
|
||||
label: localize('com_endpoint_export'),
|
||||
onClick: exportHandler,
|
||||
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
|
||||
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
|
||||
hideOnClick: false,
|
||||
ref: exportButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_share'),
|
||||
onClick: shareHandler,
|
||||
|
|
@ -63,6 +54,15 @@ export default function ExportAndShareMenu({
|
|||
ref: shareButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
{
|
||||
label: localize('com_endpoint_export'),
|
||||
onClick: exportHandler,
|
||||
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
|
||||
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
|
||||
hideOnClick: false,
|
||||
ref: exportButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -70,6 +70,7 @@ export default function ExportAndShareMenu({
|
|||
<DropdownPopup
|
||||
menuId={menuId}
|
||||
focusLoop={true}
|
||||
unmountOnHide={true}
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
trigger={
|
||||
|
|
@ -79,9 +80,9 @@ export default function ExportAndShareMenu({
|
|||
<Ariakit.MenuButton
|
||||
id="export-menu-button"
|
||||
aria-label="Export options"
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Upload
|
||||
<Share2
|
||||
className="icon-md text-text-secondary"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
|
|
@ -103,7 +104,6 @@ export default function ExportAndShareMenu({
|
|||
<ShareButton
|
||||
triggerRef={shareButtonRef}
|
||||
conversationId={conversation.conversationId ?? ''}
|
||||
title={conversation.title ?? ''}
|
||||
open={showShareDialog}
|
||||
onOpenChange={setShowShareDialog}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ export default function Footer({ className }: { className?: string }) {
|
|||
<React.Fragment key={`main-content-part-${index}`}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
a: ({ node: _n, href, children, ...otherProps }) => {
|
||||
return (
|
||||
<a
|
||||
|
|
@ -70,7 +69,7 @@ export default function Footer({ className }: { className?: string }) {
|
|||
</a>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
p: ({ node: _n, ...props }) => <span {...props} />,
|
||||
}}
|
||||
>
|
||||
|
|
@ -84,24 +83,29 @@ export default function Footer({ className }: { className?: string }) {
|
|||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
className ??
|
||||
'relative flex items-center justify-center gap-2 px-2 py-2 text-center text-xs text-text-primary md:px-[60px]'
|
||||
}
|
||||
role="contentinfo"
|
||||
>
|
||||
{footerElements.map((contentRender, index) => {
|
||||
const isLastElement = index === footerElements.length - 1;
|
||||
return (
|
||||
<React.Fragment key={`footer-element-${index}`}>
|
||||
{contentRender}
|
||||
{!isLastElement && (
|
||||
<div key={`separator-${index}`} className="h-2 border-r-[1px] border-border-medium" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={
|
||||
className ??
|
||||
'absolute bottom-0 left-0 right-0 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"
|
||||
>
|
||||
{footerElements.map((contentRender, index) => {
|
||||
const isLastElement = index === footerElements.length - 1;
|
||||
return (
|
||||
<React.Fragment key={`footer-element-${index}`}>
|
||||
{contentRender}
|
||||
{!isLastElement && (
|
||||
<div
|
||||
key={`separator-${index}`}
|
||||
className="h-2 border-r-[1px] border-border-medium"
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@ import { useMemo } from 'react';
|
|||
import { useOutletContext } from 'react-router-dom';
|
||||
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { ContextType } from '~/common';
|
||||
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
|
||||
import ModelSelector from './Menus/Endpoints/ModelSelector';
|
||||
import { PresetsMenu, HeaderNewChat, OpenSidebar } from './Menus';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import ExportAndShareMenu from './ExportAndShareMenu';
|
||||
import { useMediaQuery, useHasAccess } from '~/hooks';
|
||||
import HeaderOptions from './Input/HeaderOptions';
|
||||
import BookmarkMenu from './Menus/BookmarkMenu';
|
||||
import { TemporaryChat } from './TemporaryChat';
|
||||
import AddMultiConvo from './AddMultiConvo';
|
||||
|
||||
const defaultInterface = getConfigDefaults().interface;
|
||||
|
||||
export default function Header() {
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { navVisible } = useOutletContext<ContextType>();
|
||||
const modelSpecs = useMemo(() => startupConfig?.modelSpecs?.list ?? [], [startupConfig]);
|
||||
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||
const interfaceConfig = useMemo(
|
||||
() => startupConfig?.interface ?? defaultInterface,
|
||||
[startupConfig],
|
||||
|
|
@ -34,24 +34,31 @@ export default function Header() {
|
|||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
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="flex items-center gap-2">
|
||||
<div className="mx-1 flex items-center gap-2">
|
||||
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
|
||||
{!navVisible && <HeaderNewChat />}
|
||||
{interfaceConfig.endpointsMenu === true && <EndpointsMenu />}
|
||||
{modelSpecs.length > 0 && <ModelSpecsMenu modelSpecs={modelSpecs} />}
|
||||
{<HeaderOptions interfaceConfig={interfaceConfig} />}
|
||||
{interfaceConfig.presets === true && <PresetsMenu />}
|
||||
{<ModelSelector startupConfig={startupConfig} />}
|
||||
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
|
||||
{hasAccessToBookmarks === true && <BookmarkMenu />}
|
||||
{hasAccessToMultiConvo === true && <AddMultiConvo />}
|
||||
{isSmallScreen && (
|
||||
<ExportAndShareMenu
|
||||
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
||||
/>
|
||||
<>
|
||||
<ExportAndShareMenu
|
||||
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
||||
/>
|
||||
<TemporaryChat />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isSmallScreen && (
|
||||
<ExportAndShareMenu isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} />
|
||||
<div className="flex items-center gap-2">
|
||||
<ExportAndShareMenu
|
||||
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
||||
/>
|
||||
<TemporaryChat />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Empty div for spacing */}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,12 @@ import { globalAudioId } from '~/common';
|
|||
import { cn } from '~/utils';
|
||||
|
||||
export default function AudioRecorder({
|
||||
isRTL,
|
||||
disabled,
|
||||
ask,
|
||||
methods,
|
||||
textAreaRef,
|
||||
isSubmitting,
|
||||
}: {
|
||||
isRTL: boolean;
|
||||
disabled: boolean;
|
||||
ask: (data: { text: string }) => void;
|
||||
methods: ReturnType<typeof useChatFormContext>;
|
||||
|
|
@ -90,9 +88,7 @@ export default function AudioRecorder({
|
|||
onClick={isListening === true ? handleStopRecording : handleStartRecording}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'absolute flex size-[35px] 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',
|
||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
|
||||
)}
|
||||
title={localize('com_ui_use_micrphone')}
|
||||
aria-pressed={isListening}
|
||||
|
|
|
|||
389
client/src/components/Chat/Input/BadgeRow.tsx
Normal file
389
client/src/components/Chat/Input/BadgeRow.tsx
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import React, {
|
||||
memo,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useReducer,
|
||||
} from 'react';
|
||||
import { useRecoilValue, useRecoilCallback } from 'recoil';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import CodeInterpreter from './CodeInterpreter';
|
||||
import type { BadgeItem } from '~/common';
|
||||
import { useChatBadges } from '~/hooks';
|
||||
import { Badge } from '~/components/ui';
|
||||
import MCPSelect from './MCPSelect';
|
||||
import store from '~/store';
|
||||
|
||||
interface BadgeRowProps {
|
||||
showEphemeralBadges?: boolean;
|
||||
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
|
||||
onToggle?: (badgeId: string, currentActive: boolean) => void;
|
||||
conversationId?: string | null;
|
||||
isInChat: boolean;
|
||||
}
|
||||
|
||||
interface BadgeWrapperProps {
|
||||
badge: BadgeItem;
|
||||
isEditing: boolean;
|
||||
isInChat: 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, isInChat, onToggle, onDelete, onMouseDown, badgeRefs }, ref) => {
|
||||
const atomBadge = useRecoilValue(badge.atom);
|
||||
const isActive = badge.atom ? atomBadge : 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
|
||||
id={badge.id}
|
||||
icon={badge.icon as LucideIcon}
|
||||
label={badge.label}
|
||||
isActive={isActive}
|
||||
isEditing={isEditing}
|
||||
isAvailable={badge.isAvailable}
|
||||
isInChat={isInChat}
|
||||
onToggle={() => onToggle(badge)}
|
||||
onBadgeAction={() => onDelete(badge.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.badge.id === nextProps.badge.id &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.isInChat === nextProps.isInChat &&
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
function BadgeRow({
|
||||
showEphemeralBadges,
|
||||
conversationId,
|
||||
onChange,
|
||||
onToggle,
|
||||
isInChat,
|
||||
}: 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
|
||||
id={ghostBadge.id}
|
||||
icon={ghostBadge.icon as LucideIcon}
|
||||
label={ghostBadge.label}
|
||||
isActive={dragState.draggedBadgeActive}
|
||||
isEditing={isEditing}
|
||||
isAvailable={ghostBadge.isAvailable}
|
||||
isInChat={isInChat}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<BadgeWrapper
|
||||
badge={badge}
|
||||
isEditing={isEditing}
|
||||
isInChat={isInChat}
|
||||
onToggle={handleBadgeToggle}
|
||||
onDelete={handleDelete}
|
||||
onMouseDown={handleMouseDown}
|
||||
badgeRefs={badgeRefs}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
|
||||
<div className="badge-icon h-full">
|
||||
<Badge
|
||||
id={ghostBadge.id}
|
||||
icon={ghostBadge.icon as LucideIcon}
|
||||
label={ghostBadge.label}
|
||||
isActive={dragState.draggedBadgeActive}
|
||||
isEditing={isEditing}
|
||||
isAvailable={ghostBadge.isAvailable}
|
||||
isInChat={isInChat}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showEphemeralBadges === true && (
|
||||
<>
|
||||
<CodeInterpreter conversationId={conversationId} />
|
||||
<MCPSelect conversationId={conversationId} />
|
||||
</>
|
||||
)}
|
||||
{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
|
||||
id={ghostBadge.id}
|
||||
icon={ghostBadge.icon as LucideIcon}
|
||||
label={ghostBadge.label}
|
||||
isActive={dragState.draggedBadgeActive}
|
||||
isAvailable={ghostBadge.isAvailable}
|
||||
isInChat={isInChat}
|
||||
isEditing
|
||||
isDragging
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(BadgeRow);
|
||||
|
|
@ -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 {
|
||||
supportsFiles,
|
||||
mergeFileConfig,
|
||||
isAssistantsEndpoint,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
useChatContext,
|
||||
useChatFormContext,
|
||||
|
|
@ -19,61 +15,54 @@ import {
|
|||
useHandleKeyUp,
|
||||
useQueryParams,
|
||||
useSubmitMessage,
|
||||
useFocusChatEffect,
|
||||
} from '~/hooks';
|
||||
import { cn, removeFocusRings, checkIfScrollable } from '~/utils';
|
||||
import FileFormWrapper from './Files/FileFormWrapper';
|
||||
import { TextareaAutosize } from '~/components/ui';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { TemporaryChat } from './TemporaryChat';
|
||||
import { mainTextareaId, BadgeItem } from '~/common';
|
||||
import AttachFileChat from './Files/AttachFileChat';
|
||||
import FileFormChat from './Files/FileFormChat';
|
||||
import { TextareaAutosize } from '~/components';
|
||||
import { cn, removeFocusRings } from '~/utils';
|
||||
import TextareaHeader from './TextareaHeader';
|
||||
import PromptsCommand from './PromptsCommand';
|
||||
import AudioRecorder from './AudioRecorder';
|
||||
import { mainTextareaId } from '~/common';
|
||||
import CollapseChat from './CollapseChat';
|
||||
import StreamAudio from './StreamAudio';
|
||||
import StopButton from './StopButton';
|
||||
import SendButton from './SendButton';
|
||||
import EditBadges from './EditBadges';
|
||||
import BadgeRow from './BadgeRow';
|
||||
import Mention from './Mention';
|
||||
import store from '~/store';
|
||||
|
||||
const ChatForm = ({ index = 0 }) => {
|
||||
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
useQueryParams({ textAreaRef });
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
useFocusChatEffect(textAreaRef);
|
||||
|
||||
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 TextToSpeech = useRecoilValue(store.textToSpeech);
|
||||
const chatDirection = useRecoilValue(store.chatDirection);
|
||||
const automaticPlayback = useRecoilValue(store.automaticPlayback);
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const [isTemporaryChat, setIsTemporaryChat] = useRecoilState<boolean>(store.isTemporary);
|
||||
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
|
||||
const isTemporary = useRecoilValue(store.isTemporary);
|
||||
|
||||
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 [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index));
|
||||
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
|
||||
store.showMentionPopoverFamily(index),
|
||||
);
|
||||
|
||||
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
||||
const isRTL = chatDirection === 'rtl';
|
||||
|
||||
const { requiresKey } = useRequiresKey();
|
||||
const handleKeyUp = useHandleKeyUp({
|
||||
index,
|
||||
textAreaRef,
|
||||
setShowPlusPopover,
|
||||
setShowMentionPopover,
|
||||
});
|
||||
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
|
||||
textAreaRef,
|
||||
submitButtonRef,
|
||||
setIsScrollable,
|
||||
disabled: !!(requiresKey ?? false),
|
||||
});
|
||||
|
||||
const methods = useChatFormContext();
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
|
|
@ -83,7 +72,6 @@ const ChatForm = ({ index = 0 }) => {
|
|||
newConversation,
|
||||
handleStopGenerating,
|
||||
} = useChatContext();
|
||||
const methods = useChatFormContext();
|
||||
const {
|
||||
addedIndex,
|
||||
generateConversation,
|
||||
|
|
@ -91,80 +79,145 @@ const ChatForm = ({ index = 0 }) => {
|
|||
setConversation: setAddedConvo,
|
||||
isSubmitting: isSubmittingAdded,
|
||||
} = useAddedChatContext();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
|
||||
|
||||
const { clearDraft } = useAutoSave({
|
||||
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
|
||||
textAreaRef,
|
||||
files,
|
||||
setFiles,
|
||||
});
|
||||
const endpoint = useMemo(
|
||||
() => conversation?.endpointType ?? conversation?.endpoint,
|
||||
[conversation?.endpointType, conversation?.endpoint],
|
||||
);
|
||||
const conversationId = useMemo(
|
||||
() => conversation?.conversationId ?? Constants.NEW_CONVO,
|
||||
[conversation?.conversationId],
|
||||
);
|
||||
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const { submitMessage, submitPrompt } = useSubmitMessage({ clearDraft });
|
||||
|
||||
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 isRTL = useMemo(
|
||||
() => (chatDirection != null ? chatDirection?.toLowerCase() === 'rtl' : false),
|
||||
[chatDirection],
|
||||
);
|
||||
const invalidAssistant = useMemo(
|
||||
() =>
|
||||
isAssistantsEndpoint(conversation?.endpoint) &&
|
||||
isAssistantsEndpoint(endpoint) &&
|
||||
(!(conversation?.assistant_id ?? '') ||
|
||||
!assistantMap?.[conversation?.endpoint ?? ''][conversation?.assistant_id ?? '']),
|
||||
[conversation?.assistant_id, conversation?.endpoint, assistantMap],
|
||||
!assistantMap?.[endpoint ?? '']?.[conversation?.assistant_id ?? '']),
|
||||
[conversation?.assistant_id, endpoint, assistantMap],
|
||||
);
|
||||
const disableInputs = useMemo(
|
||||
() => !!((requiresKey ?? false) || invalidAssistant),
|
||||
() => requiresKey || invalidAssistant,
|
||||
[requiresKey, invalidAssistant],
|
||||
);
|
||||
|
||||
const { ref, ...registerProps } = methods.register('text', {
|
||||
required: true,
|
||||
onChange: (e) => {
|
||||
methods.setValue('text', e.target.value, { shouldValidate: true });
|
||||
},
|
||||
const handleContainerClick = useCallback(() => {
|
||||
/** Check if the device is a touchscreen */
|
||||
if (window.matchMedia?.('(pointer: coarse)').matches) {
|
||||
return;
|
||||
}
|
||||
textAreaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleFocusOrClick = useCallback(() => {
|
||||
if (isCollapsed) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
useAutoSave({
|
||||
files,
|
||||
setFiles,
|
||||
textAreaRef,
|
||||
conversationId,
|
||||
isSubmitting: isSubmitting || isSubmittingAdded,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearching && textAreaRef.current && !disableInputs) {
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
}, [isSearching, disableInputs]);
|
||||
const { submitMessage, submitPrompt } = useSubmitMessage();
|
||||
|
||||
const handleKeyUp = useHandleKeyUp({
|
||||
index,
|
||||
textAreaRef,
|
||||
setShowPlusPopover,
|
||||
setShowMentionPopover,
|
||||
});
|
||||
const {
|
||||
isNotAppendable,
|
||||
handlePaste,
|
||||
handleKeyDown,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
} = useTextarea({
|
||||
textAreaRef,
|
||||
submitButtonRef,
|
||||
setIsScrollable,
|
||||
disabled: disableInputs,
|
||||
});
|
||||
|
||||
useQueryParams({ textAreaRef });
|
||||
|
||||
const { ref, ...registerProps } = methods.register('text', {
|
||||
required: true,
|
||||
onChange: useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
methods.setValue('text', e.target.value, { shouldValidate: true }),
|
||||
[methods],
|
||||
),
|
||||
});
|
||||
|
||||
const textValue = useWatch({ control: methods.control, name: 'text' });
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
|
||||
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
|
||||
useEffect(() => {
|
||||
if (isEditingBadges && backupBadges.length === 0) {
|
||||
setBackupBadges([...badges]);
|
||||
}
|
||||
}, [isEditingBadges, badges, backupBadges.length]);
|
||||
|
||||
const baseClasses = cn(
|
||||
'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 handleSaveBadges = useCallback(() => {
|
||||
setIsEditingBadges(false);
|
||||
setBackupBadges([]);
|
||||
}, [setIsEditingBadges, setBackupBadges]);
|
||||
|
||||
const handleCancelBadges = useCallback(() => {
|
||||
if (backupBadges.length > 0) {
|
||||
setBadges([...backupBadges]);
|
||||
}
|
||||
setIsEditingBadges(false);
|
||||
setBackupBadges([]);
|
||||
}, [backupBadges, setBadges, setIsEditingBadges]);
|
||||
|
||||
const isMoreThanThreeRows = visualRowCount > 3;
|
||||
|
||||
const baseClasses = useMemo(
|
||||
() =>
|
||||
cn(
|
||||
'md:py-3.5 m-0 w-full resize-none py-[13px] placeholder-black/50 bg-transparent 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 (
|
||||
<form
|
||||
onSubmit={methods.handleSubmit((data) => submitMessage(data))}
|
||||
onSubmit={methods.handleSubmit(submitMessage)}
|
||||
className={cn(
|
||||
'mx-auto flex flex-row gap-3 pl-2 transition-all duration-200 last:mb-2',
|
||||
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-2xl xl:max-w-3xl',
|
||||
'mx-auto flex flex-row gap-3 sm:px-2',
|
||||
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
centerFormOnLanding &&
|
||||
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
|
||||
!isSubmitting &&
|
||||
conversation?.messages?.length === 0
|
||||
? 'transition-all duration-200 sm:mb-28'
|
||||
: 'sm:mb-10',
|
||||
)}
|
||||
>
|
||||
<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) && (
|
||||
<Mention
|
||||
setShowMentionPopover={setShowPlusPopover}
|
||||
|
|
@ -183,90 +236,111 @@ const ChatForm = ({ index = 0 }) => {
|
|||
/>
|
||||
)}
|
||||
<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">
|
||||
<TemporaryChat
|
||||
isTemporaryChat={isTemporaryChat}
|
||||
setIsTemporaryChat={setIsTemporaryChat}
|
||||
/>
|
||||
<div
|
||||
onClick={handleContainerClick}
|
||||
className={cn(
|
||||
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border pb-4 text-text-primary transition-all duration-200 sm:rounded-3xl sm:pb-0',
|
||||
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
|
||||
isTemporary
|
||||
? 'border-violet-800/60 bg-violet-950/10'
|
||||
: 'border-border-light bg-surface-chat',
|
||||
)}
|
||||
>
|
||||
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||
<FileFormWrapper disableInputs={disableInputs}>
|
||||
{endpoint && (
|
||||
<>
|
||||
<EditBadges
|
||||
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 || isNotAppendable}
|
||||
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 disabled:cursor-not-allowed',
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col items-start justify-start pt-1.5">
|
||||
<CollapseChat
|
||||
isCollapsed={isCollapsed}
|
||||
isScrollable={isScrollable}
|
||||
isScrollable={isMoreThanThreeRows}
|
||||
setIsCollapsed={setIsCollapsed}
|
||||
/>
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
ref={(e) => {
|
||||
ref(e);
|
||||
textAreaRef.current = e;
|
||||
}}
|
||||
disabled={disableInputs}
|
||||
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',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'items-between flex gap-2 pb-2',
|
||||
isRTL ? 'flex-row-reverse' : 'flex-row',
|
||||
)}
|
||||
</FileFormWrapper>
|
||||
{SpeechToText && (
|
||||
<AudioRecorder
|
||||
isRTL={isRTL}
|
||||
methods={methods}
|
||||
ask={submitMessage}
|
||||
textAreaRef={textAreaRef}
|
||||
disabled={!!disableInputs}
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
|
||||
<AttachFileChat disableInputs={disableInputs} />
|
||||
</div>
|
||||
<BadgeRow
|
||||
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
|
||||
conversationId={conversationId}
|
||||
onChange={setBadges}
|
||||
isInChat={
|
||||
Array.isArray(conversation?.messages) && conversation.messages.length >= 1
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
|
||||
</div>
|
||||
<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="mx-auto flex" />
|
||||
{SpeechToText && (
|
||||
<AudioRecorder
|
||||
methods={methods}
|
||||
ask={submitMessage}
|
||||
textAreaRef={textAreaRef}
|
||||
disabled={disableInputs || isNotAppendable}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
)}
|
||||
<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 || isNotAppendable}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default memo(ChatForm);
|
||||
export default ChatForm;
|
||||
|
|
|
|||
119
client/src/components/Chat/Input/CodeInterpreter.tsx
Normal file
119
client/src/components/Chat/Input/CodeInterpreter.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { TerminalSquareIcon } from 'lucide-react';
|
||||
import {
|
||||
Tools,
|
||||
AuthType,
|
||||
Constants,
|
||||
LocalStorageKeys,
|
||||
PermissionTypes,
|
||||
Permissions,
|
||||
} from 'librechat-data-provider';
|
||||
import ApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
|
||||
import { useLocalize, useHasAccess, useCodeApiKeyForm } from '~/hooks';
|
||||
import CheckboxButton from '~/components/ui/CheckboxButton';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import { useVerifyAgentToolAuth } from '~/data-provider';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
const currentValue = rawCurrentValue?.trim() ?? '';
|
||||
if (currentValue === 'true' && value === false) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return value !== undefined && value !== null && value !== '' && value !== false;
|
||||
};
|
||||
|
||||
function CodeInterpreter({ conversationId }: { conversationId?: string | null }) {
|
||||
const localize = useLocalize();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
|
||||
const canRunCode = useHasAccess({
|
||||
permissionType: PermissionTypes.RUN_CODE,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
const isCodeToggleEnabled = useMemo(() => {
|
||||
return ephemeralAgent?.execute_code ?? false;
|
||||
}, [ephemeralAgent?.execute_code]);
|
||||
|
||||
const { data } = useVerifyAgentToolAuth(
|
||||
{ toolId: Tools.execute_code },
|
||||
{
|
||||
retry: 1,
|
||||
},
|
||||
);
|
||||
const authType = useMemo(() => data?.message ?? false, [data?.message]);
|
||||
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
|
||||
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
|
||||
useCodeApiKeyForm({});
|
||||
|
||||
const setValue = useCallback(
|
||||
(isChecked: boolean) => {
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
execute_code: isChecked,
|
||||
}));
|
||||
},
|
||||
[setEphemeralAgent],
|
||||
);
|
||||
|
||||
const [runCode, setRunCode] = useLocalStorage<boolean>(
|
||||
`${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`,
|
||||
isCodeToggleEnabled,
|
||||
setValue,
|
||||
storageCondition,
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(isChecked: boolean) => {
|
||||
if (!isAuthenticated) {
|
||||
setIsDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
setRunCode(isChecked);
|
||||
},
|
||||
[setRunCode, setIsDialogOpen, isAuthenticated],
|
||||
);
|
||||
|
||||
const debouncedChange = useMemo(
|
||||
() => debounce(handleChange, 50, { leading: true }),
|
||||
[handleChange],
|
||||
);
|
||||
|
||||
if (!canRunCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CheckboxButton
|
||||
className="max-w-fit"
|
||||
defaultChecked={runCode}
|
||||
setValue={debouncedChange}
|
||||
label={localize('com_assistants_code_interpreter')}
|
||||
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"
|
||||
icon={<TerminalSquareIcon className="icon-md" />}
|
||||
/>
|
||||
<ApiKeyDialog
|
||||
onSubmit={onSubmit}
|
||||
isOpen={isDialogOpen}
|
||||
register={methods.register}
|
||||
onRevoke={handleRevokeApiKey}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
handleSubmit={methods.handleSubmit}
|
||||
isToolAuthenticated={isAuthenticated}
|
||||
isUserProvided={authType === AuthType.USER_PROVIDED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CodeInterpreter);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Minimize2 } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { TooltipAnchor } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -18,23 +18,37 @@ const CollapseChat = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
return null;
|
||||
}
|
||||
const description = isCollapsed
|
||||
? localize('com_ui_expand_chat')
|
||||
: localize('com_ui_collapse_chat');
|
||||
|
||||
return (
|
||||
<TooltipAnchor
|
||||
role="button"
|
||||
description={localize('com_ui_collapse_chat')}
|
||||
aria-label={localize('com_ui_collapse_chat')}
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 z-10 size-[35px] rounded-full p-2 transition-colors',
|
||||
'hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||
)}
|
||||
>
|
||||
<Minimize2 className="h-full w-full" />
|
||||
</TooltipAnchor>
|
||||
<div className="relative ml-auto items-end justify-end">
|
||||
<TooltipAnchor
|
||||
description={description}
|
||||
render={
|
||||
<button
|
||||
aria-label={description}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsCollapsed((prev) => !prev);
|
||||
}}
|
||||
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 ? (
|
||||
<ChevronUp className="h-full w-full" />
|
||||
) : (
|
||||
<ChevronDown 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,52 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { FileUpload, TooltipAnchor } from '~/components/ui';
|
||||
import { AttachmentIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { FileUpload, TooltipAnchor, AttachmentIcon } from '~/components';
|
||||
import { useLocalize, useFileHandling } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const AttachFile = ({
|
||||
isRTL,
|
||||
disabled,
|
||||
handleFileChange,
|
||||
}: {
|
||||
isRTL: boolean;
|
||||
disabled?: boolean | null;
|
||||
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) => {
|
||||
const AttachFile = ({ disabled }: { disabled?: boolean | null }) => {
|
||||
const localize = useLocalize();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isUploadDisabled = disabled ?? false;
|
||||
|
||||
const { handleFileChange } = useFileHandling();
|
||||
|
||||
return (
|
||||
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
|
||||
<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')}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
inputRef.current.value = '';
|
||||
inputRef.current.click();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
inputRef.current.value = '';
|
||||
inputRef.current.click();
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<AttachmentIcon />
|
||||
</div>
|
||||
</TooltipAnchor>
|
||||
id="attach-file"
|
||||
disabled={isUploadDisabled}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_sidepanel_attach_files')}
|
||||
disabled={isUploadDisabled}
|
||||
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',
|
||||
)}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
inputRef.current.value = '';
|
||||
inputRef.current.click();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
inputRef.current.value = '';
|
||||
inputRef.current.click();
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<AttachmentIcon />
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</FileUpload>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
51
client/src/components/Chat/Input/Files/AttachFileChat.tsx
Normal file
51
client/src/components/Chat/Input/Files/AttachFileChat.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import {
|
||||
Constants,
|
||||
supportsFiles,
|
||||
mergeFileConfig,
|
||||
isAgentsEndpoint,
|
||||
isEphemeralAgent,
|
||||
EndpointFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import AttachFileMenu from './AttachFileMenu';
|
||||
import AttachFile from './AttachFile';
|
||||
|
||||
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
const { conversation } = useChatContext();
|
||||
|
||||
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
|
||||
const key = conversation?.conversationId ?? Constants.NEW_CONVO;
|
||||
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(key));
|
||||
const isAgents = useMemo(
|
||||
() => isAgentsEndpoint(_endpoint) || isEphemeralAgent(_endpoint, ephemeralAgent),
|
||||
[_endpoint, ephemeralAgent],
|
||||
);
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
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,25 @@ import * as Ariakit from '@ariakit/react';
|
|||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
|
||||
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 { AttachmentIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useLocalize, useFileHandling } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AttachFileProps {
|
||||
isRTL: boolean;
|
||||
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 isUploadDisabled = disabled ?? false;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
});
|
||||
|
||||
const capabilities = useMemo(
|
||||
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
|
||||
|
|
@ -93,8 +93,7 @@ const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
|
|||
id="attach-file-menu-button"
|
||||
aria-label="Attach File Options"
|
||||
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-1 md:left-2',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
|
|
@ -115,17 +114,16 @@ const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
|
|||
handleFileChange(e, toolResource);
|
||||
}}
|
||||
>
|
||||
<div className="relative select-none">
|
||||
<DropdownPopup
|
||||
menuId="attach-file-menu"
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
modal={true}
|
||||
trigger={menuTrigger}
|
||||
items={dropdownItems}
|
||||
iconClassName="mr-0"
|
||||
/>
|
||||
</div>
|
||||
<DropdownPopup
|
||||
menuId="attach-file-menu"
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
modal={true}
|
||||
unmountOnHide={true}
|
||||
trigger={menuTrigger}
|
||||
items={dropdownItems}
|
||||
iconClassName="mr-0"
|
||||
/>
|
||||
</FileUpload>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
|||
label: localize('com_ui_upload_image_input'),
|
||||
value: undefined,
|
||||
icon: <ImageUpIcon className="icon-md" />,
|
||||
condition: files.every((file) => file.type.startsWith('image/')),
|
||||
condition: files.every((file) => file.type?.startsWith('image/')),
|
||||
},
|
||||
];
|
||||
for (const capability of capabilities) {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,40 @@
|
|||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { getFileType, cn } from '~/utils';
|
||||
import FilePreview from './FilePreview';
|
||||
import RemoveFile from './RemoveFile';
|
||||
import { getFileType } from '~/utils';
|
||||
|
||||
const FileContainer = ({
|
||||
file,
|
||||
overrideType,
|
||||
buttonClassName,
|
||||
containerClassName,
|
||||
onDelete,
|
||||
onClick,
|
||||
}: {
|
||||
file: ExtendedFile | TFile;
|
||||
file: Partial<ExtendedFile | TFile>;
|
||||
overrideType?: string;
|
||||
buttonClassName?: string;
|
||||
containerClassName?: string;
|
||||
onDelete?: () => void;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}) => {
|
||||
const fileType = getFileType(file.type);
|
||||
const fileType = getFileType(overrideType ?? file.type);
|
||||
|
||||
return (
|
||||
<div className="group relative inline-block text-sm text-text-primary">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border-light">
|
||||
<div className="w-56 bg-surface-hover-alt p-1.5">
|
||||
<div
|
||||
className={cn('group relative inline-block text-sm text-text-primary', containerClassName)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={file.filename}
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-2xl border border-border-light bg-surface-hover-alt',
|
||||
buttonClassName,
|
||||
)}
|
||||
>
|
||||
<div className="w-56 p-1.5">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<FilePreview file={file} fileType={fileType} className="relative" />
|
||||
<div className="overflow-hidden">
|
||||
|
|
@ -29,7 +47,7 @@ const FileContainer = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{onDelete && <RemoveFile onRemove={onDelete} />}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
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);
|
||||
|
|
@ -11,7 +11,7 @@ const FilePreview = ({
|
|||
fileType,
|
||||
className = '',
|
||||
}: {
|
||||
file?: ExtendedFile | TFile;
|
||||
file?: Partial<ExtendedFile | TFile>;
|
||||
fileType: {
|
||||
paths: React.FC;
|
||||
fill: string;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<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}
|
||||
aria-label={localize('com_ui_attach_remove')}
|
||||
>
|
||||
<span>
|
||||
<span aria-hidden="true">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ const sourceToEndpoint = {
|
|||
|
||||
const sourceToClassname = {
|
||||
[FileSources.openai]: 'bg-white/75 dark:bg-black/65',
|
||||
[FileSources.azure]: 'azure-bg-color opacity-85',
|
||||
[FileSources.azure]: 'azure-bg-color',
|
||||
[FileSources.azure_blob]: 'azure-bg-color',
|
||||
[FileSources.execute_code]: 'bg-black text-white opacity-85',
|
||||
[FileSources.text]: 'bg-blue-500 dark:bg-blue-900 opacity-85 text-white',
|
||||
[FileSources.vectordb]: 'bg-yellow-700 dark:bg-yellow-900 opacity-85 text-white',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
|
||||
import { ArrowUpDown, Database } from 'lucide-react';
|
||||
import { FileSources, FileContext } from 'librechat-data-provider';
|
||||
|
|
@ -68,7 +69,7 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
},
|
||||
cell: ({ row }) => {
|
||||
const file = row.original;
|
||||
if (file.type.startsWith('image')) {
|
||||
if (file.type?.startsWith('image')) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<ImagePreview
|
||||
|
|
@ -76,7 +77,7 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md"
|
||||
source={file.source}
|
||||
/>
|
||||
<span className="self-center truncate ">{file.filename}</span>
|
||||
<span className="self-center truncate">{file.filename}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -212,4 +213,4 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
return `${value}${suffix}`;
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,17 +2,12 @@ import { useRecoilState } from 'recoil';
|
|||
import { Settings2 } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Root, Anchor } from '@radix-ui/react-popover';
|
||||
import {
|
||||
EModelEndpoint,
|
||||
isParamEndpoint,
|
||||
isAgentsEndpoint,
|
||||
tConvoUpdateSchema,
|
||||
} from 'librechat-data-provider';
|
||||
import { EModelEndpoint, isParamEndpoint, tConvoUpdateSchema } from 'librechat-data-provider';
|
||||
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
|
||||
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
|
||||
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
|
||||
import { useSetIndexOptions, useMediaQuery, useLocalize } from '~/hooks';
|
||||
import { PluginStoreDialog, TooltipAnchor } from '~/components';
|
||||
import { ModelSelect } from '~/components/Input/ModelSelect';
|
||||
import { useSetIndexOptions, useLocalize } from '~/hooks';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import OptionsPopover from './OptionsPopover';
|
||||
import PopoverButtons from './PopoverButtons';
|
||||
|
|
@ -26,6 +21,7 @@ export default function HeaderOptions({
|
|||
interfaceConfig?: Partial<TInterfaceConfig>;
|
||||
}) {
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
|
||||
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
|
||||
store.showPluginStoreDialog,
|
||||
|
|
@ -35,6 +31,15 @@ export default function HeaderOptions({
|
|||
const { showPopover, conversation, setShowPopover } = useChatContext();
|
||||
const { setOption } = useSetIndexOptions();
|
||||
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 }>(
|
||||
() => ({
|
||||
|
|
@ -71,14 +76,6 @@ export default function HeaderOptions({
|
|||
<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">
|
||||
<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] &&
|
||||
interfaceConfig?.parameters === true &&
|
||||
paramEndpoint === false && (
|
||||
|
|
|
|||
124
client/src/components/Chat/Input/MCPSelect.tsx
Normal file
124
client/src/components/Chat/Input/MCPSelect.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import React, { memo, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { useAvailableToolsQuery } from '~/data-provider';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import MultiSelect from '~/components/ui/MultiSelect';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import MCPIcon from '~/components/ui/MCPIcon';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
const currentValue = rawCurrentValue?.trim() ?? '';
|
||||
if (currentValue.length > 2) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
};
|
||||
|
||||
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||
const localize = useLocalize();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
const hasSetFetched = useRef<string | null>(null);
|
||||
|
||||
const { data: mcpServerSet, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data) => {
|
||||
const serverNames = new Set<string>();
|
||||
data.forEach((tool) => {
|
||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
||||
if (isMCP && tool.chatMenu !== false) {
|
||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||
serverNames.add(parts[parts.length - 1]);
|
||||
}
|
||||
});
|
||||
return serverNames;
|
||||
},
|
||||
});
|
||||
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
const mcpState = useMemo(() => {
|
||||
return ephemeralAgent?.mcp ?? [];
|
||||
}, [ephemeralAgent?.mcp]);
|
||||
|
||||
const setSelectedValues = useCallback(
|
||||
(values: string[] | null | undefined) => {
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
}
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
mcp: values,
|
||||
}));
|
||||
},
|
||||
[setEphemeralAgent],
|
||||
);
|
||||
const [mcpValues, setMCPValues] = useLocalStorage<string[]>(
|
||||
`${LocalStorageKeys.LAST_MCP_}${key}`,
|
||||
mcpState,
|
||||
setSelectedValues,
|
||||
storageCondition,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSetFetched.current === key) {
|
||||
return;
|
||||
}
|
||||
if (!isFetched) {
|
||||
return;
|
||||
}
|
||||
hasSetFetched.current = key;
|
||||
if ((mcpServerSet?.size ?? 0) > 0) {
|
||||
setMCPValues(mcpValues.filter((mcp) => mcpServerSet?.has(mcp)));
|
||||
return;
|
||||
}
|
||||
setMCPValues([]);
|
||||
}, [isFetched, setMCPValues, mcpServerSet, key, mcpValues]);
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(values: string[], placeholder?: string) => {
|
||||
if (values.length === 0) {
|
||||
return placeholder || localize('com_ui_select') + '...';
|
||||
}
|
||||
if (values.length === 1) {
|
||||
return values[0];
|
||||
}
|
||||
return localize('com_ui_x_selected', { 0: values.length });
|
||||
},
|
||||
[localize],
|
||||
);
|
||||
|
||||
const mcpServers = useMemo(() => {
|
||||
return Array.from(mcpServerSet ?? []);
|
||||
}, [mcpServerSet]);
|
||||
|
||||
if (!mcpServerSet || mcpServerSet.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
items={mcpServers ?? []}
|
||||
selectedValues={mcpValues ?? []}
|
||||
setSelectedValues={setMCPValues}
|
||||
defaultSelectedValues={mcpValues ?? []}
|
||||
renderSelectedValues={renderSelectedValues}
|
||||
placeholder={localize('com_ui_mcp_servers')}
|
||||
popoverClassName="min-w-fit"
|
||||
className="badge-icon min-w-fit"
|
||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MCPSelect);
|
||||
|
|
@ -28,7 +28,7 @@ export default function Mention({
|
|||
includeAssistants?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const assistantsMap = useAssistantsMapContext();
|
||||
const {
|
||||
options,
|
||||
presets,
|
||||
|
|
@ -37,11 +37,11 @@ export default function Mention({
|
|||
modelsConfig,
|
||||
endpointsConfig,
|
||||
assistantListMap,
|
||||
} = useMentions({ assistantMap: assistantMap || {}, includeAssistants });
|
||||
} = useMentions({ assistantMap: assistantsMap || {}, includeAssistants });
|
||||
const { onSelectMention } = useSelectMention({
|
||||
presets,
|
||||
modelSpecs,
|
||||
assistantMap,
|
||||
assistantsMap,
|
||||
endpointsConfig,
|
||||
newConversation,
|
||||
});
|
||||
|
|
@ -65,7 +65,7 @@ export default function Mention({
|
|||
setSearchValue('');
|
||||
setOpen(false);
|
||||
setShowMentionPopover(false);
|
||||
onSelectMention(mention);
|
||||
onSelectMention?.(mention);
|
||||
|
||||
if (textAreaRef.current) {
|
||||
removeCharIfLast(textAreaRef.current, commandChar);
|
||||
|
|
@ -158,11 +158,11 @@ export default function Mention({
|
|||
};
|
||||
|
||||
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">
|
||||
<input
|
||||
// The user expects focus to transition to the input field when the popover is opened
|
||||
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
placeholder={localize(placeholder)}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export default function PopoverButtons({
|
|||
const endpoint = overrideEndpoint ?? endpointType ?? _endpoint ?? '';
|
||||
const model = overrideModel ?? _model;
|
||||
|
||||
const isGenerativeModel = model?.toLowerCase().includes('gemini') ?? false;
|
||||
const isGenerativeModel = /gemini|learnlm|gemma/.test(model ?? '') ?? false;
|
||||
const isChatModel = (!isGenerativeModel && model?.toLowerCase().includes('chat')) ?? false;
|
||||
const isTextModel = !isGenerativeModel && !isChatModel && /code|text/.test(model ?? '');
|
||||
|
||||
|
|
@ -133,7 +133,6 @@ export default function PopoverButtons({
|
|||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
|
||||
{disabled ? null : (
|
||||
<div className="flex w-[150px] items-center justify-end">
|
||||
{additionalButtons[settingsView].map((button, index) => (
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ function PromptsCommand({
|
|||
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
|
||||
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" />,
|
||||
}));
|
||||
|
|
@ -195,7 +197,7 @@ function PromptsCommand({
|
|||
variableGroup={variableGroup}
|
||||
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">
|
||||
<input
|
||||
// The user expects focus to transition to the input field when the popover is opened
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const SubmitButton = React.memo(
|
|||
id="send-button"
|
||||
disabled={props.disabled}
|
||||
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"
|
||||
type="submit"
|
||||
|
|
@ -34,7 +34,7 @@ const SubmitButton = React.memo(
|
|||
</span>
|
||||
</button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
/>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default function StopButton({ stop, setShowStopButton }) {
|
|||
<button
|
||||
type="button"
|
||||
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')}
|
||||
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 (
|
||||
<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} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,47 +1,66 @@
|
|||
import { useMemo } from 'react';
|
||||
import { EModelEndpoint, Constants } from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { easings } from '@react-spring/web';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
|
||||
import {
|
||||
useGetAssistantDocsQuery,
|
||||
useGetEndpointsQuery,
|
||||
useGetStartupConfig,
|
||||
} from '~/data-provider';
|
||||
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import { BirthdayIcon, TooltipAnchor, SplitText } from '~/components';
|
||||
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
|
||||
import { getIconEndpoint, getEntity, cn } from '~/utils';
|
||||
import { useLocalize, useSubmitMessage } from '~/hooks';
|
||||
import { TooltipAnchor } from '~/components/ui';
|
||||
import { BirthdayIcon } from '~/components/svg';
|
||||
import ConvoStarter from './ConvoStarter';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { getIconEndpoint, getEntity } from '~/utils';
|
||||
|
||||
export default function Landing({ Header }: { Header?: ReactNode }) {
|
||||
const containerClassName =
|
||||
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white dark:bg-presentation dark:text-white text-black dark:after:shadow-none ';
|
||||
|
||||
function getTextSizeClass(text: string | undefined | null) {
|
||||
if (!text) {
|
||||
return 'text-xl sm:text-2xl';
|
||||
}
|
||||
|
||||
if (text.length < 40) {
|
||||
return 'text-2xl sm:text-4xl';
|
||||
}
|
||||
|
||||
if (text.length < 70) {
|
||||
return 'text-xl sm:text-2xl';
|
||||
}
|
||||
|
||||
return 'text-lg sm:text-md';
|
||||
}
|
||||
|
||||
export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: boolean }) {
|
||||
const { conversation } = useChatContext();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const { user } = useAuthContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
let { endpoint = '' } = conversation ?? {};
|
||||
const [textHasMultipleLines, setTextHasMultipleLines] = useState(false);
|
||||
const [lineCount, setLineCount] = useState(1);
|
||||
const [contentHeight, setContentHeight] = useState(0);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (
|
||||
endpoint === EModelEndpoint.chatGPTBrowser ||
|
||||
endpoint === EModelEndpoint.azureOpenAI ||
|
||||
endpoint === EModelEndpoint.gptPlugins
|
||||
) {
|
||||
endpoint = EModelEndpoint.openAI;
|
||||
}
|
||||
|
||||
const iconURL = conversation?.iconURL;
|
||||
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
|
||||
const { data: documentsMap = new Map() } = useGetAssistantDocsQuery(endpoint, {
|
||||
select: (data) => new Map(data.map((dbA) => [dbA.assistant_id, dbA])),
|
||||
});
|
||||
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 { entity, isAgent, isAssistant } = getEntity({
|
||||
endpoint,
|
||||
endpoint: endpointType,
|
||||
agentsMap,
|
||||
assistantMap,
|
||||
agent_id: conversation?.agent_id,
|
||||
|
|
@ -49,103 +68,146 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
|
|||
});
|
||||
|
||||
const name = entity?.name ?? '';
|
||||
const description = entity?.description ?? '';
|
||||
const avatar = isAgent
|
||||
? (entity as t.Agent | undefined)?.avatar?.filepath ?? ''
|
||||
: ((entity as t.Assistant | undefined)?.metadata?.avatar as string | undefined) ?? '';
|
||||
const conversation_starters = useMemo(() => {
|
||||
/* 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 ?? [];
|
||||
const description = (entity?.description || conversation?.greeting) ?? '';
|
||||
|
||||
const getGreeting = useCallback(() => {
|
||||
if (typeof startupConfig?.interface?.customWelcome === 'string') {
|
||||
const customWelcome = startupConfig.interface.customWelcome;
|
||||
// Replace {{user.name}} with actual user name if available
|
||||
if (user?.name && customWelcome.includes('{{user.name}}')) {
|
||||
return customWelcome.replace(/{{user.name}}/g, user.name);
|
||||
}
|
||||
return customWelcome;
|
||||
}
|
||||
|
||||
/* If none in cache, we use the latest assistant docs */
|
||||
const entityDocs = documentsMap.get(entity?.id ?? '');
|
||||
return entityDocs?.conversation_starters ?? [];
|
||||
}, [documentsMap, isAgent, entity]);
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
|
||||
const containerClassName =
|
||||
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
|
||||
const dayOfWeek = now.getDay();
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
|
||||
const { submitMessage } = useSubmitMessage();
|
||||
const sendConversationStarter = (text: string) => submitMessage({ text });
|
||||
// Early morning (midnight to 4:59 AM)
|
||||
if (hours >= 0 && hours < 5) {
|
||||
return localize('com_ui_late_night');
|
||||
}
|
||||
// Morning (6 AM to 11:59 AM)
|
||||
else if (hours < 12) {
|
||||
if (isWeekend) {
|
||||
return localize('com_ui_weekend_morning');
|
||||
}
|
||||
return localize('com_ui_good_morning');
|
||||
}
|
||||
// Afternoon (12 PM to 4:59 PM)
|
||||
else if (hours < 17) {
|
||||
return localize('com_ui_good_afternoon');
|
||||
}
|
||||
// Evening (5 PM to 8:59 PM)
|
||||
else {
|
||||
return localize('com_ui_good_evening');
|
||||
}
|
||||
}, [localize, startupConfig?.interface?.customWelcome, user?.name]);
|
||||
|
||||
const getWelcomeMessage = () => {
|
||||
const greeting = conversation?.greeting ?? '';
|
||||
if (greeting) {
|
||||
return greeting;
|
||||
const handleLineCountChange = useCallback((count: number) => {
|
||||
setTextHasMultipleLines(count > 1);
|
||||
setLineCount(count);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
setContentHeight(contentRef.current.offsetHeight);
|
||||
}
|
||||
}, [lineCount, description]);
|
||||
|
||||
const getDynamicMargin = useMemo(() => {
|
||||
let margin = 'mb-0';
|
||||
|
||||
if (lineCount > 2 || (description && description.length > 100)) {
|
||||
margin = 'mb-10';
|
||||
} else if (lineCount > 1 || (description && description.length > 0)) {
|
||||
margin = 'mb-6';
|
||||
} else if (textHasMultipleLines) {
|
||||
margin = 'mb-4';
|
||||
}
|
||||
|
||||
if (isAssistant) {
|
||||
return localize('com_nav_welcome_assistant');
|
||||
if (contentHeight > 200) {
|
||||
margin = 'mb-16';
|
||||
} else if (contentHeight > 150) {
|
||||
margin = 'mb-12';
|
||||
}
|
||||
|
||||
if (isAgent) {
|
||||
return localize('com_nav_welcome_agent');
|
||||
}
|
||||
return margin;
|
||||
}, [lineCount, description, textHasMultipleLines, contentHeight]);
|
||||
|
||||
return typeof startupConfig?.interface?.customWelcome === 'string'
|
||||
? startupConfig?.interface?.customWelcome
|
||||
: localize('com_nav_welcome_message');
|
||||
};
|
||||
const greetingText =
|
||||
typeof startupConfig?.interface?.customWelcome === 'string'
|
||||
? getGreeting()
|
||||
: getGreeting() + (user?.name ? ', ' + user.name : '');
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div className="absolute left-0 right-0">{Header != null ? Header : null}</div>
|
||||
<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')}>
|
||||
<ConvoIcon
|
||||
agentsMap={agentsMap}
|
||||
assistantMap={assistantMap}
|
||||
conversation={conversation}
|
||||
endpointsConfig={endpointsConfig}
|
||||
containerClassName={containerClassName}
|
||||
context="landing"
|
||||
className="h-2/3 w-2/3"
|
||||
size={41}
|
||||
/>
|
||||
{startupConfig?.showBirthdayIcon === true ? (
|
||||
<TooltipAnchor
|
||||
className="absolute bottom-8 right-2.5"
|
||||
description={localize('com_ui_happy_birthday')}
|
||||
>
|
||||
<BirthdayIcon />
|
||||
</TooltipAnchor>
|
||||
) : null}
|
||||
</div>
|
||||
{name ? (
|
||||
<div className="flex flex-col items-center gap-0 p-2">
|
||||
<div className="text-center text-2xl font-medium dark:text-white">{name}</div>
|
||||
<div className="max-w-md text-center text-sm font-normal text-text-primary ">
|
||||
{description ||
|
||||
(typeof startupConfig?.interface?.customWelcome === 'string'
|
||||
? startupConfig?.interface?.customWelcome
|
||||
: localize('com_nav_welcome_message'))}
|
||||
</div>
|
||||
{/* <div className="mt-1 flex items-center gap-1 text-token-text-tertiary">
|
||||
<div className="text-sm text-token-text-tertiary">By Daniel Avila</div>
|
||||
</div> */}
|
||||
<div
|
||||
className={`flex h-full transform-gpu flex-col items-center justify-center pb-16 transition-all duration-200 ${centerFormOnLanding ? 'max-h-full sm:max-h-0' : 'max-h-full'} ${getDynamicMargin}`}
|
||||
>
|
||||
<div ref={contentRef} className="flex flex-col items-center gap-0 p-2">
|
||||
<div
|
||||
className={`flex ${textHasMultipleLines ? 'flex-col' : 'flex-col md:flex-row'} items-center justify-center gap-2`}
|
||||
>
|
||||
<div className={`relative size-10 justify-center ${textHasMultipleLines ? 'mb-2' : ''}`}>
|
||||
<ConvoIcon
|
||||
agentsMap={agentsMap}
|
||||
assistantMap={assistantMap}
|
||||
conversation={conversation}
|
||||
endpointsConfig={endpointsConfig}
|
||||
containerClassName={containerClassName}
|
||||
context="landing"
|
||||
className="h-2/3 w-2/3 text-black dark:text-white"
|
||||
size={41}
|
||||
/>
|
||||
{startupConfig?.showBirthdayIcon && (
|
||||
<TooltipAnchor
|
||||
className="absolute bottom-[27px] right-2"
|
||||
description={localize('com_ui_happy_birthday')}
|
||||
>
|
||||
<BirthdayIcon />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
</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">
|
||||
{getWelcomeMessage()}
|
||||
</h2>
|
||||
)}
|
||||
<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)}
|
||||
/>
|
||||
))}
|
||||
{((isAgent || isAssistant) && name) || name ? (
|
||||
<div className="flex flex-col items-center gap-0 p-2">
|
||||
<SplitText
|
||||
key={`split-text-${name}`}
|
||||
text={name}
|
||||
className={`${getTextSizeClass(name)} 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={easings.easeOutCubic}
|
||||
threshold={0}
|
||||
rootMargin="0px"
|
||||
onLineCountChange={handleLineCountChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<SplitText
|
||||
key={`split-text-${greetingText}${user?.name ? '-user' : ''}`}
|
||||
text={greetingText}
|
||||
className={`${getTextSizeClass(greetingText)} font-medium text-text-primary`}
|
||||
delay={50}
|
||||
textAlign="center"
|
||||
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
|
||||
animationTo={{ opacity: 1, transform: 'translate3d(0,0,0)' }}
|
||||
easing={easings.easeOutCubic}
|
||||
threshold={0}
|
||||
rootMargin="0px"
|
||||
onLineCountChange={handleLineCountChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="animate-fadeIn mt-4 max-w-md text-center text-sm font-normal text-text-primary">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ const BookmarkMenu: FC = () => {
|
|||
focusLoop={true}
|
||||
menuId={menuId}
|
||||
isOpen={isMenuOpen}
|
||||
unmountOnHide={true}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
keyPrefix={`${conversationId}-bookmark-`}
|
||||
trigger={
|
||||
|
|
@ -170,7 +171,7 @@ const BookmarkMenu: FC = () => {
|
|||
id="bookmark-menu-button"
|
||||
aria-label={localize('com_ui_bookmarks_add')}
|
||||
className={cn(
|
||||
'mt-text-sm flex size-10 items-center justify-center gap-2 rounded-lg border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
|
||||
'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-xl border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
|
||||
isMenuOpen ? 'bg-surface-hover' : '',
|
||||
)}
|
||||
data-testid="bookmark-menu"
|
||||
|
|
|
|||
247
client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx
Normal file
247
client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
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-lg border-none bg-transparent px-2 text-base',
|
||||
'sm:h-8 sm:text-sm',
|
||||
'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-white',
|
||||
)}
|
||||
/>
|
||||
</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;
|
||||
107
client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx
Normal file
107
client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import type { ModelSelectorProps } from '~/common';
|
||||
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
|
||||
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components';
|
||||
import { getSelectedIcon, getDisplayValue } from './utils';
|
||||
import { CustomMenu as Menu } from './CustomMenu';
|
||||
import DialogManager from './DialogManager';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
function ModelSelectorContent() {
|
||||
const localize = useLocalize();
|
||||
|
||||
const {
|
||||
// LibreChat
|
||||
modelSpecs,
|
||||
mappedEndpoints,
|
||||
endpointsConfig,
|
||||
// State
|
||||
searchValue,
|
||||
searchResults,
|
||||
selectedValues,
|
||||
|
||||
// Functions
|
||||
setSearchValue,
|
||||
setSelectedValues,
|
||||
// Dialog
|
||||
keyDialogOpen,
|
||||
onOpenChange,
|
||||
keyDialogEndpoint,
|
||||
} = useModelSelectorContext();
|
||||
|
||||
const selectedIcon = useMemo(
|
||||
() =>
|
||||
getSelectedIcon({
|
||||
mappedEndpoints: mappedEndpoints ?? [],
|
||||
selectedValues,
|
||||
modelSpecs,
|
||||
endpointsConfig,
|
||||
}),
|
||||
[mappedEndpoints, selectedValues, modelSpecs, endpointsConfig],
|
||||
);
|
||||
const selectedDisplayValue = useMemo(
|
||||
() =>
|
||||
getDisplayValue({
|
||||
localize,
|
||||
modelSpecs,
|
||||
selectedValues,
|
||||
mappedEndpoints,
|
||||
}),
|
||||
[localize, modelSpecs, selectedValues, mappedEndpoints],
|
||||
);
|
||||
|
||||
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_ui_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({ startupConfig }: ModelSelectorProps) {
|
||||
return (
|
||||
<ModelSelectorProvider startupConfig={startupConfig}>
|
||||
<ModelSelectorContent />
|
||||
</ModelSelectorProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { createContext, useContext, useState, useMemo } from 'react';
|
||||
import { 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 } 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
|
||||
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;
|
||||
startupConfig: t.TStartupConfig | undefined;
|
||||
}
|
||||
|
||||
export function ModelSelectorProvider({ children, startupConfig }: ModelSelectorProviderProps) {
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const assistantsMap = useAssistantsMapContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
const modelSpecs = useMemo(() => startupConfig?.modelSpecs?.list ?? [], [startupConfig]);
|
||||
const { mappedEndpoints, endpointRequiresUserKey } = useEndpoints({
|
||||
agentsMap,
|
||||
assistantsMap,
|
||||
startupConfig,
|
||||
endpointsConfig,
|
||||
});
|
||||
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 setDebouncedSearchValue = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setSearchValueState(value);
|
||||
}, 200),
|
||||
[],
|
||||
);
|
||||
const setEndpointSearchValue = (endpoint: string, value: string) => {
|
||||
setEndpointSearchValues((prev) => ({
|
||||
...prev,
|
||||
[endpoint]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectSpec = (spec: t.TModelSpec) => {
|
||||
let model = spec.preset.model ?? null;
|
||||
onSelectSpec?.(spec);
|
||||
if (isAgentsEndpoint(spec.preset.endpoint)) {
|
||||
model = spec.preset.agent_id ?? '';
|
||||
} else if (isAssistantsEndpoint(spec.preset.endpoint)) {
|
||||
model = spec.preset.assistant_id ?? '';
|
||||
}
|
||||
setSelectedValues({
|
||||
endpoint: spec.preset.endpoint,
|
||||
model,
|
||||
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,
|
||||
model: agentsMap?.[model]?.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,
|
||||
modelSpec: '',
|
||||
});
|
||||
};
|
||||
|
||||
const value = {
|
||||
// State
|
||||
searchValue,
|
||||
searchResults,
|
||||
selectedValues,
|
||||
endpointSearchValues,
|
||||
// LibreChat
|
||||
agentsMap,
|
||||
modelSpecs,
|
||||
assistantsMap,
|
||||
mappedEndpoints,
|
||||
endpointsConfig,
|
||||
|
||||
// Functions
|
||||
handleSelectSpec,
|
||||
handleSelectModel,
|
||||
setSelectedValues,
|
||||
handleSelectEndpoint,
|
||||
setEndpointSearchValue,
|
||||
endpointRequiresUserKey,
|
||||
setSearchValue: setDebouncedSearchValue,
|
||||
// Dialog
|
||||
...keyProps,
|
||||
};
|
||||
|
||||
return <ModelSelectorContext.Provider value={value}>{children}</ModelSelectorContext.Provider>;
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import { useMemo } from 'react';
|
||||
import { SettingsIcon } from 'lucide-react';
|
||||
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 { TooltipAnchor, Spinner } from '~/components';
|
||||
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>
|
||||
{/* TODO: remove this after deprecation */}
|
||||
{endpoint.value === 'gptPlugins' && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_endpoint_deprecated_info')}
|
||||
aria-label={localize('com_endpoint_deprecated_info_a11y')}
|
||||
render={
|
||||
<span className="ml-2 rounded bg-amber-600/70 px-2 py-0.5 text-xs font-semibold text-white">
|
||||
{localize('com_endpoint_deprecated')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (endpoint.hasModels) {
|
||||
const filteredModels = searchValue
|
||||
? filterModels(
|
||||
endpoint,
|
||||
(endpoint.models || []).map((model) => model.name),
|
||||
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>
|
||||
}
|
||||
>
|
||||
{isAssistantsEndpoint(endpoint.value) && 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,95 @@
|
|||
import React from 'react';
|
||||
import { EarthIcon } from 'lucide-react';
|
||||
import { isAgentsEndpoint, isAssistantsEndpoint } 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 isGlobal = false;
|
||||
let modelName = modelId;
|
||||
const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null;
|
||||
|
||||
// Use custom names if available
|
||||
if (endpoint && modelId && isAgentsEndpoint(endpoint.value) && endpoint.agentNames?.[modelId]) {
|
||||
modelName = endpoint.agentNames[modelId];
|
||||
|
||||
const modelInfo = endpoint?.models?.find((m) => m.name === modelId);
|
||||
isGlobal = modelInfo?.isGlobal ?? false;
|
||||
} else if (
|
||||
endpoint &&
|
||||
modelId &&
|
||||
isAssistantsEndpoint(endpoint.value) &&
|
||||
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>
|
||||
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
|
||||
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>
|
||||
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}
|
||||
{isSelected && (
|
||||
<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 renderEndpointModels(
|
||||
endpoint: Endpoint | null,
|
||||
models: Array<{ name: string; isGlobal?: boolean }>,
|
||||
selectedModel: string | null,
|
||||
filteredModels?: string[],
|
||||
) {
|
||||
const modelsToRender = filteredModels || models.map((model) => model.name);
|
||||
|
||||
return modelsToRender.map(
|
||||
(modelId) =>
|
||||
endpoint && (
|
||||
<EndpointModelItem
|
||||
key={modelId}
|
||||
modelId={modelId}
|
||||
endpoint={endpoint}
|
||||
isSelected={selectedModel === modelId}
|
||||
/>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
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 items-center justify-between rounded-lg px-2 text-sm',
|
||||
)}
|
||||
>
|
||||
<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="flex-shrink-0 self-center">
|
||||
<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,256 @@
|
|||
import React, { Fragment } from 'react';
|
||||
import { EarthIcon } from 'lucide-react';
|
||||
import { isAgentsEndpoint, isAssistantsEndpoint } 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,
|
||||
} = 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((model) => {
|
||||
let modelName = model.name;
|
||||
if (
|
||||
isAgentsEndpoint(endpoint.value) &&
|
||||
endpoint.agentNames &&
|
||||
endpoint.agentNames[model.name]
|
||||
) {
|
||||
modelName = endpoint.agentNames[model.name];
|
||||
} else if (
|
||||
isAssistantsEndpoint(endpoint.value) &&
|
||||
endpoint.assistantNames &&
|
||||
endpoint.assistantNames[model.name]
|
||||
) {
|
||||
modelName = endpoint.assistantNames[model.name];
|
||||
}
|
||||
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((model) => {
|
||||
const modelId = model.name;
|
||||
|
||||
let isGlobal = false;
|
||||
let modelName = modelId;
|
||||
if (
|
||||
isAgentsEndpoint(endpoint.value) &&
|
||||
endpoint.agentNames &&
|
||||
endpoint.agentNames[modelId]
|
||||
) {
|
||||
modelName = endpoint.agentNames[modelId];
|
||||
const modelInfo = endpoint?.models?.find((m) => m.name === modelId);
|
||||
isGlobal = modelInfo?.isGlobal ?? false;
|
||||
} else if (
|
||||
isAssistantsEndpoint(endpoint.value) &&
|
||||
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>
|
||||
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}
|
||||
{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="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,37 @@ import React, { memo } from 'react';
|
|||
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import type { IconMapProps } from '~/common';
|
||||
import { getModelSpecIconURL, getIconKey, getEndpointField } from '~/utils';
|
||||
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
|
||||
import { URLIcon } from '~/components/Endpoints/URLIcon';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
|
||||
interface SpecIconProps {
|
||||
currentSpec: TModelSpec;
|
||||
endpointsConfig: TEndpointsConfig;
|
||||
}
|
||||
|
||||
type IconType = (props: IconMapProps) => React.JSX.Element;
|
||||
|
||||
const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) => {
|
||||
const iconURL = getModelSpecIconURL(currentSpec);
|
||||
const { endpoint } = currentSpec.preset;
|
||||
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });
|
||||
let Icon: (props: IconMapProps) => React.JSX.Element;
|
||||
let Icon: IconType;
|
||||
|
||||
if (!iconURL.includes('http')) {
|
||||
Icon = icons[iconKey] ?? icons.unknown;
|
||||
Icon = (icons[iconURL] ?? icons[iconKey] ?? icons.unknown) as IconType;
|
||||
} else if (iconURL) {
|
||||
return <URLIcon iconURL={iconURL} altName={currentSpec.name} />;
|
||||
return (
|
||||
<URLIcon
|
||||
iconURL={iconURL}
|
||||
altName={currentSpec.name}
|
||||
containerStyle={{ width: 20, height: 20 }}
|
||||
className="icon-md shrink-0 overflow-hidden rounded-full"
|
||||
endpoint={endpoint || undefined}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
Icon = icons[endpoint ?? ''] ?? icons.unknown;
|
||||
Icon = (icons[endpoint ?? ''] ?? icons[iconKey] ?? icons.unknown) as IconType;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -31,7 +41,7 @@ const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) =>
|
|||
endpoint={endpoint}
|
||||
context="menu-item"
|
||||
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';
|
||||
212
client/src/components/Chat/Menus/Endpoints/utils.ts
Normal file
212
client/src/components/Chat/Menus/Endpoints/utils.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import React from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type {
|
||||
TModelSpec,
|
||||
TAgentsMap,
|
||||
TAssistantsMap,
|
||||
TEndpointsConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { useLocalize } from '~/hooks';
|
||||
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?: Array<{ name: string; isGlobal?: boolean }>;
|
||||
},
|
||||
>(
|
||||
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.name.toLowerCase().includes(searchTermLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isAgentsEndpoint(item.value) && agentsMap && modelId.name in agentsMap) {
|
||||
const agentName = agentsMap[modelId.name]?.name;
|
||||
return typeof agentName === 'string' && agentName.toLowerCase().includes(searchTermLower);
|
||||
}
|
||||
|
||||
if (isAssistantsEndpoint(item.value) && assistantsMap) {
|
||||
const endpoint = item.value ?? '';
|
||||
const assistant = assistantsMap[endpoint][modelId.name];
|
||||
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 (isAgentsEndpoint(endpoint.value) && agentsMap && agentsMap[modelId]) {
|
||||
modelName = agentsMap[modelId].name || modelId;
|
||||
} else if (
|
||||
isAssistantsEndpoint(endpoint.value) &&
|
||||
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;
|
||||
}
|
||||
|
||||
export const getDisplayValue = ({
|
||||
localize,
|
||||
mappedEndpoints,
|
||||
selectedValues,
|
||||
modelSpecs,
|
||||
}: {
|
||||
localize: ReturnType<typeof useLocalize>;
|
||||
selectedValues: SelectedValues;
|
||||
mappedEndpoints: Endpoint[];
|
||||
modelSpecs: TModelSpec[];
|
||||
}) => {
|
||||
if (selectedValues.modelSpec) {
|
||||
const spec = modelSpecs.find((s) => s.name === selectedValues.modelSpec);
|
||||
return spec?.label || spec?.name || localize('com_ui_select_model');
|
||||
}
|
||||
|
||||
if (selectedValues.model && selectedValues.endpoint) {
|
||||
const endpoint = mappedEndpoints.find((e) => e.value === selectedValues.endpoint);
|
||||
if (!endpoint) {
|
||||
return localize('com_ui_select_model');
|
||||
}
|
||||
|
||||
if (
|
||||
isAgentsEndpoint(endpoint.value) &&
|
||||
endpoint.agentNames &&
|
||||
endpoint.agentNames[selectedValues.model]
|
||||
) {
|
||||
return endpoint.agentNames[selectedValues.model];
|
||||
}
|
||||
|
||||
if (
|
||||
isAssistantsEndpoint(endpoint.value) &&
|
||||
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_ui_select_model');
|
||||
}
|
||||
|
||||
return localize('com_ui_select_model');
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,35 +1,44 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||
import { TooltipAnchor, Button } from '~/components/ui';
|
||||
import { NewChatIcon } from '~/components/svg';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function HeaderNewChat() {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const localize = useLocalize();
|
||||
if (isSmallScreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
|
||||
window.open('/c/new', '_blank');
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
data-testid="wide-header-new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
type="button"
|
||||
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={() => {
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
newConversation();
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<NewChatIcon />
|
||||
</div>
|
||||
</button>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_chat')}
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
data-testid="wide-header-new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<NewChatIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
33
client/src/components/Chat/Menus/OpenSidebar.tsx
Normal file
33
client/src/components/Chat/Menus/OpenSidebar.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { TooltipAnchor, Button } from '~/components/ui';
|
||||
import { Sidebar } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function OpenSidebar({
|
||||
setNavVisible,
|
||||
}: {
|
||||
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={localize('com_nav_open_sidebar')}
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
data-testid="open-sidebar-button"
|
||||
aria-label={localize('com_nav_open_sidebar')}
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
||||
onClick={() =>
|
||||
setNavVisible((prev) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
})
|
||||
}
|
||||
>
|
||||
<Sidebar />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import { Dialog, DialogTrigger, Label } from '~/components/ui';
|
|||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { MenuSeparator, MenuItem } from '../UI';
|
||||
import { icons } from '../Endpoints/Icons';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
|
@ -39,7 +39,7 @@ const PresetItems: FC<{
|
|||
<>
|
||||
<div
|
||||
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}
|
||||
>
|
||||
<div className="flex h-full grow items-center justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const PresetsMenu: FC = () => {
|
|||
tabIndex={0}
|
||||
role="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-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<BookCopy size={16} aria-label="Preset Icon" />
|
||||
</TooltipAnchor>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
export { default as PresetsMenu } from './PresetsMenu';
|
||||
export { default as EndpointsMenu } from './EndpointsMenu';
|
||||
export { default as OpenSidebar } from './OpenSidebar';
|
||||
export { default as HeaderNewChat } from './HeaderNewChat';
|
||||
export { default as ModelSpecsMenu } from './Models/ModelSpecsMenu';
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ const EditMessage = ({
|
|||
conversationId,
|
||||
},
|
||||
{
|
||||
resubmitFiles: true,
|
||||
isResubmission: true,
|
||||
overrideFiles: message.files,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -112,9 +113,9 @@ const EditMessage = ({
|
|||
messages.map((msg) =>
|
||||
msg.messageId === messageId
|
||||
? {
|
||||
...msg,
|
||||
text: data.text,
|
||||
}
|
||||
...msg,
|
||||
text: data.text,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
|
|||
|
||||
return (
|
||||
<a
|
||||
href={filepath.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
|
||||
href={filepath?.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -184,7 +184,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
|||
|
||||
const rehypePlugins = useMemo(
|
||||
() => [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[rehypeKatex],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { langSubset } from '~/utils';
|
|||
const MarkdownLite = memo(
|
||||
({ content = '', codeExecution = true }: { content?: string; codeExecution?: boolean }) => {
|
||||
const rehypePlugins: PluggableList = [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[rehypeKatex],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -38,7 +38,13 @@ const Part = memo(
|
|||
if (part.type === ContentTypes.ERROR) {
|
||||
return (
|
||||
<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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import Icon from '~/components/Endpoints/Icon';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface AgentUpdateProps {
|
||||
currentAgentId: string;
|
||||
}
|
||||
|
||||
const AgentUpdate: React.FC<AgentUpdateProps> = ({ currentAgentId }) => {
|
||||
const localize = useLocalize();
|
||||
const agentsMap = useAgentsMapContext() || {};
|
||||
const currentAgent = useMemo(() => agentsMap[currentAgentId], [agentsMap, currentAgentId]);
|
||||
if (!currentAgentId) {
|
||||
|
|
@ -23,14 +26,19 @@ const AgentUpdate: React.FC<AgentUpdateProps> = ({ currentAgentId }) => {
|
|||
</div>
|
||||
<div className="my-4 flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<Icon
|
||||
endpoint={EModelEndpoint.agents}
|
||||
agentName={currentAgent?.name ?? ''}
|
||||
iconURL={currentAgent?.avatar?.filepath}
|
||||
isCreatedByUser={false}
|
||||
<MessageIcon
|
||||
message={
|
||||
{
|
||||
endpoint: EModelEndpoint.agents,
|
||||
isCreatedByUser: false,
|
||||
} as TMessage
|
||||
}
|
||||
agent={currentAgent}
|
||||
/>
|
||||
</div>
|
||||
<div className="font-medium text-text-primary">{currentAgent?.name}</div>
|
||||
<div className="text-base font-medium text-text-primary">
|
||||
{currentAgent?.name || localize('com_ui_agent')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,27 @@
|
|||
import { memo } from 'react';
|
||||
import { imageExtRegex } from 'librechat-data-provider';
|
||||
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
|
||||
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
||||
import Image from '~/components/Chat/Messages/Content/Image';
|
||||
import { useAttachmentLink } from './LogLink';
|
||||
|
||||
const FileAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||
const { handleDownload } = useAttachmentLink({
|
||||
href: attachment.filepath,
|
||||
filename: attachment.filename,
|
||||
});
|
||||
const extension = attachment.filename.split('.').pop();
|
||||
|
||||
return (
|
||||
<FileContainer
|
||||
file={attachment}
|
||||
onClick={handleDownload}
|
||||
overrideType={extension}
|
||||
containerClassName="max-w-fit"
|
||||
buttonClassName="hover:cursor-pointer hover:bg-surface-secondary active:bg-surface-secondary focus:bg-surface-secondary hover:border-border-heavy active:border-border-heavy"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default function Attachment({ attachment }: { attachment?: TAttachment }) {
|
||||
if (!attachment) {
|
||||
|
|
@ -21,5 +42,5 @@ export default function Attachment({ attachment }: { attachment?: TAttachment })
|
|||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return <FileAttachment attachment={attachment} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
|
|||
import { useProgress, useLocalize } from '~/hooks';
|
||||
import { CodeInProgress } from './CodeProgress';
|
||||
import Attachment from './Attachment';
|
||||
import LogContent from './LogContent';
|
||||
import Stdout from './Stdout';
|
||||
import store from '~/store';
|
||||
|
||||
interface ParsedArgs {
|
||||
|
|
@ -17,8 +17,17 @@ interface ParsedArgs {
|
|||
|
||||
export function useParseArgs(args: string): ParsedArgs {
|
||||
return useMemo(() => {
|
||||
let parsedArgs: ParsedArgs | string = args;
|
||||
try {
|
||||
parsedArgs = JSON.parse(args);
|
||||
} catch {
|
||||
// console.error('Failed to parse args:', e);
|
||||
}
|
||||
if (typeof parsedArgs === 'object') {
|
||||
return parsedArgs;
|
||||
}
|
||||
const langMatch = args.match(/"lang"\s*:\s*"(\w+)"/);
|
||||
const codeMatch = args.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"args"|$)/s);
|
||||
const codeMatch = args.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"(session_id|args)"|"\s*})/s);
|
||||
|
||||
let code = '';
|
||||
if (codeMatch) {
|
||||
|
|
@ -26,7 +35,7 @@ export function useParseArgs(args: string): ParsedArgs {
|
|||
if (code.endsWith('"}')) {
|
||||
code = code.slice(0, -2);
|
||||
}
|
||||
code = code.replace(/\\n/g, '\n').replace(/\\/g, '');
|
||||
code = code.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -99,15 +108,17 @@ export default function ExecuteCode({
|
|||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<pre className="shrink-0">
|
||||
<LogContent output={output} attachments={attachments} />
|
||||
</pre>
|
||||
<Stdout output={output} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{attachments?.map((attachment, index) => <Attachment attachment={attachment} key={index} />)}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2.5">
|
||||
{attachments?.map((attachment, index) => (
|
||||
<Attachment attachment={attachment} key={index} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ interface LogLinkProps {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const LogLink: React.FC<LogLinkProps> = ({ href, filename, children }) => {
|
||||
export const useAttachmentLink = ({ href, filename }: Pick<LogLinkProps, 'href' | 'filename'>) => {
|
||||
const { showToast } = useToastContext();
|
||||
const { refetch: downloadFile } = useCodeOutputDownload(href);
|
||||
|
||||
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const stream = await downloadFile();
|
||||
|
|
@ -36,6 +36,11 @@ const LogLink: React.FC<LogLinkProps> = ({ href, filename, children }) => {
|
|||
}
|
||||
};
|
||||
|
||||
return { handleDownload };
|
||||
};
|
||||
|
||||
const LogLink: React.FC<LogLinkProps> = ({ href, filename, children }) => {
|
||||
const { handleDownload } = useAttachmentLink({ href, filename });
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
|
|
|
|||
26
client/src/components/Chat/Messages/Content/Parts/Stdout.tsx
Normal file
26
client/src/components/Chat/Messages/Content/Parts/Stdout.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React, { useMemo } from 'react';
|
||||
|
||||
interface StdoutProps {
|
||||
output?: string;
|
||||
}
|
||||
|
||||
const Stdout: React.FC<StdoutProps> = ({ output = '' }) => {
|
||||
const processedContent = useMemo(() => {
|
||||
if (!output) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = output.split('Generated files:');
|
||||
return parts[0].trim();
|
||||
}, [output]);
|
||||
|
||||
return (
|
||||
processedContent && (
|
||||
<pre className="shrink-0">
|
||||
<div>{processedContent}</div>
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Stdout;
|
||||
|
|
@ -35,7 +35,7 @@ const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) =>
|
|||
} else {
|
||||
return <>{text}</>;
|
||||
}
|
||||
}, [isCreatedByUser, enableUserMsgMarkdown, text, showCursorState, isLatestMessage]);
|
||||
}, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
import { Suspense } from 'react';
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { Agents, TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import { UnfinishedMessage } from './MessageContent';
|
||||
import { DelayedRender } from '~/components/ui';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import { cn } from '~/utils';
|
||||
import { cn, mapAttachments } from '~/utils';
|
||||
import store from '~/store';
|
||||
import Part from './Part';
|
||||
|
||||
const SearchContent = ({ message }: { message: TMessage }) => {
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
const { messageId } = message;
|
||||
const messageAttachmentsMap = useRecoilValue(store.messageAttachmentsMap);
|
||||
const attachmentMap = useMemo(
|
||||
() => mapAttachments(message?.attachments ?? messageAttachmentsMap[messageId] ?? []),
|
||||
[message?.attachments, messageAttachmentsMap, messageId],
|
||||
);
|
||||
|
||||
if (Array.isArray(message.content) && message.content.length > 0) {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -20,13 +27,17 @@ const SearchContent = ({ message }: { message: TMessage }) => {
|
|||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const attachments = attachmentMap[toolCallId];
|
||||
return (
|
||||
<Part
|
||||
key={`display-${messageId}-${idx}`}
|
||||
showCursor={false}
|
||||
isSubmitting={false}
|
||||
isCreatedByUser={message.isCreatedByUser}
|
||||
messageId={message.messageId}
|
||||
attachments={attachments}
|
||||
part={part}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessageAudio } from '~/common';
|
||||
import { BrowserTTS, EdgeTTS, ExternalTTS } from '~/components/Audio/TTS';
|
||||
import { BrowserTTS, ExternalTTS } from '~/components/Audio/TTS';
|
||||
import { TTSEndpoints } from '~/common';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -9,7 +9,6 @@ function MessageAudio(props: TMessageAudio) {
|
|||
const engineTTS = useRecoilValue<string>(store.engineTTS);
|
||||
|
||||
const TTSComponents = {
|
||||
[TTSEndpoints.edge]: EdgeTTS,
|
||||
[TTSEndpoints.browser]: BrowserTTS,
|
||||
[TTSEndpoints.external]: ExternalTTS,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
|||
import { useMessageHelpers, useLocalize } from '~/hooks';
|
||||
import ContentParts from './Content/ContentParts';
|
||||
import SiblingSwitch from './SiblingSwitch';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
||||
import MultiMessage from './MultiMessage';
|
||||
import HoverButtons from './HoverButtons';
|
||||
import SubRow from './SubRow';
|
||||
|
|
@ -33,8 +33,11 @@ export default function Message(props: TMessageProps) {
|
|||
copyToClipboard,
|
||||
regenerateMessage,
|
||||
} = useMessageHelpers(props);
|
||||
|
||||
const fontSize = useRecoilValue(store.fontSize);
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const { children, messageId = null, isCreatedByUser } = message ?? {};
|
||||
|
||||
const name = useMemo(() => {
|
||||
let result = '';
|
||||
if (isCreatedByUser === true) {
|
||||
|
|
@ -67,71 +70,86 @@ export default function Message(props: TMessageProps) {
|
|||
message?.isCreatedByUser,
|
||||
],
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
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 (
|
||||
<>
|
||||
<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}
|
||||
onTouchMove={handleScroll}
|
||||
>
|
||||
<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 className="relative flex flex-shrink-0 flex-col items-end">
|
||||
<div>
|
||||
<div className="pt-0.5">
|
||||
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<div
|
||||
id={messageId}
|
||||
aria-label={`message-${message.depth}-${messageId}`}
|
||||
className={cn(baseClasses.common, baseClasses.chat, 'message-render')}
|
||||
>
|
||||
<div className="relative flex flex-shrink-0 flex-col items-center">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full pt-0.5">
|
||||
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-full flex-col',
|
||||
isCreatedByUser === true ? '' : 'agent-turn',
|
||||
'relative flex w-11/12 flex-col',
|
||||
isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||
)}
|
||||
>
|
||||
<div className={cn('select-none font-semibold', fontSize)}>{name}</div>
|
||||
<div className="flex-col gap-1 md:gap-3">
|
||||
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>
|
||||
{name}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
<ContentParts
|
||||
edit={edit}
|
||||
isLast={isLast}
|
||||
isSubmitting={isSubmitting}
|
||||
enterEdit={enterEdit}
|
||||
siblingIdx={siblingIdx}
|
||||
messageId={message.messageId}
|
||||
isSubmitting={isSubmitting}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
attachments={message.attachments}
|
||||
isCreatedByUser={message.isCreatedByUser}
|
||||
conversationId={conversation?.conversationId}
|
||||
content={message.content as Array<TMessageContentParts | undefined>}
|
||||
/>
|
||||
</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>
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
|
||||
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
||||
|
|
@ -11,15 +10,13 @@ import store from '~/store';
|
|||
|
||||
export default function MessagesView({
|
||||
messagesTree: _messagesTree,
|
||||
Header,
|
||||
}: {
|
||||
messagesTree?: TMessage[] | null;
|
||||
Header?: ReactNode;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
|
||||
const fontSize = useRecoilValue(store.fontSize);
|
||||
const { screenshotTargetRef } = useScreenshot();
|
||||
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
|
||||
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
|
||||
|
||||
const {
|
||||
|
|
@ -34,62 +31,64 @@ export default function MessagesView({
|
|||
const { conversationId } = conversation ?? {};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden overflow-y-auto">
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
className="scrollbar-gutter-stable"
|
||||
onScroll={debouncedHandleScroll}
|
||||
ref={scrollableRef}
|
||||
style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col pb-9 dark:bg-transparent">
|
||||
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center p-3 text-text-secondary',
|
||||
fontSize,
|
||||
)}
|
||||
>
|
||||
{localize('com_ui_nothing_found')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Header != null && Header}
|
||||
<div ref={screenshotTargetRef}>
|
||||
<MultiMessage
|
||||
key={conversationId} // avoid internal state mixture
|
||||
messagesTree={_messagesTree}
|
||||
messageId={conversationId ?? null}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
currentEditId={currentEditId ?? null}
|
||||
/>
|
||||
<>
|
||||
<div className="relative flex-1 overflow-hidden overflow-y-auto">
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
className="scrollbar-gutter-stable"
|
||||
onScroll={debouncedHandleScroll}
|
||||
ref={scrollableRef}
|
||||
style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col pb-9 dark:bg-transparent">
|
||||
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center p-3 text-text-secondary',
|
||||
fontSize,
|
||||
)}
|
||||
>
|
||||
{localize('com_ui_nothing_found')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
id="messages-end"
|
||||
className="group h-0 w-full flex-shrink-0"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div ref={screenshotTargetRef}>
|
||||
<MultiMessage
|
||||
key={conversationId}
|
||||
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>
|
||||
|
||||
<CSSTransition
|
||||
in={showScrollButton && scrollButtonPreference}
|
||||
timeout={{
|
||||
enter: 550,
|
||||
exit: 700,
|
||||
}}
|
||||
classNames="scroll-animation"
|
||||
unmountOnExit={true}
|
||||
appear={true}
|
||||
>
|
||||
<ScrollToBottom scrollHandler={handleSmoothToRef} />
|
||||
</CSSTransition>
|
||||
</div>
|
||||
<CSSTransition
|
||||
in={showScrollButton}
|
||||
timeout={400}
|
||||
classNames="scroll-down"
|
||||
unmountOnExit={false}
|
||||
// appear
|
||||
>
|
||||
{() =>
|
||||
showScrollButton &&
|
||||
scrollButtonPreference && <ScrollToBottom scrollHandler={handleSmoothToRef} />
|
||||
}
|
||||
</CSSTransition>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { useEffect, useCallback } from 'react';
|
|||
import { isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
||||
import MessageContent from '~/components/Messages/MessageContent';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
||||
import MessageParts from './MessageParts';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
||||
import Message from './Message';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -30,7 +30,6 @@ export default function MultiMessage({
|
|||
useEffect(() => {
|
||||
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
|
||||
setSiblingIdx(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messagesTree?.length]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,34 +1,62 @@
|
|||
import { Link } from 'lucide-react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { TMessage, TConversation } from 'librechat-data-provider';
|
||||
import type { InfiniteData } from '@tanstack/react-query';
|
||||
import type { ConversationCursorData } from '~/utils';
|
||||
import { useLocalize, useNavigateToConvo } from '~/hooks';
|
||||
import { useSearchContext } from '~/Providers';
|
||||
import { getConversationById } from '~/utils';
|
||||
import { findConversationInInfinite } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function SearchButtons({ message }: { message: TMessage }) {
|
||||
const localize = useLocalize();
|
||||
const { searchQueryRes } = useSearchContext();
|
||||
const { navigateWithLastTools } = useNavigateToConvo();
|
||||
const queryClient = useQueryClient();
|
||||
const search = useRecoilValue(store.search);
|
||||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const conversationId = message.conversationId ?? '';
|
||||
|
||||
const clickHandler = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let title = message.title ?? '';
|
||||
let cachedConvo = queryClient.getQueryData<TConversation>([
|
||||
QueryKeys.conversation,
|
||||
conversationId,
|
||||
]);
|
||||
const convos = queryClient.getQueryData<InfiniteData<ConversationCursorData>>([
|
||||
QueryKeys.allConversations,
|
||||
{ search: search.debouncedQuery },
|
||||
]);
|
||||
if (!cachedConvo && convos) {
|
||||
cachedConvo = findConversationInInfinite(convos, conversationId);
|
||||
}
|
||||
if (!title) {
|
||||
title = cachedConvo?.title ?? '';
|
||||
}
|
||||
|
||||
document.title = title;
|
||||
navigateToConvo(
|
||||
cachedConvo ??
|
||||
({
|
||||
conversationId,
|
||||
title,
|
||||
} as TConversation),
|
||||
{ resetLatestMessage: true },
|
||||
);
|
||||
};
|
||||
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clickHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const conversation = getConversationById(searchQueryRes?.data, conversationId);
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = message.title ?? '';
|
||||
navigateWithLastTools(conversation, true, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="visible mt-0 flex items-center justify-center gap-1 self-end text-text-secondary lg:justify-start">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0 flex cursor-pointer items-center gap-1.5 rounded-md p-1 text-xs hover:text-text-primary hover:underline"
|
||||
onClick={clickHandler}
|
||||
title={localize('com_ui_go_to_conversation')}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,30 @@ import SubRow from './SubRow';
|
|||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Message({ message }: Pick<TMessageProps, 'message'>) {
|
||||
const MessageAvatar = ({ iconData }: { iconData: TMessageIcon }) => (
|
||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
||||
<div className="pt-0.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<Icon iconData={iconData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MessageBody = ({ message, messageLabel, fontSize }) => (
|
||||
<div
|
||||
className={cn('relative flex w-11/12 flex-col', message.isCreatedByUser ? '' : 'agent-turn')}
|
||||
>
|
||||
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
|
||||
<SearchContent message={message} />
|
||||
<SubRow classes="text-xs">
|
||||
<MinimalHoverButtons message={message} />
|
||||
<SearchButtons message={message} />
|
||||
</SubRow>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
|
||||
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
|
||||
const fontSize = useRecoilValue(store.fontSize);
|
||||
const { user } = useAuthContext();
|
||||
|
|
@ -18,60 +41,42 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
|
|||
|
||||
const iconData: TMessageIcon = useMemo(
|
||||
() => ({
|
||||
endpoint: message?.endpoint,
|
||||
model: message?.model,
|
||||
endpoint: message?.endpoint ?? '',
|
||||
model: message?.model ?? '',
|
||||
iconURL: message?.iconURL ?? '',
|
||||
isCreatedByUser: message?.isCreatedByUser,
|
||||
isCreatedByUser: message?.isCreatedByUser ?? false,
|
||||
}),
|
||||
[message?.model, message?.iconURL, message?.endpoint, message?.isCreatedByUser],
|
||||
[message?.endpoint, message?.model, message?.iconURL, message?.isCreatedByUser],
|
||||
);
|
||||
|
||||
const messageLabel = useMemo(() => {
|
||||
if (message?.isCreatedByUser) {
|
||||
return UsernameDisplay
|
||||
? (user?.name ?? '') || (user?.username ?? '')
|
||||
: localize('com_user_message');
|
||||
}
|
||||
return message?.sender ?? '';
|
||||
}, [
|
||||
message?.isCreatedByUser,
|
||||
message?.sender,
|
||||
UsernameDisplay,
|
||||
user?.name,
|
||||
user?.username,
|
||||
localize,
|
||||
]);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isCreatedByUser } = message;
|
||||
|
||||
let messageLabel = '';
|
||||
if (isCreatedByUser) {
|
||||
messageLabel = UsernameDisplay
|
||||
? (user?.name ?? '') || (user?.username ?? '')
|
||||
: localize('com_user_message');
|
||||
} else {
|
||||
messageLabel = message.sender ?? '';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent">
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
|
||||
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
||||
<div>
|
||||
<div className="pt-0.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<Icon iconData={iconData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn('relative flex w-11/12 flex-col', isCreatedByUser ? '' : 'agent-turn')}
|
||||
>
|
||||
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
|
||||
<div className="flex-col gap-1 md:gap-3">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
<SearchContent message={message} />
|
||||
</div>
|
||||
</div>
|
||||
<SubRow classes="text-xs">
|
||||
<MinimalHoverButtons message={message} />
|
||||
<SearchButtons message={message} />
|
||||
</SubRow>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-token-text-primary w-full bg-transparent">
|
||||
<div className="m-auto p-4 py-2 md:gap-6">
|
||||
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<MessageAvatar iconData={iconData} />
|
||||
<MessageBody message={message} messageLabel={messageLabel} fontSize={fontSize} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,20 +26,21 @@ type MessageRenderProps = {
|
|||
|
||||
const MessageRender = memo(
|
||||
({
|
||||
isCard,
|
||||
message: msg,
|
||||
isCard = false,
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
message: msg,
|
||||
setSiblingIdx,
|
||||
currentEditId,
|
||||
isMultiMessage,
|
||||
isMultiMessage = false,
|
||||
setCurrentEditId,
|
||||
isSubmittingFamily,
|
||||
isSubmittingFamily = false,
|
||||
}: MessageRenderProps) => {
|
||||
const {
|
||||
ask,
|
||||
edit,
|
||||
index,
|
||||
agent,
|
||||
assistant,
|
||||
enterEdit,
|
||||
conversation,
|
||||
|
|
@ -56,28 +57,31 @@ const MessageRender = memo(
|
|||
isMultiMessage,
|
||||
setCurrentEditId,
|
||||
});
|
||||
const fontSize = useRecoilValue(store.fontSize);
|
||||
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const fontSize = useRecoilValue(store.fontSize);
|
||||
|
||||
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
|
||||
const { isCreatedByUser, error, unfinished } = msg ?? {};
|
||||
const hasNoChildren = !(msg?.children?.length ?? 0);
|
||||
const isLast = useMemo(
|
||||
() => hasNoChildren && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
|
||||
[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(
|
||||
() => ({
|
||||
endpoint: msg?.endpoint ?? conversation?.endpoint,
|
||||
model: msg?.model ?? conversation?.model,
|
||||
iconURL: msg?.iconURL ?? conversation?.iconURL,
|
||||
iconURL: msg?.iconURL,
|
||||
modelLabel: messageLabel,
|
||||
isCreatedByUser: msg?.isCreatedByUser,
|
||||
}),
|
||||
[
|
||||
messageLabel,
|
||||
conversation?.endpoint,
|
||||
conversation?.iconURL,
|
||||
conversation?.model,
|
||||
msg?.model,
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLatestMessage = msg.messageId === latestMessage?.messageId;
|
||||
const showCardRender = isLast && !(isSubmittingFamily === true) && isCard === true;
|
||||
const isLatestCard = isCard === true && !(isSubmittingFamily === true) && isLatestMessage;
|
||||
const clickHandler =
|
||||
showCardRender && !isLatestMessage
|
||||
? () => {
|
||||
logger.log(`Message Card click: Setting ${msg.messageId} as latest message`);
|
||||
logger.dir(msg);
|
||||
setLatestMessage(msg);
|
||||
}
|
||||
: undefined;
|
||||
const baseClasses = {
|
||||
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
|
||||
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',
|
||||
chat: maximizeChatSpace
|
||||
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
|
||||
: 'md:max-w-[47rem] xl:max-w-[55rem]',
|
||||
};
|
||||
|
||||
// Style classes
|
||||
const baseClasses =
|
||||
'final-completion group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu';
|
||||
let layoutClasses = '';
|
||||
|
||||
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' : '';
|
||||
const conditionalClasses = {
|
||||
latestCard: isLatestCard ? 'bg-surface-secondary' : '',
|
||||
cardRender: showCardRender ? 'cursor-pointer transition-colors duration-300' : '',
|
||||
focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={msg.messageId}
|
||||
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
layoutClasses,
|
||||
latestCardClasses,
|
||||
showRenderClasses,
|
||||
'message-render focus:outline-none focus:ring-2 focus:ring-border-xheavy',
|
||||
baseClasses.common,
|
||||
isCard ? baseClasses.card : baseClasses.chat,
|
||||
conditionalClasses.latestCard,
|
||||
conditionalClasses.cardRender,
|
||||
conditionalClasses.focus,
|
||||
'message-render',
|
||||
)}
|
||||
onClick={clickHandler}
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -139,31 +141,31 @@ const MessageRender = memo(
|
|||
role={showCardRender ? 'button' : undefined}
|
||||
tabIndex={showCardRender ? 0 : undefined}
|
||||
>
|
||||
{isLatestCard === true && (
|
||||
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary"></div>
|
||||
{isLatestCard && (
|
||||
<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="pt-0.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<MessageIcon iconData={iconData} assistant={assistant} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-shrink-0 flex-col items-center">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'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>
|
||||
<div className="flex-col gap-1 md:gap-3">
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
<MessageContext.Provider
|
||||
value={{
|
||||
messageId: msg.messageId,
|
||||
conversationId: conversation?.conversationId,
|
||||
isExpanded: false,
|
||||
}}
|
||||
>
|
||||
{msg.plugin && <Plugin plugin={msg.plugin} />}
|
||||
|
|
@ -174,40 +176,41 @@ const MessageRender = memo(
|
|||
text={msg.text || ''}
|
||||
message={msg}
|
||||
enterEdit={enterEdit}
|
||||
error={!!(error ?? false)}
|
||||
error={!!(msg.error ?? false)}
|
||||
isSubmitting={isSubmitting}
|
||||
unfinished={unfinished ?? false}
|
||||
isCreatedByUser={isCreatedByUser ?? true}
|
||||
unfinished={msg.unfinished ?? false}
|
||||
isCreatedByUser={msg.isCreatedByUser ?? true}
|
||||
siblingIdx={siblingIdx ?? 0}
|
||||
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
</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>
|
||||
{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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import store from '~/store';
|
|||
|
||||
export default function Presentation({ children }: { children: React.ReactNode }) {
|
||||
const artifacts = useRecoilValue(store.artifactsState);
|
||||
const artifactsVisible = useRecoilValue(store.artifactsVisible);
|
||||
const artifactsVisibility = useRecoilValue(store.artifactsVisibility);
|
||||
|
||||
const setFilesToDelete = useSetFilesToDelete();
|
||||
|
||||
|
|
@ -57,14 +57,6 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
|||
}, []);
|
||||
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 (
|
||||
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
|
||||
<SidePanelGroup
|
||||
|
|
@ -72,7 +64,7 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
|||
fullPanelCollapse={fullCollapse}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
artifacts={
|
||||
artifactsVisible === true && Object.keys(artifacts ?? {}).length > 0 ? (
|
||||
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
|
||||
<EditorProvider>
|
||||
<Artifacts />
|
||||
</EditorProvider>
|
||||
|
|
|
|||
64
client/src/components/Chat/TemporaryChat.tsx
Normal file
64
client/src/components/Chat/TemporaryChat.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MessageCircleDashed } from 'lucide-react';
|
||||
import { useRecoilState, useRecoilCallback } from 'recoil';
|
||||
import { TooltipAnchor } from '~/components/ui';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export function TemporaryChat() {
|
||||
const localize = useLocalize();
|
||||
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
|
||||
const { conversation, isSubmitting } = useChatContext();
|
||||
|
||||
const temporaryBadge = {
|
||||
id: 'temporary',
|
||||
icon: MessageCircleDashed,
|
||||
label: 'com_ui_temporary' as const,
|
||||
atom: store.isTemporary,
|
||||
isAvailable: true,
|
||||
};
|
||||
|
||||
const handleBadgeToggle = useRecoilCallback(
|
||||
() => () => {
|
||||
setIsTemporary(!isTemporary);
|
||||
},
|
||||
[isTemporary],
|
||||
);
|
||||
|
||||
if (
|
||||
(Array.isArray(conversation?.messages) && conversation.messages.length >= 1) ||
|
||||
isSubmitting
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-wrap items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize(temporaryBadge.label)}
|
||||
render={
|
||||
<button
|
||||
onClick={handleBadgeToggle}
|
||||
aria-label={localize(temporaryBadge.label)}
|
||||
className={cn(
|
||||
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
|
||||
isTemporary
|
||||
? 'bg-surface-active shadow-md'
|
||||
: 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md',
|
||||
'active:shadow-inner',
|
||||
)}
|
||||
>
|
||||
{temporaryBadge.icon && (
|
||||
<temporaryBadge.icon
|
||||
className={cn('relative h-5 w-5 md:h-4 md:w-4', !temporaryBadge.label && 'mx-auto')}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,67 +1,231 @@
|
|||
import { useMemo, memo } from 'react';
|
||||
import { useMemo, memo, type FC, useCallback } from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { parseISO, isToday } from 'date-fns';
|
||||
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
|
||||
import { useLocalize, TranslationKeys, useMediaQuery } from '~/hooks';
|
||||
import { TConversation } from 'librechat-data-provider';
|
||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||
import { groupConversationsByDate } from '~/utils';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import Convo from './Convo';
|
||||
|
||||
const Conversations = ({
|
||||
conversations,
|
||||
moveToTop,
|
||||
toggleNav,
|
||||
}: {
|
||||
interface ConversationsProps {
|
||||
conversations: Array<TConversation | null>;
|
||||
moveToTop: () => void;
|
||||
toggleNav: () => void;
|
||||
}) => {
|
||||
containerRef: React.RefObject<HTMLDivElement | List>;
|
||||
loadMoreConversations: () => void;
|
||||
isLoading: boolean;
|
||||
isSearchLoading: boolean;
|
||||
}
|
||||
|
||||
const LoadingSpinner = memo(() => {
|
||||
const localize = useLocalize();
|
||||
const groupedConversations = useMemo(
|
||||
() => groupConversationsByDate(conversations),
|
||||
[conversations],
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-2 flex items-center justify-center gap-2">
|
||||
<Spinner className="h-4 w-4 text-text-primary" />
|
||||
<span className="animate-pulse text-text-primary">{localize('com_ui_loading')}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<div className="mt-2 pl-2 pt-1 text-text-secondary" style={{ fontSize: '0.7rem' }}>
|
||||
{localize(groupName as TranslationKeys) || groupName}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DateLabel.displayName = 'DateLabel';
|
||||
|
||||
type FlattenedItem =
|
||||
| { type: 'header'; groupName: string }
|
||||
| { type: 'convo'; convo: TConversation }
|
||||
| { type: 'loading' };
|
||||
|
||||
const MemoizedConvo = memo(
|
||||
({
|
||||
conversation,
|
||||
retainView,
|
||||
toggleNav,
|
||||
isLatestConvo,
|
||||
}: {
|
||||
conversation: TConversation;
|
||||
retainView: () => void;
|
||||
toggleNav: () => void;
|
||||
isLatestConvo: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Convo
|
||||
conversation={conversation}
|
||||
retainView={retainView}
|
||||
toggleNav={toggleNav}
|
||||
isLatestConvo={isLatestConvo}
|
||||
/>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
|
||||
prevProps.conversation.title === nextProps.conversation.title &&
|
||||
prevProps.isLatestConvo === nextProps.isLatestConvo &&
|
||||
prevProps.conversation.endpoint === nextProps.conversation.endpoint
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const Conversations: FC<ConversationsProps> = ({
|
||||
conversations: rawConversations,
|
||||
moveToTop,
|
||||
toggleNav,
|
||||
containerRef,
|
||||
loadMoreConversations,
|
||||
isLoading,
|
||||
isSearchLoading,
|
||||
}) => {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const convoHeight = isSmallScreen ? 44 : 34;
|
||||
|
||||
const filteredConversations = useMemo(
|
||||
() => rawConversations.filter(Boolean) as TConversation[],
|
||||
[rawConversations],
|
||||
);
|
||||
|
||||
const groupedConversations = useMemo(
|
||||
() => groupConversationsByDate(filteredConversations),
|
||||
[filteredConversations],
|
||||
);
|
||||
|
||||
const firstTodayConvoId = useMemo(
|
||||
() =>
|
||||
conversations.find((convo) => convo && convo.updatedAt && isToday(parseISO(convo.updatedAt)))
|
||||
?.conversationId,
|
||||
[conversations],
|
||||
filteredConversations.find((convo) => convo.updatedAt && isToday(parseISO(convo.updatedAt)))
|
||||
?.conversationId ?? undefined,
|
||||
[filteredConversations],
|
||||
);
|
||||
|
||||
const flattenedItems = useMemo(() => {
|
||||
const items: FlattenedItem[] = [];
|
||||
groupedConversations.forEach(([groupName, convos]) => {
|
||||
items.push({ type: 'header', groupName });
|
||||
items.push(...convos.map((convo) => ({ type: 'convo' as const, convo })));
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
items.push({ type: 'loading' } as any);
|
||||
}
|
||||
return items;
|
||||
}, [groupedConversations, isLoading]);
|
||||
|
||||
const cache = useMemo(
|
||||
() =>
|
||||
new CellMeasurerCache({
|
||||
fixedWidth: true,
|
||||
defaultHeight: convoHeight,
|
||||
keyMapper: (index) => {
|
||||
const item = flattenedItems[index];
|
||||
if (item.type === 'header') {
|
||||
return `header-${index}`;
|
||||
}
|
||||
if (item.type === 'convo') {
|
||||
return `convo-${item.convo.conversationId}`;
|
||||
}
|
||||
if (item.type === 'loading') {
|
||||
return `loading-${index}`;
|
||||
}
|
||||
return `unknown-${index}`;
|
||||
},
|
||||
}),
|
||||
[flattenedItems, convoHeight],
|
||||
);
|
||||
|
||||
const rowRenderer = useCallback(
|
||||
({ index, key, parent, style }) => {
|
||||
const item = flattenedItems[index];
|
||||
if (item.type === 'loading') {
|
||||
return (
|
||||
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
|
||||
{({ registerChild }) => (
|
||||
<div ref={registerChild} style={style}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
</CellMeasurer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
|
||||
{({ registerChild }) => (
|
||||
<div ref={registerChild} style={style}>
|
||||
{item.type === 'header' ? (
|
||||
<DateLabel groupName={item.groupName} />
|
||||
) : item.type === 'convo' ? (
|
||||
<MemoizedConvo
|
||||
conversation={item.convo}
|
||||
retainView={moveToTop}
|
||||
toggleNav={toggleNav}
|
||||
isLatestConvo={item.convo.conversationId === firstTodayConvoId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CellMeasurer>
|
||||
);
|
||||
},
|
||||
[cache, flattenedItems, firstTodayConvoId, moveToTop, toggleNav],
|
||||
);
|
||||
|
||||
const getRowHeight = useCallback(
|
||||
({ index }: { index: number }) => cache.getHeight(index, 0),
|
||||
[cache],
|
||||
);
|
||||
|
||||
const throttledLoadMore = useMemo(
|
||||
() => throttle(loadMoreConversations, 300),
|
||||
[loadMoreConversations],
|
||||
);
|
||||
|
||||
const handleRowsRendered = useCallback(
|
||||
({ stopIndex }: { stopIndex: number }) => {
|
||||
if (stopIndex >= flattenedItems.length - 8) {
|
||||
throttledLoadMore();
|
||||
}
|
||||
},
|
||||
[flattenedItems.length, throttledLoadMore],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="text-token-text-primary flex flex-col gap-2 pb-2 text-sm">
|
||||
<div>
|
||||
<span>
|
||||
{groupedConversations.map(([groupName, convos]) => (
|
||||
<div key={groupName}>
|
||||
<div
|
||||
className="text-text-secondary"
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
marginTop: '20px',
|
||||
marginBottom: '5px',
|
||||
paddingLeft: '10px',
|
||||
}}
|
||||
>
|
||||
{localize(groupName as TranslationKeys) || groupName}
|
||||
</div>
|
||||
{convos.map((convo, i) => (
|
||||
<Convo
|
||||
key={`${groupName}-${convo.conversationId}-${i}`}
|
||||
isLatestConvo={convo.conversationId === firstTodayConvoId}
|
||||
conversation={convo}
|
||||
retainView={moveToTop}
|
||||
toggleNav={toggleNav}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '5px',
|
||||
marginBottom: '5px',
|
||||
}}
|
||||
<div className="relative flex h-full flex-col pb-2 text-sm text-text-primary">
|
||||
{isSearchLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner className="text-text-primary" />
|
||||
<span className="ml-2 text-text-primary">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<List
|
||||
ref={containerRef as React.RefObject<List>}
|
||||
width={width}
|
||||
height={height}
|
||||
deferredMeasurementCache={cache}
|
||||
rowCount={flattenedItems.length}
|
||||
rowHeight={getRowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
overscanRowCount={10}
|
||||
className="outline-none"
|
||||
style={{ outline: 'none' }}
|
||||
role="list"
|
||||
aria-label="Conversations"
|
||||
onRowsRendered={handleRowsRendered}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +1,26 @@
|
|||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useNavigateToConvo, useMediaQuery, useLocalize } from '~/hooks';
|
||||
import { useUpdateConversationMutation } from '~/data-provider';
|
||||
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { ConvoOptions } from './ConvoOptions';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import RenameForm from './RenameForm';
|
||||
import ConvoLink from './ConvoLink';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
type KeyEvent = KeyboardEvent<HTMLInputElement>;
|
||||
|
||||
type ConversationProps = {
|
||||
interface ConversationProps {
|
||||
conversation: TConversation;
|
||||
retainView: () => void;
|
||||
toggleNav: () => void;
|
||||
isLatestConvo: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Conversation({
|
||||
conversation,
|
||||
|
|
@ -31,27 +29,84 @@ export default function Conversation({
|
|||
isLatestConvo,
|
||||
}: ConversationProps) {
|
||||
const params = useParams();
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]);
|
||||
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
|
||||
const activeConvos = useRecoilValue(store.allConversationsSelector);
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { navigateWithLastTools } = useNavigateToConvo();
|
||||
const { showToast } = useToastContext();
|
||||
const { conversationId, title } = conversation;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [titleInput, setTitleInput] = useState(title);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const { conversationId, title = '' } = conversation;
|
||||
|
||||
const [titleInput, setTitleInput] = useState(title || '');
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const localize = useLocalize();
|
||||
|
||||
const clickHandler = async (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.button === 0 && (event.ctrlKey || event.metaKey)) {
|
||||
toggleNav();
|
||||
const previousTitle = useRef(title);
|
||||
|
||||
useEffect(() => {
|
||||
if (title !== previousTitle.current) {
|
||||
setTitleInput(title as string);
|
||||
previousTitle.current = title;
|
||||
}
|
||||
}, [title]);
|
||||
|
||||
const isActiveConvo = useMemo(() => {
|
||||
if (conversationId === Constants.NEW_CONVO) {
|
||||
return currentConvoId === Constants.NEW_CONVO;
|
||||
}
|
||||
|
||||
if (currentConvoId !== Constants.NEW_CONVO) {
|
||||
return currentConvoId === conversationId;
|
||||
} else {
|
||||
const latestConvo = activeConvos?.[0];
|
||||
return latestConvo === conversationId;
|
||||
}
|
||||
}, [currentConvoId, conversationId, activeConvos]);
|
||||
|
||||
const handleRename = () => {
|
||||
setIsPopoverActive(false);
|
||||
setTitleInput(title as string);
|
||||
setRenaming(true);
|
||||
};
|
||||
|
||||
const handleRenameSubmit = async (newTitle: string) => {
|
||||
if (!conversationId || newTitle === title) {
|
||||
setRenaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
try {
|
||||
await updateConvoMutation.mutateAsync({
|
||||
conversationId,
|
||||
title: newTitle.trim() || localize('com_ui_untitled'),
|
||||
});
|
||||
setRenaming(false);
|
||||
} catch (error) {
|
||||
setTitleInput(title as string);
|
||||
showToast({
|
||||
message: localize('com_ui_rename_failed'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
setRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setTitleInput(title as string);
|
||||
setRenaming(false);
|
||||
};
|
||||
|
||||
const handleNavigation = (ctrlOrMetaKey: boolean) => {
|
||||
if (ctrlOrMetaKey) {
|
||||
toggleNav();
|
||||
const baseUrl = window.location.origin;
|
||||
const path = `/c/${conversationId}`;
|
||||
window.open(baseUrl + path, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentConvoId === conversationId || isPopoverActive) {
|
||||
return;
|
||||
|
|
@ -59,138 +114,68 @@ export default function Conversation({
|
|||
|
||||
toggleNav();
|
||||
|
||||
// set document title
|
||||
if (typeof title === 'string' && title.length > 0) {
|
||||
document.title = title;
|
||||
}
|
||||
/* Note: Latest Message should not be reset if existing convo */
|
||||
navigateWithLastTools(
|
||||
conversation,
|
||||
!(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
|
||||
);
|
||||
|
||||
navigateToConvo(conversation, {
|
||||
currentConvoId,
|
||||
resetLatestMessage: !(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
|
||||
});
|
||||
};
|
||||
|
||||
const renameHandler = useCallback(() => {
|
||||
setIsPopoverActive(false);
|
||||
setTitleInput(title);
|
||||
setRenaming(true);
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [renaming]);
|
||||
|
||||
const onRename = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement> | KeyEvent) => {
|
||||
e.preventDefault();
|
||||
setRenaming(false);
|
||||
if (titleInput === title) {
|
||||
return;
|
||||
}
|
||||
if (typeof conversationId !== 'string' || conversationId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
updateConvoMutation.mutate(
|
||||
{ conversationId, title: titleInput ?? '' },
|
||||
{
|
||||
onError: () => {
|
||||
setTitleInput(title);
|
||||
showToast({
|
||||
message: 'Failed to rename conversation',
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[title, titleInput, conversationId, showToast, updateConvoMutation],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setTitleInput(title);
|
||||
setRenaming(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
onRename(e);
|
||||
}
|
||||
},
|
||||
[title, onRename],
|
||||
);
|
||||
|
||||
const cancelRename = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setTitleInput(title);
|
||||
setRenaming(false);
|
||||
},
|
||||
[title],
|
||||
);
|
||||
|
||||
const isActiveConvo: boolean = useMemo(
|
||||
() =>
|
||||
currentConvoId === conversationId ||
|
||||
(isLatestConvo &&
|
||||
currentConvoId === 'new' &&
|
||||
activeConvos[0] != null &&
|
||||
activeConvos[0] !== 'new'),
|
||||
[currentConvoId, conversationId, isLatestConvo, activeConvos],
|
||||
);
|
||||
const convoOptionsProps = {
|
||||
title,
|
||||
retainView,
|
||||
renameHandler: handleRename,
|
||||
isActiveConvo,
|
||||
conversationId,
|
||||
isPopoverActive,
|
||||
setIsPopoverActive,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative mt-2 flex h-9 w-full items-center rounded-lg hover:bg-surface-active-alt',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : '',
|
||||
isSmallScreen ? 'h-12' : '',
|
||||
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
|
||||
)}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if (renaming) {
|
||||
return;
|
||||
}
|
||||
if (e.button === 0) {
|
||||
handleNavigation(e.ctrlKey || e.metaKey);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (renaming) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
handleNavigation(false);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: renaming ? 'default' : 'pointer' }}
|
||||
data-testid="convo-item"
|
||||
>
|
||||
{renaming ? (
|
||||
<div className="absolute inset-0 z-20 flex w-full items-center rounded-lg bg-surface-active-alt p-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight focus-visible:outline-none"
|
||||
value={titleInput ?? ''}
|
||||
onChange={(e) => setTitleInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={cancelRename}
|
||||
aria-label={`${localize('com_ui_cancel')} ${localize('com_ui_rename')}`}
|
||||
>
|
||||
<X
|
||||
aria-hidden={true}
|
||||
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={onRename}
|
||||
aria-label={`${localize('com_ui_submit')} ${localize('com_ui_rename')}`}
|
||||
>
|
||||
<Check
|
||||
aria-hidden={true}
|
||||
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RenameForm
|
||||
titleInput={titleInput}
|
||||
setTitleInput={setTitleInput}
|
||||
onSubmit={handleRenameSubmit}
|
||||
onCancel={handleCancelRename}
|
||||
localize={localize}
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={`/c/${conversationId}`}
|
||||
data-testid="convo-item"
|
||||
onClick={clickHandler}
|
||||
className={cn(
|
||||
'flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : '',
|
||||
)}
|
||||
title={title ?? ''}
|
||||
<ConvoLink
|
||||
isActiveConvo={isActiveConvo}
|
||||
title={title}
|
||||
onRename={handleRename}
|
||||
isSmallScreen={isSmallScreen}
|
||||
localize={localize}
|
||||
>
|
||||
<EndpointIcon
|
||||
conversation={conversation}
|
||||
|
|
@ -198,43 +183,18 @@ export default function Conversation({
|
|||
size={20}
|
||||
context="menu-item"
|
||||
/>
|
||||
<div
|
||||
className="relative line-clamp-1 flex-1 grow overflow-hidden"
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTitleInput(title);
|
||||
setRenaming(true);
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{isActiveConvo ? (
|
||||
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
|
||||
) : (
|
||||
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%" />
|
||||
)}
|
||||
</a>
|
||||
</ConvoLink>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'mr-2',
|
||||
'mr-2 flex origin-left',
|
||||
isPopoverActive || isActiveConvo
|
||||
? 'flex'
|
||||
: 'hidden group-focus-within:flex group-hover:flex',
|
||||
? 'pointer-events-auto max-w-[28px] scale-x-100 opacity-100'
|
||||
: 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[28px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[28px] group-hover:scale-x-100 group-hover:opacity-100',
|
||||
)}
|
||||
aria-hidden={!(isPopoverActive || isActiveConvo)}
|
||||
>
|
||||
{!renaming && (
|
||||
<ConvoOptions
|
||||
title={title}
|
||||
retainView={retainView}
|
||||
renameHandler={renameHandler}
|
||||
isActiveConvo={isActiveConvo}
|
||||
conversationId={conversationId}
|
||||
isPopoverActive={isPopoverActive}
|
||||
setIsPopoverActive={setIsPopoverActive}
|
||||
/>
|
||||
)}
|
||||
{!renaming && <ConvoOptions {...convoOptionsProps} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
61
client/src/components/Conversations/ConvoLink.tsx
Normal file
61
client/src/components/Conversations/ConvoLink.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface ConvoLinkProps {
|
||||
isActiveConvo: boolean;
|
||||
title: string | null;
|
||||
onRename: () => void;
|
||||
isSmallScreen: boolean;
|
||||
localize: (key: any, options?: any) => string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ConvoLink: React.FC<ConvoLinkProps> = ({
|
||||
isActiveConvo,
|
||||
title,
|
||||
onRename,
|
||||
isSmallScreen,
|
||||
localize,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex grow items-center gap-2 overflow-hidden rounded-lg px-2',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : '',
|
||||
)}
|
||||
title={title ?? undefined}
|
||||
aria-current={isActiveConvo ? 'page' : undefined}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className="relative flex-1 grow overflow-hidden whitespace-nowrap"
|
||||
style={{ textOverflow: 'clip' }}
|
||||
onDoubleClick={(e) => {
|
||||
if (isSmallScreen) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
role="button"
|
||||
aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')}
|
||||
>
|
||||
{title || localize('com_ui_untitled')}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l',
|
||||
isActiveConvo
|
||||
? 'from-surface-active-alt'
|
||||
: 'from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvoLink;
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
import { useState, useId, useRef, memo } from 'react';
|
||||
import { useState, useId, useRef, memo, useCallback, useMemo } from 'react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import type * as t from '~/common';
|
||||
import { useDuplicateConversationMutation, useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize, useArchiveHandler, useNavigateToConvo } from '~/hooks';
|
||||
import {
|
||||
useDuplicateConversationMutation,
|
||||
useGetStartupConfig,
|
||||
useArchiveConvoMutation,
|
||||
} from '~/data-provider';
|
||||
import { useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks';
|
||||
import { useToastContext, useChatContext } from '~/Providers';
|
||||
import { DropdownPopup } from '~/components/ui';
|
||||
import { DropdownPopup, Spinner } from '~/components';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import DeleteButton from './DeleteButton';
|
||||
import ShareButton from './ShareButton';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -31,14 +36,20 @@ function ConvoOptions({
|
|||
const localize = useLocalize();
|
||||
const { index } = useChatContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const archiveHandler = useArchiveHandler(conversationId, true, retainView);
|
||||
const { navigateToConvo } = useNavigateToConvo(index);
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { conversationId: currentConvoId } = useParams();
|
||||
const { newConversation } = useNewConvo();
|
||||
|
||||
const shareButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const archiveConvoMutation = useArchiveConvoMutation();
|
||||
|
||||
const duplicateConversation = useDuplicateConversationMutation({
|
||||
onSuccess: (data) => {
|
||||
navigateToConvo(data.conversation);
|
||||
|
|
@ -46,6 +57,7 @@ function ConvoOptions({
|
|||
message: localize('com_ui_duplication_success'),
|
||||
status: 'success',
|
||||
});
|
||||
setIsPopoverActive(false);
|
||||
},
|
||||
onMutate: () => {
|
||||
showToast({
|
||||
|
|
@ -61,62 +73,128 @@ function ConvoOptions({
|
|||
},
|
||||
});
|
||||
|
||||
const shareHandler = () => {
|
||||
const isDuplicateLoading = duplicateConversation.isLoading;
|
||||
const isArchiveLoading = archiveConvoMutation.isLoading;
|
||||
|
||||
const handleShareClick = useCallback(() => {
|
||||
setShowShareDialog(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const deleteHandler = () => {
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const duplicateHandler = () => {
|
||||
setIsPopoverActive(false);
|
||||
const handleArchiveClick = useCallback(async () => {
|
||||
const convoId = conversationId ?? '';
|
||||
if (!convoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
archiveConvoMutation.mutate(
|
||||
{ conversationId: convoId, isArchived: true },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (currentConvoId === convoId || currentConvoId === 'new') {
|
||||
newConversation();
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
retainView();
|
||||
setIsPopoverActive(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_archive_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [
|
||||
conversationId,
|
||||
currentConvoId,
|
||||
archiveConvoMutation,
|
||||
navigate,
|
||||
newConversation,
|
||||
retainView,
|
||||
setIsPopoverActive,
|
||||
showToast,
|
||||
localize,
|
||||
]);
|
||||
|
||||
const handleDuplicateClick = useCallback(() => {
|
||||
duplicateConversation.mutate({
|
||||
conversationId: conversationId ?? '',
|
||||
});
|
||||
};
|
||||
}, [conversationId, duplicateConversation]);
|
||||
|
||||
const dropdownItems: t.MenuItemProps[] = [
|
||||
{
|
||||
label: localize('com_ui_share'),
|
||||
onClick: shareHandler,
|
||||
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
|
||||
show: startupConfig && startupConfig.sharedLinksEnabled,
|
||||
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
|
||||
hideOnClick: false,
|
||||
ref: shareButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_rename'),
|
||||
onClick: renameHandler,
|
||||
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_duplicate'),
|
||||
onClick: duplicateHandler,
|
||||
icon: <Copy className="icon-sm mr-2 text-text-primary" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_archive'),
|
||||
onClick: archiveHandler,
|
||||
icon: <Archive className="icon-sm mr-2 text-text-primary" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_delete'),
|
||||
onClick: deleteHandler,
|
||||
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
|
||||
hideOnClick: false,
|
||||
ref: deleteButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
];
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: localize('com_ui_share'),
|
||||
onClick: handleShareClick,
|
||||
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
|
||||
show: startupConfig && startupConfig.sharedLinksEnabled,
|
||||
hideOnClick: false,
|
||||
ref: shareButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_rename'),
|
||||
onClick: renameHandler,
|
||||
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_duplicate'),
|
||||
onClick: handleDuplicateClick,
|
||||
hideOnClick: false,
|
||||
icon: isDuplicateLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Copy className="icon-sm mr-2 text-text-primary" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_archive'),
|
||||
onClick: handleArchiveClick,
|
||||
hideOnClick: false,
|
||||
icon: isArchiveLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Archive className="icon-sm mr-2 text-text-primary" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_delete'),
|
||||
onClick: handleDeleteClick,
|
||||
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
|
||||
hideOnClick: false,
|
||||
ref: deleteButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
localize,
|
||||
handleShareClick,
|
||||
startupConfig,
|
||||
renameHandler,
|
||||
handleDuplicateClick,
|
||||
isDuplicateLoading,
|
||||
handleArchiveClick,
|
||||
isArchiveLoading,
|
||||
handleDeleteClick,
|
||||
],
|
||||
);
|
||||
|
||||
const menuId = useId();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
mountByState={true}
|
||||
unmountOnHide={true}
|
||||
preserveTabOrder={true}
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
trigger={
|
||||
|
|
@ -124,17 +202,26 @@ function ConvoOptions({
|
|||
id={`conversation-menu-${conversationId}`}
|
||||
aria-label={localize('com_nav_convo_menu_options')}
|
||||
className={cn(
|
||||
'z-30 inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
isActiveConvo === true
|
||||
'inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
|
||||
isActiveConvo === true || isPopoverActive
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
|
||||
)}
|
||||
onClick={(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
items={dropdownItems}
|
||||
menuId={menuId}
|
||||
className="z-30"
|
||||
/>
|
||||
{showShareDialog && (
|
||||
<ShareButton
|
||||
|
|
@ -148,14 +235,22 @@ function ConvoOptions({
|
|||
<DeleteButton
|
||||
title={title ?? ''}
|
||||
retainView={retainView}
|
||||
conversationId={conversationId ?? ''}
|
||||
showDeleteDialog={showDeleteDialog}
|
||||
setShowDeleteDialog={setShowDeleteDialog}
|
||||
triggerRef={deleteButtonRef}
|
||||
setMenuOpen={setIsPopoverActive}
|
||||
showDeleteDialog={showDeleteDialog}
|
||||
conversationId={conversationId ?? ''}
|
||||
setShowDeleteDialog={setShowDeleteDialog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ConvoOptions);
|
||||
export default memo(ConvoOptions, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.conversationId === nextProps.conversationId &&
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.isPopoverActive === nextProps.isPopoverActive &&
|
||||
prevProps.isActiveConvo === nextProps.isActiveConvo
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogHeader,
|
||||
OGDialogContent,
|
||||
} from '~/components';
|
||||
import { useDeleteConversationMutation } from '~/data-provider';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useLocalize, useNewConvo } from '~/hooks';
|
||||
import { OGDialog, Label } from '~/components';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
|
||||
type DeleteButtonProps = {
|
||||
conversationId: string;
|
||||
|
|
@ -15,13 +23,18 @@ type DeleteButtonProps = {
|
|||
showDeleteDialog?: boolean;
|
||||
setShowDeleteDialog?: (value: boolean) => void;
|
||||
triggerRef?: React.RefObject<HTMLButtonElement>;
|
||||
setMenuOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export function DeleteConversationDialog({
|
||||
setShowDeleteDialog,
|
||||
conversationId,
|
||||
setMenuOpen,
|
||||
retainView,
|
||||
title,
|
||||
}: {
|
||||
setMenuOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowDeleteDialog: (value: boolean) => void;
|
||||
conversationId: string;
|
||||
retainView: () => void;
|
||||
title: string;
|
||||
|
|
@ -29,17 +42,27 @@ export function DeleteConversationDialog({
|
|||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const { newConversation } = useNewConvo();
|
||||
const { conversationId: currentConvoId } = useParams();
|
||||
|
||||
const deleteConvoMutation = useDeleteConversationMutation({
|
||||
const deleteMutation = useDeleteConversationMutation({
|
||||
onSuccess: () => {
|
||||
setShowDeleteDialog(false);
|
||||
if (currentConvoId === conversationId || currentConvoId === 'new') {
|
||||
newConversation();
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
setMenuOpen?.(false);
|
||||
retainView();
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_convo_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
|
|
@ -47,32 +70,30 @@ export function DeleteConversationDialog({
|
|||
const thread_id = messages?.[messages.length - 1]?.thread_id;
|
||||
const endpoint = messages?.[messages.length - 1]?.endpoint;
|
||||
|
||||
deleteConvoMutation.mutate({ conversationId, thread_id, endpoint, source: 'button' });
|
||||
}, [conversationId, deleteConvoMutation, queryClient]);
|
||||
deleteMutation.mutate({ conversationId, thread_id, endpoint, source: 'button' });
|
||||
}, [conversationId, deleteMutation, queryClient]);
|
||||
|
||||
return (
|
||||
<OGDialogTemplate
|
||||
<OGDialogContent
|
||||
title={localize('com_ui_delete_confirm') + ' ' + title}
|
||||
className="w-11/12 max-w-md"
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_conversation')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div>
|
||||
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
|
||||
</div>
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
<Button aria-label="cancel" variant="outline" onClick={() => setShowDeleteDialog(false)}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleteMutation.isLoading}>
|
||||
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -80,11 +101,12 @@ export default function DeleteButton({
|
|||
conversationId,
|
||||
retainView,
|
||||
title,
|
||||
setMenuOpen,
|
||||
showDeleteDialog,
|
||||
setShowDeleteDialog,
|
||||
triggerRef,
|
||||
}: DeleteButtonProps) {
|
||||
if (showDeleteDialog === undefined && setShowDeleteDialog === undefined) {
|
||||
if (showDeleteDialog === undefined || setShowDeleteDialog === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -93,9 +115,11 @@ export default function DeleteButton({
|
|||
}
|
||||
|
||||
return (
|
||||
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} triggerRef={triggerRef}>
|
||||
<OGDialog open={showDeleteDialog!} onOpenChange={setShowDeleteDialog!} triggerRef={triggerRef}>
|
||||
<DeleteConversationDialog
|
||||
setShowDeleteDialog={setShowDeleteDialog}
|
||||
conversationId={conversationId}
|
||||
setMenuOpen={setMenuOpen}
|
||||
retainView={retainView}
|
||||
title={title}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './DeleteButton';
|
||||
export { default as ShareButton } from './ShareButton';
|
||||
export { default as SharedLinkButton } from './SharedLinkButton';
|
||||
export { default as ConvoOptions } from './ConvoOptions';
|
||||
|
|
@ -1,21 +1,13 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { VisuallyHidden } from '@ariakit/react';
|
||||
import { GitFork, InfoIcon } from 'lucide-react';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { ForkOptions } from 'librechat-data-provider';
|
||||
import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react';
|
||||
import {
|
||||
Checkbox,
|
||||
HoverCard,
|
||||
HoverCardTrigger,
|
||||
HoverCardPortal,
|
||||
HoverCardContent,
|
||||
} from '~/components/ui';
|
||||
import OptionHover from '~/components/SidePanel/Parameters/OptionHover';
|
||||
import { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks';
|
||||
import { useForkConvoMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { ESide } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -24,11 +16,11 @@ interface PopoverButtonProps {
|
|||
setting: ForkOptions;
|
||||
onClick: (setting: ForkOptions) => void;
|
||||
setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>;
|
||||
sideOffset?: number;
|
||||
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
hoverInfo?: React.ReactNode | string;
|
||||
hoverTitle?: React.ReactNode | string;
|
||||
hoverDescription?: React.ReactNode | string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const optionLabels: Record<ForkOptions, TranslationKeys> = {
|
||||
|
|
@ -38,57 +30,84 @@ const optionLabels: Record<ForkOptions, TranslationKeys> = {
|
|||
[ForkOptions.DEFAULT]: 'com_ui_fork_from_message',
|
||||
};
|
||||
|
||||
const chevronDown = (
|
||||
<svg width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PopoverButton: React.FC<PopoverButtonProps> = ({
|
||||
children,
|
||||
setting,
|
||||
onClick,
|
||||
setActiveSetting,
|
||||
sideOffset = 30,
|
||||
timeoutRef,
|
||||
hoverInfo,
|
||||
hoverTitle,
|
||||
hoverDescription,
|
||||
label,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<HoverCard openDelay={200}>
|
||||
<Popover.Close
|
||||
onClick={() => onClick(setting)}
|
||||
onMouseEnter={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
<Ariakit.HovercardProvider>
|
||||
<div className="flex flex-col items-center">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<Ariakit.Button
|
||||
onClick={() => onClick(setting)}
|
||||
onMouseEnter={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setActiveSetting(optionLabels[setting]);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
|
||||
}, 175);
|
||||
}}
|
||||
className="mx-1 max-w-14 flex-1 rounded-lg border-2 border-border-medium bg-surface-secondary text-text-secondary transition duration-300 ease-in-out hover:border-border-xheavy hover:bg-surface-hover hover:text-text-primary"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
</Ariakit.Button>
|
||||
}
|
||||
setActiveSetting(optionLabels[setting]);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
|
||||
}, 175);
|
||||
}}
|
||||
className="mx-1 max-w-14 flex-1 rounded-lg border-2 bg-white text-gray-700 transition duration-300 ease-in-out hover:bg-gray-200 hover:text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-gray-100"
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</Popover.Close>
|
||||
{((hoverInfo != null && hoverInfo !== '') ||
|
||||
(hoverTitle != null && hoverTitle !== '') ||
|
||||
(hoverDescription != null && hoverDescription !== '')) && (
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side="right" className="z-[999] w-80 dark:bg-gray-700" sideOffset={sideOffset}>
|
||||
/>
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>
|
||||
{localize('com_ui_fork_more_details_about', { 0: label })}
|
||||
</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
{((hoverInfo != null && hoverInfo !== '') ||
|
||||
(hoverTitle != null && hoverTitle !== '') ||
|
||||
(hoverDescription != null && hoverDescription !== '')) && (
|
||||
<Ariakit.Hovercard
|
||||
gutter={16}
|
||||
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p className="flex flex-col gap-2 text-sm text-text-secondary">
|
||||
{hoverInfo && hoverInfo}
|
||||
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
|
||||
{hoverDescription && hoverDescription}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
)}
|
||||
</HoverCard>
|
||||
</Ariakit.Hovercard>
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.HovercardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -114,6 +133,9 @@ export default function Fork({
|
|||
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
|
||||
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
|
||||
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberDefaultFork);
|
||||
const popoverStore = Ariakit.usePopoverStore({
|
||||
placement: 'top',
|
||||
});
|
||||
const forkConvo = useForkConvoMutation({
|
||||
onSuccess: (data) => {
|
||||
navigateToConvo(data.conversation);
|
||||
|
|
@ -157,173 +179,230 @@ export default function Fork({
|
|||
};
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button active rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
|
||||
'data-[state=open]:active focus:opacity-100 data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
|
||||
!isLast ? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100' : '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (rememberGlobal) {
|
||||
e.preventDefault();
|
||||
forkConvo.mutate({
|
||||
messageId,
|
||||
splitAtTarget,
|
||||
conversationId,
|
||||
option: forkSetting,
|
||||
latestMessageId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
title={localize('com_ui_fork')}
|
||||
>
|
||||
<GitFork className="h-4 w-4 hover:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<div dir="ltr">
|
||||
<Popover.Content
|
||||
side="top"
|
||||
role="menu"
|
||||
className="bg-token-surface-primary flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg bg-white p-2 px-3 shadow-lg dark:bg-gray-700"
|
||||
style={{ outline: 'none', pointerEvents: 'auto', boxSizing: 'border-box' }}
|
||||
tabIndex={-1}
|
||||
sideOffset={5}
|
||||
align="center"
|
||||
<>
|
||||
<Ariakit.PopoverAnchor
|
||||
store={popoverStore}
|
||||
render={
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button active rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
|
||||
'data-[state=open]:active focus:opacity-100 data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
|
||||
!isLast
|
||||
? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (rememberGlobal) {
|
||||
e.preventDefault();
|
||||
forkConvo.mutate({
|
||||
messageId,
|
||||
splitAtTarget,
|
||||
conversationId,
|
||||
option: forkSetting,
|
||||
latestMessageId,
|
||||
});
|
||||
} else {
|
||||
popoverStore.toggle();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
aria-label={localize('com_ui_fork')}
|
||||
>
|
||||
<div className="flex h-6 w-full items-center justify-center text-sm dark:text-gray-200">
|
||||
{localize(activeSetting )}
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild>
|
||||
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-gray-500 dark:text-white/50" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
className="z-[999] w-80 dark:bg-gray-700"
|
||||
sideOffset={19}
|
||||
<GitFork className="h-4 w-4 hover:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.Popover
|
||||
store={popoverStore}
|
||||
gutter={5}
|
||||
className="flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg border border-border-heavy bg-surface-secondary p-2 px-3 shadow-lg"
|
||||
style={{
|
||||
outline: 'none',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 50,
|
||||
}}
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="flex h-8 w-full items-center justify-center text-sm text-text-primary">
|
||||
{localize(activeSetting)}
|
||||
<Ariakit.HovercardProvider>
|
||||
<div className="ml-auto flex h-6 w-6 items-center justify-center gap-1">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<button
|
||||
className="flex h-5 w-5 items-center rounded-full text-text-secondary"
|
||||
aria-label={localize('com_ui_fork_info_button_label')}
|
||||
>
|
||||
<div className="flex flex-col gap-2 space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span>{localize('com_ui_fork_info_1')}</span>
|
||||
<span>{localize('com_ui_fork_info_2')}</span>
|
||||
<span>
|
||||
{localize('com_ui_fork_info_3', {
|
||||
0: localize('com_ui_fork_split_target'),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<div className="flex h-full w-full items-center justify-center gap-1">
|
||||
<PopoverButton
|
||||
sideOffset={155}
|
||||
setActiveSetting={setActiveSetting}
|
||||
timeoutRef={timeoutRef}
|
||||
onClick={onClick}
|
||||
setting={ForkOptions.DIRECT_PATH}
|
||||
hoverTitle={
|
||||
<>
|
||||
<GitCommit className="h-5 w-5 rotate-90" />
|
||||
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
|
||||
</>
|
||||
<InfoIcon />
|
||||
</button>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_visible')}
|
||||
>
|
||||
<HoverCardTrigger asChild>
|
||||
<GitCommit className="h-full w-full rotate-90 p-2" />
|
||||
</HoverCardTrigger>
|
||||
</PopoverButton>
|
||||
<PopoverButton
|
||||
sideOffset={90}
|
||||
setActiveSetting={setActiveSetting}
|
||||
timeoutRef={timeoutRef}
|
||||
onClick={onClick}
|
||||
setting={ForkOptions.INCLUDE_BRANCHES}
|
||||
hoverTitle={
|
||||
<>
|
||||
<GitBranchPlus className="h-4 w-4 rotate-180" />
|
||||
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
|
||||
</>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_branches')}
|
||||
>
|
||||
<HoverCardTrigger asChild>
|
||||
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
|
||||
</HoverCardTrigger>
|
||||
</PopoverButton>
|
||||
<PopoverButton
|
||||
sideOffset={25}
|
||||
setActiveSetting={setActiveSetting}
|
||||
timeoutRef={timeoutRef}
|
||||
onClick={onClick}
|
||||
setting={ForkOptions.TARGET_LEVEL}
|
||||
hoverTitle={
|
||||
<>
|
||||
<ListTree className="h-5 w-5" />
|
||||
{`${localize(
|
||||
optionLabels[ForkOptions.TARGET_LEVEL],
|
||||
)} (${localize('com_endpoint_default')})`}
|
||||
</>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_target')}
|
||||
>
|
||||
<HoverCardTrigger asChild>
|
||||
<ListTree className="h-full w-full p-2" />
|
||||
</HoverCardTrigger>
|
||||
</PopoverButton>
|
||||
</div>
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex h-6 w-full items-center justify-start text-sm dark:text-gray-300 dark:hover:text-gray-200">
|
||||
<Checkbox
|
||||
checked={splitAtTarget}
|
||||
onCheckedChange={(checked: boolean) => setSplitAtTarget(checked)}
|
||||
className="m-2 transition duration-300 ease-in-out"
|
||||
/>
|
||||
{localize('com_ui_fork_split_target')}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover
|
||||
side={ESide.Right}
|
||||
description="com_ui_fork_info_start"
|
||||
langCode={true}
|
||||
sideOffset={20}
|
||||
/>
|
||||
</HoverCard>
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex h-6 w-full items-center justify-start text-sm dark:text-gray-300 dark:hover:text-gray-200">
|
||||
<Checkbox
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>{localize('com_ui_fork_more_info_options')}</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
</div>
|
||||
<Ariakit.Hovercard
|
||||
gutter={19}
|
||||
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="flex flex-col gap-2 space-y-2 text-sm text-text-secondary">
|
||||
<span>{localize('com_ui_fork_info_1')}</span>
|
||||
<span>{localize('com_ui_fork_info_2')}</span>
|
||||
<span>
|
||||
{localize('com_ui_fork_info_3', {
|
||||
0: localize('com_ui_fork_split_target'),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
</div>
|
||||
<div className="flex h-full w-full items-center justify-center gap-1">
|
||||
<PopoverButton
|
||||
setActiveSetting={setActiveSetting}
|
||||
timeoutRef={timeoutRef}
|
||||
onClick={onClick}
|
||||
setting={ForkOptions.DIRECT_PATH}
|
||||
label={localize(optionLabels[ForkOptions.DIRECT_PATH])}
|
||||
hoverTitle={
|
||||
<>
|
||||
<GitCommit className="h-5 w-5 rotate-90" />
|
||||
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
|
||||
</>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_visible')}
|
||||
>
|
||||
<GitCommit className="h-full w-full rotate-90 p-2" />
|
||||
</PopoverButton>
|
||||
<PopoverButton
|
||||
setActiveSetting={setActiveSetting}
|
||||
timeoutRef={timeoutRef}
|
||||
onClick={onClick}
|
||||
setting={ForkOptions.INCLUDE_BRANCHES}
|
||||
label={localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
|
||||
hoverTitle={
|
||||
<>
|
||||
<GitBranchPlus className="h-4 w-4 rotate-180" />
|
||||
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
|
||||
</>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_branches')}
|
||||
>
|
||||
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
|
||||
</PopoverButton>
|
||||
<PopoverButton
|
||||
setActiveSetting={setActiveSetting}
|
||||
timeoutRef={timeoutRef}
|
||||
onClick={onClick}
|
||||
setting={ForkOptions.TARGET_LEVEL}
|
||||
label={localize(optionLabels[ForkOptions.TARGET_LEVEL])}
|
||||
hoverTitle={
|
||||
<>
|
||||
<ListTree className="h-5 w-5" />
|
||||
{`${localize(
|
||||
optionLabels[ForkOptions.TARGET_LEVEL],
|
||||
)} (${localize('com_endpoint_default')})`}
|
||||
</>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_target')}
|
||||
>
|
||||
<ListTree className="h-full w-full p-2" />
|
||||
</PopoverButton>
|
||||
</div>
|
||||
<Ariakit.HovercardProvider>
|
||||
<div className="flex items-center">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<div className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary">
|
||||
<Ariakit.Checkbox
|
||||
id="split-target-checkbox"
|
||||
checked={splitAtTarget}
|
||||
onChange={(event) => setSplitAtTarget(event.target.checked)}
|
||||
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
aria-label={localize('com_ui_fork_split_target')}
|
||||
/>
|
||||
<label htmlFor="split-target-checkbox" className="ml-2 cursor-pointer">
|
||||
{localize('com_ui_fork_split_target')}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>
|
||||
{localize('com_ui_fork_more_info_split_target', {
|
||||
0: localize('com_ui_fork_split_target'),
|
||||
})}
|
||||
</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
</div>
|
||||
<Ariakit.Hovercard
|
||||
gutter={32}
|
||||
className="z-[999] w-80 select-none rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_start')}</p>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
<Ariakit.HovercardProvider>
|
||||
<div className="flex items-center">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<div
|
||||
onClick={() => setRemember((prev) => !prev)}
|
||||
className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<Ariakit.Checkbox
|
||||
id="remember-checkbox"
|
||||
checked={remember}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
console.log('checked', checked);
|
||||
if (checked) {
|
||||
showToast({
|
||||
message: localize('com_ui_fork_remember_checked'),
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
setRemember(checked);
|
||||
return setRemember(checked);
|
||||
}}
|
||||
className="m-2 transition duration-300 ease-in-out"
|
||||
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
aria-label={localize('com_ui_fork_remember')}
|
||||
/>
|
||||
{localize('com_ui_fork_remember')}
|
||||
<label htmlFor="remember-checkbox" className="ml-2 cursor-pointer">
|
||||
{localize('com_ui_fork_remember')}
|
||||
</label>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover
|
||||
side={ESide.Right}
|
||||
description="com_ui_fork_info_remember"
|
||||
langCode={true}
|
||||
sideOffset={20}
|
||||
/>
|
||||
</HoverCard>
|
||||
</Popover.Content>
|
||||
</div>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
}
|
||||
/>
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>
|
||||
{localize('com_ui_fork_more_info_remember', {
|
||||
0: localize('com_ui_fork_remember'),
|
||||
})}
|
||||
</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
</div>
|
||||
<Ariakit.Hovercard
|
||||
gutter={14}
|
||||
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_remember')}</p>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
</Ariakit.Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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