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