summaryrefslogtreecommitdiff
path: root/examples/server/webui/src/components/Sidebar.tsx
diff options
context:
space:
mode:
authorfirecoperana <xuqiaowei1124@gmail.com>2025-07-20 05:33:55 -0500
committerGitHub <noreply@github.com>2025-07-20 12:33:55 +0200
commitd44c2d3f5aeab25a9405896f48a36082cee5d8ac (patch)
tree6768d4d8c72fb0b5c7b4a5a4187d2eccb292f0ad /examples/server/webui/src/components/Sidebar.tsx
parentf989fb03bd12752ad6e93717ca4bd298d5001d99 (diff)
Webui: New Features for Conversations, Settings, and Chat Messages (#618)main
* 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 <firecoperana>
Diffstat (limited to 'examples/server/webui/src/components/Sidebar.tsx')
-rw-r--r--examples/server/webui/src/components/Sidebar.tsx382
1 files changed, 346 insertions, 36 deletions
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<Conversation[]>([]);
const [currConv, setCurrConv] = useState<Conversation | null>(null);
@@ -25,68 +48,176 @@ export default function Sidebar() {
StorageUtils.offConversationChanged(handleConversationChange);
};
}, []);
+ const { showConfirm, showPrompt } = useModals();
+
+ const groupedConv = useMemo(
+ () => groupConversationsByDate(conversations),
+ [conversations]
+ );
+
+
return (
<>
<input
id="toggle-drawer"
type="checkbox"
className="drawer-toggle"
+ aria-label="Toggle sidebar"
defaultChecked
/>
- <div className="drawer-side h-screen lg:h-screen z-50 lg:max-w-64">
+ <div
+ className="drawer-side h-screen lg:h-screen z-50 lg:max-w-64"
+ role="complementary"
+ aria-label="Sidebar"
+ tabIndex={0}
+ >
<label
htmlFor="toggle-drawer"
- aria-label="close sidebar"
+ aria-label="Close sidebar"
className="drawer-overlay"
></label>
+
+ <a
+ href="#main-scroll"
+ className="absolute -left-80 top-0 w-1 h-1 overflow-hidden"
+ >
+ Skip to main content
+ </a>
+
<div className="flex flex-col bg-base-200 min-h-full max-w-64 py-4 px-4">
<div className="flex flex-row items-center justify-between mb-4 mt-4">
- <h2 className="font-bold ml-4">Conversations</h2>
+ <h2 className="font-bold ml-4" role="heading">
+ Conversations
+ </h2>
{/* 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>
+ <label
+ htmlFor="toggle-drawer"
+ className="btn btn-ghost lg:hidden"
+ aria-label="Close sidebar"
+ role="button"
+ tabIndex={0}
+ >
+ <XMarkIcon className="w-5 h-5" />
</label>
</div>
- {/* list of conversations */}
- <div
+ {/* new conversation button */}
+ <button
className={classNames({
- 'btn btn-ghost justify-start': true,
- 'btn-active': !currConv,
+ 'btn btn-ghost justify-start px-2': true,
+ 'btn-soft': !currConv,
})}
onClick={() => navigate('/')}
+ aria-label="New conversation"
>
- + 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>
+ <PencilSquareIcon className="w-5 h-5" />
+ New conversation
+ </button>
+
+ {/* list of conversations */}
+ {groupedConv.map((group, i) => (
+ <div key={i} role="group">
+ {/* group name (by date) */}
+ {group.title ? (
+ // we use btn class here to make sure that the padding/margin are aligned with the other items
+ <b
+ className="btn btn-ghost btn-xs bg-none btn-disabled block text-xs text-base-content text-start px-2 mb-0 mt-6 font-bold"
+ role="note"
+ aria-description={group.title}
+ tabIndex={0}
+ >
+ {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={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);
+ }
+ }}
+ />
+ ))}
</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 +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 (
+ <div
+ role="menuitem"
+ tabIndex={0}
+ aria-label={conv.name}
+ className={classNames({
+ 'group flex flex-row btn btn-ghost justify-start items-center font-normal px-2 h-9':
+ true,
+ 'btn-soft': isCurrConv,
+ })}
+ >
+ <button
+ key={conv.id}
+ className="w-full overflow-hidden truncate text-start"
+ onClick={onSelect}
+ dir="auto"
+ >
+ {conv.name}
+ </button>
+ <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
+ aria-label="More options"
+ tabIndex={0}
+ className="dropdown-content menu bg-base-100 rounded-box z-[1] p-2 shadow"
+ >
+ {/* Always show Upload when viewingChat is false */}
+
+ <li onClick={importConversation}>
+ <a>
+ <ArrowUpTrayIcon className="w-4 h-4" />
+ Upload
+ </a>
+ </li>
+ <li onClick={onRename} tabIndex={0}>
+ <a>
+ <PencilIcon className="w-4 h-4" />
+ Rename
+ </a>
+ </li>
+ <li onClick={onDownload} tabIndex={0}>
+ <a>
+ <ArrowDownTrayIcon className="w-4 h-4" />
+ Download
+ </a>
+ </li>
+ <li className="text-error" onClick={onDelete} tabIndex={0}>
+ <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;
+}