mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-26 13:18:51 +01:00
🔖 feat: Conversation Bookmarks (#3344)
* feat: add tags property in Conversation model * feat: add ConversationTag model * feat: add the tags parameter to getConvosByPage * feat: add API route to ConversationTag * feat: add types of ConversationTag * feat: add data access functions for conversation tags * feat: add Bookmark table component * feat: Add an action to bookmark * feat: add Bookmark nav component * fix: failed test * refactor: made 'Saved' tag a constant * feat: add new bookmark to current conversation * chore: Add comment * fix: delete tag from conversations when it's deleted * fix: Update the query cache when the tag title is changed. * chore: fix typo * refactor: add description of rebuilding bookmarks * chore: remove unused variables * fix: position when adding a new bookmark * refactor: add comment, rename a function * refactor: add a unique constraint in ConversationTag * chore: add localizations
This commit is contained in:
parent
d4d56281e3
commit
e565e0faab
65 changed files with 3751 additions and 36 deletions
72
client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx
Normal file
72
client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { BookmarkPlusIcon } from 'lucide-react';
|
||||
import type { FC } from 'react';
|
||||
import { useConversationTagsQuery, useRebuildConversationTagsMutation } from '~/data-provider';
|
||||
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
|
||||
import { BookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import { BookmarkEditDialog } from '~/components/Bookmarks';
|
||||
import BookmarkTable from './BookmarkTable';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
|
||||
|
||||
const BookmarkPanel: FC<{ open: boolean; onOpenChange: (open: boolean) => void }> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { mutate, isLoading } = useRebuildConversationTagsMutation();
|
||||
const { data } = useConversationTagsQuery();
|
||||
const rebuildTags = () => {
|
||||
mutate({});
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={true}
|
||||
className={cn(
|
||||
'overflow-x-auto shadow-2xl dark:bg-gray-700 dark:text-white md:max-h-[600px] md:min-h-[373px] md:w-[680px]',
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
|
||||
{localize('com_ui_bookmarks')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<BookmarkContext.Provider value={{ bookmarks: data || [] }}>
|
||||
<div className="p-0 sm:p-6 sm:pt-4">
|
||||
<BookmarkTable />
|
||||
<div className="mt-5 sm:mt-4" />
|
||||
<div className="flex justify-between gap-2 pr-2 sm:pr-0">
|
||||
<Button variant="outline" onClick={rebuildTags} className="text-sm">
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
{localize('com_ui_bookmarks_rebuild')}
|
||||
<HoverCardSettings side="bottom" text="com_nav_info_bookmarks_rebuild" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<BookmarkEditDialog
|
||||
trigger={
|
||||
<Button variant="outline" onClick={rebuildTags} className="text-sm">
|
||||
<BookmarkPlusIcon className="mr-1 size-4" />
|
||||
<div className="break-all">{localize('com_ui_bookmarks_new')}</div>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button variant="subtle" onClick={() => onOpenChange(!open)} className="text-sm">
|
||||
{localize('com_ui_close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BookmarkContext.Provider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default BookmarkPanel;
|
||||
59
client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx
Normal file
59
client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { ConversationTagsResponse, TConversationTag } from 'librechat-data-provider';
|
||||
import { BookmarkContext, useBookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import BookmarkTableRow from './BookmarkTableRow';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const BookmarkTable = () => {
|
||||
const localize = useLocalize();
|
||||
const [rows, setRows] = useState<ConversationTagsResponse>([]);
|
||||
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
useEffect(() => {
|
||||
setRows(bookmarks?.map((item) => ({ id: item.tag, ...item })) || []);
|
||||
}, [bookmarks]);
|
||||
|
||||
const moveRow = useCallback((dragIndex: number, hoverIndex: number) => {
|
||||
setRows((prevTags: TConversationTag[]) => {
|
||||
const updatedRows = [...prevTags];
|
||||
const [movedRow] = updatedRows.splice(dragIndex, 1);
|
||||
updatedRows.splice(hoverIndex, 0, movedRow);
|
||||
return updatedRows;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderRow = useCallback((row: TConversationTag, position: number) => {
|
||||
return <BookmarkTableRow key={row.tag} moveRow={moveRow} row={row} position={position} />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BookmarkContext.Provider value={{ bookmarks }}>
|
||||
<div
|
||||
className={cn(
|
||||
'container',
|
||||
'relative h-[300px] overflow-auto',
|
||||
'-mx-4 w-auto ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg',
|
||||
)}
|
||||
>
|
||||
<table className="min-w-full divide-gray-300">
|
||||
<thead className="sticky top-0 z-10 border-b bg-white">
|
||||
<tr className="text-left text-sm font-semibold text-gray-900">
|
||||
<th className="w-96 px-3 py-3.5 pl-6">
|
||||
<div>{localize('com_ui_bookmarks_title')}</div>
|
||||
</th>
|
||||
<th className="w-28 px-3 py-3.5 sm:pl-6">
|
||||
<div>{localize('com_ui_bookmarks_count')}</div>
|
||||
</th>
|
||||
|
||||
<th className="flex-grow px-3 py-3.5 sm:pl-6"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm">{rows.map((row, i) => renderRow(row, i))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</BookmarkContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkTable;
|
||||
135
client/src/components/SidePanel/Bookmarks/BookmarkTableRow.tsx
Normal file
135
client/src/components/SidePanel/Bookmarks/BookmarkTableRow.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useRef } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import type { FC } from 'react';
|
||||
import type { Identifier, XYCoord } from 'dnd-core';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import { DeleteBookmarkButton, EditBookmarkButton } from '~/components/Bookmarks';
|
||||
import { useConversationTagMutation } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export const ItemTypes = {
|
||||
CARD: 'card',
|
||||
};
|
||||
|
||||
export interface BookmarkItemProps {
|
||||
position: number;
|
||||
moveRow: (dragIndex: number, hoverIndex: number) => void;
|
||||
row: TConversationTag;
|
||||
}
|
||||
|
||||
interface DragItem {
|
||||
index: number;
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const BookmarkTableRow: FC<BookmarkItemProps> = ({ position, moveRow, row, ...rest }) => {
|
||||
const ref = useRef<HTMLTableRowElement>(null);
|
||||
|
||||
const mutation = useConversationTagMutation(row.tag);
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const handleDrop = (item: DragItem) => {
|
||||
const data = {
|
||||
...row,
|
||||
position: item.index,
|
||||
};
|
||||
mutation.mutate(data, {
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_endpoint_preset_save_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const [{ handlerId }, drop] = useDrop<DragItem, void, { handlerId: Identifier | null }>({
|
||||
accept: ItemTypes.CARD,
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
drop(item: DragItem, monitor) {
|
||||
handleDrop(item);
|
||||
},
|
||||
hover(item: DragItem, monitor) {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = position;
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveRow(dragIndex, hoverIndex);
|
||||
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.CARD,
|
||||
item: () => {
|
||||
return { id: row.tag, index: position };
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (position > 0) {
|
||||
drag(drop(ref));
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
'group cursor-pointer gap-2 rounded text-sm hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5',
|
||||
isDragging ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
key={row.tag}
|
||||
ref={ref}
|
||||
data-handler-id={handlerId}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
{...rest}
|
||||
>
|
||||
<td className="w-96 py-2 pl-6 pr-3">{row.tag}</td>
|
||||
<td className={cn('w-28 py-2 pl-4 pr-3 sm:pl-6')}>
|
||||
<span className="py-1">{row.count}</span>
|
||||
</td>
|
||||
<td className="flex-grow py-2 pl-4 pr-4 sm:pl-6">
|
||||
{position > 0 && (
|
||||
<div className="flex w-full items-center justify-end gap-2 py-1 text-gray-400">
|
||||
<EditBookmarkButton bookmark={row} />
|
||||
<DeleteBookmarkButton bookmark={row.tag} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkTableRow;
|
||||
|
|
@ -41,9 +41,9 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
|
|||
? 'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
|
||||
: '',
|
||||
)}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (link.onClick) {
|
||||
link.onClick();
|
||||
link.onClick(e);
|
||||
setActive('');
|
||||
return;
|
||||
}
|
||||
|
|
@ -87,9 +87,9 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
|
|||
'hover:bg-gray-50 data-[state=open]:bg-gray-50 data-[state=open]:text-black dark:hover:bg-gray-700 dark:data-[state=open]:bg-gray-700 dark:data-[state=open]:text-white',
|
||||
'w-full justify-start rounded-md border dark:border-gray-700',
|
||||
)}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (link.onClick) {
|
||||
link.onClick();
|
||||
link.onClick(e);
|
||||
setActive('');
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { ResizableHandleAlt, ResizablePanel, ResizablePanelGroup } from '~/compo
|
|||
import { TooltipProvider, Tooltip } from '~/components/ui/Tooltip';
|
||||
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
|
||||
import { useMediaQuery, useLocalStorage } from '~/hooks';
|
||||
import BookmarkPanel from './Bookmarks/BookmarkPanel';
|
||||
import NavToggle from '~/components/Nav/NavToggle';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import Switcher from './Switcher';
|
||||
|
|
@ -79,8 +80,20 @@ const SidePanel = ({
|
|||
localStorage.setItem('fullPanelCollapse', 'true');
|
||||
panelRef.current?.collapse();
|
||||
}, []);
|
||||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
const manageBookmarks = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setShowBookmarks((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const Links = useSideNavLinks({ hidePanel, assistants, keyProvided, endpoint, interfaceConfig });
|
||||
const Links = useSideNavLinks({
|
||||
hidePanel,
|
||||
assistants,
|
||||
keyProvided,
|
||||
endpoint,
|
||||
interfaceConfig,
|
||||
manageBookmarks,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const throttledSaveLayout = useCallback(
|
||||
|
|
@ -128,6 +141,7 @@ const SidePanel = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{showBookmarks && <BookmarkPanel open={showBookmarks} onOpenChange={setShowBookmarks} />}
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
|
|
@ -216,7 +230,7 @@ const SidePanel = ({
|
|||
</ResizablePanelGroup>
|
||||
</TooltipProvider>
|
||||
<div
|
||||
className={`nav-mask${!isCollapsed ? ' active' : ''}`}
|
||||
className={`nav-mask${!isCollapsed ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setIsCollapsed(() => {
|
||||
localStorage.setItem('fullPanelCollapse', 'true');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue