mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
WIP: Responsive Segmented Controls
This commit is contained in:
parent
f4d97e1672
commit
fcfb0f47f9
7 changed files with 325 additions and 5 deletions
130
client/src/components/SidePanel/Parameters/DynamicSegment.tsx
Normal file
130
client/src/components/SidePanel/Parameters/DynamicSegment.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { OptionTypes } from 'librechat-data-provider';
|
||||||
|
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||||
|
import { Label, HoverCard, HoverCardTrigger, SegmentedControl } from '~/components/ui';
|
||||||
|
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||||
|
import { useChatContext } from '~/Providers';
|
||||||
|
import OptionHover from './OptionHover';
|
||||||
|
import { ESide } from '~/common';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
function DynamicSegment({
|
||||||
|
label = '',
|
||||||
|
settingKey,
|
||||||
|
defaultValue,
|
||||||
|
description = '',
|
||||||
|
columnSpan,
|
||||||
|
setOption,
|
||||||
|
optionType,
|
||||||
|
options,
|
||||||
|
enumMappings,
|
||||||
|
readonly = false,
|
||||||
|
showLabel = true,
|
||||||
|
showDefault = false,
|
||||||
|
labelCode = false,
|
||||||
|
descriptionCode = false,
|
||||||
|
conversation,
|
||||||
|
}: DynamicSettingProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { preset } = useChatContext();
|
||||||
|
const [inputValue, setInputValue] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedValue = useMemo(() => {
|
||||||
|
if (optionType === OptionTypes.Custom) {
|
||||||
|
// TODO: custom logic, add to payload but not to conversation
|
||||||
|
return inputValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversation?.[settingKey] ?? defaultValue;
|
||||||
|
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
|
||||||
|
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
if (optionType === OptionTypes.Custom) {
|
||||||
|
// TODO: custom logic, add to payload but not to conversation
|
||||||
|
setInputValue(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOption(settingKey)(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
useParameterEffects({
|
||||||
|
preset,
|
||||||
|
settingKey,
|
||||||
|
defaultValue,
|
||||||
|
conversation,
|
||||||
|
inputValue,
|
||||||
|
setInputValue,
|
||||||
|
preventDelayedUpdate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!options || options.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert options to SegmentedControl format with proper localization
|
||||||
|
const segmentOptions =
|
||||||
|
options?.map((option) => {
|
||||||
|
const optionValue = typeof option === 'string' ? option : String(option);
|
||||||
|
const optionLabel = typeof option === 'string' ? option : String(option);
|
||||||
|
|
||||||
|
// Use enum mappings for localization if available
|
||||||
|
const localizedLabel = enumMappings?.[optionValue]
|
||||||
|
? localize(enumMappings[optionValue] as TranslationKeys) ||
|
||||||
|
String(enumMappings[optionValue])
|
||||||
|
: optionLabel;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: String(localizedLabel),
|
||||||
|
value: optionValue,
|
||||||
|
disabled: false,
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-start gap-6',
|
||||||
|
columnSpan != null ? `col-span-${columnSpan}` : 'col-span-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HoverCard openDelay={300}>
|
||||||
|
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||||
|
{showLabel === true && (
|
||||||
|
<div className="flex w-full justify-between">
|
||||||
|
<Label
|
||||||
|
htmlFor={`${settingKey}-dynamic-segment`}
|
||||||
|
className="text-left text-sm font-medium"
|
||||||
|
>
|
||||||
|
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}
|
||||||
|
{showDefault && (
|
||||||
|
<small className="opacity-40">
|
||||||
|
({localize('com_endpoint_default')}: {defaultValue})
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SegmentedControl
|
||||||
|
options={segmentOptions}
|
||||||
|
value={selectedValue}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
disabled={readonly}
|
||||||
|
className="w-full min-w-0"
|
||||||
|
/>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
{description && (
|
||||||
|
<OptionHover
|
||||||
|
description={
|
||||||
|
descriptionCode
|
||||||
|
? (localize(description as TranslationKeys) ?? description)
|
||||||
|
: description
|
||||||
|
}
|
||||||
|
side={ESide.Left}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HoverCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DynamicSegment;
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DynamicSwitch,
|
DynamicSwitch,
|
||||||
DynamicInput,
|
DynamicInput,
|
||||||
DynamicTags,
|
DynamicTags,
|
||||||
|
DynamicSegment,
|
||||||
} from './';
|
} from './';
|
||||||
|
|
||||||
export const componentMapping: Record<
|
export const componentMapping: Record<
|
||||||
|
|
@ -23,4 +24,5 @@ export const componentMapping: Record<
|
||||||
[ComponentTypes.Checkbox]: DynamicCheckbox,
|
[ComponentTypes.Checkbox]: DynamicCheckbox,
|
||||||
[ComponentTypes.Tags]: DynamicTags,
|
[ComponentTypes.Tags]: DynamicTags,
|
||||||
[ComponentTypes.Combobox]: DynamicCombobox,
|
[ComponentTypes.Combobox]: DynamicCombobox,
|
||||||
|
[ComponentTypes.Segment]: DynamicSegment,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,5 @@ export { default as DynamicSlider } from './DynamicSlider';
|
||||||
export { default as DynamicSwitch } from './DynamicSwitch';
|
export { default as DynamicSwitch } from './DynamicSwitch';
|
||||||
export { default as DynamicInput } from './DynamicInput';
|
export { default as DynamicInput } from './DynamicInput';
|
||||||
export { default as DynamicTags } from './DynamicTags';
|
export { default as DynamicTags } from './DynamicTags';
|
||||||
|
export { default as DynamicSegment } from './DynamicSegment';
|
||||||
export { default as OptionHoverAlt } from './OptionHover';
|
export { default as OptionHoverAlt } from './OptionHover';
|
||||||
|
|
|
||||||
169
client/src/components/ui/SegmentedControl.tsx
Normal file
169
client/src/components/ui/SegmentedControl.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { forwardRef, useEffect, useRef, useState } from 'react';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
export interface SegmentedControlOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SegmentedControlProps {
|
||||||
|
options: SegmentedControlOption[];
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
name?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
|
||||||
|
({ options, value, onValueChange, name, className, disabled }, ref) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, height: 0, left: 0, top: 0 });
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const [useGrid, setUseGrid] = useState(false);
|
||||||
|
|
||||||
|
// Ensure we always have a current value
|
||||||
|
const currentValue = value !== undefined ? value : options[0]?.value;
|
||||||
|
|
||||||
|
const handleChange = (newValue: string) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onValueChange?.(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateIndicator = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const selector = currentValue === '' ? '[data-value=""]' : `[data-value="${currentValue}"]`;
|
||||||
|
const activeButton = containerRef.current.querySelector(selector) as HTMLButtonElement;
|
||||||
|
|
||||||
|
if (activeButton) {
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const buttonRect = activeButton.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (useGrid) {
|
||||||
|
// 2x2 grid layout - use full button dimensions
|
||||||
|
setIndicatorStyle({
|
||||||
|
width: buttonRect.width,
|
||||||
|
height: buttonRect.height,
|
||||||
|
left: buttonRect.left - containerRect.left,
|
||||||
|
top: buttonRect.top - containerRect.top,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 1-row layout - account for flex-1 distribution
|
||||||
|
const containerPadding = 4; // p-1 = 4px
|
||||||
|
setIndicatorStyle({
|
||||||
|
width: buttonRect.width,
|
||||||
|
height: buttonRect.height,
|
||||||
|
left: buttonRect.left - containerRect.left - containerPadding,
|
||||||
|
top: buttonRect.top - containerRect.top - containerPadding,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if text is being truncated and switch to grid if needed
|
||||||
|
const checkLayout = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const buttons = containerRef.current.querySelectorAll('button');
|
||||||
|
let needsGrid = false;
|
||||||
|
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
if (button.scrollWidth > button.clientWidth) {
|
||||||
|
needsGrid = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsGrid !== useGrid) {
|
||||||
|
setUseGrid(needsGrid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize and handle resize
|
||||||
|
useEffect(() => {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
checkLayout();
|
||||||
|
updateIndicator();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
// Initial check
|
||||||
|
setTimeout(() => {
|
||||||
|
checkLayout();
|
||||||
|
updateIndicator();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update indicator when value changes
|
||||||
|
useEffect(() => {
|
||||||
|
updateIndicator();
|
||||||
|
}, [currentValue, options]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
'relative rounded-lg bg-surface-secondary p-1',
|
||||||
|
useGrid ? 'grid grid-cols-2 gap-1' : 'flex items-center',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
role="radiogroup"
|
||||||
|
>
|
||||||
|
{/* Sliding background indicator */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'ring-border-light/20 absolute rounded-md bg-surface-primary shadow-sm ring-1 transition-all duration-300 ease-out',
|
||||||
|
!isInitialized && 'opacity-0',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: indicatorStyle.width,
|
||||||
|
height: indicatorStyle.height,
|
||||||
|
transform: `translate(${indicatorStyle.left}px, ${indicatorStyle.top}px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{options.map((option) => {
|
||||||
|
const isActive = currentValue === option.value;
|
||||||
|
const isDisabled = disabled || option.disabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
disabled={isDisabled}
|
||||||
|
data-value={option.value}
|
||||||
|
onClick={() => handleChange(option.value)}
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 px-2 py-1.5 text-xs font-medium transition-colors duration-200 ease-out',
|
||||||
|
'rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'min-w-0 truncate',
|
||||||
|
useGrid ? 'w-full' : 'flex-1',
|
||||||
|
isActive ? 'text-text-primary' : 'text-text-secondary hover:text-text-primary',
|
||||||
|
!isDisabled && 'cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
SegmentedControl.displayName = 'SegmentedControl';
|
||||||
|
|
@ -46,3 +46,4 @@ export { default as InputWithDropdown } from './InputWithDropDown';
|
||||||
export { default as SelectDropDownPop } from './SelectDropDownPop';
|
export { default as SelectDropDownPop } from './SelectDropDownPop';
|
||||||
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
|
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
|
||||||
export { default as MultiSelectDropDown } from './MultiSelectDropDown';
|
export { default as MultiSelectDropDown } from './MultiSelectDropDown';
|
||||||
|
export { SegmentedControl } from './SegmentedControl';
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ export type ComponentType =
|
||||||
| 'switch'
|
| 'switch'
|
||||||
| 'dropdown'
|
| 'dropdown'
|
||||||
| 'combobox'
|
| 'combobox'
|
||||||
| 'tags';
|
| 'tags'
|
||||||
|
| 'segment';
|
||||||
|
|
||||||
export type OptionType = 'conversation' | 'model' | 'custom';
|
export type OptionType = 'conversation' | 'model' | 'custom';
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@ export enum ComponentTypes {
|
||||||
Dropdown = 'dropdown',
|
Dropdown = 'dropdown',
|
||||||
Combobox = 'combobox',
|
Combobox = 'combobox',
|
||||||
Tags = 'tags',
|
Tags = 'tags',
|
||||||
|
Segment = 'segment',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SettingTypes {
|
export enum SettingTypes {
|
||||||
|
|
@ -206,6 +208,7 @@ const maxColumns = 4;
|
||||||
const minSliderOptions = 2;
|
const minSliderOptions = 2;
|
||||||
const minDropdownOptions = 2;
|
const minDropdownOptions = 2;
|
||||||
const minComboboxOptions = 2;
|
const minComboboxOptions = 2;
|
||||||
|
const minSegmentOptions = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the provided setting using the constraints unique to each component type.
|
* Validates the provided setting using the constraints unique to each component type.
|
||||||
|
|
@ -361,6 +364,20 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
|
||||||
setting.type === SettingTypes.Number ? (setting.includeInput ?? true) : false; // Default to true if type is number
|
setting.type === SettingTypes.Number ? (setting.includeInput ?? true) : false; // Default to true if type is number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (setting.component === ComponentTypes.Segment) {
|
||||||
|
if (
|
||||||
|
setting.type === SettingTypes.Enum &&
|
||||||
|
(!setting.options || setting.options.length < minSegmentOptions)
|
||||||
|
) {
|
||||||
|
errors.push({
|
||||||
|
code: ZodIssueCode.custom,
|
||||||
|
message: `Segment component for setting ${setting.key} requires at least ${minSegmentOptions} options for enum type.`,
|
||||||
|
path: ['options'],
|
||||||
|
});
|
||||||
|
// continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (setting.component === ComponentTypes.Slider && setting.type === SettingTypes.Number) {
|
if (setting.component === ComponentTypes.Slider && setting.type === SettingTypes.Number) {
|
||||||
if (setting.default === undefined && setting.range) {
|
if (setting.default === undefined && setting.range) {
|
||||||
// Set default to the middle of the range if unspecified
|
// Set default to the middle of the range if unspecified
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ const baseDefinitions: Record<string, SettingDefinition> = {
|
||||||
descriptionCode: true,
|
descriptionCode: true,
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
default: ImageDetail.auto,
|
default: ImageDetail.auto,
|
||||||
component: 'slider',
|
component: 'segment',
|
||||||
options: [ImageDetail.low, ImageDetail.auto, ImageDetail.high],
|
options: [ImageDetail.low, ImageDetail.auto, ImageDetail.high],
|
||||||
enumMappings: {
|
enumMappings: {
|
||||||
[ImageDetail.low]: 'com_ui_low',
|
[ImageDetail.low]: 'com_ui_low',
|
||||||
|
|
@ -218,7 +218,7 @@ const openAIParams: Record<string, SettingDefinition> = {
|
||||||
descriptionCode: true,
|
descriptionCode: true,
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
default: ReasoningEffort.none,
|
default: ReasoningEffort.none,
|
||||||
component: 'slider',
|
component: 'segment',
|
||||||
options: [
|
options: [
|
||||||
ReasoningEffort.none,
|
ReasoningEffort.none,
|
||||||
ReasoningEffort.low,
|
ReasoningEffort.low,
|
||||||
|
|
@ -268,7 +268,7 @@ const openAIParams: Record<string, SettingDefinition> = {
|
||||||
descriptionCode: true,
|
descriptionCode: true,
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
default: ReasoningSummary.none,
|
default: ReasoningSummary.none,
|
||||||
component: 'slider',
|
component: 'segment',
|
||||||
options: [
|
options: [
|
||||||
ReasoningSummary.none,
|
ReasoningSummary.none,
|
||||||
ReasoningSummary.auto,
|
ReasoningSummary.auto,
|
||||||
|
|
@ -276,7 +276,7 @@ const openAIParams: Record<string, SettingDefinition> = {
|
||||||
ReasoningSummary.detailed,
|
ReasoningSummary.detailed,
|
||||||
],
|
],
|
||||||
enumMappings: {
|
enumMappings: {
|
||||||
[ReasoningSummary.none]: 'com_ui_none',
|
[ReasoningSummary.none]: 'com_ui_none_',
|
||||||
[ReasoningSummary.auto]: 'com_ui_auto',
|
[ReasoningSummary.auto]: 'com_ui_auto',
|
||||||
[ReasoningSummary.concise]: 'com_ui_concise',
|
[ReasoningSummary.concise]: 'com_ui_concise',
|
||||||
[ReasoningSummary.detailed]: 'com_ui_detailed',
|
[ReasoningSummary.detailed]: 'com_ui_detailed',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue