ChatScreen.svelte 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. <script lang="ts">
  2. import { afterNavigate } from '$app/navigation';
  3. import {
  4. ChatForm,
  5. ChatScreenHeader,
  6. ChatMessages,
  7. ChatScreenProcessingInfo,
  8. DialogEmptyFileAlert,
  9. DialogChatError,
  10. ServerLoadingSplash,
  11. DialogConfirmation
  12. } from '$lib/components/app';
  13. import * as Alert from '$lib/components/ui/alert';
  14. import * as AlertDialog from '$lib/components/ui/alert-dialog';
  15. import {
  16. AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
  17. AUTO_SCROLL_INTERVAL,
  18. INITIAL_SCROLL_DELAY
  19. } from '$lib/constants/auto-scroll';
  20. import { chatStore, errorDialog, isLoading } from '$lib/stores/chat.svelte';
  21. import {
  22. conversationsStore,
  23. activeMessages,
  24. activeConversation
  25. } from '$lib/stores/conversations.svelte';
  26. import { config } from '$lib/stores/settings.svelte';
  27. import { serverLoading, serverError, serverStore, isRouterMode } from '$lib/stores/server.svelte';
  28. import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
  29. import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
  30. import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
  31. import { onMount } from 'svelte';
  32. import { fade, fly, slide } from 'svelte/transition';
  33. import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
  34. import ChatScreenDragOverlay from './ChatScreenDragOverlay.svelte';
  35. let { showCenteredEmpty = false } = $props();
  36. let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
  37. let autoScrollEnabled = $state(true);
  38. let chatScrollContainer: HTMLDivElement | undefined = $state();
  39. let dragCounter = $state(0);
  40. let isDragOver = $state(false);
  41. let lastScrollTop = $state(0);
  42. let scrollInterval: ReturnType<typeof setInterval> | undefined;
  43. let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
  44. let showFileErrorDialog = $state(false);
  45. let uploadedFiles = $state<ChatUploadedFile[]>([]);
  46. let userScrolledUp = $state(false);
  47. let fileErrorData = $state<{
  48. generallyUnsupported: File[];
  49. modalityUnsupported: File[];
  50. modalityReasons: Record<string, string>;
  51. supportedTypes: string[];
  52. }>({
  53. generallyUnsupported: [],
  54. modalityUnsupported: [],
  55. modalityReasons: {},
  56. supportedTypes: []
  57. });
  58. let showDeleteDialog = $state(false);
  59. let showEmptyFileDialog = $state(false);
  60. let emptyFileNames = $state<string[]>([]);
  61. let isEmpty = $derived(
  62. showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
  63. );
  64. let activeErrorDialog = $derived(errorDialog());
  65. let isServerLoading = $derived(serverLoading());
  66. let hasPropsError = $derived(!!serverError());
  67. let isCurrentConversationLoading = $derived(isLoading());
  68. let isRouter = $derived(isRouterMode());
  69. let conversationModel = $derived(
  70. chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
  71. );
  72. let activeModelId = $derived.by(() => {
  73. const options = modelOptions();
  74. if (!isRouter) {
  75. return options.length > 0 ? options[0].model : null;
  76. }
  77. const selectedId = selectedModelId();
  78. if (selectedId) {
  79. const model = options.find((m) => m.id === selectedId);
  80. if (model) return model.model;
  81. }
  82. if (conversationModel) {
  83. const model = options.find((m) => m.model === conversationModel);
  84. if (model) return model.model;
  85. }
  86. return null;
  87. });
  88. let modelPropsVersion = $state(0);
  89. $effect(() => {
  90. if (activeModelId) {
  91. const cached = modelsStore.getModelProps(activeModelId);
  92. if (!cached) {
  93. modelsStore.fetchModelProps(activeModelId).then(() => {
  94. modelPropsVersion++;
  95. });
  96. }
  97. }
  98. });
  99. let hasAudioModality = $derived.by(() => {
  100. if (activeModelId) {
  101. void modelPropsVersion;
  102. return modelsStore.modelSupportsAudio(activeModelId);
  103. }
  104. return false;
  105. });
  106. let hasVisionModality = $derived.by(() => {
  107. if (activeModelId) {
  108. void modelPropsVersion;
  109. return modelsStore.modelSupportsVision(activeModelId);
  110. }
  111. return false;
  112. });
  113. async function handleDeleteConfirm() {
  114. const conversation = activeConversation();
  115. if (conversation) {
  116. await conversationsStore.deleteConversation(conversation.id);
  117. }
  118. showDeleteDialog = false;
  119. }
  120. function handleDragEnter(event: DragEvent) {
  121. event.preventDefault();
  122. dragCounter++;
  123. if (event.dataTransfer?.types.includes('Files')) {
  124. isDragOver = true;
  125. }
  126. }
  127. function handleDragLeave(event: DragEvent) {
  128. event.preventDefault();
  129. dragCounter--;
  130. if (dragCounter === 0) {
  131. isDragOver = false;
  132. }
  133. }
  134. function handleErrorDialogOpenChange(open: boolean) {
  135. if (!open) {
  136. chatStore.dismissErrorDialog();
  137. }
  138. }
  139. function handleDragOver(event: DragEvent) {
  140. event.preventDefault();
  141. }
  142. function handleDrop(event: DragEvent) {
  143. event.preventDefault();
  144. isDragOver = false;
  145. dragCounter = 0;
  146. if (event.dataTransfer?.files) {
  147. processFiles(Array.from(event.dataTransfer.files));
  148. }
  149. }
  150. function handleFileRemove(fileId: string) {
  151. uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
  152. }
  153. function handleFileUpload(files: File[]) {
  154. processFiles(files);
  155. }
  156. function handleKeydown(event: KeyboardEvent) {
  157. const isCtrlOrCmd = event.ctrlKey || event.metaKey;
  158. if (isCtrlOrCmd && event.shiftKey && (event.key === 'd' || event.key === 'D')) {
  159. event.preventDefault();
  160. if (activeConversation()) {
  161. showDeleteDialog = true;
  162. }
  163. }
  164. }
  165. function handleScroll() {
  166. if (disableAutoScroll || !chatScrollContainer) return;
  167. const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer;
  168. const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
  169. const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
  170. if (scrollTop < lastScrollTop && !isAtBottom) {
  171. userScrolledUp = true;
  172. autoScrollEnabled = false;
  173. } else if (isAtBottom && userScrolledUp) {
  174. userScrolledUp = false;
  175. autoScrollEnabled = true;
  176. }
  177. if (scrollTimeout) {
  178. clearTimeout(scrollTimeout);
  179. }
  180. scrollTimeout = setTimeout(() => {
  181. if (isAtBottom) {
  182. userScrolledUp = false;
  183. autoScrollEnabled = true;
  184. }
  185. }, AUTO_SCROLL_INTERVAL);
  186. lastScrollTop = scrollTop;
  187. }
  188. async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
  189. const result = files
  190. ? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
  191. : undefined;
  192. if (result?.emptyFiles && result.emptyFiles.length > 0) {
  193. emptyFileNames = result.emptyFiles;
  194. showEmptyFileDialog = true;
  195. if (files) {
  196. const emptyFileNamesSet = new Set(result.emptyFiles);
  197. uploadedFiles = uploadedFiles.filter((file) => !emptyFileNamesSet.has(file.name));
  198. }
  199. return false;
  200. }
  201. const extras = result?.extras;
  202. // Enable autoscroll for user-initiated message sending
  203. if (!disableAutoScroll) {
  204. userScrolledUp = false;
  205. autoScrollEnabled = true;
  206. }
  207. await chatStore.sendMessage(message, extras);
  208. scrollChatToBottom();
  209. return true;
  210. }
  211. async function processFiles(files: File[]) {
  212. const generallySupported: File[] = [];
  213. const generallyUnsupported: File[] = [];
  214. for (const file of files) {
  215. if (isFileTypeSupported(file.name, file.type)) {
  216. generallySupported.push(file);
  217. } else {
  218. generallyUnsupported.push(file);
  219. }
  220. }
  221. // Use model-specific capabilities for file validation
  222. const capabilities = { hasVision: hasVisionModality, hasAudio: hasAudioModality };
  223. const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
  224. generallySupported,
  225. capabilities
  226. );
  227. const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
  228. if (allUnsupportedFiles.length > 0) {
  229. const supportedTypes: string[] = ['text files', 'PDFs'];
  230. if (hasVisionModality) supportedTypes.push('images');
  231. if (hasAudioModality) supportedTypes.push('audio files');
  232. fileErrorData = {
  233. generallyUnsupported,
  234. modalityUnsupported: unsupportedFiles,
  235. modalityReasons,
  236. supportedTypes
  237. };
  238. showFileErrorDialog = true;
  239. }
  240. if (supportedFiles.length > 0) {
  241. const processed = await processFilesToChatUploaded(
  242. supportedFiles,
  243. activeModelId ?? undefined
  244. );
  245. uploadedFiles = [...uploadedFiles, ...processed];
  246. }
  247. }
  248. function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') {
  249. if (disableAutoScroll) return;
  250. chatScrollContainer?.scrollTo({
  251. top: chatScrollContainer?.scrollHeight,
  252. behavior
  253. });
  254. }
  255. afterNavigate(() => {
  256. if (!disableAutoScroll) {
  257. setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
  258. }
  259. });
  260. onMount(() => {
  261. if (!disableAutoScroll) {
  262. setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
  263. }
  264. });
  265. $effect(() => {
  266. if (disableAutoScroll) {
  267. autoScrollEnabled = false;
  268. if (scrollInterval) {
  269. clearInterval(scrollInterval);
  270. scrollInterval = undefined;
  271. }
  272. return;
  273. }
  274. if (isCurrentConversationLoading && autoScrollEnabled) {
  275. scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL);
  276. } else if (scrollInterval) {
  277. clearInterval(scrollInterval);
  278. scrollInterval = undefined;
  279. }
  280. });
  281. </script>
  282. {#if isDragOver}
  283. <ChatScreenDragOverlay />
  284. {/if}
  285. <svelte:window onkeydown={handleKeydown} />
  286. <ChatScreenHeader />
  287. {#if !isEmpty}
  288. <div
  289. bind:this={chatScrollContainer}
  290. aria-label="Chat interface with file drop zone"
  291. class="flex h-full flex-col overflow-y-auto px-4 md:px-6"
  292. ondragenter={handleDragEnter}
  293. ondragleave={handleDragLeave}
  294. ondragover={handleDragOver}
  295. ondrop={handleDrop}
  296. onscroll={handleScroll}
  297. role="main"
  298. >
  299. <ChatMessages
  300. class="mb-16 md:mb-24"
  301. messages={activeMessages()}
  302. onUserAction={() => {
  303. if (!disableAutoScroll) {
  304. userScrolledUp = false;
  305. autoScrollEnabled = true;
  306. scrollChatToBottom();
  307. }
  308. }}
  309. />
  310. <div
  311. class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
  312. in:slide={{ duration: 150, axis: 'y' }}
  313. >
  314. <ChatScreenProcessingInfo />
  315. {#if hasPropsError}
  316. <div
  317. class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
  318. in:fly={{ y: 10, duration: 250 }}
  319. >
  320. <Alert.Root variant="destructive">
  321. <AlertTriangle class="h-4 w-4" />
  322. <Alert.Title class="flex items-center justify-between">
  323. <span>Server unavailable</span>
  324. <button
  325. onclick={() => serverStore.fetch()}
  326. disabled={isServerLoading}
  327. class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
  328. >
  329. <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
  330. {isServerLoading ? 'Retrying...' : 'Retry'}
  331. </button>
  332. </Alert.Title>
  333. <Alert.Description>{serverError()}</Alert.Description>
  334. </Alert.Root>
  335. </div>
  336. {/if}
  337. <div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
  338. <ChatForm
  339. disabled={hasPropsError}
  340. isLoading={isCurrentConversationLoading}
  341. onFileRemove={handleFileRemove}
  342. onFileUpload={handleFileUpload}
  343. onSend={handleSendMessage}
  344. onStop={() => chatStore.stopGeneration()}
  345. showHelperText={false}
  346. bind:uploadedFiles
  347. />
  348. </div>
  349. </div>
  350. </div>
  351. {:else if isServerLoading}
  352. <!-- Server Loading State -->
  353. <ServerLoadingSplash />
  354. {:else}
  355. <div
  356. aria-label="Welcome screen with file drop zone"
  357. class="flex h-full items-center justify-center"
  358. ondragenter={handleDragEnter}
  359. ondragleave={handleDragLeave}
  360. ondragover={handleDragOver}
  361. ondrop={handleDrop}
  362. role="main"
  363. >
  364. <div class="w-full max-w-[48rem] px-4">
  365. <div class="mb-10 text-center" in:fade={{ duration: 300 }}>
  366. <h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
  367. <p class="text-lg text-muted-foreground">
  368. {serverStore.props?.modalities?.audio
  369. ? 'Record audio, type a message '
  370. : 'Type a message'} or upload files to get started
  371. </p>
  372. </div>
  373. {#if hasPropsError}
  374. <div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
  375. <Alert.Root variant="destructive">
  376. <AlertTriangle class="h-4 w-4" />
  377. <Alert.Title class="flex items-center justify-between">
  378. <span>Server unavailable</span>
  379. <button
  380. onclick={() => serverStore.fetch()}
  381. disabled={isServerLoading}
  382. class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
  383. >
  384. <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
  385. {isServerLoading ? 'Retrying...' : 'Retry'}
  386. </button>
  387. </Alert.Title>
  388. <Alert.Description>{serverError()}</Alert.Description>
  389. </Alert.Root>
  390. </div>
  391. {/if}
  392. <div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
  393. <ChatForm
  394. disabled={hasPropsError}
  395. isLoading={isCurrentConversationLoading}
  396. onFileRemove={handleFileRemove}
  397. onFileUpload={handleFileUpload}
  398. onSend={handleSendMessage}
  399. onStop={() => chatStore.stopGeneration()}
  400. showHelperText={true}
  401. bind:uploadedFiles
  402. />
  403. </div>
  404. </div>
  405. </div>
  406. {/if}
  407. <!-- File Upload Error Alert Dialog -->
  408. <AlertDialog.Root bind:open={showFileErrorDialog}>
  409. <AlertDialog.Portal>
  410. <AlertDialog.Overlay />
  411. <AlertDialog.Content class="flex max-w-md flex-col">
  412. <AlertDialog.Header>
  413. <AlertDialog.Title>File Upload Error</AlertDialog.Title>
  414. <AlertDialog.Description class="text-sm text-muted-foreground">
  415. Some files cannot be uploaded with the current model.
  416. </AlertDialog.Description>
  417. </AlertDialog.Header>
  418. <div class="!max-h-[50vh] min-h-0 flex-1 space-y-4 overflow-y-auto">
  419. {#if fileErrorData.generallyUnsupported.length > 0}
  420. <div class="space-y-2">
  421. <h4 class="text-sm font-medium text-destructive">Unsupported File Types</h4>
  422. <div class="space-y-1">
  423. {#each fileErrorData.generallyUnsupported as file (file.name)}
  424. <div class="rounded-md bg-destructive/10 px-3 py-2">
  425. <p class="font-mono text-sm break-all text-destructive">
  426. {file.name}
  427. </p>
  428. <p class="mt-1 text-xs text-muted-foreground">File type not supported</p>
  429. </div>
  430. {/each}
  431. </div>
  432. </div>
  433. {/if}
  434. {#if fileErrorData.modalityUnsupported.length > 0}
  435. <div class="space-y-2">
  436. <div class="space-y-1">
  437. {#each fileErrorData.modalityUnsupported as file (file.name)}
  438. <div class="rounded-md bg-destructive/10 px-3 py-2">
  439. <p class="font-mono text-sm break-all text-destructive">
  440. {file.name}
  441. </p>
  442. <p class="mt-1 text-xs text-muted-foreground">
  443. {fileErrorData.modalityReasons[file.name] || 'Not supported by current model'}
  444. </p>
  445. </div>
  446. {/each}
  447. </div>
  448. </div>
  449. {/if}
  450. </div>
  451. <div class="rounded-md bg-muted/50 p-3">
  452. <h4 class="mb-2 text-sm font-medium">This model supports:</h4>
  453. <p class="text-sm text-muted-foreground">
  454. {fileErrorData.supportedTypes.join(', ')}
  455. </p>
  456. </div>
  457. <AlertDialog.Footer>
  458. <AlertDialog.Action onclick={() => (showFileErrorDialog = false)}>
  459. Got it
  460. </AlertDialog.Action>
  461. </AlertDialog.Footer>
  462. </AlertDialog.Content>
  463. </AlertDialog.Portal>
  464. </AlertDialog.Root>
  465. <DialogConfirmation
  466. bind:open={showDeleteDialog}
  467. title="Delete Conversation"
  468. description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
  469. confirmText="Delete"
  470. cancelText="Cancel"
  471. variant="destructive"
  472. icon={Trash2}
  473. onConfirm={handleDeleteConfirm}
  474. onCancel={() => (showDeleteDialog = false)}
  475. />
  476. <DialogEmptyFileAlert
  477. bind:open={showEmptyFileDialog}
  478. emptyFiles={emptyFileNames}
  479. onOpenChange={(open) => {
  480. if (!open) {
  481. emptyFileNames = [];
  482. }
  483. }}
  484. />
  485. <DialogChatError
  486. message={activeErrorDialog?.message ?? ''}
  487. contextInfo={activeErrorDialog?.contextInfo}
  488. onOpenChange={handleErrorDialogOpenChange}
  489. open={Boolean(activeErrorDialog)}
  490. type={activeErrorDialog?.type ?? 'server'}
  491. />
  492. <style>
  493. .conversation-chat-form {
  494. position: relative;
  495. &::after {
  496. content: '';
  497. position: absolute;
  498. bottom: 0;
  499. z-index: -1;
  500. left: 0;
  501. right: 0;
  502. width: 100%;
  503. height: 2.375rem;
  504. background-color: var(--background);
  505. }
  506. }
  507. </style>