ChatSidebar.svelte 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. <script lang="ts">
  2. import { goto } from '$app/navigation';
  3. import { page } from '$app/state';
  4. import { Trash2 } from '@lucide/svelte';
  5. import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
  6. import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
  7. import * as Sidebar from '$lib/components/ui/sidebar';
  8. import * as AlertDialog from '$lib/components/ui/alert-dialog';
  9. import Input from '$lib/components/ui/input/input.svelte';
  10. import {
  11. conversations,
  12. deleteConversation,
  13. updateConversationName
  14. } from '$lib/stores/chat.svelte';
  15. import { DatabaseStore } from '$lib/stores/database';
  16. import ChatSidebarActions from './ChatSidebarActions.svelte';
  17. const sidebar = Sidebar.useSidebar();
  18. let currentChatId = $derived(page.params.id);
  19. let isSearchModeActive = $state(false);
  20. let searchQuery = $state('');
  21. let showDeleteDialog = $state(false);
  22. let showEditDialog = $state(false);
  23. let showClearAllDialog = $state(false);
  24. let showMemoryDialog = $state(false);
  25. let selectedConversation = $state<DatabaseConversation | null>(null);
  26. let editedName = $state('');
  27. let memoryContent = $state('');
  28. let memoryLoading = $state(false);
  29. let memorySaving = $state(false);
  30. let filteredConversations = $derived.by(() => {
  31. if (searchQuery.trim().length > 0) {
  32. return conversations().filter((conversation: { name: string }) =>
  33. conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
  34. );
  35. }
  36. return conversations();
  37. });
  38. async function handleDeleteConversation(id: string) {
  39. const conversation = conversations().find((conv) => conv.id === id);
  40. if (conversation) {
  41. selectedConversation = conversation;
  42. showDeleteDialog = true;
  43. }
  44. }
  45. async function handleEditConversation(id: string) {
  46. const conversation = conversations().find((conv) => conv.id === id);
  47. if (conversation) {
  48. selectedConversation = conversation;
  49. editedName = conversation.name;
  50. showEditDialog = true;
  51. }
  52. }
  53. function handleConfirmDelete() {
  54. if (selectedConversation) {
  55. showDeleteDialog = false;
  56. setTimeout(() => {
  57. deleteConversation(selectedConversation.id);
  58. selectedConversation = null;
  59. }, 100); // Wait for animation to finish
  60. }
  61. }
  62. function handleConfirmEdit() {
  63. if (!editedName.trim() || !selectedConversation) return;
  64. showEditDialog = false;
  65. updateConversationName(selectedConversation.id, editedName);
  66. selectedConversation = null;
  67. }
  68. export function handleMobileSidebarItemClick() {
  69. if (sidebar.isMobile) {
  70. sidebar.toggle();
  71. }
  72. }
  73. export function activateSearchMode() {
  74. isSearchModeActive = true;
  75. }
  76. export function editActiveConversation() {
  77. if (currentChatId) {
  78. const activeConversation = filteredConversations.find((conv) => conv.id === currentChatId);
  79. if (activeConversation) {
  80. const event = new CustomEvent('edit-active-conversation', {
  81. detail: { conversationId: currentChatId }
  82. });
  83. document.dispatchEvent(event);
  84. }
  85. }
  86. }
  87. function handleClearAll() {
  88. showClearAllDialog = true;
  89. }
  90. async function handleConfirmClearAll() {
  91. showClearAllDialog = false;
  92. const count = await DatabaseStore.deleteAllConversations();
  93. console.log(`Cleared ${count} conversations`);
  94. // Navigate to home
  95. goto('/?new_chat=true#/');
  96. // Reload conversations
  97. window.location.reload();
  98. }
  99. async function loadMemory() {
  100. memoryLoading = true;
  101. try {
  102. const response = await fetch('/memory');
  103. const data = await response.json();
  104. memoryContent = data.content || '';
  105. } catch (error) {
  106. console.error('Failed to load memory:', error);
  107. memoryContent = '';
  108. } finally {
  109. memoryLoading = false;
  110. }
  111. }
  112. async function saveMemory() {
  113. memorySaving = true;
  114. try {
  115. const response = await fetch('/memory', {
  116. method: 'POST',
  117. headers: {
  118. 'Content-Type': 'application/json'
  119. },
  120. body: JSON.stringify({ content: memoryContent })
  121. });
  122. const data = await response.json();
  123. if (!data.success) {
  124. alert('Failed to save memory');
  125. }
  126. } catch (error) {
  127. console.error('Failed to save memory:', error);
  128. alert('Failed to save memory');
  129. } finally {
  130. memorySaving = false;
  131. }
  132. }
  133. async function handleOpenMemory() {
  134. showMemoryDialog = true;
  135. await loadMemory();
  136. }
  137. async function selectConversation(id: string) {
  138. if (isSearchModeActive) {
  139. isSearchModeActive = false;
  140. searchQuery = '';
  141. }
  142. await goto(`#/chat/${id}`);
  143. }
  144. </script>
  145. <ScrollArea class="h-[100vh]">
  146. <Sidebar.Header class=" top-0 z-10 gap-6 bg-sidebar/50 px-4 pt-4 pb-2 backdrop-blur-lg md:sticky">
  147. <a href="#/" onclick={handleMobileSidebarItemClick}>
  148. <h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
  149. </a>
  150. <ChatSidebarActions
  151. {handleMobileSidebarItemClick}
  152. bind:isSearchModeActive
  153. bind:searchQuery
  154. onClearAll={handleClearAll}
  155. onOpenMemory={handleOpenMemory}
  156. />
  157. </Sidebar.Header>
  158. <Sidebar.Group class="mt-4 space-y-2 p-0 px-4">
  159. {#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
  160. <Sidebar.GroupLabel>
  161. {isSearchModeActive ? 'Search results' : 'Conversations'}
  162. </Sidebar.GroupLabel>
  163. {/if}
  164. <Sidebar.GroupContent>
  165. <Sidebar.Menu>
  166. {#each filteredConversations as conversation (conversation.id)}
  167. <Sidebar.MenuItem class="mb-1">
  168. <ChatSidebarConversationItem
  169. conversation={{
  170. id: conversation.id,
  171. name: conversation.name,
  172. lastModified: conversation.lastModified,
  173. currNode: conversation.currNode
  174. }}
  175. {handleMobileSidebarItemClick}
  176. isActive={currentChatId === conversation.id}
  177. onSelect={selectConversation}
  178. onEdit={handleEditConversation}
  179. onDelete={handleDeleteConversation}
  180. />
  181. </Sidebar.MenuItem>
  182. {/each}
  183. {#if filteredConversations.length === 0}
  184. <div class="px-2 py-4 text-center">
  185. <p class="mb-4 p-4 text-sm text-muted-foreground">
  186. {searchQuery.length > 0
  187. ? 'No results found'
  188. : isSearchModeActive
  189. ? 'Start typing to see results'
  190. : 'No conversations yet'}
  191. </p>
  192. </div>
  193. {/if}
  194. </Sidebar.Menu>
  195. </Sidebar.GroupContent>
  196. </Sidebar.Group>
  197. <div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"></div>
  198. </ScrollArea>
  199. <DialogConfirmation
  200. bind:open={showDeleteDialog}
  201. title="Delete Conversation"
  202. description={selectedConversation
  203. ? `Are you sure you want to delete "${selectedConversation.name}"? This action cannot be undone and will permanently remove all messages in this conversation.`
  204. : ''}
  205. confirmText="Delete"
  206. cancelText="Cancel"
  207. variant="destructive"
  208. icon={Trash2}
  209. onConfirm={handleConfirmDelete}
  210. onCancel={() => {
  211. showDeleteDialog = false;
  212. selectedConversation = null;
  213. }}
  214. />
  215. <AlertDialog.Root bind:open={showEditDialog}>
  216. <AlertDialog.Content>
  217. <AlertDialog.Header>
  218. <AlertDialog.Title>Edit Conversation Name</AlertDialog.Title>
  219. <AlertDialog.Description>
  220. <Input
  221. class="mt-4 text-foreground"
  222. onkeydown={(e) => {
  223. if (e.key === 'Enter') {
  224. e.preventDefault();
  225. handleConfirmEdit();
  226. }
  227. }}
  228. placeholder="Enter a new name"
  229. type="text"
  230. bind:value={editedName}
  231. />
  232. </AlertDialog.Description>
  233. </AlertDialog.Header>
  234. <AlertDialog.Footer>
  235. <AlertDialog.Cancel
  236. onclick={() => {
  237. showEditDialog = false;
  238. selectedConversation = null;
  239. }}>Cancel</AlertDialog.Cancel
  240. >
  241. <AlertDialog.Action onclick={handleConfirmEdit}>Save</AlertDialog.Action>
  242. </AlertDialog.Footer>
  243. </AlertDialog.Content>
  244. </AlertDialog.Root>
  245. <DialogConfirmation
  246. bind:open={showClearAllDialog}
  247. title="Clear All Conversations"
  248. description="Are you sure you want to delete ALL conversations? This action cannot be undone and will permanently remove all conversations and messages from your local database."
  249. confirmText="Clear All"
  250. cancelText="Cancel"
  251. variant="destructive"
  252. icon={Trash2}
  253. onConfirm={handleConfirmClearAll}
  254. onCancel={() => {
  255. showClearAllDialog = false;
  256. }}
  257. />
  258. <AlertDialog.Root bind:open={showMemoryDialog}>
  259. <AlertDialog.Content class="max-w-2xl max-h-[80vh]">
  260. <AlertDialog.Header>
  261. <AlertDialog.Title>💾 Memory Manager</AlertDialog.Title>
  262. <AlertDialog.Description>
  263. View and edit AI's persistent memory. Stored in <code>llama_memory.txt</code> (max 5KB).
  264. </AlertDialog.Description>
  265. </AlertDialog.Header>
  266. <div class="my-4">
  267. {#if memoryLoading}
  268. <div class="flex items-center justify-center p-8">
  269. <p class="text-sm text-muted-foreground">Loading memory...</p>
  270. </div>
  271. {:else}
  272. <div class="space-y-2">
  273. <p class="text-sm text-muted-foreground">
  274. The AI will read this memory at the start of each new conversation to personalize responses.
  275. </p>
  276. <textarea
  277. class="w-full rounded border p-3 bg-background min-h-[300px] max-h-[400px] font-mono text-sm"
  278. placeholder="Memory is empty. Add user preferences, name, language, etc.&#10;&#10;Example:&#10;User's name: John&#10;Language preference: English&#10;Context: Software engineer"
  279. bind:value={memoryContent}
  280. />
  281. <div class="flex justify-between items-center text-xs text-muted-foreground">
  282. <span>{memoryContent.length} / 5120 bytes</span>
  283. {#if memoryContent.length > 5120}
  284. <span class="text-destructive">⚠️ Exceeds 5KB limit!</span>
  285. {/if}
  286. </div>
  287. </div>
  288. {/if}
  289. </div>
  290. <AlertDialog.Footer>
  291. <AlertDialog.Cancel
  292. onclick={() => {
  293. showMemoryDialog = false;
  294. }}>Cancel</AlertDialog.Cancel
  295. >
  296. <AlertDialog.Action
  297. onclick={async () => {
  298. await saveMemory();
  299. showMemoryDialog = false;
  300. }}
  301. disabled={memorySaving || memoryContent.length > 5120}
  302. >
  303. {memorySaving ? 'Saving...' : 'Save Memory'}
  304. </AlertDialog.Action>
  305. </AlertDialog.Footer>
  306. </AlertDialog.Content>
  307. </AlertDialog.Root>