mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-26 12:16:13 +01:00
253 lines
8.6 KiB
TypeScript
253 lines
8.6 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { VisuallyHidden } from '@ariakit/react';
|
|
import { Spinner, TooltipAnchor } from '@librechat/client';
|
|
import { CheckCircle2, MousePointerClick, SettingsIcon } from 'lucide-react';
|
|
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
|
import type { TModelSpec } 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 { ModelSpecItem } from './ModelSpecItem';
|
|
import { filterModels } from '../utils';
|
|
import { useLocalize } from '~/hooks';
|
|
import { cn } from '~/utils';
|
|
|
|
interface EndpointItemProps {
|
|
endpoint: Endpoint;
|
|
endpointIndex: number;
|
|
}
|
|
|
|
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');
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
if (!endpoint.value) {
|
|
return;
|
|
}
|
|
e.stopPropagation();
|
|
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (endpoint.value) {
|
|
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e as unknown as React.MouseEvent);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
id={`endpoint-${endpoint.value}-settings`}
|
|
onClick={handleClick}
|
|
onKeyDown={handleKeyDown}
|
|
className={cn(
|
|
'group/button flex items-center gap-1.5 rounded-md px-1.5',
|
|
'text-text-secondary transition-colors duration-150',
|
|
'hover:bg-surface-tertiary hover:text-text-primary',
|
|
'focus-visible:bg-surface-tertiary focus-visible:text-text-primary',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
|
className,
|
|
)}
|
|
aria-label={`${text} ${endpoint.label}`}
|
|
>
|
|
<SettingsIcon className="size-4 shrink-0" aria-hidden="true" />
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'grid overflow-hidden transition-[grid-template-columns,opacity] duration-150 ease-out',
|
|
'grid-cols-[0fr] opacity-0',
|
|
'group-hover/button:grid-cols-[1fr] group-hover/button:opacity-100',
|
|
'group-focus-visible/button:grid-cols-[1fr] group-focus-visible/button:opacity-100',
|
|
)}
|
|
>
|
|
<span className="min-w-0 truncate pr-0.5">{text}</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
|
|
const localize = useLocalize();
|
|
const {
|
|
agentsMap,
|
|
assistantsMap,
|
|
modelSpecs,
|
|
selectedValues,
|
|
handleOpenKeyDialog,
|
|
handleSelectEndpoint,
|
|
endpointSearchValues,
|
|
setEndpointSearchValue,
|
|
endpointRequiresUserKey,
|
|
} = useModelSelectorContext();
|
|
const {
|
|
model: selectedModel,
|
|
endpoint: selectedEndpoint,
|
|
modelSpec: selectedSpec,
|
|
} = selectedValues;
|
|
|
|
// Filter modelSpecs for this endpoint (by group matching endpoint value)
|
|
const endpointSpecs = useMemo(() => {
|
|
if (!modelSpecs || !modelSpecs.length) {
|
|
return [];
|
|
}
|
|
return modelSpecs.filter((spec: TModelSpec) => spec.group === endpoint.value);
|
|
}, [modelSpecs, endpoint.value]);
|
|
|
|
const searchValue = endpointSearchValues[endpoint.value] || '';
|
|
const isUserProvided = useMemo(
|
|
() => endpointRequiresUserKey(endpoint.value),
|
|
[endpointRequiresUserKey, endpoint.value],
|
|
);
|
|
|
|
const isAssistantsNotLoaded =
|
|
isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined;
|
|
|
|
const renderIconLabel = () => (
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{endpoint.icon && (
|
|
<div className="flex shrink-0 items-center justify-center" aria-hidden="true">
|
|
{endpoint.icon}
|
|
</div>
|
|
)}
|
|
<span className="truncate text-left">{endpoint.label}</span>
|
|
</div>
|
|
);
|
|
|
|
const isEndpointSelected = selectedEndpoint === endpoint.value;
|
|
|
|
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`}
|
|
defaultOpen={endpoint.value === selectedEndpoint}
|
|
searchValue={searchValue}
|
|
onSearch={(value) => setEndpointSearchValue(endpoint.value, value)}
|
|
combobox={<input placeholder=" " />}
|
|
comboboxLabel={placeholder}
|
|
onClick={() => handleSelectEndpoint(endpoint)}
|
|
label={
|
|
<div className="group flex w-full min-w-0 items-center justify-between gap-1.5 py-1 text-sm">
|
|
{renderIconLabel()}
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{isUserProvided && (
|
|
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
|
)}
|
|
{isEndpointSelected && (
|
|
<>
|
|
<CheckCircle2 className="size-4 shrink-0 text-text-primary" aria-hidden="true" />
|
|
<VisuallyHidden>{localize('com_a11y_selected')}</VisuallyHidden>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
{isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined ? (
|
|
<div
|
|
className="flex items-center justify-center p-2"
|
|
role="status"
|
|
aria-label={localize('com_ui_loading')}
|
|
>
|
|
<Spinner aria-hidden="true" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Render modelSpecs for this endpoint */}
|
|
{endpointSpecs.map((spec: TModelSpec) => (
|
|
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
|
|
))}
|
|
{/* Render endpoint models */}
|
|
{filteredModels
|
|
? renderEndpointModels(
|
|
endpoint,
|
|
endpoint.models || [],
|
|
selectedModel,
|
|
filteredModels,
|
|
endpointIndex,
|
|
)
|
|
: endpoint.models &&
|
|
renderEndpointModels(
|
|
endpoint,
|
|
endpoint.models,
|
|
selectedModel,
|
|
undefined,
|
|
endpointIndex,
|
|
)}
|
|
</>
|
|
)}
|
|
</Menu>
|
|
);
|
|
} else {
|
|
return (
|
|
<MenuItem
|
|
id={`endpoint-${endpoint.value}-menu`}
|
|
key={`endpoint-${endpoint.value}-item`}
|
|
onClick={() => handleSelectEndpoint(endpoint)}
|
|
aria-selected={isEndpointSelected || undefined}
|
|
className="group flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm"
|
|
>
|
|
{renderIconLabel()}
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{endpointRequiresUserKey(endpoint.value) && (
|
|
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
|
)}
|
|
{isAssistantsNotLoaded && (
|
|
<TooltipAnchor
|
|
description={localize('com_ui_click_to_view_var', { 0: endpoint.label })}
|
|
side="top"
|
|
render={
|
|
<span className="flex items-center">
|
|
<MousePointerClick className="size-4 text-text-secondary" aria-hidden="true" />
|
|
</span>
|
|
}
|
|
/>
|
|
)}
|
|
{isEndpointSelected && !isAssistantsNotLoaded && (
|
|
<>
|
|
<CheckCircle2 className="size-4 shrink-0 text-text-primary" aria-hidden="true" />
|
|
<VisuallyHidden>{localize('com_a11y_selected')}</VisuallyHidden>
|
|
</>
|
|
)}
|
|
</div>
|
|
</MenuItem>
|
|
);
|
|
}
|
|
}
|
|
|
|
export function renderEndpoints(mappedEndpoints: Endpoint[]) {
|
|
return mappedEndpoints.map((endpoint, index) => (
|
|
<EndpointItem
|
|
endpoint={endpoint}
|
|
endpointIndex={index}
|
|
key={`endpoint-${endpoint.value}-${index}`}
|
|
/>
|
|
));
|
|
}
|