mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-31 23:58:50 +01:00
🎨 feat: UI Refresh for Enhanced UX (#6346)
* ✨ feat: Add Expand Chat functionality and improve UI components * ✨ feat: Introduce Chat Badges feature with editing capabilities and UI enhancements * ✨ feat: re-implement file attachment functionality with new components and improved UI * ✨ feat: Enhance BadgeRow component with drag-and-drop functionality and add animations for better user experience * ✨ feat: Add useChatBadges hook and enhance Badge component with animations and toggle functionality * feat: Improve Add/Delete Badges + style and bug fixes * ✨ feat: Refactor EditBadges component and optimize useChatBadges hook for improved performance and readability * ✨ feat: Add type definition for LucideIcon in EditBadges component * refactor: Clean up BadgeRow component by removing outdated comment and improving code readability * refactor: Rename app-icon class to badge-icon for consistency and improve badge styling * feat: Add Center Chat Input toggle and update related components for improved UI/UX * refactor: Simplify ChatView and MessagesView components for improved readability and performance * refactor: Improve layout and positioning of scroll button in MessagesView component * refactor: Adjust scroll button position in MessagesView component for better visibility * refactor: Remove redundant background class from Badge component for cleaner styling * feat: disable chat badges * refactor: adjust positioning of scroll button and popover for improved layout * refactor: simplify class names in ChatForm and RemoveFile components for cleaner code * refactor: move Switcher to HeaderOptions from SidePanel * fix(Landing): duplicate description * feat: add SplitText component for animated text display and update Landing component to use it * feat(Chat): add ConversationStarters component and integrate it into ChatView; remove ConvoStarter component * feat(Chat): enhance Message component layout and styling for improved readability * feat(ControlCombobox, Select): enhance styling and add animation for improved UI experience * feat(Chat): update Header and HeaderNewChat components for improved layout and styling * feat(Chat): add ModelDropdown (now includes both endpoint and model) and refactor Menu components for improved UI * feat(ModelDropdown): add Agent Select; removed old AgentSwitcher components * feat(ModelDropdown): add settings button for user key configuration * fix(ModelDropdown): the model dropdown wasn't opening automatically when opening the endpoint one * refactor(Chat): remove unused EndpointsMenu and related components to streamline codebase * feat: enhance greeting message and improve accessibility fro ModelDropdown * refactor(Endpoints): add new hooks and components for endpoint management * feat(Endpoint): add support for modelSpecs * feat(Endpoints): add mobile support * fix: type issues * fix(modelSpec): type issue * fix(EndpointMenuDropdown): double overflow scroller in mobile model list * fix: search model on mobile * refactor: Endpoint/Model/modelSpec dropdown * refactor: reorganize imports in Endpoint components * refactor: remove unused translation keys from English locale * BREAKING: moving to ariakit with new CustomMenu * refactor: remove unnecessary comments * refactor: remove EndpointItem, ModelDropdownButton, SpecIcon, and SpecItem components * 🔧 fix: AI Icon bump when regenerating message * wip: chat UI refactoring, fix issues * chore: add recent update to useAutoSave * feat: add access control for agent permissions in useMentions hook * refactor: streamline ModelSelector by removing unused endpoints logic * refactor: enhance ModelSelector and context by integrating endpointsConfig and improving type usage * feat: update ModelSelectorContext to utilize conversation data for initial state * feat: add selector effects for synced endpoint handling * feat: add guard clause for conversation endpoint in useSelectorEffects hook * fix: safely call onSelectMention and add autofocus to mention input * chore: typing * refactor: ModelSelector to streamline key dialog handling and improve endpoint rendering * refactor: extract SettingsButton component for cleaner endpoint item rendering * wip: first pass, expand set api key * wip: first pass, expanding set key * refactor: update EndpointItem styles for improved layout and hover effects * refactor: adjust padding in EndpointItem for improved layout consistency * refactor: update preset structure in useSelectMention to include spec as null * refactor: rename setKeyDialogOpen to onOpenChange for clarity and consistency, bring focus back to button that opened dialog * feat: add SpecIcon component for dynamic model spec icons in menu, adjust icon styling * refactor: update getSelectedIcon to accept additional parameters and improve icon rendering logic * fix: adjust padding in MessageRender for improved layout * refactor: remove inline style for menu width in CustomMenu component * refactor: enhance layout and styling in ModelSpecItem component for better responsiveness * refactor: update getDefaultModelSpec to accept startupConfig and improve model spec retrieval logic * refactor: improve key management and default values in ModelSelector and related components * refactor: adjust menu width and improve responsiveness in CustomMenu and EndpointItem components * refactor: enhance focus styles and responsiveness in EndpointItem component * refactor: improve layout and spacing in Header and ModelSelector components for better responsiveness * refactor: adjust button styles for consistency and improved layout in AddMultiConvo and PresetsMenu components * fix: initial fix of assistant names * fix: assistants handling * chore: update version of librechat-data-provider to 0.7.75 and add 'spec' to excludedKeys * fix: improve endpoint filtering logic based on interface configuration and access rights * fix: remove unused HeaderOptions import and set spec to null in presets and mentions * fix: ensure currentExample is always an object when updating examples * fix: update interfaceConfig checks to ensure modelSelect is considered for rendering components * fix: update model selection logic to consider interface configuration when prioritizing model specs * fix: add missing localizations * fix: remove unused agent and assistant selection translations * fix: implement debounced state updates for selected values in useSelectorEffects * style: minor style changes related to the ModelSelector * fix: adjust maximum height for popover and set fixed height for model item * fix: update placeholders for model and endpoint search inputs * fix: refactor MessageRender and ContentRender components to better match each other * fix: remove convo fallback for iconURL in MessageRender and ContentRender components * fix: update handling of spec, iconURL, and modelLabel in conversation presets, to allow better interchangeability * fix: replace chatGptLabel with modelLabel in OpenAI settings configuration (fully deprecate chatGptLabel) * fix: remove console log for assistantNames in useEndpoints hook * refactor: add cleanInput and cleanOutput options to default conversation handling * chore: update bun.lockb * fix: set default value for showIconInHeader in getSelectedIcon function * refactor: enhance error handling in message processing when latest message has existing content blocks * chore: allow import/no-cycle for messages * fix: adjust flex properties in BookmarkMenu for better layout * feat: support both 'prompt' and 'q' as query parameters in useQueryParams hook * feat: re-enable Badges components * refactor: disable edit badge component * chore: rename assistantMap to assistantsMap for consistency * chore: rename assistantMap to assistantsMap for consistency in Mention component * feat: set staleTime for various queries to improve data freshness * feat: add spec field to tQueryParamsSchema for model specification * feat: enhance useQueryParams to handle model specs --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
c4fea9cd79
commit
7f29f2f676
127 changed files with 4507 additions and 2163 deletions
|
|
@ -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}
|
||||
|
|
|
|||
357
client/src/components/Chat/Input/BadgeRow.tsx
Normal file
357
client/src/components/Chat/Input/BadgeRow.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useReducer,
|
||||
} from 'react';
|
||||
import { useRecoilValue, useRecoilCallback } from 'recoil';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useChatBadges } from '~/hooks';
|
||||
import { Badge } from '~/components/ui';
|
||||
import { BadgeItem } from '~/common';
|
||||
import store from '~/store';
|
||||
|
||||
interface BadgeRowProps {
|
||||
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
|
||||
onToggle?: (badgeId: string, currentActive: boolean) => void;
|
||||
}
|
||||
|
||||
interface BadgeWrapperProps {
|
||||
badge: BadgeItem;
|
||||
isEditing: boolean;
|
||||
onToggle: (badge: BadgeItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onMouseDown: (e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => void;
|
||||
badgeRefs: React.MutableRefObject<Record<string, HTMLDivElement>>;
|
||||
}
|
||||
|
||||
const BadgeWrapper = React.memo(
|
||||
forwardRef<HTMLDivElement, BadgeWrapperProps>(
|
||||
({ badge, isEditing, onToggle, onDelete, onMouseDown, badgeRefs }, ref) => {
|
||||
const isActive = badge.atom ? useRecoilValue(badge.atom) : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
badgeRefs.current[badge.id] = el;
|
||||
}
|
||||
if (typeof ref === 'function') {
|
||||
ref(el);
|
||||
} else if (ref) {
|
||||
ref.current = el;
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => onMouseDown(e, badge, isActive)}
|
||||
className={isEditing ? 'ios-wiggle badge-icon h-full' : 'badge-icon h-full'}
|
||||
>
|
||||
<Badge
|
||||
icon={badge.icon as LucideIcon}
|
||||
label={badge.label}
|
||||
isActive={isActive}
|
||||
isEditing={isEditing}
|
||||
isAvailable={badge.isAvailable}
|
||||
onToggle={() => onToggle(badge)}
|
||||
onBadgeAction={() => onDelete(badge.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.badge.id === nextProps.badge.id &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.onToggle === nextProps.onToggle &&
|
||||
prevProps.onDelete === nextProps.onDelete &&
|
||||
prevProps.onMouseDown === nextProps.onMouseDown &&
|
||||
prevProps.badgeRefs === nextProps.badgeRefs,
|
||||
);
|
||||
|
||||
BadgeWrapper.displayName = 'BadgeWrapper';
|
||||
|
||||
interface DragState {
|
||||
draggedBadge: BadgeItem | null;
|
||||
mouseX: number;
|
||||
offsetX: number;
|
||||
insertIndex: number | null;
|
||||
draggedBadgeActive: boolean;
|
||||
}
|
||||
|
||||
type DragAction =
|
||||
| {
|
||||
type: 'START_DRAG';
|
||||
badge: BadgeItem;
|
||||
mouseX: number;
|
||||
offsetX: number;
|
||||
insertIndex: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
| { type: 'UPDATE_POSITION'; mouseX: number; insertIndex: number }
|
||||
| { type: 'END_DRAG' };
|
||||
|
||||
const dragReducer = (state: DragState, action: DragAction): DragState => {
|
||||
switch (action.type) {
|
||||
case 'START_DRAG':
|
||||
return {
|
||||
draggedBadge: action.badge,
|
||||
mouseX: action.mouseX,
|
||||
offsetX: action.offsetX,
|
||||
insertIndex: action.insertIndex,
|
||||
draggedBadgeActive: action.isActive,
|
||||
};
|
||||
case 'UPDATE_POSITION':
|
||||
return {
|
||||
...state,
|
||||
mouseX: action.mouseX,
|
||||
insertIndex: action.insertIndex,
|
||||
};
|
||||
case 'END_DRAG':
|
||||
return {
|
||||
draggedBadge: null,
|
||||
mouseX: 0,
|
||||
offsetX: 0,
|
||||
insertIndex: null,
|
||||
draggedBadgeActive: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export function BadgeRow({ onChange, onToggle }: BadgeRowProps) {
|
||||
const [orderedBadges, setOrderedBadges] = useState<BadgeItem[]>([]);
|
||||
const [dragState, dispatch] = useReducer(dragReducer, {
|
||||
draggedBadge: null,
|
||||
mouseX: 0,
|
||||
offsetX: 0,
|
||||
insertIndex: null,
|
||||
draggedBadgeActive: false,
|
||||
});
|
||||
|
||||
const badgeRefs = useRef<Record<string, HTMLDivElement>>({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const animationFrame = useRef<number | null>(null);
|
||||
const containerRectRef = useRef<DOMRect | null>(null);
|
||||
|
||||
const allBadges = useChatBadges() || [];
|
||||
const isEditing = useRecoilValue(store.isEditingBadges);
|
||||
|
||||
const badges = useMemo(
|
||||
() => allBadges.filter((badge) => badge.isAvailable !== false),
|
||||
[allBadges],
|
||||
);
|
||||
|
||||
const toggleBadge = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
async (badgeAtom: any) => {
|
||||
const current = await snapshot.getPromise(badgeAtom);
|
||||
set(badgeAtom, !current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOrderedBadges((prev) => {
|
||||
const currentIds = new Set(prev.map((b) => b.id));
|
||||
const newBadges = badges.filter((b) => !currentIds.has(b.id));
|
||||
return newBadges.length > 0 ? [...prev, ...newBadges] : prev;
|
||||
});
|
||||
}, [badges]);
|
||||
|
||||
const tempBadges = dragState.draggedBadge
|
||||
? orderedBadges.filter((b) => b.id !== dragState.draggedBadge?.id)
|
||||
: orderedBadges;
|
||||
const ghostBadge = dragState.draggedBadge || null;
|
||||
|
||||
const calculateInsertIndex = useCallback(
|
||||
(currentMouseX: number): number => {
|
||||
if (!dragState.draggedBadge || !containerRef.current || !containerRectRef.current) {
|
||||
return 0;
|
||||
}
|
||||
const relativeMouseX = currentMouseX - containerRectRef.current.left;
|
||||
const refs = tempBadges.map((b) => badgeRefs.current[b.id]).filter(Boolean);
|
||||
if (refs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
let idx = 0;
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
const rect = refs[i].getBoundingClientRect();
|
||||
const relativeLeft = rect.left - containerRectRef.current.left;
|
||||
const relativeCenter = relativeLeft + rect.width / 2;
|
||||
if (relativeMouseX < relativeCenter) {
|
||||
break;
|
||||
}
|
||||
idx = i + 1;
|
||||
}
|
||||
return idx;
|
||||
},
|
||||
[dragState.draggedBadge, tempBadges],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => {
|
||||
if (!isEditing || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
const el = badgeRefs.current[badge.id];
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left;
|
||||
const mouseX = e.clientX;
|
||||
const initialIndex = orderedBadges.findIndex((b) => b.id === badge.id);
|
||||
containerRectRef.current = containerRef.current.getBoundingClientRect();
|
||||
dispatch({
|
||||
type: 'START_DRAG',
|
||||
badge,
|
||||
mouseX,
|
||||
offsetX,
|
||||
insertIndex: initialIndex,
|
||||
isActive,
|
||||
});
|
||||
},
|
||||
[isEditing, orderedBadges],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!dragState.draggedBadge) {
|
||||
return;
|
||||
}
|
||||
if (animationFrame.current) {
|
||||
cancelAnimationFrame(animationFrame.current);
|
||||
}
|
||||
animationFrame.current = requestAnimationFrame(() => {
|
||||
const newMouseX = e.clientX;
|
||||
const newInsertIndex = calculateInsertIndex(newMouseX);
|
||||
if (newInsertIndex !== dragState.insertIndex) {
|
||||
dispatch({ type: 'UPDATE_POSITION', mouseX: newMouseX, insertIndex: newInsertIndex });
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'UPDATE_POSITION',
|
||||
mouseX: newMouseX,
|
||||
insertIndex: dragState.insertIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
[dragState.draggedBadge, dragState.insertIndex, calculateInsertIndex],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (dragState.draggedBadge && dragState.insertIndex !== null) {
|
||||
const otherBadges = orderedBadges.filter((b) => b.id !== dragState.draggedBadge?.id);
|
||||
const newBadges = [
|
||||
...otherBadges.slice(0, dragState.insertIndex),
|
||||
dragState.draggedBadge,
|
||||
...otherBadges.slice(dragState.insertIndex),
|
||||
];
|
||||
setOrderedBadges(newBadges);
|
||||
onChange(newBadges.map((badge) => ({ id: badge.id })));
|
||||
}
|
||||
dispatch({ type: 'END_DRAG' });
|
||||
containerRectRef.current = null;
|
||||
}, [dragState.draggedBadge, dragState.insertIndex, orderedBadges, onChange]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(badgeId: string) => {
|
||||
const newBadges = orderedBadges.filter((b) => b.id !== badgeId);
|
||||
setOrderedBadges(newBadges);
|
||||
onChange(newBadges.map((badge) => ({ id: badge.id })));
|
||||
},
|
||||
[orderedBadges, onChange],
|
||||
);
|
||||
|
||||
const handleBadgeToggle = useCallback(
|
||||
(badge: BadgeItem) => {
|
||||
if (badge.atom) {
|
||||
toggleBadge(badge.atom);
|
||||
}
|
||||
if (onToggle) {
|
||||
onToggle(badge.id, !!badge.atom);
|
||||
}
|
||||
},
|
||||
[toggleBadge, onToggle],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.draggedBadge) {
|
||||
return;
|
||||
}
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
if (animationFrame.current) {
|
||||
cancelAnimationFrame(animationFrame.current);
|
||||
animationFrame.current = null;
|
||||
}
|
||||
};
|
||||
}, [dragState.draggedBadge, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
|
||||
{tempBadges.map((badge, index) => (
|
||||
<React.Fragment key={badge.id}>
|
||||
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
|
||||
<div className="badge-icon h-full">
|
||||
<Badge
|
||||
icon={ghostBadge.icon as LucideIcon}
|
||||
label={ghostBadge.label}
|
||||
isActive={dragState.draggedBadgeActive}
|
||||
isEditing={isEditing}
|
||||
isAvailable={ghostBadge.isAvailable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<BadgeWrapper
|
||||
badge={badge}
|
||||
isEditing={isEditing}
|
||||
onToggle={handleBadgeToggle}
|
||||
onDelete={handleDelete}
|
||||
onMouseDown={handleMouseDown}
|
||||
badgeRefs={badgeRefs}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
|
||||
<div className="badge-icon h-full">
|
||||
<Badge
|
||||
icon={ghostBadge.icon as LucideIcon}
|
||||
label={ghostBadge.label}
|
||||
isActive={dragState.draggedBadgeActive}
|
||||
isEditing={isEditing}
|
||||
isAvailable={ghostBadge.isAvailable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{ghostBadge && (
|
||||
<div
|
||||
className="ghost-badge h-full"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
transform: `translateX(${dragState.mouseX - dragState.offsetX - (containerRectRef.current?.left || 0)}px)`,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
icon={ghostBadge.icon as LucideIcon}
|
||||
label={ghostBadge.label}
|
||||
isActive={dragState.draggedBadgeActive}
|
||||
isAvailable={ghostBadge.isAvailable}
|
||||
isEditing
|
||||
isDragging
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import { memo, useRef, useMemo, useEffect, useState } from 'react';
|
||||
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
||||
import { useWatch } from 'react-hook-form';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import {
|
||||
supportsFiles,
|
||||
mergeFileConfig,
|
||||
isAssistantsEndpoint,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
useChatContext,
|
||||
useChatFormContext,
|
||||
|
|
@ -20,47 +16,105 @@ import {
|
|||
useQueryParams,
|
||||
useSubmitMessage,
|
||||
} 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 { BadgeRow } from './BadgeRow';
|
||||
import EditBadges from './EditBadges';
|
||||
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);
|
||||
|
||||
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 automaticPlayback = useRecoilValue(store.automaticPlayback);
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const [isTemporaryChat, setIsTemporaryChat] = useRecoilState<boolean>(store.isTemporary);
|
||||
|
||||
const chatDirection = useRecoilValue(store.chatDirection);
|
||||
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 methods = useChatFormContext();
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
conversation,
|
||||
isSubmitting,
|
||||
filesLoading,
|
||||
newConversation,
|
||||
handleStopGenerating,
|
||||
} = useChatContext();
|
||||
const {
|
||||
addedIndex,
|
||||
generateConversation,
|
||||
conversation: addedConvo,
|
||||
setConversation: setAddedConvo,
|
||||
isSubmitting: isSubmittingAdded,
|
||||
} = useAddedChatContext();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
|
||||
|
||||
const endpoint = useMemo(
|
||||
() => conversation?.endpointType ?? conversation?.endpoint,
|
||||
[conversation?.endpointType, conversation?.endpoint],
|
||||
);
|
||||
|
||||
const isRTL = useMemo(() => chatDirection === 'rtl', [chatDirection.toLowerCase()]);
|
||||
const invalidAssistant = useMemo(
|
||||
() =>
|
||||
isAssistantsEndpoint(endpoint) &&
|
||||
(!(conversation?.assistant_id ?? '') ||
|
||||
!assistantMap?.[endpoint ?? '']?.[conversation?.assistant_id ?? '']),
|
||||
[conversation?.assistant_id, endpoint, assistantMap],
|
||||
);
|
||||
const disableInputs = useMemo(
|
||||
() => requiresKey || invalidAssistant,
|
||||
[requiresKey, invalidAssistant],
|
||||
);
|
||||
|
||||
const handleContainerClick = useCallback(() => {
|
||||
textAreaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleFocusOrClick = useCallback(() => {
|
||||
if (isCollapsed) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
useAutoSave({
|
||||
conversationId: conversation?.conversationId,
|
||||
textAreaRef,
|
||||
files,
|
||||
setFiles,
|
||||
});
|
||||
|
||||
const { submitMessage, submitPrompt } = useSubmitMessage();
|
||||
const handleKeyUp = useHandleKeyUp({
|
||||
index,
|
||||
textAreaRef,
|
||||
|
|
@ -71,65 +125,22 @@ const ChatForm = ({ index = 0 }) => {
|
|||
textAreaRef,
|
||||
submitButtonRef,
|
||||
setIsScrollable,
|
||||
disabled: !!(requiresKey ?? false),
|
||||
disabled: disableInputs,
|
||||
});
|
||||
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
conversation,
|
||||
isSubmitting,
|
||||
filesLoading,
|
||||
newConversation,
|
||||
handleStopGenerating,
|
||||
} = useChatContext();
|
||||
const methods = useChatFormContext();
|
||||
const {
|
||||
addedIndex,
|
||||
generateConversation,
|
||||
conversation: addedConvo,
|
||||
setConversation: setAddedConvo,
|
||||
isSubmitting: isSubmittingAdded,
|
||||
} = useAddedChatContext();
|
||||
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
|
||||
|
||||
useAutoSave({
|
||||
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
|
||||
textAreaRef,
|
||||
files,
|
||||
setFiles,
|
||||
});
|
||||
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const { submitMessage, submitPrompt } = useSubmitMessage();
|
||||
|
||||
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
const endpoint = endpointType ?? _endpoint;
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
|
||||
const invalidAssistant = useMemo(
|
||||
() =>
|
||||
isAssistantsEndpoint(conversation?.endpoint) &&
|
||||
(!(conversation?.assistant_id ?? '') ||
|
||||
!assistantMap?.[conversation?.endpoint ?? ''][conversation?.assistant_id ?? '']),
|
||||
[conversation?.assistant_id, conversation?.endpoint, assistantMap],
|
||||
);
|
||||
const disableInputs = useMemo(
|
||||
() => !!((requiresKey ?? false) || invalidAssistant),
|
||||
[requiresKey, invalidAssistant],
|
||||
);
|
||||
useQueryParams({ textAreaRef });
|
||||
|
||||
const { ref, ...registerProps } = methods.register('text', {
|
||||
required: true,
|
||||
onChange: (e) => {
|
||||
methods.setValue('text', e.target.value, { shouldValidate: 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 (!isSearching && textAreaRef.current && !disableInputs) {
|
||||
textAreaRef.current.focus();
|
||||
|
|
@ -138,33 +149,53 @@ const ChatForm = ({ index = 0 }) => {
|
|||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingBadges && backupBadges.length === 0) {
|
||||
setBackupBadges([...badges]);
|
||||
}
|
||||
}, [isEditingBadges, badges, backupBadges.length]);
|
||||
|
||||
const handleSaveBadges = useCallback(() => {
|
||||
setIsEditingBadges(false);
|
||||
setBackupBadges([]);
|
||||
}, []);
|
||||
|
||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
|
||||
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
|
||||
const handleCancelBadges = useCallback(() => {
|
||||
if (backupBadges.length > 0) {
|
||||
setBadges([...backupBadges]);
|
||||
}
|
||||
setIsEditingBadges(false);
|
||||
setBackupBadges([]);
|
||||
}, [backupBadges, setBadges]);
|
||||
|
||||
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 isMoreThanThreeRows = visualRowCount > 3;
|
||||
|
||||
const baseClasses = useMemo(
|
||||
() =>
|
||||
cn(
|
||||
'md:py-3.5 m-0 w-full resize-none py-[13px] bg-surface-chat placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
|
||||
isCollapsed ? 'max-h-[52px]' : 'max-h-[45vh] md:max-h-[55vh]',
|
||||
isMoreThanThreeRows ? 'pl-5' : 'px-5',
|
||||
),
|
||||
[isCollapsed, isMoreThanThreeRows],
|
||||
);
|
||||
|
||||
const uploadActive = endpointSupportsFiles && !isUploadDisabled;
|
||||
const speechClass = isRTL
|
||||
? `pr-${uploadActive ? '12' : '4'} pl-12`
|
||||
: `pl-${uploadActive ? '12' : '4'} pr-12`;
|
||||
|
||||
return (
|
||||
<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 transition-all duration-200 sm:mb-2 sm:px-2',
|
||||
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
)}
|
||||
>
|
||||
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
|
||||
<div className="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 +214,101 @@ 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 border-border-light bg-surface-chat text-text-primary transition-all duration-200 sm:rounded-3xl',
|
||||
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
|
||||
)}
|
||||
>
|
||||
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||
<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}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
id={mainTextareaId}
|
||||
tabIndex={0}
|
||||
data-testid="text-input"
|
||||
rows={1}
|
||||
onFocus={() => {
|
||||
handleFocusOrClick();
|
||||
setIsTextAreaFocused(true);
|
||||
}}
|
||||
onBlur={setIsTextAreaFocused.bind(null, false)}
|
||||
onClick={handleFocusOrClick}
|
||||
style={{ height: 44, overflowY: 'auto' }}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
removeFocusRings,
|
||||
'transition-[max-height] duration-200',
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col items-start justify-start pt-1.5">
|
||||
<CollapseChat
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
{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={`${isRTL ? 'mr-2' : 'ml-2'}`}>
|
||||
<AttachFileChat disableInputs={disableInputs} />
|
||||
</div>
|
||||
<BadgeRow onChange={(newBadges) => setBadges(newBadges)} />
|
||||
<div className="mx-auto flex" />
|
||||
{SpeechToText && (
|
||||
<AudioRecorder
|
||||
methods={methods}
|
||||
ask={submitMessage}
|
||||
textAreaRef={textAreaRef}
|
||||
disabled={disableInputs}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default memo(ChatForm);
|
||||
export default ChatForm;
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<ChevronDown className="h-full w-full" />
|
||||
) : (
|
||||
<ChevronUp className="h-full w-full" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
85
client/src/components/Chat/Input/ConversationStarters.tsx
Normal file
85
client/src/components/Chat/Input/ConversationStarters.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useMemo, useCallback } from 'react';
|
||||
import { EModelEndpoint, Constants } from 'librechat-data-provider';
|
||||
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
|
||||
import { useGetAssistantDocsQuery, useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getIconEndpoint, getEntity } from '~/utils';
|
||||
import { useSubmitMessage } from '~/hooks';
|
||||
|
||||
const ConversationStarters = () => {
|
||||
const { conversation } = useChatContext();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const endpointType = useMemo(() => {
|
||||
let ep = conversation?.endpoint ?? '';
|
||||
if (
|
||||
[
|
||||
EModelEndpoint.chatGPTBrowser,
|
||||
EModelEndpoint.azureOpenAI,
|
||||
EModelEndpoint.gptPlugins,
|
||||
].includes(ep as EModelEndpoint)
|
||||
) {
|
||||
ep = EModelEndpoint.openAI;
|
||||
}
|
||||
return getIconEndpoint({
|
||||
endpointsConfig,
|
||||
iconURL: conversation?.iconURL,
|
||||
endpoint: ep,
|
||||
});
|
||||
}, [conversation?.endpoint, conversation?.iconURL, endpointsConfig]);
|
||||
|
||||
const { data: documentsMap = new Map() } = useGetAssistantDocsQuery(endpointType, {
|
||||
select: (data) => new Map(data.map((dbA) => [dbA.assistant_id, dbA])),
|
||||
});
|
||||
|
||||
const { entity, isAgent } = getEntity({
|
||||
endpoint: endpointType,
|
||||
agentsMap,
|
||||
assistantMap,
|
||||
agent_id: conversation?.agent_id,
|
||||
assistant_id: conversation?.assistant_id,
|
||||
});
|
||||
|
||||
const conversation_starters = useMemo(() => {
|
||||
if (entity?.conversation_starters?.length) {
|
||||
return entity.conversation_starters;
|
||||
}
|
||||
|
||||
if (isAgent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return documentsMap.get(entity?.id ?? '')?.conversation_starters ?? [];
|
||||
}, [documentsMap, isAgent, entity]);
|
||||
|
||||
const { submitMessage } = useSubmitMessage();
|
||||
const sendConversationStarter = useCallback(
|
||||
(text: string) => submitMessage({ text }),
|
||||
[submitMessage],
|
||||
);
|
||||
|
||||
if (!conversation_starters.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3 px-4">
|
||||
{conversation_starters
|
||||
.slice(0, Constants.MAX_CONVO_STARTERS)
|
||||
.map((text: string, index: number) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => sendConversationStarter(text)}
|
||||
className="relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border border-border-medium px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-surface-tertiary"
|
||||
>
|
||||
<p className="break-word line-clamp-3 overflow-hidden text-balance break-all text-text-secondary">
|
||||
{text}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationStarters;
|
||||
87
client/src/components/Chat/Input/EditBadges.tsx
Normal file
87
client/src/components/Chat/Input/EditBadges.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Edit3, Check, X } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { BadgeItem } from '~/common';
|
||||
import { useChatBadges, useLocalize } from '~/hooks';
|
||||
import { Button, Badge } from '~/components/ui';
|
||||
|
||||
interface EditBadgesProps {
|
||||
isEditingChatBadges: boolean;
|
||||
handleCancelBadges: () => void;
|
||||
handleSaveBadges: () => void;
|
||||
setBadges: React.Dispatch<React.SetStateAction<Pick<BadgeItem, 'id'>[]>>;
|
||||
}
|
||||
|
||||
const EditBadgesComponent = ({
|
||||
isEditingChatBadges,
|
||||
handleCancelBadges,
|
||||
handleSaveBadges,
|
||||
setBadges,
|
||||
}: EditBadgesProps) => {
|
||||
const localize = useLocalize();
|
||||
const allBadges = useChatBadges() || [];
|
||||
const unavailableBadges = allBadges.filter((badge) => !badge.isAvailable);
|
||||
|
||||
const handleRestoreBadge = useCallback(
|
||||
(badgeId: string) => {
|
||||
setBadges((prev: Pick<BadgeItem, 'id'>[]) => [...prev, { id: badgeId }]);
|
||||
},
|
||||
[setBadges],
|
||||
);
|
||||
|
||||
if (!isEditingChatBadges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-1.5 flex flex-col overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
|
||||
<div className="flex items-center gap-4 py-2 pl-3 pr-1.5 text-sm">
|
||||
<span className="mt-0 flex size-6 flex-shrink-0 items-center justify-center">
|
||||
<div className="icon-md">
|
||||
<Edit3 className="icon-md" aria-hidden="true" />
|
||||
</div>
|
||||
</span>
|
||||
<span className="text-token-text-secondary line-clamp-3 flex-1 py-0.5 font-semibold">
|
||||
{localize('com_ui_save_badge_changes')}
|
||||
</span>
|
||||
<div className="flex h-8 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
aria-label="Cancel"
|
||||
onClick={handleCancelBadges}
|
||||
className="h-8"
|
||||
>
|
||||
<X className="icon-md" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="submit"
|
||||
aria-label="Save changes"
|
||||
onClick={handleSaveBadges}
|
||||
className="h-8 rounded-b-lg rounded-tr-xl"
|
||||
>
|
||||
<Check className="icon-md" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{unavailableBadges && unavailableBadges.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 p-2">
|
||||
{unavailableBadges.map((badge) => (
|
||||
<div key={badge.id} className="badge-icon">
|
||||
<Badge
|
||||
icon={badge.icon as unknown as LucideIcon}
|
||||
label={badge.label}
|
||||
isAvailable={false}
|
||||
isEditing={true}
|
||||
onBadgeAction={() => handleRestoreBadge(badge.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(EditBadgesComponent);
|
||||
|
|
@ -1,55 +1,51 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { 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
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
44
client/src/components/Chat/Input/Files/AttachFileChat.tsx
Normal file
44
client/src/components/Chat/Input/Files/AttachFileChat.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import {
|
||||
supportsFiles,
|
||||
mergeFileConfig,
|
||||
isAgentsEndpoint,
|
||||
EndpointFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import AttachFileMenu from './AttachFileMenu';
|
||||
import AttachFile from './AttachFile';
|
||||
import store from '~/store';
|
||||
|
||||
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
const { conversation } = useChatContext();
|
||||
|
||||
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
|
||||
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
|
||||
| EndpointFileConfig
|
||||
| undefined;
|
||||
|
||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
|
||||
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
|
||||
|
||||
if (isAgents) {
|
||||
return <AttachFileMenu disabled={disableInputs} />;
|
||||
}
|
||||
if (endpointSupportsFiles && !isUploadDisabled) {
|
||||
return <AttachFile disabled={disableInputs} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default memo(AttachFileChat);
|
||||
|
|
@ -2,25 +2,23 @@ import * as Ariakit from '@ariakit/react';
|
|||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import { 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();
|
||||
|
||||
const capabilities = useMemo(
|
||||
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
|
||||
|
|
@ -93,8 +91,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 +112,15 @@ 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}
|
||||
trigger={menuTrigger}
|
||||
items={dropdownItems}
|
||||
iconClassName="mr-0"
|
||||
/>
|
||||
</FileUpload>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
30
client/src/components/Chat/Input/Files/FileFormChat.tsx
Normal file
30
client/src/components/Chat/Input/Files/FileFormChat.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useFileHandling } from '~/hooks';
|
||||
import FileRow from './FileRow';
|
||||
import store from '~/store';
|
||||
|
||||
function FileFormChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
||||
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
|
||||
const { endpoint: _endpoint } = conversation ?? { endpoint: null };
|
||||
const { abortUpload } = useFileHandling();
|
||||
|
||||
const isRTL = chatDirection === 'rtl';
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileRow
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
abortUpload={abortUpload}
|
||||
setFilesLoading={setFilesLoading}
|
||||
isRTL={isRTL}
|
||||
Wrapper={({ children }) => <div className="mx-2 mt-2 flex flex-wrap gap-2">{children}</div>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(FileFormChat);
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import {
|
||||
supportsFiles,
|
||||
mergeFileConfig,
|
||||
isAgentsEndpoint,
|
||||
EndpointFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import AttachFileMenu from './AttachFileMenu';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useFileHandling } from '~/hooks';
|
||||
import AttachFile from './AttachFile';
|
||||
import FileRow from './FileRow';
|
||||
import store from '~/store';
|
||||
|
||||
function FileFormWrapper({
|
||||
children,
|
||||
disableInputs,
|
||||
}: {
|
||||
disableInputs: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
||||
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
|
||||
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
|
||||
|
||||
const { handleFileChange, abortUpload } = useFileHandling();
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
const isRTL = chatDirection === 'rtl';
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
|
||||
| EndpointFileConfig
|
||||
| undefined;
|
||||
|
||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
|
||||
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
|
||||
|
||||
const renderAttachFile = () => {
|
||||
if (isAgents) {
|
||||
return (
|
||||
<AttachFileMenu
|
||||
isRTL={isRTL}
|
||||
disabled={disableInputs}
|
||||
handleFileChange={handleFileChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (endpointSupportsFiles && !isUploadDisabled) {
|
||||
return (
|
||||
<AttachFile isRTL={isRTL} disabled={disableInputs} handleFileChange={handleFileChange} />
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileRow
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
abortUpload={abortUpload}
|
||||
setFilesLoading={setFilesLoading}
|
||||
isRTL={isRTL}
|
||||
Wrapper={({ children }) => <div className="mx-2 mt-2 flex flex-wrap gap-2">{children}</div>}
|
||||
/>
|
||||
{children}
|
||||
{renderAttachFile()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(FileFormWrapper);
|
||||
|
|
@ -2,7 +2,7 @@ export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
|
|||
return (
|
||||
<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}
|
||||
>
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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,11 +197,11 @@ 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
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
placeholder={localize('com_ui_command_usage_placeholder')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue