Skip to content

feat: Add Clear All Conversations for llama-server web-ui #12924

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified examples/server/public/index.html.gz
Binary file not shown.
7 changes: 6 additions & 1 deletion examples/server/webui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion examples/server/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"remark-math": "^6.0.0",
"tailwindcss": "^4.1.1",
"textlinestream": "^1.1.1",
"vite-plugin-singlefile": "^2.0.3"
"vite-plugin-singlefile": "^2.0.3",
"webui": "file:"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
Expand Down
74 changes: 70 additions & 4 deletions examples/server/webui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,70 @@ import { classNames } from '../utils/misc';
import { Conversation } from '../utils/types';
import StorageUtils from '../utils/storage';
import { useNavigate, useParams } from 'react-router';
import { useAppContext } from '../utils/app.context';

export default function Sidebar() {
const params = useParams();
const navigate = useNavigate();
const { pendingMessages } = useAppContext();

const [conversations, setConversations] = useState<Conversation[]>([]);
const [currConv, setCurrConv] = useState<Conversation | null>(null);

// Handler function for the clear all button
const handleClearAll = async () => {
const isAnyGenerating = Object.keys(pendingMessages).length > 0;
if (isAnyGenerating) {
alert(
'Cannot clear conversations while message generation is in progress. Please wait or stop the generation.'
);
return; // Stop the function here
}
// Show confirmation dialog to the user
const isConfirmed = window.confirm(
'Are you sure you want to delete ALL conversations? This action cannot be undone.'
);
if (isConfirmed) {
try {
// Call the storage utility function to clear data
await StorageUtils.clearAllConversations();
// Navigate to the home/new conversation page after clearing
// The onConversationChanged listener will handle updating the 'conversations' state automatically
navigate('/');
} catch (error) {
console.error('Failed to clear conversations:', error);
alert('Failed to clear conversations. See console for details.');
}
}
};

useEffect(() => {
StorageUtils.getOneConversation(params.convId ?? '').then(setCurrConv);
}, [params.convId]);

useEffect(() => {
const handleConversationChange = async () => {
// Always refresh the full list
setConversations(await StorageUtils.getAllConversations());

// Check if the currently selected conversation still exists after a change (deletion/clear all)
if (currConv?.id) {
// Check if there *was* a selected conversation
const stillExists = await StorageUtils.getOneConversation(currConv.id);
if (!stillExists) {
// If the current conv was deleted/cleared, update the local state for highlighting
setCurrConv(null);
// Navigation happens via handleClearAll or if user manually deletes and stays on the page
}
}
Comment on lines +53 to +61
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think many changes in this PR are redundant. You can simply window.location.reload() upon clear the database, and everything will be in sync

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the page is light and window.location.reload() would be quick. However, I generally try to avoid full page reloads as a principle, considering that the update system is already built into the app – using the StorageUtils.onConversationChanged events and React state (useState/useEffect). I made small tweaks (from my point of view :) ) to that system so it could also handle clearing everything.

};
StorageUtils.onConversationChanged(handleConversationChange);
handleConversationChange();
return () => {
StorageUtils.offConversationChanged(handleConversationChange);
};
}, []);
// Dependency added to re-check existence if currConv changes while mounted
}, [currConv]); // Changed dependency from [] to [currConv]

return (
<>
Expand All @@ -41,7 +83,7 @@ export default function Sidebar() {
aria-label="close sidebar"
className="drawer-overlay"
></label>
<div className="flex flex-col bg-base-200 min-h-full max-w-64 py-4 px-4">
<div className="flex flex-col bg-base-200 min-h-full max-w-[100%] 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>

Expand Down Expand Up @@ -77,18 +119,42 @@ export default function Sidebar() {
<div
key={conv.id}
className={classNames({
'btn btn-ghost justify-start font-normal': true,
// 'btn btn-ghost justify-start font-normal w-full overflow-hidden',
'btn btn-ghost justify-start font-normal': true,
'btn-active': conv.id === currConv?.id,
// Additional styles for active conversation
'border-1 border-blue-400': conv.id === currConv?.id,
})}
onClick={() => navigate(`/chat/${conv.id}`)}
dir="auto"
>
<span className="truncate">{conv.name}</span>
</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 pb-2 ">
Conversations are saved to browser's IndexedDB
</div>
{/* Clear All Button - Added */}
{conversations.length > 0 && ( // Only show if there are conversations to clear
<button
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From UX perspective, this button should be hidden in one of the sections under "Settings". It's strange to expose such a dangerous button on the main UI (even when it requires confirmation)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are your thoughts on replacing the button with a less intrusive text link in the sidebar footer, next to the IndexedDB notice? Something like this:

 <div className="text-center text-xs opacity-40 mt-auto mx-4 pb-2">
            Conversations are saved to browser's IndexedDB&nbsp;
            <span
              onClick={handleClearAll}
              className="text-error underline cursor-pointer hover:opacity-70"
            >
              clear
            </span>
          </div>

image

className="btn btn-outline btn-error btn-sm w-full mb-3 pb-1"
onClick={handleClearAll}
title="Conversations are saved to browser's IndexedDB"
>
Clear All
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash ml-2"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z" />
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z" />
</svg>
</button>
)}
</div>
</div>
</>
Expand Down
14 changes: 10 additions & 4 deletions examples/server/webui/src/utils/app.context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,22 @@ export const AppContextProvider = ({
useEffect(() => {
// also reset the canvas data
setCanvasData(null);
const handleConversationChange = async (changedConvId: string) => {
if (changedConvId !== convId) return;
setViewingChat(await getViewingChat(changedConvId));
const handleConversationChange = async (changedConvId: string | null) => {
// Refresh if the change affects the current viewing conversation OR if all conversations were cleared (null)
if (changedConvId === convId || changedConvId === null) {
// Re-fetch data for the current URL's convId (which might be undefined now)
const currentUrlConvId = params?.params?.convId;
// Ensure getViewingChat can handle potential undefined/null input if needed, or provide fallback like ''
setViewingChat(await getViewingChat(currentUrlConvId ?? '')); // Use currentUrlConvId
}
// Otherwise, ignore changes for conversations not being viewed.
};
StorageUtils.onConversationChanged(handleConversationChange);
getViewingChat(convId ?? '').then(setViewingChat);
return () => {
StorageUtils.offConversationChanged(handleConversationChange);
};
}, [convId]);
}, [convId, params?.params?.convId]);

const setPending = (convId: string, pendingMsg: PendingMessage | null) => {
// if pendingMsg is null, remove the key from the object
Expand Down
41 changes: 33 additions & 8 deletions examples/server/webui/src/utils/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import { Conversation, Message, TimingReport } from './types';
import Dexie, { Table } from 'dexie';

const event = new EventTarget();

type CallbackConversationChanged = (convId: string) => void;
// Modify callback type to accept null for "clear all" events
type CallbackConversationChanged = (convId: string | null) => void;
let onConversationChangedHandlers: [
CallbackConversationChanged,
EventListener,
][] = [];
const dispatchConversationChange = (convId: string) => {
const dispatchConversationChange = (convId: string | null) => {
event.dispatchEvent(
new CustomEvent('conversationChange', { detail: { convId } })
);
Expand Down Expand Up @@ -167,18 +167,43 @@ const StorageUtils = {
dispatchConversationChange(convId);
},

/**
* Added function to clear all conversation data.
*/
async clearAllConversations(): Promise<void> {
try {
await db.transaction('rw', db.conversations, db.messages, async () => {
await db.conversations.clear(); // Clear conversations table
await db.messages.clear(); // Clear messages table
});
console.log('All conversations cleared.');
// Dispatch change with null to indicate everything was cleared
dispatchConversationChange(null);
} catch (error) {
console.error('Failed to clear all conversations:', error);
throw error; // Re-throw error for potential handling by the caller
}
},

// event listeners
onConversationChanged(callback: CallbackConversationChanged) {
const fn = (e: Event) => callback((e as CustomEvent).detail.convId);
// Ensure the event listener correctly handles the detail (string | null)
const fn = (e: Event) =>
callback((e as CustomEvent).detail.convId as string | null);
onConversationChangedHandlers.push([callback, fn]);
event.addEventListener('conversationChange', fn);
},
offConversationChanged(callback: CallbackConversationChanged) {
const fn = onConversationChangedHandlers.find(([cb, _]) => cb === callback);
if (fn) {
event.removeEventListener('conversationChange', fn[1]);
const handlerTuple = onConversationChangedHandlers.find(
([cb]) => cb === callback
);
if (handlerTuple) {
event.removeEventListener('conversationChange', handlerTuple[1]);
// Filter out the specific handler, don't reset the whole array
onConversationChangedHandlers = onConversationChangedHandlers.filter(
(tuple) => tuple[0] !== callback
);
}
onConversationChangedHandlers = [];
},

// manage config
Expand Down