mirror of
				https://github.com/ggml-org/llama.cpp.git
				synced 2025-10-30 08:42:00 +00:00 
			
		
		
		
	server : (webui) revamp the input area, plus many small UI improvements (#13365)
* rework the input area * process selected file * change all icons to heroicons * fix thought process collapse * move conversation more menu to sidebar * sun icon --> moon icon * rm default system message * stricter upload file check, only allow image if server has mtmd * build it * add renaming * better autoscroll * build * add conversation group * fix scroll * extra context first, then user input in the end * fix <hr> tag * clean up a bit * build * add mb-3 for <pre> * throttle adjustTextareaHeight to make it less laggy * (nits) missing padding in sidebar * rm stray console log
This commit is contained in:
		| @@ -1,13 +1,25 @@ | ||||
| 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 { | ||||
|   ArrowDownTrayIcon, | ||||
|   EllipsisVerticalIcon, | ||||
|   PencilIcon, | ||||
|   TrashIcon, | ||||
|   XMarkIcon, | ||||
| } from '@heroicons/react/24/outline'; | ||||
| import { BtnWithTooltips } from '../utils/common'; | ||||
| import { useAppContext } from '../utils/app.context'; | ||||
| import toast from 'react-hot-toast'; | ||||
|  | ||||
| export default function Sidebar() { | ||||
|   const params = useParams(); | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   const { isGenerating } = useAppContext(); | ||||
|  | ||||
|   const [conversations, setConversations] = useState<Conversation[]>([]); | ||||
|   const [currConv, setCurrConv] = useState<Conversation | null>(null); | ||||
|  | ||||
| @@ -26,6 +38,11 @@ export default function Sidebar() { | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   const groupedConv = useMemo( | ||||
|     () => groupConversationsByDate(conversations), | ||||
|     [conversations] | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <input | ||||
| @@ -47,46 +64,96 @@ export default function Sidebar() { | ||||
|  | ||||
|             {/* close sidebar button */} | ||||
|             <label htmlFor="toggle-drawer" className="btn btn-ghost lg:hidden"> | ||||
|               <svg | ||||
|                 xmlns="http://www.w3.org/2000/svg" | ||||
|                 width="16" | ||||
|                 height="16" | ||||
|                 fill="currentColor" | ||||
|                 className="bi bi-arrow-bar-left" | ||||
|                 viewBox="0 0 16 16" | ||||
|               > | ||||
|                 <path | ||||
|                   fillRule="evenodd" | ||||
|                   d="M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5M10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5" | ||||
|                 /> | ||||
|               </svg> | ||||
|               <XMarkIcon className="w-5 h-5" /> | ||||
|             </label> | ||||
|           </div> | ||||
|  | ||||
|           {/* list of conversations */} | ||||
|           {/* new conversation button */} | ||||
|           <div | ||||
|             className={classNames({ | ||||
|               'btn btn-ghost justify-start': true, | ||||
|               'btn-active': !currConv, | ||||
|               'btn btn-ghost justify-start px-2': true, | ||||
|               'btn-soft': !currConv, | ||||
|             })} | ||||
|             onClick={() => navigate('/')} | ||||
|           > | ||||
|             + New conversation | ||||
|           </div> | ||||
|           {conversations.map((conv) => ( | ||||
|             <div | ||||
|               key={conv.id} | ||||
|               className={classNames({ | ||||
|                 'btn btn-ghost justify-start font-normal': true, | ||||
|                 'btn-active': conv.id === currConv?.id, | ||||
|               })} | ||||
|               onClick={() => navigate(`/chat/${conv.id}`)} | ||||
|               dir="auto" | ||||
|             > | ||||
|               <span className="truncate">{conv.name}</span> | ||||
|  | ||||
|           {/* list of conversations */} | ||||
|           {groupedConv.map((group) => ( | ||||
|             <div> | ||||
|               {/* group name (by date) */} | ||||
|               {group.title ? ( | ||||
|                 <b className="block text-xs px-2 mb-2 mt-6">{group.title}</b> | ||||
|               ) : ( | ||||
|                 <div className="h-2" /> | ||||
|               )} | ||||
|  | ||||
|               {group.conversations.map((conv) => ( | ||||
|                 <ConversationItem | ||||
|                   key={conv.id} | ||||
|                   conv={conv} | ||||
|                   isCurrConv={currConv?.id === conv.id} | ||||
|                   onSelect={() => { | ||||
|                     navigate(`/chat/${conv.id}`); | ||||
|                   }} | ||||
|                   onDelete={() => { | ||||
|                     if (isGenerating(conv.id)) { | ||||
|                       toast.error( | ||||
|                         'Cannot delete conversation while generating' | ||||
|                       ); | ||||
|                       return; | ||||
|                     } | ||||
|                     if ( | ||||
|                       window.confirm( | ||||
|                         '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; | ||||
|                     } | ||||
|                     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={() => { | ||||
|                     if (isGenerating(conv.id)) { | ||||
|                       toast.error( | ||||
|                         'Cannot rename conversation while generating' | ||||
|                       ); | ||||
|                       return; | ||||
|                     } | ||||
|                     const newName = window.prompt( | ||||
|                       'Enter new name for the conversation', | ||||
|                       conv.name | ||||
|                     ); | ||||
|                     if (newName && newName.trim().length > 0) { | ||||
|                       StorageUtils.updateConversationName(conv.id, newName); | ||||
|                     } | ||||
|                   }} | ||||
|                 /> | ||||
|               ))} | ||||
|             </div> | ||||
|           ))} | ||||
|           <div className="text-center text-xs opacity-40 mt-auto mx-4"> | ||||
|           <div className="text-center text-xs opacity-40 mt-auto mx-4 pt-8"> | ||||
|             Conversations are saved to browser's IndexedDB | ||||
|           </div> | ||||
|         </div> | ||||
| @@ -94,3 +161,170 @@ 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 ( | ||||
|     <div | ||||
|       className={classNames({ | ||||
|         'group flex flex-row btn btn-ghost justify-start items-center font-normal px-2 h-9': | ||||
|           true, | ||||
|         'btn-soft': isCurrConv, | ||||
|       })} | ||||
|     > | ||||
|       <div | ||||
|         key={conv.id} | ||||
|         className="w-full overflow-hidden truncate text-start" | ||||
|         onClick={onSelect} | ||||
|         dir="auto" | ||||
|       > | ||||
|         {conv.name} | ||||
|       </div> | ||||
|       <div className="dropdown dropdown-end h-5"> | ||||
|         <BtnWithTooltips | ||||
|           // on mobile, we always show the ellipsis icon | ||||
|           // on desktop, we only show it when the user hovers over the conversation item | ||||
|           // we use opacity instead of hidden to avoid layout shift | ||||
|           className="cursor-pointer opacity-100 md:opacity-0 group-hover:opacity-100" | ||||
|           onClick={() => {}} | ||||
|           tooltipsContent="More" | ||||
|         > | ||||
|           <EllipsisVerticalIcon className="w-5 h-5" /> | ||||
|         </BtnWithTooltips> | ||||
|         {/* dropdown menu */} | ||||
|         <ul | ||||
|           tabIndex={0} | ||||
|           className="dropdown-content menu bg-base-100 rounded-box z-[1] p-2 shadow" | ||||
|         > | ||||
|           <li onClick={onRename}> | ||||
|             <a> | ||||
|               <PencilIcon className="w-4 h-4" /> | ||||
|               Rename | ||||
|             </a> | ||||
|           </li> | ||||
|           <li onClick={onDownload}> | ||||
|             <a> | ||||
|               <ArrowDownTrayIcon className="w-4 h-4" /> | ||||
|               Download | ||||
|             </a> | ||||
|           </li> | ||||
|           <li className="text-error" onClick={onDelete}> | ||||
|             <a> | ||||
|               <TrashIcon className="w-4 h-4" /> | ||||
|               Delete | ||||
|             </a> | ||||
|           </li> | ||||
|         </ul> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| // 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; | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Xuan-Son Nguyen
					Xuan-Son Nguyen