From d44c2d3f5aeab25a9405896f48a36082cee5d8ac Mon Sep 17 00:00:00 2001 From: firecoperana Date: Sun, 20 Jul 2025 05:33:55 -0500 Subject: Webui: New Features for Conversations, Settings, and Chat Messages (#618) * Webui: add Rename/Upload conversation in header and sidebar webui: don't change modified date when renaming conversation * webui: add a preset feature to the settings #14649 * webui: Add editing assistant messages #13522 Webui: keep the following message while editing assistance response. webui: change icon to edit message * webui: DB import and export #14347 * webui: Wrap long numbers instead of infinite horizontal scroll (#14062) fix sidebar being covered by main content #14082 --------- Co-authored-by: firecoperana --- examples/server/webui/src/components/Sidebar.tsx | 382 ++++++++++++++++++++--- 1 file changed, 346 insertions(+), 36 deletions(-) (limited to 'examples/server/webui/src/components/Sidebar.tsx') diff --git a/examples/server/webui/src/components/Sidebar.tsx b/examples/server/webui/src/components/Sidebar.tsx index 34727c62..4b0adbae 100644 --- a/examples/server/webui/src/components/Sidebar.tsx +++ b/examples/server/webui/src/components/Sidebar.tsx @@ -1,13 +1,36 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { classNames } from '../utils/misc'; import { Conversation } from '../utils/types'; import StorageUtils from '../utils/storage'; import { useNavigate, useParams } from 'react-router'; +import { + ArrowUpTrayIcon, + ArrowDownTrayIcon, + EllipsisVerticalIcon, + PencilIcon, + PencilSquareIcon, + TrashIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import { BtnWithTooltips } from '../utils/common'; +import { useAppContext } from '../utils/app.context'; +import toast from 'react-hot-toast'; +import { useModals } from './ModalProvider'; + // at the top of your file, alongside ConversationExport: + async function importConversation() { + const importedConv = await StorageUtils.importConversationFromFile(); + if (importedConv) { + console.log('Successfully imported:', importedConv.name); + // Refresh UI or navigate to conversation + } + }; export default function Sidebar() { const params = useParams(); const navigate = useNavigate(); + const { isGenerating, viewingChat } = useAppContext(); + const [conversations, setConversations] = useState([]); const [currConv, setCurrConv] = useState(null); @@ -25,68 +48,176 @@ export default function Sidebar() { StorageUtils.offConversationChanged(handleConversationChange); }; }, []); + const { showConfirm, showPrompt } = useModals(); + + const groupedConv = useMemo( + () => groupConversationsByDate(conversations), + [conversations] + ); + + return ( <> -
+
+ + + Skip to main content + +
-

Conversations

+

+ Conversations +

{/* close sidebar button */} -
- {/* list of conversations */} -
navigate('/')} + aria-label="New conversation" > - + New conversation -
- {conversations.map((conv) => ( -
navigate(`/chat/${conv.id}`)} - dir="auto" - > - {conv.name} + + New conversation + + + {/* list of conversations */} + {groupedConv.map((group, i) => ( +
+ {/* group name (by date) */} + {group.title ? ( + // we use btn class here to make sure that the padding/margin are aligned with the other items + + {group.title} + + ) : ( +
+ )} + + {group.conversations.map((conv) => ( + { + navigate(`/chat/${conv.id}`); + }} + onDelete={async () => { + if (isGenerating(conv.id)) { + toast.error( + 'Cannot delete conversation while generating' + ); + return; + } + if ( + await showConfirm( + 'Are you sure to delete this conversation?' + ) + ) { + toast.success('Conversation deleted'); + StorageUtils.remove(conv.id); + navigate('/'); + } + }} + + onDownload={() => { + if (isGenerating(conv.id)) { + toast.error( + 'Cannot download conversation while generating' + ); + return; + } + // Get the current system message from config + const systemMessage = StorageUtils.getConfig().systemMessage; + + // Clone the viewingChat object to avoid modifying the original + const exportData = { + conv: { ...viewingChat?.conv }, + messages: viewingChat?.messages.map(msg => ({ ...msg })) + }; + + // Find the root message and update its content + const rootMessage = exportData.messages?.find(m => m.type === 'root'); + if (rootMessage) { + rootMessage.content = systemMessage; + } + + + const conversationJson = JSON.stringify(exportData, null, 2); + // const conversationJson = JSON.stringify(conv, null, 2); + const blob = new Blob([conversationJson], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `conversation_${conv.id}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }} + onRename={async () => { + if (isGenerating(conv.id)) { + toast.error( + 'Cannot rename conversation while generating' + ); + return; + } + const newName = await showPrompt( + 'Enter new name for the conversation', + conv.name + ); + if (newName && newName.trim().length > 0) { + StorageUtils.updateConversationName(conv.id, newName); + } + }} + /> + ))}
))} -
+
Conversations are saved to browser's IndexedDB
@@ -94,3 +225,182 @@ export default function Sidebar() { ); } + +function ConversationItem({ + conv, + isCurrConv, + onSelect, + onDelete, + onDownload, + onRename, +}: { + conv: Conversation; + isCurrConv: boolean; + onSelect: () => void; + onDelete: () => void; + onDownload: () => void; + onRename: () => void; +}) { + return ( +
+ +
+ + {/* dropdown menu */} + +
+
+ ); +} + +// WARN: vibe code below + +export interface GroupedConversations { + title?: string; + conversations: Conversation[]; +} + +// TODO @ngxson : add test for this function +// Group conversations by date +// - "Previous 7 Days" +// - "Previous 30 Days" +// - "Month Year" (e.g., "April 2023") +export function groupConversationsByDate( + conversations: Conversation[] +): GroupedConversations[] { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Start of today + + const sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(today.getDate() - 7); + + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(today.getDate() - 30); + + const groups: { [key: string]: Conversation[] } = { + Today: [], + 'Previous 7 Days': [], + 'Previous 30 Days': [], + }; + const monthlyGroups: { [key: string]: Conversation[] } = {}; // Key format: "Month Year" e.g., "April 2023" + + // Sort conversations by lastModified date in descending order (newest first) + // This helps when adding to groups, but the final output order of groups is fixed. + const sortedConversations = [...conversations].sort( + (a, b) => b.lastModified - a.lastModified + ); + + for (const conv of sortedConversations) { + const convDate = new Date(conv.lastModified); + + if (convDate >= today) { + groups['Today'].push(conv); + } else if (convDate >= sevenDaysAgo) { + groups['Previous 7 Days'].push(conv); + } else if (convDate >= thirtyDaysAgo) { + groups['Previous 30 Days'].push(conv); + } else { + const monthName = convDate.toLocaleString('default', { month: 'long' }); + const year = convDate.getFullYear(); + const monthYearKey = `${monthName} ${year}`; + if (!monthlyGroups[monthYearKey]) { + monthlyGroups[monthYearKey] = []; + } + monthlyGroups[monthYearKey].push(conv); + } + } + + const result: GroupedConversations[] = []; + + if (groups['Today'].length > 0) { + result.push({ + title: undefined, // no title for Today + conversations: groups['Today'], + }); + } + + if (groups['Previous 7 Days'].length > 0) { + result.push({ + title: 'Previous 7 Days', + conversations: groups['Previous 7 Days'], + }); + } + + if (groups['Previous 30 Days'].length > 0) { + result.push({ + title: 'Previous 30 Days', + conversations: groups['Previous 30 Days'], + }); + } + + // Sort monthly groups by date (most recent month first) + const sortedMonthKeys = Object.keys(monthlyGroups).sort((a, b) => { + const dateA = new Date(a); // "Month Year" can be parsed by Date constructor + const dateB = new Date(b); + return dateB.getTime() - dateA.getTime(); + }); + + for (const monthKey of sortedMonthKeys) { + if (monthlyGroups[monthKey].length > 0) { + result.push({ title: monthKey, conversations: monthlyGroups[monthKey] }); + } + } + + return result; +} -- cgit v1.2.3