summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src
diff options
context:
space:
mode:
Diffstat (limited to 'llama.cpp/tools/server/webui/src')
-rw-r--r--llama.cpp/tools/server/webui/src/app.css138
-rw-r--r--llama.cpp/tools/server/webui/src/app.d.ts133
-rw-r--r--llama.cpp/tools/server/webui/src/app.html12
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte283
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte165
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte64
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte243
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte117
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte315
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte123
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte52
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte55
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte204
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte30
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormHelperText.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte59
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte286
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte100
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte418
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte84
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte391
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte175
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte216
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte68
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte163
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte143
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte617
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte28
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte120
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte508
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte255
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte59
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte317
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte18
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte211
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte81
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte200
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte19
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts9
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte67
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte54
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte70
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte37
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte72
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte68
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte46
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte61
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte211
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte76
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/index.ts75
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte47
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte86
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte44
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte27
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte39
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte93
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte205
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte18
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte31
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte870
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte26
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte73
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte97
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte56
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte555
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte282
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/server/ServerLoadingSplash.svelte33
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte65
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte18
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte18
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte35
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte23
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/index.ts39
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte23
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert.svelte44
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/alert/index.ts14
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/badge/badge.svelte49
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/badge/index.ts2
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/button/button.svelte87
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/button/index.ts17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/card/card-action.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/card/card-content.svelte15
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/card/card-description.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/card/card-footer.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/card/card-header.svelte23
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/card/card-title.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/card/card.svelte23
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/card/index.ts25
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/checkbox.svelte36
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/index.ts6
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-content.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-trigger.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible.svelte11
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/index.ts13
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-close.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-content.svelte43
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-description.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-footer.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-header.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-overlay.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-title.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-trigger.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dialog/index.ts37
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte41
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte27
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte22
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte27
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte24
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte16
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte31
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte29
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/index.ts49
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/input/index.ts7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/input/input.svelte51
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/label/index.ts7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/label/label.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/popover/index.ts19
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte37
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/index.ts10
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte31
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area.svelte40
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/select/index.ts37
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/select/select-content.svelte111
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group-heading.svelte21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/select/select-item.svelte38
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/select/select-label.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-down-button.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-up-button.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/select/select-separator.svelte18
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte40
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/separator/index.ts7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/separator/separator.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sheet/index.ts36
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-close.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-content.svelte60
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-description.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-footer.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-header.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-overlay.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-title.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-trigger.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/constants.ts6
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/context.svelte.ts79
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/index.ts75
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-content.svelte24
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-footer.svelte21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-action.svelte36
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-content.svelte21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-label.svelte34
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group.svelte21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-header.svelte21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-input.svelte21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-inset.svelte24
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte43
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte29
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte106
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte36
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte43
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte25
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu.svelte21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte50
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-rail.svelte36
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-separator.svelte19
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-trigger.svelte35
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar.svelte101
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/index.ts7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/skeleton.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/switch/index.ts7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/switch/switch.svelte29
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/table/index.ts28
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/table/table-body.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/table/table-caption.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/table/table-cell.svelte23
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/table/table-footer.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/table/table-head.svelte23
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/table/table-header.svelte20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/table/table-row.svelte23
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/table/table.svelte22
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/textarea/index.ts7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/textarea/textarea.svelte22
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/index.ts21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte47
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-trigger.svelte7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/ui/utils.ts13
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/auto-scroll.ts3
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/binary-detection.ts14
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/default-context.ts1
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/floating-ui-constraints.ts2
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/icons.ts32
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/input-classes.ts6
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/latex-protection.ts35
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/literal-html.ts15
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/localstorage-keys.ts2
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/max-bundle-size.ts1
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/precision.ts2
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/processing-info.ts1
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/settings-config.ts117
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/supported-file-types.ts217
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/table-html-restorer.ts20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/tooltip-config.ts1
-rw-r--r--llama.cpp/tools/server/webui/src/lib/constants/viewport.ts1
-rw-r--r--llama.cpp/tools/server/webui/src/lib/enums/attachment.ts10
-rw-r--r--llama.cpp/tools/server/webui/src/lib/enums/chat.ts4
-rw-r--r--llama.cpp/tools/server/webui/src/lib/enums/files.ts206
-rw-r--r--llama.cpp/tools/server/webui/src/lib/enums/index.ts23
-rw-r--r--llama.cpp/tools/server/webui/src/lib/enums/model.ts5
-rw-r--r--llama.cpp/tools/server/webui/src/lib/enums/server.ts20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/hooks/is-mobile.svelte.ts8
-rw-r--r--llama.cpp/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts118
-rw-r--r--llama.cpp/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts262
-rw-r--r--llama.cpp/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts162
-rw-r--r--llama.cpp/tools/server/webui/src/lib/markdown/enhance-links.ts33
-rw-r--r--llama.cpp/tools/server/webui/src/lib/markdown/literal-html.ts121
-rw-r--r--llama.cpp/tools/server/webui/src/lib/markdown/table-html-restorer.ts181
-rw-r--r--llama.cpp/tools/server/webui/src/lib/services/chat.ts784
-rw-r--r--llama.cpp/tools/server/webui/src/lib/services/database.ts400
-rw-r--r--llama.cpp/tools/server/webui/src/lib/services/index.ts5
-rw-r--r--llama.cpp/tools/server/webui/src/lib/services/models.ts124
-rw-r--r--llama.cpp/tools/server/webui/src/lib/services/parameter-sync.spec.ts148
-rw-r--r--llama.cpp/tools/server/webui/src/lib/services/parameter-sync.ts279
-rw-r--r--llama.cpp/tools/server/webui/src/lib/services/props.ts77
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts1487
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts662
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts605
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts50
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts140
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts421
-rw-r--r--llama.cpp/tools/server/webui/src/lib/types/api.d.ts430
-rw-r--r--llama.cpp/tools/server/webui/src/lib/types/chat.d.ts55
-rw-r--r--llama.cpp/tools/server/webui/src/lib/types/database.d.ts85
-rw-r--r--llama.cpp/tools/server/webui/src/lib/types/index.ts70
-rw-r--r--llama.cpp/tools/server/webui/src/lib/types/models.d.ts21
-rw-r--r--llama.cpp/tools/server/webui/src/lib/types/settings.d.ts67
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts22
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts45
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts61
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts105
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts226
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts10
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/branching.ts283
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts35
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts259
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts51
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts30
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts192
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts36
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/file-type.ts222
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/formatters.ts53
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/index.ts95
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts5
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts270
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts162
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/model-names.ts56
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts150
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts20
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/precision.ts25
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts136
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts71
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts145
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/text-files.ts97
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/text.ts7
-rw-r--r--llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts73
-rw-r--r--llama.cpp/tools/server/webui/src/routes/+error.svelte70
-rw-r--r--llama.cpp/tools/server/webui/src/routes/+layout.svelte223
-rw-r--r--llama.cpp/tools/server/webui/src/routes/+page.svelte91
-rw-r--r--llama.cpp/tools/server/webui/src/routes/+page.ts6
-rw-r--r--llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte176
-rw-r--r--llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts6
-rw-r--r--llama.cpp/tools/server/webui/src/styles/katex-custom.scss13
288 files changed, 24466 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/app.css b/llama.cpp/tools/server/webui/src/app.css
new file mode 100644
index 0000000..9705040
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/app.css
@@ -0,0 +1,138 @@
1@import 'tailwindcss';
2
3@import 'tw-animate-css';
4
5@custom-variant dark (&:is(.dark *));
6
7:root {
8 --radius: 0.625rem;
9 --background: oklch(1 0 0);
10 --foreground: oklch(0.145 0 0);
11 --card: oklch(1 0 0);
12 --card-foreground: oklch(0.145 0 0);
13 --popover: oklch(1 0 0);
14 --popover-foreground: oklch(0.145 0 0);
15 --primary: oklch(0.205 0 0);
16 --primary-foreground: oklch(0.985 0 0);
17 --secondary: oklch(0.97 0 0);
18 --secondary-foreground: oklch(0.205 0 0);
19 --muted: oklch(0.97 0 0);
20 --muted-foreground: oklch(0.556 0 0);
21 --accent: oklch(0.97 0 0);
22 --accent-foreground: oklch(0.205 0 0);
23 --destructive: oklch(0.577 0.245 27.325);
24 --border: oklch(0.875 0 0);
25 --input: oklch(0.92 0 0);
26 --ring: oklch(0.708 0 0);
27 --chart-1: oklch(0.646 0.222 41.116);
28 --chart-2: oklch(0.6 0.118 184.704);
29 --chart-3: oklch(0.398 0.07 227.392);
30 --chart-4: oklch(0.828 0.189 84.429);
31 --chart-5: oklch(0.769 0.188 70.08);
32 --sidebar: oklch(0.987 0 0);
33 --sidebar-foreground: oklch(0.145 0 0);
34 --sidebar-primary: oklch(0.205 0 0);
35 --sidebar-primary-foreground: oklch(0.985 0 0);
36 --sidebar-accent: oklch(0.97 0 0);
37 --sidebar-accent-foreground: oklch(0.205 0 0);
38 --sidebar-border: oklch(0.922 0 0);
39 --sidebar-ring: oklch(0.708 0 0);
40 --code-background: oklch(0.975 0 0);
41 --code-foreground: oklch(0.145 0 0);
42 --layer-popover: 1000000;
43}
44
45.dark {
46 --background: oklch(0.16 0 0);
47 --foreground: oklch(0.985 0 0);
48 --card: oklch(0.205 0 0);
49 --card-foreground: oklch(0.985 0 0);
50 --popover: oklch(0.205 0 0);
51 --popover-foreground: oklch(0.985 0 0);
52 --primary: oklch(0.922 0 0);
53 --primary-foreground: oklch(0.205 0 0);
54 --secondary: oklch(0.269 0 0);
55 --secondary-foreground: oklch(0.985 0 0);
56 --muted: oklch(0.269 0 0);
57 --muted-foreground: oklch(0.708 0 0);
58 --accent: oklch(0.269 0 0);
59 --accent-foreground: oklch(0.985 0 0);
60 --destructive: oklch(0.704 0.191 22.216);
61 --border: oklch(1 0 0 / 30%);
62 --input: oklch(1 0 0 / 30%);
63 --ring: oklch(0.556 0 0);
64 --chart-1: oklch(0.488 0.243 264.376);
65 --chart-2: oklch(0.696 0.17 162.48);
66 --chart-3: oklch(0.769 0.188 70.08);
67 --chart-4: oklch(0.627 0.265 303.9);
68 --chart-5: oklch(0.645 0.246 16.439);
69 --sidebar: oklch(0.19 0 0);
70 --sidebar-foreground: oklch(0.985 0 0);
71 --sidebar-primary: oklch(0.488 0.243 264.376);
72 --sidebar-primary-foreground: oklch(0.985 0 0);
73 --sidebar-accent: oklch(0.269 0 0);
74 --sidebar-accent-foreground: oklch(0.985 0 0);
75 --sidebar-border: oklch(1 0 0 / 10%);
76 --sidebar-ring: oklch(0.556 0 0);
77 --code-background: oklch(0.225 0 0);
78 --code-foreground: oklch(0.875 0 0);
79}
80
81@theme inline {
82 --radius-sm: calc(var(--radius) - 4px);
83 --radius-md: calc(var(--radius) - 2px);
84 --radius-lg: var(--radius);
85 --radius-xl: calc(var(--radius) + 4px);
86 --color-background: var(--background);
87 --color-foreground: var(--foreground);
88 --color-card: var(--card);
89 --color-card-foreground: var(--card-foreground);
90 --color-popover: var(--popover);
91 --color-popover-foreground: var(--popover-foreground);
92 --color-primary: var(--primary);
93 --color-primary-foreground: var(--primary-foreground);
94 --color-secondary: var(--secondary);
95 --color-secondary-foreground: var(--secondary-foreground);
96 --color-muted: var(--muted);
97 --color-muted-foreground: var(--muted-foreground);
98 --color-accent: var(--accent);
99 --color-accent-foreground: var(--accent-foreground);
100 --color-destructive: var(--destructive);
101 --color-border: var(--border);
102 --color-input: var(--input);
103 --color-ring: var(--ring);
104 --color-chart-1: var(--chart-1);
105 --color-chart-2: var(--chart-2);
106 --color-chart-3: var(--chart-3);
107 --color-chart-4: var(--chart-4);
108 --color-chart-5: var(--chart-5);
109 --color-sidebar: var(--sidebar);
110 --color-sidebar-foreground: var(--sidebar-foreground);
111 --color-sidebar-primary: var(--sidebar-primary);
112 --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
113 --color-sidebar-accent: var(--sidebar-accent);
114 --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
115 --color-sidebar-border: var(--sidebar-border);
116 --color-sidebar-ring: var(--sidebar-ring);
117}
118
119@layer base {
120 * {
121 @apply border-border outline-ring/50;
122 }
123 body {
124 @apply bg-background text-foreground;
125 }
126}
127
128@layer utilities {
129 .scrollbar-hide {
130 /* Hide scrollbar for Chrome, Safari and Opera */
131 &::-webkit-scrollbar {
132 display: none;
133 }
134 /* Hide scrollbar for IE, Edge and Firefox */
135 -ms-overflow-style: none;
136 scrollbar-width: none;
137 }
138}
diff --git a/llama.cpp/tools/server/webui/src/app.d.ts b/llama.cpp/tools/server/webui/src/app.d.ts
new file mode 100644
index 0000000..73287d9
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/app.d.ts
@@ -0,0 +1,133 @@
1// See https://svelte.dev/docs/kit/types#app.d.ts
2// for information about these interfaces
3
4// Import chat types from dedicated module
5
6import type {
7 // API types
8 ApiChatCompletionRequest,
9 ApiChatCompletionResponse,
10 ApiChatCompletionStreamChunk,
11 ApiChatCompletionToolCall,
12 ApiChatCompletionToolCallDelta,
13 ApiChatMessageData,
14 ApiChatMessageContentPart,
15 ApiContextSizeError,
16 ApiErrorResponse,
17 ApiLlamaCppServerProps,
18 ApiModelDataEntry,
19 ApiModelListResponse,
20 ApiProcessingState,
21 ApiRouterModelMeta,
22 ApiRouterModelsLoadRequest,
23 ApiRouterModelsLoadResponse,
24 ApiRouterModelsStatusRequest,
25 ApiRouterModelsStatusResponse,
26 ApiRouterModelsListResponse,
27 ApiRouterModelsUnloadRequest,
28 ApiRouterModelsUnloadResponse,
29 // Chat types
30 ChatAttachmentDisplayItem,
31 ChatAttachmentPreviewItem,
32 ChatMessageType,
33 ChatRole,
34 ChatUploadedFile,
35 ChatMessageSiblingInfo,
36 ChatMessagePromptProgress,
37 ChatMessageTimings,
38 // Database types
39 DatabaseConversation,
40 DatabaseMessage,
41 DatabaseMessageExtra,
42 DatabaseMessageExtraAudioFile,
43 DatabaseMessageExtraImageFile,
44 DatabaseMessageExtraTextFile,
45 DatabaseMessageExtraPdfFile,
46 DatabaseMessageExtraLegacyContext,
47 ExportedConversation,
48 ExportedConversations,
49 // Model types
50 ModelModalities,
51 ModelOption,
52 // Settings types
53 SettingsChatServiceOptions,
54 SettingsConfigValue,
55 SettingsFieldConfig,
56 SettingsConfigType
57} from '$lib/types';
58
59import { ServerRole, ServerModelStatus, ModelModality } from '$lib/enums';
60
61declare global {
62 // namespace App {
63 // interface Error {}
64 // interface Locals {}
65 // interface PageData {}
66 // interface PageState {}
67 // interface Platform {}
68 // }
69
70 export {
71 // API types
72 ApiChatCompletionRequest,
73 ApiChatCompletionResponse,
74 ApiChatCompletionStreamChunk,
75 ApiChatCompletionToolCall,
76 ApiChatCompletionToolCallDelta,
77 ApiChatMessageData,
78 ApiChatMessageContentPart,
79 ApiContextSizeError,
80 ApiErrorResponse,
81 ApiLlamaCppServerProps,
82 ApiModelDataEntry,
83 ApiModelListResponse,
84 ApiProcessingState,
85 ApiRouterModelMeta,
86 ApiRouterModelsLoadRequest,
87 ApiRouterModelsLoadResponse,
88 ApiRouterModelsStatusRequest,
89 ApiRouterModelsStatusResponse,
90 ApiRouterModelsListResponse,
91 ApiRouterModelsUnloadRequest,
92 ApiRouterModelsUnloadResponse,
93 // Chat types
94 ChatAttachmentDisplayItem,
95 ChatAttachmentPreviewItem,
96 ChatMessagePromptProgress,
97 ChatMessageSiblingInfo,
98 ChatMessageTimings,
99 ChatMessageType,
100 ChatRole,
101 ChatUploadedFile,
102 // Database types
103 DatabaseConversation,
104 DatabaseMessage,
105 DatabaseMessageExtra,
106 DatabaseMessageExtraAudioFile,
107 DatabaseMessageExtraImageFile,
108 DatabaseMessageExtraTextFile,
109 DatabaseMessageExtraPdfFile,
110 DatabaseMessageExtraLegacyContext,
111 ExportedConversation,
112 ExportedConversations,
113 // Enum types
114 ModelModality,
115 ServerRole,
116 ServerModelStatus,
117 // Model types
118 ModelModalities,
119 ModelOption,
120 // Settings types
121 SettingsChatServiceOptions,
122 SettingsConfigValue,
123 SettingsFieldConfig,
124 SettingsConfigType
125 };
126}
127
128declare global {
129 interface Window {
130 idxThemeStyle?: number;
131 idxCodeBlock?: number;
132 }
133}
diff --git a/llama.cpp/tools/server/webui/src/app.html b/llama.cpp/tools/server/webui/src/app.html
new file mode 100644
index 0000000..1391f88
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/app.html
@@ -0,0 +1,12 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
6 <meta name="viewport" content="width=device-width, initial-scale=1" />
7 %sveltekit.head%
8 </head>
9 <body data-sveltekit-preload-data="hover">
10 <div style="display: contents">%sveltekit.body%</div>
11 </body>
12</html>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte
new file mode 100644
index 0000000..0b0bf52
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte
@@ -0,0 +1,283 @@
1<script lang="ts">
2 import { Button } from '$lib/components/ui/button';
3 import * as Alert from '$lib/components/ui/alert';
4 import { SyntaxHighlightedCode } from '$lib/components/app';
5 import { FileText, Image, Music, FileIcon, Eye, Info } from '@lucide/svelte';
6 import {
7 isTextFile,
8 isImageFile,
9 isPdfFile,
10 isAudioFile,
11 getLanguageFromFilename
12 } from '$lib/utils';
13 import { convertPDFToImage } from '$lib/utils/browser-only';
14 import { modelsStore } from '$lib/stores/models.svelte';
15
16 interface Props {
17 // Either an uploaded file or a stored attachment
18 uploadedFile?: ChatUploadedFile;
19 attachment?: DatabaseMessageExtra;
20 // For uploaded files
21 preview?: string;
22 name?: string;
23 textContent?: string;
24 // For checking vision modality
25 activeModelId?: string;
26 }
27
28 let { uploadedFile, attachment, preview, name, textContent, activeModelId }: Props = $props();
29
30 let hasVisionModality = $derived(
31 activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
32 );
33
34 let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
35
36 // Determine file type from uploaded file or attachment
37 let isAudio = $derived(isAudioFile(attachment, uploadedFile));
38 let isImage = $derived(isImageFile(attachment, uploadedFile));
39 let isPdf = $derived(isPdfFile(attachment, uploadedFile));
40 let isText = $derived(isTextFile(attachment, uploadedFile));
41
42 let displayPreview = $derived(
43 uploadedFile?.preview ||
44 (isImage && attachment && 'base64Url' in attachment ? attachment.base64Url : preview)
45 );
46
47 let displayTextContent = $derived(
48 uploadedFile?.textContent ||
49 (attachment && 'content' in attachment ? attachment.content : textContent)
50 );
51
52 let language = $derived(getLanguageFromFilename(displayName));
53
54 let IconComponent = $derived(() => {
55 if (isImage) return Image;
56 if (isText || isPdf) return FileText;
57 if (isAudio) return Music;
58
59 return FileIcon;
60 });
61
62 let pdfViewMode = $state<'text' | 'pages'>('pages');
63
64 let pdfImages = $state<string[]>([]);
65
66 let pdfImagesLoading = $state(false);
67
68 let pdfImagesError = $state<string | null>(null);
69
70 async function loadPdfImages() {
71 if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
72
73 pdfImagesLoading = true;
74 pdfImagesError = null;
75
76 try {
77 let file: File | null = null;
78
79 if (uploadedFile?.file) {
80 file = uploadedFile.file;
81 } else if (isPdf && attachment) {
82 // Check if we have pre-processed images
83 if (
84 'images' in attachment &&
85 attachment.images &&
86 Array.isArray(attachment.images) &&
87 attachment.images.length > 0
88 ) {
89 pdfImages = attachment.images;
90 return;
91 }
92
93 // Convert base64 back to File for processing
94 if ('base64Data' in attachment && attachment.base64Data) {
95 const base64Data = attachment.base64Data;
96 const byteCharacters = atob(base64Data);
97 const byteNumbers = new Array(byteCharacters.length);
98 for (let i = 0; i < byteCharacters.length; i++) {
99 byteNumbers[i] = byteCharacters.charCodeAt(i);
100 }
101 const byteArray = new Uint8Array(byteNumbers);
102 file = new File([byteArray], displayName, { type: 'application/pdf' });
103 }
104 }
105
106 if (file) {
107 pdfImages = await convertPDFToImage(file);
108 } else {
109 throw new Error('No PDF file available for conversion');
110 }
111 } catch (error) {
112 pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
113 } finally {
114 pdfImagesLoading = false;
115 }
116 }
117
118 export function reset() {
119 pdfImages = [];
120 pdfImagesLoading = false;
121 pdfImagesError = null;
122 pdfViewMode = 'pages';
123 }
124
125 $effect(() => {
126 if (isPdf && pdfViewMode === 'pages') {
127 loadPdfImages();
128 }
129 });
130</script>
131
132<div class="space-y-4">
133 <div class="flex items-center justify-end gap-6">
134 {#if isPdf}
135 <div class="flex items-center gap-2">
136 <Button
137 variant={pdfViewMode === 'text' ? 'default' : 'outline'}
138 size="sm"
139 onclick={() => (pdfViewMode = 'text')}
140 disabled={pdfImagesLoading}
141 >
142 <FileText class="mr-1 h-4 w-4" />
143
144 Text
145 </Button>
146
147 <Button
148 variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
149 size="sm"
150 onclick={() => {
151 pdfViewMode = 'pages';
152 loadPdfImages();
153 }}
154 disabled={pdfImagesLoading}
155 >
156 {#if pdfImagesLoading}
157 <div
158 class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
159 ></div>
160 {:else}
161 <Eye class="mr-1 h-4 w-4" />
162 {/if}
163
164 Pages
165 </Button>
166 </div>
167 {/if}
168 </div>
169
170 <div class="flex-1 overflow-auto">
171 {#if isImage && displayPreview}
172 <div class="flex items-center justify-center">
173 <img
174 src={displayPreview}
175 alt={displayName}
176 class="max-h-full rounded-lg object-contain shadow-lg"
177 />
178 </div>
179 {:else if isPdf && pdfViewMode === 'pages'}
180 {#if !hasVisionModality && activeModelId}
181 <Alert.Root class="mb-4">
182 <Info class="h-4 w-4" />
183 <Alert.Title>Preview only</Alert.Title>
184 <Alert.Description>
185 <span class="inline-flex">
186 The selected model does not support vision. Only the extracted
187 <!-- svelte-ignore a11y_click_events_have_key_events -->
188 <!-- svelte-ignore a11y_no_static_element_interactions -->
189 <span class="mx-1 cursor-pointer underline" onclick={() => (pdfViewMode = 'text')}>
190 text
191 </span>
192 will be sent to the model.
193 </span>
194 </Alert.Description>
195 </Alert.Root>
196 {/if}
197
198 {#if pdfImagesLoading}
199 <div class="flex items-center justify-center p-8">
200 <div class="text-center">
201 <div
202 class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
203 ></div>
204
205 <p class="text-muted-foreground">Converting PDF to images...</p>
206 </div>
207 </div>
208 {:else if pdfImagesError}
209 <div class="flex items-center justify-center p-8">
210 <div class="text-center">
211 <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
212
213 <p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
214
215 <p class="text-sm text-muted-foreground">{pdfImagesError}</p>
216
217 <Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
218 </div>
219 </div>
220 {:else if pdfImages.length > 0}
221 <div class="max-h-[70vh] space-y-4 overflow-auto">
222 {#each pdfImages as image, index (image)}
223 <div class="text-center">
224 <p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
225
226 <img
227 src={image}
228 alt="PDF Page {index + 1}"
229 class="mx-auto max-w-full rounded-lg shadow-lg"
230 />
231 </div>
232 {/each}
233 </div>
234 {:else}
235 <div class="flex items-center justify-center p-8">
236 <div class="text-center">
237 <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
238
239 <p class="mb-4 text-muted-foreground">No PDF pages available</p>
240 </div>
241 </div>
242 {/if}
243 {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
244 <SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="calc(69rem - 2rem)" />
245 {:else if isAudio}
246 <div class="flex items-center justify-center p-8">
247 <div class="w-full max-w-md text-center">
248 <Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
249
250 {#if uploadedFile?.preview}
251 <audio controls class="mb-4 w-full" src={uploadedFile.preview}>
252 Your browser does not support the audio element.
253 </audio>
254 {:else if isAudio && attachment && 'mimeType' in attachment && 'base64Data' in attachment}
255 <audio
256 controls
257 class="mb-4 w-full"
258 src={`data:${attachment.mimeType};base64,${attachment.base64Data}`}
259 >
260 Your browser does not support the audio element.
261 </audio>
262 {:else}
263 <p class="mb-4 text-muted-foreground">Audio preview not available</p>
264 {/if}
265
266 <p class="text-sm text-muted-foreground">
267 {displayName}
268 </p>
269 </div>
270 </div>
271 {:else}
272 <div class="flex items-center justify-center p-8">
273 <div class="text-center">
274 {#if IconComponent}
275 <IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
276 {/if}
277
278 <p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
279 </div>
280 </div>
281 {/if}
282 </div>
283</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte
new file mode 100644
index 0000000..908db58
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte
@@ -0,0 +1,165 @@
1<script lang="ts">
2 import { RemoveButton } from '$lib/components/app';
3 import { formatFileSize, getFileTypeLabel, getPreviewText, isTextFile } from '$lib/utils';
4 import { AttachmentType } from '$lib/enums';
5
6 interface Props {
7 class?: string;
8 id: string;
9 onClick?: (event?: MouseEvent) => void;
10 onRemove?: (id: string) => void;
11 name: string;
12 readonly?: boolean;
13 size?: number;
14 textContent?: string;
15 // Either uploaded file or stored attachment
16 uploadedFile?: ChatUploadedFile;
17 attachment?: DatabaseMessageExtra;
18 }
19
20 let {
21 class: className = '',
22 id,
23 onClick,
24 onRemove,
25 name,
26 readonly = false,
27 size,
28 textContent,
29 uploadedFile,
30 attachment
31 }: Props = $props();
32
33 let isText = $derived(isTextFile(attachment, uploadedFile));
34
35 let fileTypeLabel = $derived.by(() => {
36 if (uploadedFile?.type) {
37 return getFileTypeLabel(uploadedFile.type);
38 }
39
40 if (attachment) {
41 if ('mimeType' in attachment && attachment.mimeType) {
42 return getFileTypeLabel(attachment.mimeType);
43 }
44
45 if (attachment.type) {
46 return getFileTypeLabel(attachment.type);
47 }
48 }
49
50 return getFileTypeLabel(name);
51 });
52
53 let pdfProcessingMode = $derived.by(() => {
54 if (attachment?.type === AttachmentType.PDF) {
55 const pdfAttachment = attachment as DatabaseMessageExtraPdfFile;
56
57 return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text';
58 }
59 return null;
60 });
61</script>
62
63{#if isText}
64 {#if readonly}
65 <!-- Readonly mode (ChatMessage) -->
66 <button
67 class="cursor-pointer rounded-lg border border-border bg-muted p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl"
68 onclick={onClick}
69 aria-label={`Preview ${name}`}
70 type="button"
71 >
72 <div class="flex items-start gap-3">
73 <div class="flex min-w-0 flex-1 flex-col items-start text-left">
74 <span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
75
76 {#if size}
77 <span class="text-xs text-muted-foreground">{formatFileSize(size)}</span>
78 {/if}
79
80 {#if textContent}
81 <div class="relative mt-2 w-full">
82 <div
83 class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
84 >
85 {getPreviewText(textContent)}
86 </div>
87
88 {#if textContent.length > 150}
89 <div
90 class="pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-muted to-transparent"
91 ></div>
92 {/if}
93 </div>
94 {/if}
95 </div>
96 </div>
97 </button>
98 {:else}
99 <!-- Non-readonly mode (ChatForm) -->
100 <button
101 class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
102 ? 'max-h-24 max-w-72'
103 : 'max-w-36'} cursor-pointer text-left"
104 onclick={onClick}
105 >
106 <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
107 <RemoveButton {id} {onRemove} />
108 </div>
109
110 <div class="pr-8">
111 <span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
112
113 {#if textContent}
114 <div class="relative">
115 <div
116 class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
117 style="max-height: 3rem; line-height: 1.2em;"
118 >
119 {getPreviewText(textContent)}
120 </div>
121
122 {#if textContent.length > 150}
123 <div
124 class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent"
125 ></div>
126 {/if}
127 </div>
128 {/if}
129 </div>
130 </button>
131 {/if}
132{:else}
133 <button
134 class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
135 onclick={onClick}
136 >
137 <div
138 class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
139 >
140 {fileTypeLabel}
141 </div>
142
143 <div class="flex flex-col gap-0.5">
144 <span
145 class="max-w-24 truncate text-sm font-medium text-foreground {readonly
146 ? ''
147 : 'group-hover:pr-6'} md:max-w-32"
148 >
149 {name}
150 </span>
151
152 {#if pdfProcessingMode}
153 <span class="text-left text-xs text-muted-foreground">{pdfProcessingMode}</span>
154 {:else if size}
155 <span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
156 {/if}
157 </div>
158
159 {#if !readonly}
160 <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
161 <RemoveButton {id} {onRemove} />
162 </div>
163 {/if}
164 </button>
165{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte
new file mode 100644
index 0000000..ba711a9
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte
@@ -0,0 +1,64 @@
1<script lang="ts">
2 import { RemoveButton } from '$lib/components/app';
3
4 interface Props {
5 id: string;
6 name: string;
7 preview: string;
8 readonly?: boolean;
9 onRemove?: (id: string) => void;
10 onClick?: (event?: MouseEvent) => void;
11 class?: string;
12 // Customizable size props
13 width?: string;
14 height?: string;
15 imageClass?: string;
16 }
17
18 let {
19 id,
20 name,
21 preview,
22 readonly = false,
23 onRemove,
24 onClick,
25 class: className = '',
26 // Default to small size for form previews
27 width = 'w-auto',
28 height = 'h-16',
29 imageClass = ''
30 }: Props = $props();
31</script>
32
33<div
34 class="group relative overflow-hidden rounded-lg bg-muted shadow-lg dark:border dark:border-muted {className}"
35>
36 {#if onClick}
37 <button
38 type="button"
39 class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
40 onclick={onClick}
41 aria-label="Preview {name}"
42 >
43 <img
44 src={preview}
45 alt={name}
46 class="{height} {width} cursor-pointer object-cover {imageClass}"
47 />
48 </button>
49 {:else}
50 <img
51 src={preview}
52 alt={name}
53 class="{height} {width} cursor-pointer object-cover {imageClass}"
54 />
55 {/if}
56
57 {#if !readonly}
58 <div
59 class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
60 >
61 <RemoveButton {id} {onRemove} class="text-white" />
62 </div>
63 {/if}
64</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte
new file mode 100644
index 0000000..a1f5af5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte
@@ -0,0 +1,243 @@
1<script lang="ts">
2 import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
3 import { Button } from '$lib/components/ui/button';
4 import { ChevronLeft, ChevronRight } from '@lucide/svelte';
5 import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
6 import { getAttachmentDisplayItems } from '$lib/utils';
7
8 interface Props {
9 class?: string;
10 style?: string;
11 // For ChatMessage - stored attachments
12 attachments?: DatabaseMessageExtra[];
13 readonly?: boolean;
14 // For ChatForm - pending uploads
15 onFileRemove?: (fileId: string) => void;
16 uploadedFiles?: ChatUploadedFile[];
17 // Image size customization
18 imageClass?: string;
19 imageHeight?: string;
20 imageWidth?: string;
21 // Limit display to single row with "+ X more" button
22 limitToSingleRow?: boolean;
23 // For vision modality check
24 activeModelId?: string;
25 }
26
27 let {
28 class: className = '',
29 style = '',
30 attachments = [],
31 readonly = false,
32 onFileRemove,
33 uploadedFiles = $bindable([]),
34 // Default to small size for form previews
35 imageClass = '',
36 imageHeight = 'h-24',
37 imageWidth = 'w-auto',
38 limitToSingleRow = false,
39 activeModelId
40 }: Props = $props();
41
42 let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
43
44 let canScrollLeft = $state(false);
45 let canScrollRight = $state(false);
46 let isScrollable = $state(false);
47 let previewDialogOpen = $state(false);
48 let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
49 let scrollContainer: HTMLDivElement | undefined = $state();
50 let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
51 let viewAllDialogOpen = $state(false);
52
53 function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
54 event?.stopPropagation();
55 event?.preventDefault();
56
57 previewItem = {
58 uploadedFile: item.uploadedFile,
59 attachment: item.attachment,
60 preview: item.preview,
61 name: item.name,
62 size: item.size,
63 textContent: item.textContent
64 };
65 previewDialogOpen = true;
66 }
67
68 function scrollLeft(event?: MouseEvent) {
69 event?.stopPropagation();
70 event?.preventDefault();
71
72 if (!scrollContainer) return;
73
74 scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
75 }
76
77 function scrollRight(event?: MouseEvent) {
78 event?.stopPropagation();
79 event?.preventDefault();
80
81 if (!scrollContainer) return;
82
83 scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
84 }
85
86 function updateScrollButtons() {
87 if (!scrollContainer) return;
88
89 const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
90
91 canScrollLeft = scrollLeft > 0;
92 canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
93 isScrollable = scrollWidth > clientWidth;
94 }
95
96 $effect(() => {
97 if (scrollContainer && displayItems.length) {
98 scrollContainer.scrollLeft = 0;
99
100 setTimeout(() => {
101 updateScrollButtons();
102 }, 0);
103 }
104 });
105</script>
106
107{#if displayItems.length > 0}
108 <div class={className} {style}>
109 {#if limitToSingleRow}
110 <div class="relative">
111 <button
112 class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollLeft
113 ? 'opacity-100'
114 : 'pointer-events-none opacity-0'}"
115 onclick={scrollLeft}
116 aria-label="Scroll left"
117 >
118 <ChevronLeft class="h-4 w-4" />
119 </button>
120
121 <div
122 class="scrollbar-hide flex items-start gap-3 overflow-x-auto"
123 bind:this={scrollContainer}
124 onscroll={updateScrollButtons}
125 >
126 {#each displayItems as item (item.id)}
127 {#if item.isImage && item.preview}
128 <ChatAttachmentThumbnailImage
129 class="flex-shrink-0 cursor-pointer {limitToSingleRow
130 ? 'first:ml-4 last:mr-4'
131 : ''}"
132 id={item.id}
133 name={item.name}
134 preview={item.preview}
135 {readonly}
136 onRemove={onFileRemove}
137 height={imageHeight}
138 width={imageWidth}
139 {imageClass}
140 onClick={(event) => openPreview(item, event)}
141 />
142 {:else}
143 <ChatAttachmentThumbnailFile
144 class="flex-shrink-0 cursor-pointer {limitToSingleRow
145 ? 'first:ml-4 last:mr-4'
146 : ''}"
147 id={item.id}
148 name={item.name}
149 size={item.size}
150 {readonly}
151 onRemove={onFileRemove}
152 textContent={item.textContent}
153 attachment={item.attachment}
154 uploadedFile={item.uploadedFile}
155 onClick={(event) => openPreview(item, event)}
156 />
157 {/if}
158 {/each}
159 </div>
160
161 <button
162 class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight
163 ? 'opacity-100'
164 : 'pointer-events-none opacity-0'}"
165 onclick={scrollRight}
166 aria-label="Scroll right"
167 >
168 <ChevronRight class="h-4 w-4" />
169 </button>
170 </div>
171
172 {#if showViewAll}
173 <div class="mt-2 -mr-2 flex justify-end px-4">
174 <Button
175 type="button"
176 variant="ghost"
177 size="sm"
178 class="h-6 text-xs text-muted-foreground hover:text-foreground"
179 onclick={() => (viewAllDialogOpen = true)}
180 >
181 View all ({displayItems.length})
182 </Button>
183 </div>
184 {/if}
185 {:else}
186 <div class="flex flex-wrap items-start justify-end gap-3">
187 {#each displayItems as item (item.id)}
188 {#if item.isImage && item.preview}
189 <ChatAttachmentThumbnailImage
190 class="cursor-pointer"
191 id={item.id}
192 name={item.name}
193 preview={item.preview}
194 {readonly}
195 onRemove={onFileRemove}
196 height={imageHeight}
197 width={imageWidth}
198 {imageClass}
199 onClick={(event) => openPreview(item, event)}
200 />
201 {:else}
202 <ChatAttachmentThumbnailFile
203 class="cursor-pointer"
204 id={item.id}
205 name={item.name}
206 size={item.size}
207 {readonly}
208 onRemove={onFileRemove}
209 textContent={item.textContent}
210 attachment={item.attachment}
211 uploadedFile={item.uploadedFile}
212 onClick={(event?: MouseEvent) => openPreview(item, event)}
213 />
214 {/if}
215 {/each}
216 </div>
217 {/if}
218 </div>
219{/if}
220
221{#if previewItem}
222 <DialogChatAttachmentPreview
223 bind:open={previewDialogOpen}
224 uploadedFile={previewItem.uploadedFile}
225 attachment={previewItem.attachment}
226 preview={previewItem.preview}
227 name={previewItem.name}
228 size={previewItem.size}
229 textContent={previewItem.textContent}
230 {activeModelId}
231 />
232{/if}
233
234<DialogChatAttachmentsViewAll
235 bind:open={viewAllDialogOpen}
236 {uploadedFiles}
237 {attachments}
238 {readonly}
239 {onFileRemove}
240 imageHeight="h-64"
241 {imageClass}
242 {activeModelId}
243/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte
new file mode 100644
index 0000000..279b2e2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte
@@ -0,0 +1,117 @@
1<script lang="ts">
2 import {
3 ChatAttachmentThumbnailImage,
4 ChatAttachmentThumbnailFile,
5 DialogChatAttachmentPreview
6 } from '$lib/components/app';
7 import { getAttachmentDisplayItems } from '$lib/utils';
8
9 interface Props {
10 uploadedFiles?: ChatUploadedFile[];
11 attachments?: DatabaseMessageExtra[];
12 readonly?: boolean;
13 onFileRemove?: (fileId: string) => void;
14 imageHeight?: string;
15 imageWidth?: string;
16 imageClass?: string;
17 activeModelId?: string;
18 }
19
20 let {
21 uploadedFiles = [],
22 attachments = [],
23 readonly = false,
24 onFileRemove,
25 imageHeight = 'h-24',
26 imageWidth = 'w-auto',
27 imageClass = '',
28 activeModelId
29 }: Props = $props();
30
31 let previewDialogOpen = $state(false);
32 let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
33
34 let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
35 let imageItems = $derived(displayItems.filter((item) => item.isImage));
36 let fileItems = $derived(displayItems.filter((item) => !item.isImage));
37
38 function openPreview(item: (typeof displayItems)[0], event?: Event) {
39 if (event) {
40 event.preventDefault();
41 event.stopPropagation();
42 }
43
44 previewItem = {
45 uploadedFile: item.uploadedFile,
46 attachment: item.attachment,
47 preview: item.preview,
48 name: item.name,
49 size: item.size,
50 textContent: item.textContent
51 };
52 previewDialogOpen = true;
53 }
54</script>
55
56<div class="space-y-4">
57 <div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
58 {#if fileItems.length > 0}
59 <div>
60 <h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
61 <div class="flex flex-wrap items-start gap-3">
62 {#each fileItems as item (item.id)}
63 <ChatAttachmentThumbnailFile
64 class="cursor-pointer"
65 id={item.id}
66 name={item.name}
67 size={item.size}
68 {readonly}
69 onRemove={onFileRemove}
70 textContent={item.textContent}
71 attachment={item.attachment}
72 uploadedFile={item.uploadedFile}
73 onClick={(event?: MouseEvent) => openPreview(item, event)}
74 />
75 {/each}
76 </div>
77 </div>
78 {/if}
79
80 {#if imageItems.length > 0}
81 <div>
82 <h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
83 <div class="flex flex-wrap items-start gap-3">
84 {#each imageItems as item (item.id)}
85 {#if item.preview}
86 <ChatAttachmentThumbnailImage
87 class="cursor-pointer"
88 id={item.id}
89 name={item.name}
90 preview={item.preview}
91 {readonly}
92 onRemove={onFileRemove}
93 height={imageHeight}
94 width={imageWidth}
95 {imageClass}
96 onClick={(event) => openPreview(item, event)}
97 />
98 {/if}
99 {/each}
100 </div>
101 </div>
102 {/if}
103 </div>
104</div>
105
106{#if previewItem}
107 <DialogChatAttachmentPreview
108 bind:open={previewDialogOpen}
109 uploadedFile={previewItem.uploadedFile}
110 attachment={previewItem.attachment}
111 preview={previewItem.preview}
112 name={previewItem.name}
113 size={previewItem.size}
114 textContent={previewItem.textContent}
115 {activeModelId}
116 />
117{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
new file mode 100644
index 0000000..27ab975
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
@@ -0,0 +1,315 @@
1<script lang="ts">
2 import { afterNavigate } from '$app/navigation';
3 import {
4 ChatAttachmentsList,
5 ChatFormActions,
6 ChatFormFileInputInvisible,
7 ChatFormHelperText,
8 ChatFormTextarea
9 } from '$lib/components/app';
10 import { INPUT_CLASSES } from '$lib/constants/input-classes';
11 import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
12 import { config } from '$lib/stores/settings.svelte';
13 import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
14 import { isRouterMode } from '$lib/stores/server.svelte';
15 import { chatStore } from '$lib/stores/chat.svelte';
16 import { activeMessages } from '$lib/stores/conversations.svelte';
17 import { MimeTypeText } from '$lib/enums';
18 import { isIMEComposing, parseClipboardContent } from '$lib/utils';
19 import {
20 AudioRecorder,
21 convertToWav,
22 createAudioFile,
23 isAudioRecordingSupported
24 } from '$lib/utils/browser-only';
25 import { onMount } from 'svelte';
26
27 interface Props {
28 class?: string;
29 disabled?: boolean;
30 isLoading?: boolean;
31 onFileRemove?: (fileId: string) => void;
32 onFileUpload?: (files: File[]) => void;
33 onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
34 onStop?: () => void;
35 showHelperText?: boolean;
36 uploadedFiles?: ChatUploadedFile[];
37 }
38
39 let {
40 class: className,
41 disabled = false,
42 isLoading = false,
43 onFileRemove,
44 onFileUpload,
45 onSend,
46 onStop,
47 showHelperText = true,
48 uploadedFiles = $bindable([])
49 }: Props = $props();
50
51 let audioRecorder: AudioRecorder | undefined;
52 let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
53 let currentConfig = $derived(config());
54 let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
55 let isRecording = $state(false);
56 let message = $state('');
57 let pasteLongTextToFileLength = $derived.by(() => {
58 const n = Number(currentConfig.pasteLongTextToFileLen);
59 return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
60 });
61 let previousIsLoading = $state(isLoading);
62 let recordingSupported = $state(false);
63 let textareaRef: ChatFormTextarea | undefined = $state(undefined);
64
65 // Check if model is selected (in ROUTER mode)
66 let conversationModel = $derived(
67 chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
68 );
69 let isRouter = $derived(isRouterMode());
70 let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
71
72 // Get active model ID for capability detection
73 let activeModelId = $derived.by(() => {
74 const options = modelOptions();
75
76 if (!isRouter) {
77 return options.length > 0 ? options[0].model : null;
78 }
79
80 // First try user-selected model
81 const selectedId = selectedModelId();
82 if (selectedId) {
83 const model = options.find((m) => m.id === selectedId);
84 if (model) return model.model;
85 }
86
87 // Fallback to conversation model
88 if (conversationModel) {
89 const model = options.find((m) => m.model === conversationModel);
90 if (model) return model.model;
91 }
92
93 return null;
94 });
95
96 function checkModelSelected(): boolean {
97 if (!hasModelSelected) {
98 // Open the model selector
99 chatFormActionsRef?.openModelSelector();
100 return false;
101 }
102
103 return true;
104 }
105
106 function handleFileSelect(files: File[]) {
107 onFileUpload?.(files);
108 }
109
110 function handleFileUpload() {
111 fileInputRef?.click();
112 }
113
114 async function handleKeydown(event: KeyboardEvent) {
115 if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
116 event.preventDefault();
117
118 if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
119
120 if (!checkModelSelected()) return;
121
122 const messageToSend = message.trim();
123 const filesToSend = [...uploadedFiles];
124
125 message = '';
126 uploadedFiles = [];
127
128 textareaRef?.resetHeight();
129
130 const success = await onSend?.(messageToSend, filesToSend);
131
132 if (!success) {
133 message = messageToSend;
134 uploadedFiles = filesToSend;
135 }
136 }
137 }
138
139 function handlePaste(event: ClipboardEvent) {
140 if (!event.clipboardData) return;
141
142 const files = Array.from(event.clipboardData.items)
143 .filter((item) => item.kind === 'file')
144 .map((item) => item.getAsFile())
145 .filter((file): file is File => file !== null);
146
147 if (files.length > 0) {
148 event.preventDefault();
149 onFileUpload?.(files);
150
151 return;
152 }
153
154 const text = event.clipboardData.getData(MimeTypeText.PLAIN);
155
156 if (text.startsWith('"')) {
157 const parsed = parseClipboardContent(text);
158
159 if (parsed.textAttachments.length > 0) {
160 event.preventDefault();
161
162 message = parsed.message;
163
164 const attachmentFiles = parsed.textAttachments.map(
165 (att) =>
166 new File([att.content], att.name, {
167 type: MimeTypeText.PLAIN
168 })
169 );
170
171 onFileUpload?.(attachmentFiles);
172
173 setTimeout(() => {
174 textareaRef?.focus();
175 }, 10);
176
177 return;
178 }
179 }
180
181 if (
182 text.length > 0 &&
183 pasteLongTextToFileLength > 0 &&
184 text.length > pasteLongTextToFileLength
185 ) {
186 event.preventDefault();
187
188 const textFile = new File([text], 'Pasted', {
189 type: MimeTypeText.PLAIN
190 });
191
192 onFileUpload?.([textFile]);
193 }
194 }
195
196 async function handleMicClick() {
197 if (!audioRecorder || !recordingSupported) {
198 console.warn('Audio recording not supported');
199
200 return;
201 }
202
203 if (isRecording) {
204 try {
205 const audioBlob = await audioRecorder.stopRecording();
206 const wavBlob = await convertToWav(audioBlob);
207 const audioFile = createAudioFile(wavBlob);
208
209 onFileUpload?.([audioFile]);
210 isRecording = false;
211 } catch (error) {
212 console.error('Failed to stop recording:', error);
213 isRecording = false;
214 }
215 } else {
216 try {
217 await audioRecorder.startRecording();
218 isRecording = true;
219 } catch (error) {
220 console.error('Failed to start recording:', error);
221 }
222 }
223 }
224
225 function handleStop() {
226 onStop?.();
227 }
228
229 async function handleSubmit(event: SubmitEvent) {
230 event.preventDefault();
231 if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
232
233 // Check if model is selected first
234 if (!checkModelSelected()) return;
235
236 const messageToSend = message.trim();
237 const filesToSend = [...uploadedFiles];
238
239 message = '';
240 uploadedFiles = [];
241
242 textareaRef?.resetHeight();
243
244 const success = await onSend?.(messageToSend, filesToSend);
245
246 if (!success) {
247 message = messageToSend;
248 uploadedFiles = filesToSend;
249 }
250 }
251
252 onMount(() => {
253 setTimeout(() => textareaRef?.focus(), 10);
254 recordingSupported = isAudioRecordingSupported();
255 audioRecorder = new AudioRecorder();
256 });
257
258 afterNavigate(() => {
259 setTimeout(() => textareaRef?.focus(), 10);
260 });
261
262 $effect(() => {
263 if (previousIsLoading && !isLoading) {
264 setTimeout(() => textareaRef?.focus(), 10);
265 }
266
267 previousIsLoading = isLoading;
268 });
269</script>
270
271<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
272
273<form
274 onsubmit={handleSubmit}
275 class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
276 ? 'cursor-not-allowed opacity-60'
277 : ''} {className}"
278 data-slot="chat-form"
279>
280 <ChatAttachmentsList
281 bind:uploadedFiles
282 {onFileRemove}
283 limitToSingleRow
284 class="py-5"
285 style="scroll-padding: 1rem;"
286 activeModelId={activeModelId ?? undefined}
287 />
288
289 <div
290 class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
291 onpaste={handlePaste}
292 >
293 <ChatFormTextarea
294 bind:this={textareaRef}
295 bind:value={message}
296 onKeydown={handleKeydown}
297 {disabled}
298 />
299
300 <ChatFormActions
301 bind:this={chatFormActionsRef}
302 canSend={message.trim().length > 0 || uploadedFiles.length > 0}
303 hasText={message.trim().length > 0}
304 {disabled}
305 {isLoading}
306 {isRecording}
307 {uploadedFiles}
308 onFileUpload={handleFileUpload}
309 onMicClick={handleMicClick}
310 onStop={handleStop}
311 />
312 </div>
313</form>
314
315<ChatFormHelperText show={showHelperText} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte
new file mode 100644
index 0000000..dd37268
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte
@@ -0,0 +1,123 @@
1<script lang="ts">
2 import { Paperclip } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
5 import * as Tooltip from '$lib/components/ui/tooltip';
6 import { FILE_TYPE_ICONS } from '$lib/constants/icons';
7
8 interface Props {
9 class?: string;
10 disabled?: boolean;
11 hasAudioModality?: boolean;
12 hasVisionModality?: boolean;
13 onFileUpload?: () => void;
14 }
15
16 let {
17 class: className = '',
18 disabled = false,
19 hasAudioModality = false,
20 hasVisionModality = false,
21 onFileUpload
22 }: Props = $props();
23
24 const fileUploadTooltipText = $derived.by(() => {
25 return !hasVisionModality
26 ? 'Text files and PDFs supported. Images, audio, and video require vision models.'
27 : 'Attach files';
28 });
29</script>
30
31<div class="flex items-center gap-1 {className}">
32 <DropdownMenu.Root>
33 <DropdownMenu.Trigger name="Attach files" {disabled}>
34 <Tooltip.Root>
35 <Tooltip.Trigger>
36 <Button
37 class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
38 {disabled}
39 type="button"
40 >
41 <span class="sr-only">Attach files</span>
42
43 <Paperclip class="h-4 w-4" />
44 </Button>
45 </Tooltip.Trigger>
46
47 <Tooltip.Content>
48 <p>{fileUploadTooltipText}</p>
49 </Tooltip.Content>
50 </Tooltip.Root>
51 </DropdownMenu.Trigger>
52
53 <DropdownMenu.Content align="start" class="w-48">
54 <Tooltip.Root>
55 <Tooltip.Trigger class="w-full">
56 <DropdownMenu.Item
57 class="images-button flex cursor-pointer items-center gap-2"
58 disabled={!hasVisionModality}
59 onclick={() => onFileUpload?.()}
60 >
61 <FILE_TYPE_ICONS.image class="h-4 w-4" />
62
63 <span>Images</span>
64 </DropdownMenu.Item>
65 </Tooltip.Trigger>
66
67 {#if !hasVisionModality}
68 <Tooltip.Content>
69 <p>Images require vision models to be processed</p>
70 </Tooltip.Content>
71 {/if}
72 </Tooltip.Root>
73
74 <Tooltip.Root>
75 <Tooltip.Trigger class="w-full">
76 <DropdownMenu.Item
77 class="audio-button flex cursor-pointer items-center gap-2"
78 disabled={!hasAudioModality}
79 onclick={() => onFileUpload?.()}
80 >
81 <FILE_TYPE_ICONS.audio class="h-4 w-4" />
82
83 <span>Audio Files</span>
84 </DropdownMenu.Item>
85 </Tooltip.Trigger>
86
87 {#if !hasAudioModality}
88 <Tooltip.Content>
89 <p>Audio files require audio models to be processed</p>
90 </Tooltip.Content>
91 {/if}
92 </Tooltip.Root>
93
94 <DropdownMenu.Item
95 class="flex cursor-pointer items-center gap-2"
96 onclick={() => onFileUpload?.()}
97 >
98 <FILE_TYPE_ICONS.text class="h-4 w-4" />
99
100 <span>Text Files</span>
101 </DropdownMenu.Item>
102
103 <Tooltip.Root>
104 <Tooltip.Trigger class="w-full">
105 <DropdownMenu.Item
106 class="flex cursor-pointer items-center gap-2"
107 onclick={() => onFileUpload?.()}
108 >
109 <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
110
111 <span>PDF Files</span>
112 </DropdownMenu.Item>
113 </Tooltip.Trigger>
114
115 {#if !hasVisionModality}
116 <Tooltip.Content>
117 <p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
118 </Tooltip.Content>
119 {/if}
120 </Tooltip.Root>
121 </DropdownMenu.Content>
122 </DropdownMenu.Root>
123</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
new file mode 100644
index 0000000..f1b0849
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
@@ -0,0 +1,52 @@
1<script lang="ts">
2 import { Mic, Square } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import * as Tooltip from '$lib/components/ui/tooltip';
5
6 interface Props {
7 class?: string;
8 disabled?: boolean;
9 hasAudioModality?: boolean;
10 isLoading?: boolean;
11 isRecording?: boolean;
12 onMicClick?: () => void;
13 }
14
15 let {
16 class: className = '',
17 disabled = false,
18 hasAudioModality = false,
19 isLoading = false,
20 isRecording = false,
21 onMicClick
22 }: Props = $props();
23</script>
24
25<div class="flex items-center gap-1 {className}">
26 <Tooltip.Root>
27 <Tooltip.Trigger>
28 <Button
29 class="h-8 w-8 rounded-full p-0 {isRecording
30 ? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
31 : ''}"
32 disabled={disabled || isLoading || !hasAudioModality}
33 onclick={onMicClick}
34 type="button"
35 >
36 <span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
37
38 {#if isRecording}
39 <Square class="h-4 w-4 animate-pulse fill-white" />
40 {:else}
41 <Mic class="h-4 w-4" />
42 {/if}
43 </Button>
44 </Tooltip.Trigger>
45
46 {#if !hasAudioModality}
47 <Tooltip.Content>
48 <p>Current model does not support audio</p>
49 </Tooltip.Content>
50 {/if}
51 </Tooltip.Root>
52</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte
new file mode 100644
index 0000000..861cd18
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte
@@ -0,0 +1,55 @@
1<script lang="ts">
2 import { ArrowUp } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import * as Tooltip from '$lib/components/ui/tooltip';
5 import { cn } from '$lib/components/ui/utils';
6
7 interface Props {
8 canSend?: boolean;
9 disabled?: boolean;
10 isLoading?: boolean;
11 showErrorState?: boolean;
12 tooltipLabel?: string;
13 }
14
15 let {
16 canSend = false,
17 disabled = false,
18 isLoading = false,
19 showErrorState = false,
20 tooltipLabel
21 }: Props = $props();
22
23 let isDisabled = $derived(!canSend || disabled || isLoading);
24</script>
25
26{#snippet submitButton(props = {})}
27 <Button
28 type="submit"
29 disabled={isDisabled}
30 class={cn(
31 'h-8 w-8 rounded-full p-0',
32 showErrorState
33 ? 'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
34 : ''
35 )}
36 {...props}
37 >
38 <span class="sr-only">Send</span>
39 <ArrowUp class="h-12 w-12" />
40 </Button>
41{/snippet}
42
43{#if tooltipLabel}
44 <Tooltip.Root>
45 <Tooltip.Trigger>
46 {@render submitButton()}
47 </Tooltip.Trigger>
48
49 <Tooltip.Content>
50 <p>{tooltipLabel}</p>
51 </Tooltip.Content>
52 </Tooltip.Root>
53{:else}
54 {@render submitButton()}
55{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
new file mode 100644
index 0000000..dde9bda
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
@@ -0,0 +1,204 @@
1<script lang="ts">
2 import { Square } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import {
5 ChatFormActionFileAttachments,
6 ChatFormActionRecord,
7 ChatFormActionSubmit,
8 ModelsSelector
9 } from '$lib/components/app';
10 import { FileTypeCategory } from '$lib/enums';
11 import { getFileTypeCategory } from '$lib/utils';
12 import { config } from '$lib/stores/settings.svelte';
13 import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
14 import { isRouterMode } from '$lib/stores/server.svelte';
15 import { chatStore } from '$lib/stores/chat.svelte';
16 import { activeMessages, usedModalities } from '$lib/stores/conversations.svelte';
17 import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
18
19 interface Props {
20 canSend?: boolean;
21 class?: string;
22 disabled?: boolean;
23 isLoading?: boolean;
24 isRecording?: boolean;
25 hasText?: boolean;
26 uploadedFiles?: ChatUploadedFile[];
27 onFileUpload?: () => void;
28 onMicClick?: () => void;
29 onStop?: () => void;
30 }
31
32 let {
33 canSend = false,
34 class: className = '',
35 disabled = false,
36 isLoading = false,
37 isRecording = false,
38 hasText = false,
39 uploadedFiles = [],
40 onFileUpload,
41 onMicClick,
42 onStop
43 }: Props = $props();
44
45 let currentConfig = $derived(config());
46 let isRouter = $derived(isRouterMode());
47
48 let conversationModel = $derived(
49 chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
50 );
51
52 let previousConversationModel: string | null = null;
53
54 $effect(() => {
55 if (conversationModel && conversationModel !== previousConversationModel) {
56 previousConversationModel = conversationModel;
57 modelsStore.selectModelByName(conversationModel);
58 }
59 });
60
61 let activeModelId = $derived.by(() => {
62 const options = modelOptions();
63
64 if (!isRouter) {
65 return options.length > 0 ? options[0].model : null;
66 }
67
68 const selectedId = selectedModelId();
69 if (selectedId) {
70 const model = options.find((m) => m.id === selectedId);
71 if (model) return model.model;
72 }
73
74 if (conversationModel) {
75 const model = options.find((m) => m.model === conversationModel);
76 if (model) return model.model;
77 }
78
79 return null;
80 });
81
82 let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
83
84 $effect(() => {
85 if (activeModelId) {
86 const cached = modelsStore.getModelProps(activeModelId);
87
88 if (!cached) {
89 modelsStore.fetchModelProps(activeModelId).then(() => {
90 modelPropsVersion++;
91 });
92 }
93 }
94 });
95
96 let hasAudioModality = $derived.by(() => {
97 if (activeModelId) {
98 void modelPropsVersion;
99
100 return modelsStore.modelSupportsAudio(activeModelId);
101 }
102
103 return false;
104 });
105
106 let hasVisionModality = $derived.by(() => {
107 if (activeModelId) {
108 void modelPropsVersion;
109
110 return modelsStore.modelSupportsVision(activeModelId);
111 }
112
113 return false;
114 });
115
116 let hasAudioAttachments = $derived(
117 uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
118 );
119 let shouldShowRecordButton = $derived(
120 hasAudioModality && !hasText && !hasAudioAttachments && currentConfig.autoMicOnEmpty
121 );
122
123 let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
124
125 let isSelectedModelInCache = $derived.by(() => {
126 if (!isRouter) return true;
127
128 if (conversationModel) {
129 return modelOptions().some((option) => option.model === conversationModel);
130 }
131
132 const currentModelId = selectedModelId();
133 if (!currentModelId) return false;
134
135 return modelOptions().some((option) => option.id === currentModelId);
136 });
137
138 let submitTooltip = $derived.by(() => {
139 if (!hasModelSelected) {
140 return 'Please select a model first';
141 }
142
143 if (!isSelectedModelInCache) {
144 return 'Selected model is not available, please select another';
145 }
146
147 return '';
148 });
149
150 let selectorModelRef: ModelsSelector | undefined = $state(undefined);
151
152 export function openModelSelector() {
153 selectorModelRef?.open();
154 }
155
156 const { handleModelChange } = useModelChangeValidation({
157 getRequiredModalities: () => usedModalities(),
158 onValidationFailure: async (previousModelId) => {
159 if (previousModelId) {
160 await modelsStore.selectModelById(previousModelId);
161 }
162 }
163 });
164</script>
165
166<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
167 <ChatFormActionFileAttachments
168 class="mr-auto"
169 {disabled}
170 {hasAudioModality}
171 {hasVisionModality}
172 {onFileUpload}
173 />
174
175 <ModelsSelector
176 {disabled}
177 bind:this={selectorModelRef}
178 currentModel={conversationModel}
179 forceForegroundText={true}
180 useGlobalSelection={true}
181 onModelChange={handleModelChange}
182 />
183
184 {#if isLoading}
185 <Button
186 type="button"
187 onclick={onStop}
188 class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
189 >
190 <span class="sr-only">Stop</span>
191 <Square class="h-8 w-8 fill-destructive stroke-destructive" />
192 </Button>
193 {:else if shouldShowRecordButton}
194 <ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
195 {:else}
196 <ChatFormActionSubmit
197 canSend={canSend && hasModelSelected && isSelectedModelInCache}
198 {disabled}
199 {isLoading}
200 tooltipLabel={submitTooltip}
201 showErrorState={hasModelSelected && !isSelectedModelInCache}
202 />
203 {/if}
204</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte
new file mode 100644
index 0000000..d758822
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte
@@ -0,0 +1,30 @@
1<script lang="ts">
2 interface Props {
3 class?: string;
4 multiple?: boolean;
5 onFileSelect?: (files: File[]) => void;
6 }
7
8 let { class: className = '', multiple = true, onFileSelect }: Props = $props();
9
10 let fileInputElement: HTMLInputElement | undefined;
11
12 export function click() {
13 fileInputElement?.click();
14 }
15
16 function handleFileSelect(event: Event) {
17 const input = event.target as HTMLInputElement;
18 if (input.files) {
19 onFileSelect?.(Array.from(input.files));
20 }
21 }
22</script>
23
24<input
25 bind:this={fileInputElement}
26 type="file"
27 {multiple}
28 onchange={handleFileSelect}
29 class="hidden {className}"
30/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormHelperText.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormHelperText.svelte
new file mode 100644
index 0000000..f8246f2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormHelperText.svelte
@@ -0,0 +1,17 @@
1<script lang="ts">
2 interface Props {
3 class?: string;
4 show?: boolean;
5 }
6
7 let { class: className = '', show = true }: Props = $props();
8</script>
9
10{#if show}
11 <div class="mt-4 flex items-center justify-center {className}">
12 <p class="text-xs text-muted-foreground">
13 Press <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Enter</kbd> to send,
14 <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Shift + Enter</kbd> for new line
15 </p>
16 </div>
17{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte
new file mode 100644
index 0000000..19b763f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte
@@ -0,0 +1,59 @@
1<script lang="ts">
2 import { autoResizeTextarea } from '$lib/utils';
3 import { onMount } from 'svelte';
4
5 interface Props {
6 class?: string;
7 disabled?: boolean;
8 onKeydown?: (event: KeyboardEvent) => void;
9 onPaste?: (event: ClipboardEvent) => void;
10 placeholder?: string;
11 value?: string;
12 }
13
14 let {
15 class: className = '',
16 disabled = false,
17 onKeydown,
18 onPaste,
19 placeholder = 'Ask anything...',
20 value = $bindable('')
21 }: Props = $props();
22
23 let textareaElement: HTMLTextAreaElement | undefined;
24
25 onMount(() => {
26 if (textareaElement) {
27 textareaElement.focus();
28 }
29 });
30
31 // Expose the textarea element for external access
32 export function getElement() {
33 return textareaElement;
34 }
35
36 export function focus() {
37 textareaElement?.focus();
38 }
39
40 export function resetHeight() {
41 if (textareaElement) {
42 textareaElement.style.height = '1rem';
43 }
44 }
45</script>
46
47<div class="flex-1 {className}">
48 <textarea
49 bind:this={textareaElement}
50 bind:value
51 class="text-md max-h-32 min-h-12 w-full resize-none border-0 bg-transparent p-0 leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0"
52 class:cursor-not-allowed={disabled}
53 {disabled}
54 onkeydown={onKeydown}
55 oninput={(event) => autoResizeTextarea(event.currentTarget)}
56 onpaste={onPaste}
57 {placeholder}
58 ></textarea>
59</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
new file mode 100644
index 0000000..220276f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
@@ -0,0 +1,286 @@
1<script lang="ts">
2 import { chatStore } from '$lib/stores/chat.svelte';
3 import { config } from '$lib/stores/settings.svelte';
4 import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
5 import ChatMessageAssistant from './ChatMessageAssistant.svelte';
6 import ChatMessageUser from './ChatMessageUser.svelte';
7 import ChatMessageSystem from './ChatMessageSystem.svelte';
8
9 interface Props {
10 class?: string;
11 message: DatabaseMessage;
12 onCopy?: (message: DatabaseMessage) => void;
13 onContinueAssistantMessage?: (message: DatabaseMessage) => void;
14 onDelete?: (message: DatabaseMessage) => void;
15 onEditWithBranching?: (
16 message: DatabaseMessage,
17 newContent: string,
18 newExtras?: DatabaseMessageExtra[]
19 ) => void;
20 onEditWithReplacement?: (
21 message: DatabaseMessage,
22 newContent: string,
23 shouldBranch: boolean
24 ) => void;
25 onEditUserMessagePreserveResponses?: (
26 message: DatabaseMessage,
27 newContent: string,
28 newExtras?: DatabaseMessageExtra[]
29 ) => void;
30 onNavigateToSibling?: (siblingId: string) => void;
31 onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
32 siblingInfo?: ChatMessageSiblingInfo | null;
33 }
34
35 let {
36 class: className = '',
37 message,
38 onCopy,
39 onContinueAssistantMessage,
40 onDelete,
41 onEditWithBranching,
42 onEditWithReplacement,
43 onEditUserMessagePreserveResponses,
44 onNavigateToSibling,
45 onRegenerateWithBranching,
46 siblingInfo = null
47 }: Props = $props();
48
49 let deletionInfo = $state<{
50 totalCount: number;
51 userMessages: number;
52 assistantMessages: number;
53 messageTypes: string[];
54 } | null>(null);
55 let editedContent = $state(message.content);
56 let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
57 let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
58 let isEditing = $state(false);
59 let showDeleteDialog = $state(false);
60 let shouldBranchAfterEdit = $state(false);
61 let textareaElement: HTMLTextAreaElement | undefined = $state();
62
63 let thinkingContent = $derived.by(() => {
64 if (message.role === 'assistant') {
65 const trimmedThinking = message.thinking?.trim();
66
67 return trimmedThinking ? trimmedThinking : null;
68 }
69 return null;
70 });
71
72 let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
73 if (message.role === 'assistant') {
74 const trimmedToolCalls = message.toolCalls?.trim();
75
76 if (!trimmedToolCalls) {
77 return null;
78 }
79
80 try {
81 const parsed = JSON.parse(trimmedToolCalls);
82
83 if (Array.isArray(parsed)) {
84 return parsed as ApiChatCompletionToolCall[];
85 }
86 } catch {
87 // Harmony-only path: fall back to the raw string so issues surface visibly.
88 }
89
90 return trimmedToolCalls;
91 }
92 return null;
93 });
94
95 function handleCancelEdit() {
96 isEditing = false;
97 editedContent = message.content;
98 editedExtras = message.extra ? [...message.extra] : [];
99 editedUploadedFiles = [];
100 }
101
102 function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
103 editedExtras = extras;
104 }
105
106 function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
107 editedUploadedFiles = files;
108 }
109
110 async function handleCopy() {
111 const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
112 const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
113 await copyToClipboard(clipboardContent, 'Message copied to clipboard');
114 onCopy?.(message);
115 }
116
117 function handleConfirmDelete() {
118 onDelete?.(message);
119 showDeleteDialog = false;
120 }
121
122 async function handleDelete() {
123 deletionInfo = await chatStore.getDeletionInfo(message.id);
124 showDeleteDialog = true;
125 }
126
127 function handleEdit() {
128 isEditing = true;
129 editedContent = message.content;
130 editedExtras = message.extra ? [...message.extra] : [];
131 editedUploadedFiles = [];
132
133 setTimeout(() => {
134 if (textareaElement) {
135 textareaElement.focus();
136 textareaElement.setSelectionRange(
137 textareaElement.value.length,
138 textareaElement.value.length
139 );
140 }
141 }, 0);
142 }
143
144 function handleEditedContentChange(content: string) {
145 editedContent = content;
146 }
147
148 function handleEditKeydown(event: KeyboardEvent) {
149 // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
150 // This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
151 if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
152 event.preventDefault();
153 handleSaveEdit();
154 } else if (event.key === 'Escape') {
155 event.preventDefault();
156 handleCancelEdit();
157 }
158 }
159
160 function handleRegenerate(modelOverride?: string) {
161 onRegenerateWithBranching?.(message, modelOverride);
162 }
163
164 function handleContinue() {
165 onContinueAssistantMessage?.(message);
166 }
167
168 async function handleSaveEdit() {
169 if (message.role === 'user' || message.role === 'system') {
170 const finalExtras = await getMergedExtras();
171 onEditWithBranching?.(message, editedContent.trim(), finalExtras);
172 } else {
173 // For assistant messages, preserve exact content including trailing whitespace
174 // This is important for the Continue feature to work properly
175 onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
176 }
177
178 isEditing = false;
179 shouldBranchAfterEdit = false;
180 editedUploadedFiles = [];
181 }
182
183 async function handleSaveEditOnly() {
184 if (message.role === 'user') {
185 // For user messages, trim to avoid accidental whitespace
186 const finalExtras = await getMergedExtras();
187 onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
188 }
189
190 isEditing = false;
191 editedUploadedFiles = [];
192 }
193
194 async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
195 if (editedUploadedFiles.length === 0) {
196 return editedExtras;
197 }
198
199 const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
200 const result = await parseFilesToMessageExtras(editedUploadedFiles);
201 const newExtras = result?.extras || [];
202
203 return [...editedExtras, ...newExtras];
204 }
205
206 function handleShowDeleteDialogChange(show: boolean) {
207 showDeleteDialog = show;
208 }
209</script>
210
211{#if message.role === 'system'}
212 <ChatMessageSystem
213 bind:textareaElement
214 class={className}
215 {deletionInfo}
216 {editedContent}
217 {isEditing}
218 {message}
219 onCancelEdit={handleCancelEdit}
220 onConfirmDelete={handleConfirmDelete}
221 onCopy={handleCopy}
222 onDelete={handleDelete}
223 onEdit={handleEdit}
224 onEditKeydown={handleEditKeydown}
225 onEditedContentChange={handleEditedContentChange}
226 {onNavigateToSibling}
227 onSaveEdit={handleSaveEdit}
228 onShowDeleteDialogChange={handleShowDeleteDialogChange}
229 {showDeleteDialog}
230 {siblingInfo}
231 />
232{:else if message.role === 'user'}
233 <ChatMessageUser
234 bind:textareaElement
235 class={className}
236 {deletionInfo}
237 {editedContent}
238 {editedExtras}
239 {editedUploadedFiles}
240 {isEditing}
241 {message}
242 onCancelEdit={handleCancelEdit}
243 onConfirmDelete={handleConfirmDelete}
244 onCopy={handleCopy}
245 onDelete={handleDelete}
246 onEdit={handleEdit}
247 onEditKeydown={handleEditKeydown}
248 onEditedContentChange={handleEditedContentChange}
249 onEditedExtrasChange={handleEditedExtrasChange}
250 onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
251 {onNavigateToSibling}
252 onSaveEdit={handleSaveEdit}
253 onSaveEditOnly={handleSaveEditOnly}
254 onShowDeleteDialogChange={handleShowDeleteDialogChange}
255 {showDeleteDialog}
256 {siblingInfo}
257 />
258{:else}
259 <ChatMessageAssistant
260 bind:textareaElement
261 class={className}
262 {deletionInfo}
263 {editedContent}
264 {isEditing}
265 {message}
266 messageContent={message.content}
267 onCancelEdit={handleCancelEdit}
268 onConfirmDelete={handleConfirmDelete}
269 onContinue={handleContinue}
270 onCopy={handleCopy}
271 onDelete={handleDelete}
272 onEdit={handleEdit}
273 onEditKeydown={handleEditKeydown}
274 onEditedContentChange={handleEditedContentChange}
275 {onNavigateToSibling}
276 onRegenerate={handleRegenerate}
277 onSaveEdit={handleSaveEdit}
278 onShowDeleteDialogChange={handleShowDeleteDialogChange}
279 {shouldBranchAfterEdit}
280 onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
281 {showDeleteDialog}
282 {siblingInfo}
283 {thinkingContent}
284 {toolCallContent}
285 />
286{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
new file mode 100644
index 0000000..3cb4815
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
@@ -0,0 +1,100 @@
1<script lang="ts">
2 import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
3 import {
4 ActionButton,
5 ChatMessageBranchingControls,
6 DialogConfirmation
7 } from '$lib/components/app';
8
9 interface Props {
10 role: 'user' | 'assistant';
11 justify: 'start' | 'end';
12 actionsPosition: 'left' | 'right';
13 siblingInfo?: ChatMessageSiblingInfo | null;
14 showDeleteDialog: boolean;
15 deletionInfo: {
16 totalCount: number;
17 userMessages: number;
18 assistantMessages: number;
19 messageTypes: string[];
20 } | null;
21 onCopy: () => void;
22 onEdit?: () => void;
23 onRegenerate?: () => void;
24 onContinue?: () => void;
25 onDelete: () => void;
26 onConfirmDelete: () => void;
27 onNavigateToSibling?: (siblingId: string) => void;
28 onShowDeleteDialogChange: (show: boolean) => void;
29 }
30
31 let {
32 actionsPosition,
33 deletionInfo,
34 justify,
35 onCopy,
36 onEdit,
37 onConfirmDelete,
38 onContinue,
39 onDelete,
40 onNavigateToSibling,
41 onShowDeleteDialogChange,
42 onRegenerate,
43 role,
44 siblingInfo = null,
45 showDeleteDialog
46 }: Props = $props();
47
48 function handleConfirmDelete() {
49 onConfirmDelete();
50 onShowDeleteDialogChange(false);
51 }
52</script>
53
54<div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-{justify}">
55 <div
56 class="absolute top-0 {actionsPosition === 'left'
57 ? 'left-0'
58 : 'right-0'} flex items-center gap-2 opacity-100 transition-opacity"
59 >
60 {#if siblingInfo && siblingInfo.totalSiblings > 1}
61 <ChatMessageBranchingControls {siblingInfo} {onNavigateToSibling} />
62 {/if}
63
64 <div
65 class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
66 >
67 <ActionButton icon={Copy} tooltip="Copy" onclick={onCopy} />
68
69 {#if onEdit}
70 <ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} />
71 {/if}
72
73 {#if role === 'assistant' && onRegenerate}
74 <ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
75 {/if}
76
77 {#if role === 'assistant' && onContinue}
78 <ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
79 {/if}
80
81 <ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
82 </div>
83 </div>
84</div>
85
86<DialogConfirmation
87 bind:open={showDeleteDialog}
88 title="Delete Message"
89 description={deletionInfo && deletionInfo.totalCount > 1
90 ? `This will delete ${deletionInfo.totalCount} messages including: ${deletionInfo.userMessages} user message${deletionInfo.userMessages > 1 ? 's' : ''} and ${deletionInfo.assistantMessages} assistant response${deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.`
91 : 'Are you sure you want to delete this message? This action cannot be undone.'}
92 confirmText={deletionInfo && deletionInfo.totalCount > 1
93 ? `Delete ${deletionInfo.totalCount} Messages`
94 : 'Delete'}
95 cancelText="Cancel"
96 variant="destructive"
97 icon={Trash2}
98 onConfirm={handleConfirmDelete}
99 onCancel={() => onShowDeleteDialogChange(false)}
100/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
new file mode 100644
index 0000000..2b34b1c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
@@ -0,0 +1,418 @@
1<script lang="ts">
2 import {
3 ModelBadge,
4 ChatMessageActions,
5 ChatMessageStatistics,
6 ChatMessageThinkingBlock,
7 CopyToClipboardIcon,
8 MarkdownContent,
9 ModelsSelector
10 } from '$lib/components/app';
11 import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
12 import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
13 import { isLoading } from '$lib/stores/chat.svelte';
14 import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
15 import { fade } from 'svelte/transition';
16 import { Check, X, Wrench } from '@lucide/svelte';
17 import { Button } from '$lib/components/ui/button';
18 import { Checkbox } from '$lib/components/ui/checkbox';
19 import { INPUT_CLASSES } from '$lib/constants/input-classes';
20 import Label from '$lib/components/ui/label/label.svelte';
21 import { config } from '$lib/stores/settings.svelte';
22 import { conversationsStore } from '$lib/stores/conversations.svelte';
23 import { isRouterMode } from '$lib/stores/server.svelte';
24
25 interface Props {
26 class?: string;
27 deletionInfo: {
28 totalCount: number;
29 userMessages: number;
30 assistantMessages: number;
31 messageTypes: string[];
32 } | null;
33 editedContent?: string;
34 isEditing?: boolean;
35 message: DatabaseMessage;
36 messageContent: string | undefined;
37 onCancelEdit?: () => void;
38 onCopy: () => void;
39 onConfirmDelete: () => void;
40 onContinue?: () => void;
41 onDelete: () => void;
42 onEdit?: () => void;
43 onEditKeydown?: (event: KeyboardEvent) => void;
44 onEditedContentChange?: (content: string) => void;
45 onNavigateToSibling?: (siblingId: string) => void;
46 onRegenerate: (modelOverride?: string) => void;
47 onSaveEdit?: () => void;
48 onShowDeleteDialogChange: (show: boolean) => void;
49 onShouldBranchAfterEditChange?: (value: boolean) => void;
50 showDeleteDialog: boolean;
51 shouldBranchAfterEdit?: boolean;
52 siblingInfo?: ChatMessageSiblingInfo | null;
53 textareaElement?: HTMLTextAreaElement;
54 thinkingContent: string | null;
55 toolCallContent: ApiChatCompletionToolCall[] | string | null;
56 }
57
58 let {
59 class: className = '',
60 deletionInfo,
61 editedContent = '',
62 isEditing = false,
63 message,
64 messageContent,
65 onCancelEdit,
66 onConfirmDelete,
67 onContinue,
68 onCopy,
69 onDelete,
70 onEdit,
71 onEditKeydown,
72 onEditedContentChange,
73 onNavigateToSibling,
74 onRegenerate,
75 onSaveEdit,
76 onShowDeleteDialogChange,
77 onShouldBranchAfterEditChange,
78 showDeleteDialog,
79 shouldBranchAfterEdit = false,
80 siblingInfo = null,
81 textareaElement = $bindable(),
82 thinkingContent,
83 toolCallContent = null
84 }: Props = $props();
85
86 const toolCalls = $derived(
87 Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
88 );
89 const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
90
91 const processingState = useProcessingState();
92
93 let currentConfig = $derived(config());
94 let isRouter = $derived(isRouterMode());
95 let displayedModel = $derived((): string | null => {
96 if (message.model) {
97 return message.model;
98 }
99
100 return null;
101 });
102
103 const { handleModelChange } = useModelChangeValidation({
104 getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
105 onSuccess: (modelName) => onRegenerate(modelName)
106 });
107
108 function handleCopyModel() {
109 const model = displayedModel();
110
111 void copyToClipboard(model ?? '');
112 }
113
114 $effect(() => {
115 if (isEditing && textareaElement) {
116 autoResizeTextarea(textareaElement);
117 }
118 });
119
120 $effect(() => {
121 if (isLoading() && !message?.content?.trim()) {
122 processingState.startMonitoring();
123 }
124 });
125
126 function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
127 const callNumber = index + 1;
128 const functionName = toolCall.function?.name?.trim();
129 const label = functionName || `Call #${callNumber}`;
130
131 const payload: Record<string, unknown> = {};
132
133 const id = toolCall.id?.trim();
134 if (id) {
135 payload.id = id;
136 }
137
138 const type = toolCall.type?.trim();
139 if (type) {
140 payload.type = type;
141 }
142
143 if (toolCall.function) {
144 const fnPayload: Record<string, unknown> = {};
145
146 const name = toolCall.function.name?.trim();
147 if (name) {
148 fnPayload.name = name;
149 }
150
151 const rawArguments = toolCall.function.arguments?.trim();
152 if (rawArguments) {
153 try {
154 fnPayload.arguments = JSON.parse(rawArguments);
155 } catch {
156 fnPayload.arguments = rawArguments;
157 }
158 }
159
160 if (Object.keys(fnPayload).length > 0) {
161 payload.function = fnPayload;
162 }
163 }
164
165 const formattedPayload = JSON.stringify(payload, null, 2);
166
167 return {
168 label,
169 tooltip: formattedPayload,
170 copyValue: formattedPayload
171 };
172 }
173
174 function handleCopyToolCall(payload: string) {
175 void copyToClipboard(payload, 'Tool call copied to clipboard');
176 }
177</script>
178
179<div
180 class="text-md group w-full leading-7.5 {className}"
181 role="group"
182 aria-label="Assistant message with actions"
183>
184 {#if thinkingContent}
185 <ChatMessageThinkingBlock
186 reasoningContent={thinkingContent}
187 isStreaming={!message.timestamp}
188 hasRegularContent={!!messageContent?.trim()}
189 />
190 {/if}
191
192 {#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
193 <div class="mt-6 w-full max-w-[48rem]" in:fade>
194 <div class="processing-container">
195 <span class="processing-text">
196 {processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
197 </span>
198 </div>
199 </div>
200 {/if}
201
202 {#if isEditing}
203 <div class="w-full">
204 <textarea
205 bind:this={textareaElement}
206 bind:value={editedContent}
207 class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
208 onkeydown={onEditKeydown}
209 oninput={(e) => {
210 autoResizeTextarea(e.currentTarget);
211 onEditedContentChange?.(e.currentTarget.value);
212 }}
213 placeholder="Edit assistant message..."
214 ></textarea>
215
216 <div class="mt-2 flex items-center justify-between">
217 <div class="flex items-center space-x-2">
218 <Checkbox
219 id="branch-after-edit"
220 bind:checked={shouldBranchAfterEdit}
221 onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)}
222 />
223 <Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
224 Branch conversation after edit
225 </Label>
226 </div>
227 <div class="flex gap-2">
228 <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
229 <X class="mr-1 h-3 w-3" />
230 Cancel
231 </Button>
232
233 <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm">
234 <Check class="mr-1 h-3 w-3" />
235 Save
236 </Button>
237 </div>
238 </div>
239 </div>
240 {:else if message.role === 'assistant'}
241 {#if config().disableReasoningFormat}
242 <pre class="raw-output">{messageContent || ''}</pre>
243 {:else}
244 <MarkdownContent content={messageContent || ''} />
245 {/if}
246 {:else}
247 <div class="text-sm whitespace-pre-wrap">
248 {messageContent}
249 </div>
250 {/if}
251
252 <div class="info my-6 grid gap-4 tabular-nums">
253 {#if displayedModel()}
254 <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
255 {#if isRouter}
256 <ModelsSelector
257 currentModel={displayedModel()}
258 onModelChange={handleModelChange}
259 disabled={isLoading()}
260 upToMessageId={message.id}
261 />
262 {:else}
263 <ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
264 {/if}
265
266 {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
267 <ChatMessageStatistics
268 promptTokens={message.timings.prompt_n}
269 promptMs={message.timings.prompt_ms}
270 predictedTokens={message.timings.predicted_n}
271 predictedMs={message.timings.predicted_ms}
272 />
273 {:else if isLoading() && currentConfig.showMessageStats}
274 {@const liveStats = processingState.getLiveProcessingStats()}
275 {@const genStats = processingState.getLiveGenerationStats()}
276 {@const promptProgress = processingState.processingState?.promptProgress}
277 {@const isStillProcessingPrompt =
278 promptProgress && promptProgress.processed < promptProgress.total}
279
280 {#if liveStats || genStats}
281 <ChatMessageStatistics
282 isLive={true}
283 isProcessingPrompt={!!isStillProcessingPrompt}
284 promptTokens={liveStats?.tokensProcessed}
285 promptMs={liveStats?.timeMs}
286 predictedTokens={genStats?.tokensGenerated}
287 predictedMs={genStats?.timeMs}
288 />
289 {/if}
290 {/if}
291 </div>
292 {/if}
293
294 {#if config().showToolCalls}
295 {#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
296 <span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
297 <span class="inline-flex items-center gap-1">
298 <Wrench class="h-3.5 w-3.5" />
299
300 <span>Tool calls:</span>
301 </span>
302
303 {#if toolCalls && toolCalls.length > 0}
304 {#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
305 {@const badge = formatToolCallBadge(toolCall, index)}
306 <button
307 type="button"
308 class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
309 title={badge.tooltip}
310 aria-label={`Copy tool call ${badge.label}`}
311 onclick={() => handleCopyToolCall(badge.copyValue)}
312 >
313 {badge.label}
314 <CopyToClipboardIcon
315 text={badge.copyValue}
316 ariaLabel={`Copy tool call ${badge.label}`}
317 />
318 </button>
319 {/each}
320 {:else if fallbackToolCalls}
321 <button
322 type="button"
323 class="tool-call-badge tool-call-badge--fallback inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
324 title={fallbackToolCalls}
325 aria-label="Copy tool call payload"
326 onclick={() => handleCopyToolCall(fallbackToolCalls)}
327 >
328 {fallbackToolCalls}
329 <CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" />
330 </button>
331 {/if}
332 </span>
333 {/if}
334 {/if}
335 </div>
336
337 {#if message.timestamp && !isEditing}
338 <ChatMessageActions
339 role="assistant"
340 justify="start"
341 actionsPosition="left"
342 {siblingInfo}
343 {showDeleteDialog}
344 {deletionInfo}
345 {onCopy}
346 {onEdit}
347 {onRegenerate}
348 onContinue={currentConfig.enableContinueGeneration && !thinkingContent
349 ? onContinue
350 : undefined}
351 {onDelete}
352 {onConfirmDelete}
353 {onNavigateToSibling}
354 {onShowDeleteDialogChange}
355 />
356 {/if}
357</div>
358
359<style>
360 .processing-container {
361 display: flex;
362 flex-direction: column;
363 align-items: flex-start;
364 gap: 0.5rem;
365 }
366
367 .processing-text {
368 background: linear-gradient(
369 90deg,
370 var(--muted-foreground),
371 var(--foreground),
372 var(--muted-foreground)
373 );
374 background-size: 200% 100%;
375 background-clip: text;
376 -webkit-background-clip: text;
377 -webkit-text-fill-color: transparent;
378 animation: shine 1s linear infinite;
379 font-weight: 500;
380 font-size: 0.875rem;
381 }
382
383 @keyframes shine {
384 to {
385 background-position: -200% 0;
386 }
387 }
388
389 .raw-output {
390 width: 100%;
391 max-width: 48rem;
392 margin-top: 1.5rem;
393 padding: 1rem 1.25rem;
394 border-radius: 1rem;
395 background: hsl(var(--muted) / 0.3);
396 color: var(--foreground);
397 font-family:
398 ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
399 'Liberation Mono', Menlo, monospace;
400 font-size: 0.875rem;
401 line-height: 1.6;
402 white-space: pre-wrap;
403 word-break: break-word;
404 }
405
406 .tool-call-badge {
407 max-width: 12rem;
408 white-space: nowrap;
409 overflow: hidden;
410 text-overflow: ellipsis;
411 }
412
413 .tool-call-badge--fallback {
414 max-width: 20rem;
415 white-space: normal;
416 word-break: break-word;
417 }
418</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte
new file mode 100644
index 0000000..7420bb1
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte
@@ -0,0 +1,84 @@
1<script lang="ts">
2 import { ChevronLeft, ChevronRight } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import * as Tooltip from '$lib/components/ui/tooltip';
5
6 interface Props {
7 class?: string;
8 siblingInfo: ChatMessageSiblingInfo | null;
9 onNavigateToSibling?: (siblingId: string) => void;
10 }
11
12 let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props();
13
14 let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0);
15 let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1);
16 let nextSiblingId = $derived(
17 hasNext ? siblingInfo!.siblingIds[siblingInfo!.currentIndex + 1] : null
18 );
19 let previousSiblingId = $derived(
20 hasPrevious ? siblingInfo!.siblingIds[siblingInfo!.currentIndex - 1] : null
21 );
22
23 function handleNext() {
24 if (nextSiblingId) {
25 onNavigateToSibling?.(nextSiblingId);
26 }
27 }
28
29 function handlePrevious() {
30 if (previousSiblingId) {
31 onNavigateToSibling?.(previousSiblingId);
32 }
33 }
34</script>
35
36{#if siblingInfo && siblingInfo.totalSiblings > 1}
37 <div
38 aria-label="Message version {siblingInfo.currentIndex + 1} of {siblingInfo.totalSiblings}"
39 class="flex items-center gap-1 text-xs text-muted-foreground {className}"
40 role="navigation"
41 >
42 <Tooltip.Root>
43 <Tooltip.Trigger>
44 <Button
45 aria-label="Previous message version"
46 class="h-5 w-5 p-0 {!hasPrevious ? 'cursor-not-allowed opacity-30' : ''}"
47 disabled={!hasPrevious}
48 onclick={handlePrevious}
49 size="sm"
50 variant="ghost"
51 >
52 <ChevronLeft class="h-3 w-3" />
53 </Button>
54 </Tooltip.Trigger>
55
56 <Tooltip.Content>
57 <p>Previous version</p>
58 </Tooltip.Content>
59 </Tooltip.Root>
60
61 <span class="px-1 font-mono text-xs">
62 {siblingInfo.currentIndex + 1}/{siblingInfo.totalSiblings}
63 </span>
64
65 <Tooltip.Root>
66 <Tooltip.Trigger>
67 <Button
68 aria-label="Next message version"
69 class="h-5 w-5 p-0 {!hasNext ? 'cursor-not-allowed opacity-30' : ''}"
70 disabled={!hasNext}
71 onclick={handleNext}
72 size="sm"
73 variant="ghost"
74 >
75 <ChevronRight class="h-3 w-3" />
76 </Button>
77 </Tooltip.Trigger>
78
79 <Tooltip.Content>
80 <p>Next version</p>
81 </Tooltip.Content>
82 </Tooltip.Root>
83 </div>
84{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte
new file mode 100644
index 0000000..f812ea2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte
@@ -0,0 +1,391 @@
1<script lang="ts">
2 import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import { Switch } from '$lib/components/ui/switch';
5 import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
6 import { INPUT_CLASSES } from '$lib/constants/input-classes';
7 import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
8 import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums';
9 import { config } from '$lib/stores/settings.svelte';
10 import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
11 import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte';
12 import { conversationsStore } from '$lib/stores/conversations.svelte';
13 import { modelsStore } from '$lib/stores/models.svelte';
14 import { isRouterMode } from '$lib/stores/server.svelte';
15 import {
16 autoResizeTextarea,
17 getFileTypeCategory,
18 getFileTypeCategoryByExtension,
19 parseClipboardContent
20 } from '$lib/utils';
21
22 interface Props {
23 messageId: string;
24 editedContent: string;
25 editedExtras?: DatabaseMessageExtra[];
26 editedUploadedFiles?: ChatUploadedFile[];
27 originalContent: string;
28 originalExtras?: DatabaseMessageExtra[];
29 showSaveOnlyOption?: boolean;
30 onCancelEdit: () => void;
31 onSaveEdit: () => void;
32 onSaveEditOnly?: () => void;
33 onEditKeydown: (event: KeyboardEvent) => void;
34 onEditedContentChange: (content: string) => void;
35 onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
36 onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
37 textareaElement?: HTMLTextAreaElement;
38 }
39
40 let {
41 messageId,
42 editedContent,
43 editedExtras = [],
44 editedUploadedFiles = [],
45 originalContent,
46 originalExtras = [],
47 showSaveOnlyOption = false,
48 onCancelEdit,
49 onSaveEdit,
50 onSaveEditOnly,
51 onEditKeydown,
52 onEditedContentChange,
53 onEditedExtrasChange,
54 onEditedUploadedFilesChange,
55 textareaElement = $bindable()
56 }: Props = $props();
57
58 let fileInputElement: HTMLInputElement | undefined = $state();
59 let saveWithoutRegenerate = $state(false);
60 let showDiscardDialog = $state(false);
61 let isRouter = $derived(isRouterMode());
62 let currentConfig = $derived(config());
63
64 let pasteLongTextToFileLength = $derived.by(() => {
65 const n = Number(currentConfig.pasteLongTextToFileLen);
66
67 return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
68 });
69
70 let hasUnsavedChanges = $derived.by(() => {
71 if (editedContent !== originalContent) return true;
72 if (editedUploadedFiles.length > 0) return true;
73
74 const extrasChanged =
75 editedExtras.length !== originalExtras.length ||
76 editedExtras.some((extra, i) => extra !== originalExtras[i]);
77
78 if (extrasChanged) return true;
79
80 return false;
81 });
82
83 let hasAttachments = $derived(
84 (editedExtras && editedExtras.length > 0) ||
85 (editedUploadedFiles && editedUploadedFiles.length > 0)
86 );
87
88 let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
89
90 function getEditedAttachmentsModalities(): ModelModalities {
91 const modalities: ModelModalities = { vision: false, audio: false };
92
93 for (const extra of editedExtras) {
94 if (extra.type === AttachmentType.IMAGE) {
95 modalities.vision = true;
96 }
97
98 if (
99 extra.type === AttachmentType.PDF &&
100 'processedAsImages' in extra &&
101 extra.processedAsImages
102 ) {
103 modalities.vision = true;
104 }
105
106 if (extra.type === AttachmentType.AUDIO) {
107 modalities.audio = true;
108 }
109 }
110
111 for (const file of editedUploadedFiles) {
112 const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
113 if (category === FileTypeCategory.IMAGE) {
114 modalities.vision = true;
115 }
116 if (category === FileTypeCategory.AUDIO) {
117 modalities.audio = true;
118 }
119 }
120
121 return modalities;
122 }
123
124 function getRequiredModalities(): ModelModalities {
125 const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
126 const editedModalities = getEditedAttachmentsModalities();
127
128 return {
129 vision: beforeModalities.vision || editedModalities.vision,
130 audio: beforeModalities.audio || editedModalities.audio
131 };
132 }
133
134 const { handleModelChange } = useModelChangeValidation({
135 getRequiredModalities,
136 onValidationFailure: async (previousModelId) => {
137 if (previousModelId) {
138 await modelsStore.selectModelById(previousModelId);
139 }
140 }
141 });
142
143 function handleFileInputChange(event: Event) {
144 const input = event.target as HTMLInputElement;
145 if (!input.files || input.files.length === 0) return;
146
147 const files = Array.from(input.files);
148
149 processNewFiles(files);
150 input.value = '';
151 }
152
153 function handleGlobalKeydown(event: KeyboardEvent) {
154 if (event.key === 'Escape') {
155 event.preventDefault();
156 attemptCancel();
157 }
158 }
159
160 function attemptCancel() {
161 if (hasUnsavedChanges) {
162 showDiscardDialog = true;
163 } else {
164 onCancelEdit();
165 }
166 }
167
168 function handleRemoveExistingAttachment(index: number) {
169 if (!onEditedExtrasChange) return;
170
171 const newExtras = [...editedExtras];
172
173 newExtras.splice(index, 1);
174 onEditedExtrasChange(newExtras);
175 }
176
177 function handleRemoveUploadedFile(fileId: string) {
178 if (!onEditedUploadedFilesChange) return;
179
180 const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
181
182 onEditedUploadedFilesChange(newFiles);
183 }
184
185 function handleSubmit() {
186 if (!canSubmit) return;
187
188 if (saveWithoutRegenerate && onSaveEditOnly) {
189 onSaveEditOnly();
190 } else {
191 onSaveEdit();
192 }
193
194 saveWithoutRegenerate = false;
195 }
196
197 async function processNewFiles(files: File[]) {
198 if (!onEditedUploadedFilesChange) return;
199
200 const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
201 const processed = await processFilesToChatUploaded(files);
202
203 onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
204 }
205
206 function handlePaste(event: ClipboardEvent) {
207 if (!event.clipboardData) return;
208
209 const files = Array.from(event.clipboardData.items)
210 .filter((item) => item.kind === 'file')
211 .map((item) => item.getAsFile())
212 .filter((file): file is File => file !== null);
213
214 if (files.length > 0) {
215 event.preventDefault();
216 processNewFiles(files);
217
218 return;
219 }
220
221 const text = event.clipboardData.getData(MimeTypeText.PLAIN);
222
223 if (text.startsWith('"')) {
224 const parsed = parseClipboardContent(text);
225
226 if (parsed.textAttachments.length > 0) {
227 event.preventDefault();
228 onEditedContentChange(parsed.message);
229
230 const attachmentFiles = parsed.textAttachments.map(
231 (att) =>
232 new File([att.content], att.name, {
233 type: MimeTypeText.PLAIN
234 })
235 );
236
237 processNewFiles(attachmentFiles);
238
239 setTimeout(() => {
240 textareaElement?.focus();
241 }, 10);
242
243 return;
244 }
245 }
246
247 if (
248 text.length > 0 &&
249 pasteLongTextToFileLength > 0 &&
250 text.length > pasteLongTextToFileLength
251 ) {
252 event.preventDefault();
253
254 const textFile = new File([text], 'Pasted', {
255 type: MimeTypeText.PLAIN
256 });
257
258 processNewFiles([textFile]);
259 }
260 }
261
262 $effect(() => {
263 if (textareaElement) {
264 autoResizeTextarea(textareaElement);
265 }
266 });
267
268 $effect(() => {
269 setEditModeActive(processNewFiles);
270
271 return () => {
272 clearEditMode();
273 };
274 });
275</script>
276
277<svelte:window onkeydown={handleGlobalKeydown} />
278
279<input
280 bind:this={fileInputElement}
281 type="file"
282 multiple
283 class="hidden"
284 onchange={handleFileInputChange}
285/>
286
287<div
288 class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
289 data-slot="edit-form"
290>
291 <ChatAttachmentsList
292 attachments={editedExtras}
293 uploadedFiles={editedUploadedFiles}
294 readonly={false}
295 onFileRemove={(fileId) => {
296 if (fileId.startsWith('attachment-')) {
297 const index = parseInt(fileId.replace('attachment-', ''), 10);
298 if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
299 handleRemoveExistingAttachment(index);
300 }
301 } else {
302 handleRemoveUploadedFile(fileId);
303 }
304 }}
305 limitToSingleRow
306 class="py-5"
307 style="scroll-padding: 1rem;"
308 />
309
310 <div class="relative min-h-[48px] px-5 py-3">
311 <textarea
312 bind:this={textareaElement}
313 bind:value={editedContent}
314 class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
315 onkeydown={onEditKeydown}
316 oninput={(e) => {
317 autoResizeTextarea(e.currentTarget);
318 onEditedContentChange(e.currentTarget.value);
319 }}
320 onpaste={handlePaste}
321 placeholder="Edit your message..."
322 ></textarea>
323
324 <div class="flex w-full items-center gap-3" style="container-type: inline-size">
325 <Button
326 class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
327 onclick={() => fileInputElement?.click()}
328 type="button"
329 title="Add attachment"
330 >
331 <span class="sr-only">Attach files</span>
332
333 <Paperclip class="h-4 w-4" />
334 </Button>
335
336 <div class="flex-1"></div>
337
338 {#if isRouter}
339 <ModelsSelector
340 forceForegroundText={true}
341 useGlobalSelection={true}
342 onModelChange={handleModelChange}
343 />
344 {/if}
345
346 <Button
347 class="h-8 w-8 shrink-0 rounded-full p-0"
348 onclick={handleSubmit}
349 disabled={!canSubmit}
350 type="button"
351 title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
352 >
353 <span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
354
355 <ArrowUp class="h-5 w-5" />
356 </Button>
357 </div>
358 </div>
359</div>
360
361<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
362 {#if showSaveOnlyOption && onSaveEditOnly}
363 <div class="flex items-center gap-2">
364 <Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
365
366 <label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground">
367 Update without re-sending
368 </label>
369 </div>
370 {:else}
371 <div></div>
372 {/if}
373
374 <Button class="h-7 px-3 text-xs" onclick={attemptCancel} size="sm" variant="ghost">
375 <X class="mr-1 h-3 w-3" />
376
377 Cancel
378 </Button>
379</div>
380
381<DialogConfirmation
382 bind:open={showDiscardDialog}
383 title="Discard changes?"
384 description="You have unsaved changes. Are you sure you want to discard them?"
385 confirmText="Discard"
386 cancelText="Keep editing"
387 variant="destructive"
388 icon={AlertTriangle}
389 onConfirm={onCancelEdit}
390 onCancel={() => (showDiscardDialog = false)}
391/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
new file mode 100644
index 0000000..24fe592
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
@@ -0,0 +1,175 @@
1<script lang="ts">
2 import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
3 import { BadgeChatStatistic } from '$lib/components/app';
4 import * as Tooltip from '$lib/components/ui/tooltip';
5 import { ChatMessageStatsView } from '$lib/enums';
6
7 interface Props {
8 predictedTokens?: number;
9 predictedMs?: number;
10 promptTokens?: number;
11 promptMs?: number;
12 // Live mode: when true, shows stats during streaming
13 isLive?: boolean;
14 // Whether prompt processing is still in progress
15 isProcessingPrompt?: boolean;
16 // Initial view to show (defaults to READING in live mode)
17 initialView?: ChatMessageStatsView;
18 }
19
20 let {
21 predictedTokens,
22 predictedMs,
23 promptTokens,
24 promptMs,
25 isLive = false,
26 isProcessingPrompt = false,
27 initialView = ChatMessageStatsView.GENERATION
28 }: Props = $props();
29
30 let activeView: ChatMessageStatsView = $state(initialView);
31 let hasAutoSwitchedToGeneration = $state(false);
32
33 // In live mode: auto-switch to GENERATION tab when prompt processing completes
34 $effect(() => {
35 if (isLive) {
36 // Auto-switch to generation tab only when prompt processing is done (once)
37 if (
38 !hasAutoSwitchedToGeneration &&
39 !isProcessingPrompt &&
40 predictedTokens &&
41 predictedTokens > 0
42 ) {
43 activeView = ChatMessageStatsView.GENERATION;
44 hasAutoSwitchedToGeneration = true;
45 } else if (!hasAutoSwitchedToGeneration) {
46 // Stay on READING while prompt is still being processed
47 activeView = ChatMessageStatsView.READING;
48 }
49 }
50 });
51
52 let hasGenerationStats = $derived(
53 predictedTokens !== undefined &&
54 predictedTokens > 0 &&
55 predictedMs !== undefined &&
56 predictedMs > 0
57 );
58
59 let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
60 let timeInSeconds = $derived(
61 predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00'
62 );
63
64 let promptTokensPerSecond = $derived(
65 promptTokens !== undefined && promptMs !== undefined && promptMs > 0
66 ? (promptTokens / promptMs) * 1000
67 : undefined
68 );
69
70 let promptTimeInSeconds = $derived(
71 promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined
72 );
73
74 let hasPromptStats = $derived(
75 promptTokens !== undefined &&
76 promptMs !== undefined &&
77 promptTokensPerSecond !== undefined &&
78 promptTimeInSeconds !== undefined
79 );
80
81 // In live mode, generation tab is disabled until we have generation stats
82 let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
83</script>
84
85<div class="inline-flex items-center text-xs text-muted-foreground">
86 <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
87 {#if hasPromptStats || isLive}
88 <Tooltip.Root>
89 <Tooltip.Trigger>
90 <button
91 type="button"
92 class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
93 ChatMessageStatsView.READING
94 ? 'bg-background text-foreground shadow-sm'
95 : 'hover:text-foreground'}"
96 onclick={() => (activeView = ChatMessageStatsView.READING)}
97 >
98 <BookOpenText class="h-3 w-3" />
99 <span class="sr-only">Reading</span>
100 </button>
101 </Tooltip.Trigger>
102 <Tooltip.Content>
103 <p>Reading (prompt processing)</p>
104 </Tooltip.Content>
105 </Tooltip.Root>
106 {/if}
107 <Tooltip.Root>
108 <Tooltip.Trigger>
109 <button
110 type="button"
111 class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
112 ChatMessageStatsView.GENERATION
113 ? 'bg-background text-foreground shadow-sm'
114 : isGenerationDisabled
115 ? 'cursor-not-allowed opacity-40'
116 : 'hover:text-foreground'}"
117 onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)}
118 disabled={isGenerationDisabled}
119 >
120 <Sparkles class="h-3 w-3" />
121 <span class="sr-only">Generation</span>
122 </button>
123 </Tooltip.Trigger>
124 <Tooltip.Content>
125 <p>
126 {isGenerationDisabled
127 ? 'Generation (waiting for tokens...)'
128 : 'Generation (token output)'}
129 </p>
130 </Tooltip.Content>
131 </Tooltip.Root>
132 </div>
133
134 <div class="flex items-center gap-1 px-2">
135 {#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
136 <BadgeChatStatistic
137 class="bg-transparent"
138 icon={WholeWord}
139 value="{predictedTokens?.toLocaleString()} tokens"
140 tooltipLabel="Generated tokens"
141 />
142 <BadgeChatStatistic
143 class="bg-transparent"
144 icon={Clock}
145 value="{timeInSeconds}s"
146 tooltipLabel="Generation time"
147 />
148 <BadgeChatStatistic
149 class="bg-transparent"
150 icon={Gauge}
151 value="{tokensPerSecond.toFixed(2)} tokens/s"
152 tooltipLabel="Generation speed"
153 />
154 {:else if hasPromptStats}
155 <BadgeChatStatistic
156 class="bg-transparent"
157 icon={WholeWord}
158 value="{promptTokens} tokens"
159 tooltipLabel="Prompt tokens"
160 />
161 <BadgeChatStatistic
162 class="bg-transparent"
163 icon={Clock}
164 value="{promptTimeInSeconds}s"
165 tooltipLabel="Prompt processing time"
166 />
167 <BadgeChatStatistic
168 class="bg-transparent"
169 icon={Gauge}
170 value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
171 tooltipLabel="Prompt processing speed"
172 />
173 {/if}
174 </div>
175</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte
new file mode 100644
index 0000000..c203822
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte
@@ -0,0 +1,216 @@
1<script lang="ts">
2 import { Check, X } from '@lucide/svelte';
3 import { Card } from '$lib/components/ui/card';
4 import { Button } from '$lib/components/ui/button';
5 import { MarkdownContent } from '$lib/components/app';
6 import { INPUT_CLASSES } from '$lib/constants/input-classes';
7 import { config } from '$lib/stores/settings.svelte';
8 import ChatMessageActions from './ChatMessageActions.svelte';
9
10 interface Props {
11 class?: string;
12 message: DatabaseMessage;
13 isEditing: boolean;
14 editedContent: string;
15 siblingInfo?: ChatMessageSiblingInfo | null;
16 showDeleteDialog: boolean;
17 deletionInfo: {
18 totalCount: number;
19 userMessages: number;
20 assistantMessages: number;
21 messageTypes: string[];
22 } | null;
23 onCancelEdit: () => void;
24 onSaveEdit: () => void;
25 onEditKeydown: (event: KeyboardEvent) => void;
26 onEditedContentChange: (content: string) => void;
27 onCopy: () => void;
28 onEdit: () => void;
29 onDelete: () => void;
30 onConfirmDelete: () => void;
31 onNavigateToSibling?: (siblingId: string) => void;
32 onShowDeleteDialogChange: (show: boolean) => void;
33 textareaElement?: HTMLTextAreaElement;
34 }
35
36 let {
37 class: className = '',
38 message,
39 isEditing,
40 editedContent,
41 siblingInfo = null,
42 showDeleteDialog,
43 deletionInfo,
44 onCancelEdit,
45 onSaveEdit,
46 onEditKeydown,
47 onEditedContentChange,
48 onCopy,
49 onEdit,
50 onDelete,
51 onConfirmDelete,
52 onNavigateToSibling,
53 onShowDeleteDialogChange,
54 textareaElement = $bindable()
55 }: Props = $props();
56
57 let isMultiline = $state(false);
58 let messageElement: HTMLElement | undefined = $state();
59 let isExpanded = $state(false);
60 let contentHeight = $state(0);
61 const MAX_HEIGHT = 200; // pixels
62 const currentConfig = config();
63
64 let showExpandButton = $derived(contentHeight > MAX_HEIGHT);
65
66 $effect(() => {
67 if (!messageElement || !message.content.trim()) return;
68
69 if (message.content.includes('\n')) {
70 isMultiline = true;
71 }
72
73 const resizeObserver = new ResizeObserver((entries) => {
74 for (const entry of entries) {
75 const element = entry.target as HTMLElement;
76 const estimatedSingleLineHeight = 24;
77
78 isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
79 contentHeight = element.scrollHeight;
80 }
81 });
82
83 resizeObserver.observe(messageElement);
84
85 return () => {
86 resizeObserver.disconnect();
87 };
88 });
89
90 function toggleExpand() {
91 isExpanded = !isExpanded;
92 }
93</script>
94
95<div
96 aria-label="System message with actions"
97 class="group flex flex-col items-end gap-3 md:gap-2 {className}"
98 role="group"
99>
100 {#if isEditing}
101 <div class="w-full max-w-[80%]">
102 <textarea
103 bind:this={textareaElement}
104 bind:value={editedContent}
105 class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
106 onkeydown={onEditKeydown}
107 oninput={(e) => onEditedContentChange(e.currentTarget.value)}
108 placeholder="Edit system message..."
109 ></textarea>
110
111 <div class="mt-2 flex justify-end gap-2">
112 <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
113 <X class="mr-1 h-3 w-3" />
114 Cancel
115 </Button>
116
117 <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
118 <Check class="mr-1 h-3 w-3" />
119 Send
120 </Button>
121 </div>
122 </div>
123 {:else}
124 {#if message.content.trim()}
125 <div class="relative max-w-[80%]">
126 <button
127 class="group/expand w-full text-left {!isExpanded && showExpandButton
128 ? 'cursor-pointer'
129 : 'cursor-auto'}"
130 onclick={showExpandButton && !isExpanded ? toggleExpand : undefined}
131 type="button"
132 >
133 <Card
134 class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
135 data-multiline={isMultiline ? '' : undefined}
136 style="border: 2px dashed hsl(var(--border));"
137 >
138 <div
139 class="relative overflow-hidden transition-all duration-300 {isExpanded
140 ? 'cursor-text select-text'
141 : 'select-none'}"
142 style={!isExpanded && showExpandButton
143 ? `max-height: ${MAX_HEIGHT}px;`
144 : 'max-height: none;'}
145 >
146 {#if currentConfig.renderUserContentAsMarkdown}
147 <div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
148 <MarkdownContent class="markdown-system-content" content={message.content} />
149 </div>
150 {:else}
151 <span
152 bind:this={messageElement}
153 class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}"
154 >
155 {message.content}
156 </span>
157 {/if}
158
159 {#if !isExpanded && showExpandButton}
160 <div
161 class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
162 ></div>
163 <div
164 class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
165 >
166 <Button
167 class="rounded-full px-4 py-1.5 text-xs shadow-md"
168 size="sm"
169 variant="outline"
170 >
171 Show full system message
172 </Button>
173 </div>
174 {/if}
175 </div>
176
177 {#if isExpanded && showExpandButton}
178 <div class="mb-2 flex justify-center">
179 <Button
180 class="rounded-full px-4 py-1.5 text-xs"
181 onclick={(e) => {
182 e.stopPropagation();
183 toggleExpand();
184 }}
185 size="sm"
186 variant="outline"
187 >
188 Collapse System Message
189 </Button>
190 </div>
191 {/if}
192 </Card>
193 </button>
194 </div>
195 {/if}
196
197 {#if message.timestamp}
198 <div class="max-w-[80%]">
199 <ChatMessageActions
200 actionsPosition="right"
201 {deletionInfo}
202 justify="end"
203 {onConfirmDelete}
204 {onCopy}
205 {onDelete}
206 {onEdit}
207 {onNavigateToSibling}
208 {onShowDeleteDialogChange}
209 {siblingInfo}
210 {showDeleteDialog}
211 role="user"
212 />
213 </div>
214 {/if}
215 {/if}
216</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte
new file mode 100644
index 0000000..9245ad5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte
@@ -0,0 +1,68 @@
1<script lang="ts">
2 import { Brain } from '@lucide/svelte';
3 import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
4 import * as Collapsible from '$lib/components/ui/collapsible/index.js';
5 import { buttonVariants } from '$lib/components/ui/button/index.js';
6 import { Card } from '$lib/components/ui/card';
7 import { config } from '$lib/stores/settings.svelte';
8
9 interface Props {
10 class?: string;
11 hasRegularContent?: boolean;
12 isStreaming?: boolean;
13 reasoningContent: string | null;
14 }
15
16 let {
17 class: className = '',
18 hasRegularContent = false,
19 isStreaming = false,
20 reasoningContent
21 }: Props = $props();
22
23 const currentConfig = config();
24
25 let isExpanded = $state(currentConfig.showThoughtInProgress);
26
27 $effect(() => {
28 if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) {
29 isExpanded = false;
30 }
31 });
32</script>
33
34<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
35 <Card class="gap-0 border-muted bg-muted/30 py-0">
36 <Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
37 <div class="flex items-center gap-2 text-muted-foreground">
38 <Brain class="h-4 w-4" />
39
40 <span class="text-sm font-medium">
41 {isStreaming ? 'Reasoning...' : 'Reasoning'}
42 </span>
43 </div>
44
45 <div
46 class={buttonVariants({
47 variant: 'ghost',
48 size: 'sm',
49 class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
50 })}
51 >
52 <ChevronsUpDownIcon class="h-4 w-4" />
53
54 <span class="sr-only">Toggle reasoning content</span>
55 </div>
56 </Collapsible.Trigger>
57
58 <Collapsible.Content>
59 <div class="border-t border-muted px-3 pb-3">
60 <div class="pt-3">
61 <div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
62 {reasoningContent ?? ''}
63 </div>
64 </div>
65 </div>
66 </Collapsible.Content>
67 </Card>
68</Collapsible.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
new file mode 100644
index 0000000..041c6bd
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
@@ -0,0 +1,163 @@
1<script lang="ts">
2 import { Card } from '$lib/components/ui/card';
3 import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
4 import { config } from '$lib/stores/settings.svelte';
5 import ChatMessageActions from './ChatMessageActions.svelte';
6 import ChatMessageEditForm from './ChatMessageEditForm.svelte';
7
8 interface Props {
9 class?: string;
10 message: DatabaseMessage;
11 isEditing: boolean;
12 editedContent: string;
13 editedExtras?: DatabaseMessageExtra[];
14 editedUploadedFiles?: ChatUploadedFile[];
15 siblingInfo?: ChatMessageSiblingInfo | null;
16 showDeleteDialog: boolean;
17 deletionInfo: {
18 totalCount: number;
19 userMessages: number;
20 assistantMessages: number;
21 messageTypes: string[];
22 } | null;
23 onCancelEdit: () => void;
24 onSaveEdit: () => void;
25 onSaveEditOnly?: () => void;
26 onEditKeydown: (event: KeyboardEvent) => void;
27 onEditedContentChange: (content: string) => void;
28 onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
29 onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
30 onCopy: () => void;
31 onEdit: () => void;
32 onDelete: () => void;
33 onConfirmDelete: () => void;
34 onNavigateToSibling?: (siblingId: string) => void;
35 onShowDeleteDialogChange: (show: boolean) => void;
36 textareaElement?: HTMLTextAreaElement;
37 }
38
39 let {
40 class: className = '',
41 message,
42 isEditing,
43 editedContent,
44 editedExtras = [],
45 editedUploadedFiles = [],
46 siblingInfo = null,
47 showDeleteDialog,
48 deletionInfo,
49 onCancelEdit,
50 onSaveEdit,
51 onSaveEditOnly,
52 onEditKeydown,
53 onEditedContentChange,
54 onEditedExtrasChange,
55 onEditedUploadedFilesChange,
56 onCopy,
57 onEdit,
58 onDelete,
59 onConfirmDelete,
60 onNavigateToSibling,
61 onShowDeleteDialogChange,
62 textareaElement = $bindable()
63 }: Props = $props();
64
65 let isMultiline = $state(false);
66 let messageElement: HTMLElement | undefined = $state();
67 const currentConfig = config();
68
69 $effect(() => {
70 if (!messageElement || !message.content.trim()) return;
71
72 if (message.content.includes('\n')) {
73 isMultiline = true;
74 return;
75 }
76
77 const resizeObserver = new ResizeObserver((entries) => {
78 for (const entry of entries) {
79 const element = entry.target as HTMLElement;
80 const estimatedSingleLineHeight = 24; // Typical line height for text-md
81
82 isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
83 }
84 });
85
86 resizeObserver.observe(messageElement);
87
88 return () => {
89 resizeObserver.disconnect();
90 };
91 });
92</script>
93
94<div
95 aria-label="User message with actions"
96 class="group flex flex-col items-end gap-3 md:gap-2 {className}"
97 role="group"
98>
99 {#if isEditing}
100 <ChatMessageEditForm
101 bind:textareaElement
102 messageId={message.id}
103 {editedContent}
104 {editedExtras}
105 {editedUploadedFiles}
106 originalContent={message.content}
107 originalExtras={message.extra}
108 showSaveOnlyOption={!!onSaveEditOnly}
109 {onCancelEdit}
110 {onSaveEdit}
111 {onSaveEditOnly}
112 {onEditKeydown}
113 {onEditedContentChange}
114 {onEditedExtrasChange}
115 {onEditedUploadedFilesChange}
116 />
117 {:else}
118 {#if message.extra && message.extra.length > 0}
119 <div class="mb-2 max-w-[80%]">
120 <ChatAttachmentsList attachments={message.extra} readonly={true} imageHeight="h-80" />
121 </div>
122 {/if}
123
124 {#if message.content.trim()}
125 <Card
126 class="max-w-[80%] rounded-[1.125rem] border-none bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5"
127 data-multiline={isMultiline ? '' : undefined}
128 >
129 {#if currentConfig.renderUserContentAsMarkdown}
130 <div bind:this={messageElement} class="text-md">
131 <MarkdownContent
132 class="markdown-user-content text-primary-foreground"
133 content={message.content}
134 />
135 </div>
136 {:else}
137 <span bind:this={messageElement} class="text-md whitespace-pre-wrap">
138 {message.content}
139 </span>
140 {/if}
141 </Card>
142 {/if}
143
144 {#if message.timestamp}
145 <div class="max-w-[80%]">
146 <ChatMessageActions
147 actionsPosition="right"
148 {deletionInfo}
149 justify="end"
150 {onConfirmDelete}
151 {onCopy}
152 {onDelete}
153 {onEdit}
154 {onNavigateToSibling}
155 {onShowDeleteDialogChange}
156 {siblingInfo}
157 {showDeleteDialog}
158 role="user"
159 />
160 </div>
161 {/if}
162 {/if}
163</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
new file mode 100644
index 0000000..c203f10
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
@@ -0,0 +1,143 @@
1<script lang="ts">
2 import { ChatMessage } from '$lib/components/app';
3 import { chatStore } from '$lib/stores/chat.svelte';
4 import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
5 import { config } from '$lib/stores/settings.svelte';
6 import { getMessageSiblings } from '$lib/utils';
7
8 interface Props {
9 class?: string;
10 messages?: DatabaseMessage[];
11 onUserAction?: () => void;
12 }
13
14 let { class: className, messages = [], onUserAction }: Props = $props();
15
16 let allConversationMessages = $state<DatabaseMessage[]>([]);
17 const currentConfig = config();
18
19 function refreshAllMessages() {
20 const conversation = activeConversation();
21
22 if (conversation) {
23 conversationsStore.getConversationMessages(conversation.id).then((messages) => {
24 allConversationMessages = messages;
25 });
26 } else {
27 allConversationMessages = [];
28 }
29 }
30
31 // Single effect that tracks both conversation and message changes
32 $effect(() => {
33 const conversation = activeConversation();
34
35 if (conversation) {
36 refreshAllMessages();
37 }
38 });
39
40 let displayMessages = $derived.by(() => {
41 if (!messages.length) {
42 return [];
43 }
44
45 // Filter out system messages if showSystemMessage is false
46 const filteredMessages = currentConfig.showSystemMessage
47 ? messages
48 : messages.filter((msg) => msg.type !== 'system');
49
50 return filteredMessages.map((message) => {
51 const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
52
53 return {
54 message,
55 siblingInfo: siblingInfo || {
56 message,
57 siblingIds: [message.id],
58 currentIndex: 0,
59 totalSiblings: 1
60 }
61 };
62 });
63 });
64
65 async function handleNavigateToSibling(siblingId: string) {
66 await conversationsStore.navigateToSibling(siblingId);
67 }
68
69 async function handleEditWithBranching(
70 message: DatabaseMessage,
71 newContent: string,
72 newExtras?: DatabaseMessageExtra[]
73 ) {
74 onUserAction?.();
75
76 await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
77
78 refreshAllMessages();
79 }
80
81 async function handleEditWithReplacement(
82 message: DatabaseMessage,
83 newContent: string,
84 shouldBranch: boolean
85 ) {
86 onUserAction?.();
87
88 await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
89
90 refreshAllMessages();
91 }
92
93 async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
94 onUserAction?.();
95
96 await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
97
98 refreshAllMessages();
99 }
100
101 async function handleContinueAssistantMessage(message: DatabaseMessage) {
102 onUserAction?.();
103
104 await chatStore.continueAssistantMessage(message.id);
105
106 refreshAllMessages();
107 }
108
109 async function handleEditUserMessagePreserveResponses(
110 message: DatabaseMessage,
111 newContent: string,
112 newExtras?: DatabaseMessageExtra[]
113 ) {
114 onUserAction?.();
115
116 await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
117
118 refreshAllMessages();
119 }
120
121 async function handleDeleteMessage(message: DatabaseMessage) {
122 await chatStore.deleteMessage(message.id);
123
124 refreshAllMessages();
125 }
126</script>
127
128<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
129 {#each displayMessages as { message, siblingInfo } (message.id)}
130 <ChatMessage
131 class="mx-auto w-full max-w-[48rem]"
132 {message}
133 {siblingInfo}
134 onDelete={handleDeleteMessage}
135 onNavigateToSibling={handleNavigateToSibling}
136 onEditWithBranching={handleEditWithBranching}
137 onEditWithReplacement={handleEditWithReplacement}
138 onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
139 onRegenerateWithBranching={handleRegenerateWithBranching}
140 onContinueAssistantMessage={handleContinueAssistantMessage}
141 />
142 {/each}
143</div>
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>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
new file mode 100644
index 0000000..5a668aa
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
@@ -0,0 +1,508 @@
1<script lang="ts">
2 import {
3 Settings,
4 Funnel,
5 AlertTriangle,
6 Code,
7 Monitor,
8 Sun,
9 Moon,
10 ChevronLeft,
11 ChevronRight,
12 Database
13 } from '@lucide/svelte';
14 import {
15 ChatSettingsFooter,
16 ChatSettingsImportExportTab,
17 ChatSettingsFields
18 } from '$lib/components/app';
19 import { ScrollArea } from '$lib/components/ui/scroll-area';
20 import { config, settingsStore } from '$lib/stores/settings.svelte';
21 import { setMode } from 'mode-watcher';
22 import type { Component } from 'svelte';
23
24 interface Props {
25 onSave?: () => void;
26 }
27
28 let { onSave }: Props = $props();
29
30 const settingSections: Array<{
31 fields: SettingsFieldConfig[];
32 icon: Component;
33 title: string;
34 }> = [
35 {
36 title: 'General',
37 icon: Settings,
38 fields: [
39 {
40 key: 'theme',
41 label: 'Theme',
42 type: 'select',
43 options: [
44 { value: 'system', label: 'System', icon: Monitor },
45 { value: 'light', label: 'Light', icon: Sun },
46 { value: 'dark', label: 'Dark', icon: Moon }
47 ]
48 },
49 { key: 'apiKey', label: 'API Key', type: 'input' },
50 {
51 key: 'systemMessage',
52 label: 'System Message',
53 type: 'textarea'
54 },
55 {
56 key: 'pasteLongTextToFileLen',
57 label: 'Paste long text to file length',
58 type: 'input'
59 },
60 {
61 key: 'copyTextAttachmentsAsPlainText',
62 label: 'Copy text attachments as plain text',
63 type: 'checkbox'
64 },
65 {
66 key: 'enableContinueGeneration',
67 label: 'Enable "Continue" button',
68 type: 'checkbox',
69 isExperimental: true
70 },
71 {
72 key: 'pdfAsImage',
73 label: 'Parse PDF as image',
74 type: 'checkbox'
75 },
76 {
77 key: 'askForTitleConfirmation',
78 label: 'Ask for confirmation before changing conversation title',
79 type: 'checkbox'
80 }
81 ]
82 },
83 {
84 title: 'Display',
85 icon: Monitor,
86 fields: [
87 {
88 key: 'showMessageStats',
89 label: 'Show message generation statistics',
90 type: 'checkbox'
91 },
92 {
93 key: 'showThoughtInProgress',
94 label: 'Show thought in progress',
95 type: 'checkbox'
96 },
97 {
98 key: 'keepStatsVisible',
99 label: 'Keep stats visible after generation',
100 type: 'checkbox'
101 },
102 {
103 key: 'autoMicOnEmpty',
104 label: 'Show microphone on empty input',
105 type: 'checkbox',
106 isExperimental: true
107 },
108 {
109 key: 'renderUserContentAsMarkdown',
110 label: 'Render user content as Markdown',
111 type: 'checkbox'
112 },
113 {
114 key: 'disableAutoScroll',
115 label: 'Disable automatic scroll',
116 type: 'checkbox'
117 },
118 {
119 key: 'alwaysShowSidebarOnDesktop',
120 label: 'Always show sidebar on desktop',
121 type: 'checkbox'
122 },
123 {
124 key: 'autoShowSidebarOnNewChat',
125 label: 'Auto-show sidebar on new chat',
126 type: 'checkbox'
127 }
128 ]
129 },
130 {
131 title: 'Sampling',
132 icon: Funnel,
133 fields: [
134 {
135 key: 'temperature',
136 label: 'Temperature',
137 type: 'input'
138 },
139 {
140 key: 'dynatemp_range',
141 label: 'Dynamic temperature range',
142 type: 'input'
143 },
144 {
145 key: 'dynatemp_exponent',
146 label: 'Dynamic temperature exponent',
147 type: 'input'
148 },
149 {
150 key: 'top_k',
151 label: 'Top K',
152 type: 'input'
153 },
154 {
155 key: 'top_p',
156 label: 'Top P',
157 type: 'input'
158 },
159 {
160 key: 'min_p',
161 label: 'Min P',
162 type: 'input'
163 },
164 {
165 key: 'xtc_probability',
166 label: 'XTC probability',
167 type: 'input'
168 },
169 {
170 key: 'xtc_threshold',
171 label: 'XTC threshold',
172 type: 'input'
173 },
174 {
175 key: 'typ_p',
176 label: 'Typical P',
177 type: 'input'
178 },
179 {
180 key: 'max_tokens',
181 label: 'Max tokens',
182 type: 'input'
183 },
184 {
185 key: 'samplers',
186 label: 'Samplers',
187 type: 'input'
188 },
189 {
190 key: 'backend_sampling',
191 label: 'Backend sampling',
192 type: 'checkbox'
193 }
194 ]
195 },
196 {
197 title: 'Penalties',
198 icon: AlertTriangle,
199 fields: [
200 {
201 key: 'repeat_last_n',
202 label: 'Repeat last N',
203 type: 'input'
204 },
205 {
206 key: 'repeat_penalty',
207 label: 'Repeat penalty',
208 type: 'input'
209 },
210 {
211 key: 'presence_penalty',
212 label: 'Presence penalty',
213 type: 'input'
214 },
215 {
216 key: 'frequency_penalty',
217 label: 'Frequency penalty',
218 type: 'input'
219 },
220 {
221 key: 'dry_multiplier',
222 label: 'DRY multiplier',
223 type: 'input'
224 },
225 {
226 key: 'dry_base',
227 label: 'DRY base',
228 type: 'input'
229 },
230 {
231 key: 'dry_allowed_length',
232 label: 'DRY allowed length',
233 type: 'input'
234 },
235 {
236 key: 'dry_penalty_last_n',
237 label: 'DRY penalty last N',
238 type: 'input'
239 }
240 ]
241 },
242 {
243 title: 'Import/Export',
244 icon: Database,
245 fields: []
246 },
247 {
248 title: 'Developer',
249 icon: Code,
250 fields: [
251 {
252 key: 'showToolCalls',
253 label: 'Show tool call labels',
254 type: 'checkbox'
255 },
256 {
257 key: 'disableReasoningFormat',
258 label: 'Show raw LLM output',
259 type: 'checkbox'
260 },
261 {
262 key: 'custom',
263 label: 'Custom JSON',
264 type: 'textarea'
265 }
266 ]
267 }
268 // TODO: Experimental features section will be implemented after initial release
269 // This includes Python interpreter (Pyodide integration) and other experimental features
270 // {
271 // title: 'Experimental',
272 // icon: Beaker,
273 // fields: [
274 // {
275 // key: 'pyInterpreterEnabled',
276 // label: 'Enable Python interpreter',
277 // type: 'checkbox'
278 // }
279 // ]
280 // }
281 ];
282
283 let activeSection = $state('General');
284 let currentSection = $derived(
285 settingSections.find((section) => section.title === activeSection) || settingSections[0]
286 );
287 let localConfig: SettingsConfigType = $state({ ...config() });
288
289 let canScrollLeft = $state(false);
290 let canScrollRight = $state(false);
291 let scrollContainer: HTMLDivElement | undefined = $state();
292
293 function handleThemeChange(newTheme: string) {
294 localConfig.theme = newTheme;
295
296 setMode(newTheme as 'light' | 'dark' | 'system');
297 }
298
299 function handleConfigChange(key: string, value: string | boolean) {
300 localConfig[key] = value;
301 }
302
303 function handleReset() {
304 localConfig = { ...config() };
305
306 setMode(localConfig.theme as 'light' | 'dark' | 'system');
307 }
308
309 function handleSave() {
310 if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
311 try {
312 JSON.parse(localConfig.custom);
313 } catch (error) {
314 alert('Invalid JSON in custom parameters. Please check the format and try again.');
315 console.error(error);
316 return;
317 }
318 }
319
320 // Convert numeric strings to numbers for numeric fields
321 const processedConfig = { ...localConfig };
322 const numericFields = [
323 'temperature',
324 'top_k',
325 'top_p',
326 'min_p',
327 'max_tokens',
328 'pasteLongTextToFileLen',
329 'dynatemp_range',
330 'dynatemp_exponent',
331 'typ_p',
332 'xtc_probability',
333 'xtc_threshold',
334 'repeat_last_n',
335 'repeat_penalty',
336 'presence_penalty',
337 'frequency_penalty',
338 'dry_multiplier',
339 'dry_base',
340 'dry_allowed_length',
341 'dry_penalty_last_n'
342 ];
343
344 for (const field of numericFields) {
345 if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
346 const numValue = Number(processedConfig[field]);
347 if (!isNaN(numValue)) {
348 processedConfig[field] = numValue;
349 } else {
350 alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
351 return;
352 }
353 }
354 }
355
356 settingsStore.updateMultipleConfig(processedConfig);
357 onSave?.();
358 }
359
360 function scrollToCenter(element: HTMLElement) {
361 if (!scrollContainer) return;
362
363 const containerRect = scrollContainer.getBoundingClientRect();
364 const elementRect = element.getBoundingClientRect();
365
366 const elementCenter = elementRect.left + elementRect.width / 2;
367 const containerCenter = containerRect.left + containerRect.width / 2;
368 const scrollOffset = elementCenter - containerCenter;
369
370 scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
371 }
372
373 function scrollLeft() {
374 if (!scrollContainer) return;
375
376 scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
377 }
378
379 function scrollRight() {
380 if (!scrollContainer) return;
381
382 scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
383 }
384
385 function updateScrollButtons() {
386 if (!scrollContainer) return;
387
388 const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
389 canScrollLeft = scrollLeft > 0;
390 canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
391 }
392
393 export function reset() {
394 localConfig = { ...config() };
395
396 setTimeout(updateScrollButtons, 100);
397 }
398
399 $effect(() => {
400 if (scrollContainer) {
401 updateScrollButtons();
402 }
403 });
404</script>
405
406<div class="flex h-full flex-col overflow-hidden md:flex-row">
407 <!-- Desktop Sidebar -->
408 <div class="hidden w-64 border-r border-border/30 p-6 md:block">
409 <nav class="space-y-1 py-2">
410 {#each settingSections as section (section.title)}
411 <button
412 class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
413 section.title
414 ? 'bg-accent text-accent-foreground'
415 : 'text-muted-foreground'}"
416 onclick={() => (activeSection = section.title)}
417 >
418 <section.icon class="h-4 w-4" />
419
420 <span class="ml-2">{section.title}</span>
421 </button>
422 {/each}
423 </nav>
424 </div>
425
426 <!-- Mobile Header with Horizontal Scrollable Menu -->
427 <div class="flex flex-col pt-6 md:hidden">
428 <div class="border-b border-border/30 py-4">
429 <!-- Horizontal Scrollable Category Menu with Navigation -->
430 <div class="relative flex items-center" style="scroll-padding: 1rem;">
431 <button
432 class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
433 ? 'opacity-100'
434 : 'pointer-events-none opacity-0'}"
435 onclick={scrollLeft}
436 aria-label="Scroll left"
437 >
438 <ChevronLeft class="h-4 w-4" />
439 </button>
440
441 <div
442 class="scrollbar-hide overflow-x-auto py-2"
443 bind:this={scrollContainer}
444 onscroll={updateScrollButtons}
445 >
446 <div class="flex min-w-max gap-2">
447 {#each settingSections as section (section.title)}
448 <button
449 class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
450 section.title
451 ? 'bg-accent text-accent-foreground'
452 : 'text-muted-foreground'}"
453 onclick={(e: MouseEvent) => {
454 activeSection = section.title;
455 scrollToCenter(e.currentTarget as HTMLElement);
456 }}
457 >
458 <section.icon class="h-4 w-4 flex-shrink-0" />
459 <span>{section.title}</span>
460 </button>
461 {/each}
462 </div>
463 </div>
464
465 <button
466 class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
467 ? 'opacity-100'
468 : 'pointer-events-none opacity-0'}"
469 onclick={scrollRight}
470 aria-label="Scroll right"
471 >
472 <ChevronRight class="h-4 w-4" />
473 </button>
474 </div>
475 </div>
476 </div>
477
478 <ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
479 <div class="space-y-6 p-4 md:p-6">
480 <div class="grid">
481 <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
482 <currentSection.icon class="h-5 w-5" />
483
484 <h3 class="text-lg font-semibold">{currentSection.title}</h3>
485 </div>
486
487 {#if currentSection.title === 'Import/Export'}
488 <ChatSettingsImportExportTab />
489 {:else}
490 <div class="space-y-6">
491 <ChatSettingsFields
492 fields={currentSection.fields}
493 {localConfig}
494 onConfigChange={handleConfigChange}
495 onThemeChange={handleThemeChange}
496 />
497 </div>
498 {/if}
499 </div>
500
501 <div class="mt-8 border-t pt-6">
502 <p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
503 </div>
504 </div>
505 </ScrollArea>
506</div>
507
508<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
new file mode 100644
index 0000000..a6f51f4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
@@ -0,0 +1,255 @@
1<script lang="ts">
2 import { RotateCcw, FlaskConical } from '@lucide/svelte';
3 import { Checkbox } from '$lib/components/ui/checkbox';
4 import { Input } from '$lib/components/ui/input';
5 import Label from '$lib/components/ui/label/label.svelte';
6 import * as Select from '$lib/components/ui/select';
7 import { Textarea } from '$lib/components/ui/textarea';
8 import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
9 import { settingsStore } from '$lib/stores/settings.svelte';
10 import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
11 import type { Component } from 'svelte';
12
13 interface Props {
14 fields: SettingsFieldConfig[];
15 localConfig: SettingsConfigType;
16 onConfigChange: (key: string, value: string | boolean) => void;
17 onThemeChange?: (theme: string) => void;
18 }
19
20 let { fields, localConfig, onConfigChange, onThemeChange }: Props = $props();
21
22 // Helper function to get parameter source info for syncable parameters
23 function getParameterSourceInfo(key: string) {
24 if (!settingsStore.canSyncParameter(key)) {
25 return null;
26 }
27
28 return settingsStore.getParameterInfo(key);
29 }
30</script>
31
32{#each fields as field (field.key)}
33 <div class="space-y-2">
34 {#if field.type === 'input'}
35 {@const paramInfo = getParameterSourceInfo(field.key)}
36 {@const currentValue = String(localConfig[field.key] ?? '')}
37 {@const propsDefault = paramInfo?.serverDefault}
38 {@const isCustomRealTime = (() => {
39 if (!paramInfo || propsDefault === undefined) return false;
40
41 // Apply same rounding logic for real-time comparison
42 const inputValue = currentValue;
43 const numericInput = parseFloat(inputValue);
44 const normalizedInput = !isNaN(numericInput)
45 ? Math.round(numericInput * 1000000) / 1000000
46 : inputValue;
47 const normalizedDefault =
48 typeof propsDefault === 'number'
49 ? Math.round(propsDefault * 1000000) / 1000000
50 : propsDefault;
51
52 return normalizedInput !== normalizedDefault;
53 })()}
54
55 <div class="flex items-center gap-2">
56 <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
57 {field.label}
58
59 {#if field.isExperimental}
60 <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
61 {/if}
62 </Label>
63 {#if isCustomRealTime}
64 <ChatSettingsParameterSourceIndicator />
65 {/if}
66 </div>
67
68 <div class="relative w-full md:max-w-md">
69 <Input
70 id={field.key}
71 value={currentValue}
72 oninput={(e) => {
73 // Update local config immediately for real-time badge feedback
74 onConfigChange(field.key, e.currentTarget.value);
75 }}
76 placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
77 class="w-full {isCustomRealTime ? 'pr-8' : ''}"
78 />
79 {#if isCustomRealTime}
80 <button
81 type="button"
82 onclick={() => {
83 settingsStore.resetParameterToServerDefault(field.key);
84 // Trigger UI update by calling onConfigChange with the default value
85 const defaultValue = propsDefault ?? SETTING_CONFIG_DEFAULT[field.key];
86 onConfigChange(field.key, String(defaultValue));
87 }}
88 class="absolute top-1/2 right-2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
89 aria-label="Reset to default"
90 title="Reset to default"
91 >
92 <RotateCcw class="h-3 w-3" />
93 </button>
94 {/if}
95 </div>
96 {#if field.help || SETTING_CONFIG_INFO[field.key]}
97 <p class="mt-1 text-xs text-muted-foreground">
98 {@html field.help || SETTING_CONFIG_INFO[field.key]}
99 </p>
100 {/if}
101 {:else if field.type === 'textarea'}
102 <Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
103 {field.label}
104
105 {#if field.isExperimental}
106 <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
107 {/if}
108 </Label>
109
110 <Textarea
111 id={field.key}
112 value={String(localConfig[field.key] ?? '')}
113 onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
114 placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
115 class="min-h-[10rem] w-full md:max-w-2xl"
116 />
117
118 {#if field.help || SETTING_CONFIG_INFO[field.key]}
119 <p class="mt-1 text-xs text-muted-foreground">
120 {field.help || SETTING_CONFIG_INFO[field.key]}
121 </p>
122 {/if}
123
124 {#if field.key === 'systemMessage'}
125 <div class="mt-3 flex items-center gap-2">
126 <Checkbox
127 id="showSystemMessage"
128 checked={Boolean(localConfig.showSystemMessage ?? true)}
129 onCheckedChange={(checked) => onConfigChange('showSystemMessage', Boolean(checked))}
130 />
131
132 <Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
133 Show system message in conversations
134 </Label>
135 </div>
136 {/if}
137 {:else if field.type === 'select'}
138 {@const selectedOption = field.options?.find(
139 (opt: { value: string; label: string; icon?: Component }) =>
140 opt.value === localConfig[field.key]
141 )}
142 {@const paramInfo = getParameterSourceInfo(field.key)}
143 {@const currentValue = localConfig[field.key]}
144 {@const propsDefault = paramInfo?.serverDefault}
145 {@const isCustomRealTime = (() => {
146 if (!paramInfo || propsDefault === undefined) return false;
147
148 // For select fields, do direct comparison (no rounding needed)
149 return currentValue !== propsDefault;
150 })()}
151
152 <div class="flex items-center gap-2">
153 <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
154 {field.label}
155
156 {#if field.isExperimental}
157 <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
158 {/if}
159 </Label>
160 {#if isCustomRealTime}
161 <ChatSettingsParameterSourceIndicator />
162 {/if}
163 </div>
164
165 <Select.Root
166 type="single"
167 value={currentValue}
168 onValueChange={(value) => {
169 if (field.key === 'theme' && value && onThemeChange) {
170 onThemeChange(value);
171 } else {
172 onConfigChange(field.key, value);
173 }
174 }}
175 >
176 <div class="relative w-full md:w-auto md:max-w-md">
177 <Select.Trigger class="w-full">
178 <div class="flex items-center gap-2">
179 {#if selectedOption?.icon}
180 {@const IconComponent = selectedOption.icon}
181 <IconComponent class="h-4 w-4" />
182 {/if}
183
184 {selectedOption?.label || `Select ${field.label.toLowerCase()}`}
185 </div>
186 </Select.Trigger>
187 {#if isCustomRealTime}
188 <button
189 type="button"
190 onclick={() => {
191 settingsStore.resetParameterToServerDefault(field.key);
192 // Trigger UI update by calling onConfigChange with the default value
193 const defaultValue = propsDefault ?? SETTING_CONFIG_DEFAULT[field.key];
194 onConfigChange(field.key, String(defaultValue));
195 }}
196 class="absolute top-1/2 right-8 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
197 aria-label="Reset to default"
198 title="Reset to default"
199 >
200 <RotateCcw class="h-3 w-3" />
201 </button>
202 {/if}
203 </div>
204 <Select.Content>
205 {#if field.options}
206 {#each field.options as option (option.value)}
207 <Select.Item value={option.value} label={option.label}>
208 <div class="flex items-center gap-2">
209 {#if option.icon}
210 {@const IconComponent = option.icon}
211 <IconComponent class="h-4 w-4" />
212 {/if}
213 {option.label}
214 </div>
215 </Select.Item>
216 {/each}
217 {/if}
218 </Select.Content>
219 </Select.Root>
220 {#if field.help || SETTING_CONFIG_INFO[field.key]}
221 <p class="mt-1 text-xs text-muted-foreground">
222 {field.help || SETTING_CONFIG_INFO[field.key]}
223 </p>
224 {/if}
225 {:else if field.type === 'checkbox'}
226 <div class="flex items-start space-x-3">
227 <Checkbox
228 id={field.key}
229 checked={Boolean(localConfig[field.key])}
230 onCheckedChange={(checked) => onConfigChange(field.key, checked)}
231 class="mt-1"
232 />
233
234 <div class="space-y-1">
235 <label
236 for={field.key}
237 class="flex cursor-pointer items-center gap-1.5 pt-1 pb-0.5 text-sm leading-none font-medium"
238 >
239 {field.label}
240
241 {#if field.isExperimental}
242 <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
243 {/if}
244 </label>
245
246 {#if field.help || SETTING_CONFIG_INFO[field.key]}
247 <p class="text-xs text-muted-foreground">
248 {field.help || SETTING_CONFIG_INFO[field.key]}
249 </p>
250 {/if}
251 </div>
252 </div>
253 {/if}
254 </div>
255{/each}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte
new file mode 100644
index 0000000..1f7eb4e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte
@@ -0,0 +1,59 @@
1<script lang="ts">
2 import { Button } from '$lib/components/ui/button';
3 import * as AlertDialog from '$lib/components/ui/alert-dialog';
4 import { settingsStore } from '$lib/stores/settings.svelte';
5 import { RotateCcw } from '@lucide/svelte';
6
7 interface Props {
8 onReset?: () => void;
9 onSave?: () => void;
10 }
11
12 let { onReset, onSave }: Props = $props();
13
14 let showResetDialog = $state(false);
15
16 function handleResetClick() {
17 showResetDialog = true;
18 }
19
20 function handleConfirmReset() {
21 settingsStore.forceSyncWithServerDefaults();
22 onReset?.();
23
24 showResetDialog = false;
25 }
26
27 function handleSave() {
28 onSave?.();
29 }
30</script>
31
32<div class="flex justify-between border-t border-border/30 p-6">
33 <div class="flex gap-2">
34 <Button variant="outline" onclick={handleResetClick}>
35 <RotateCcw class="h-3 w-3" />
36
37 Reset to default
38 </Button>
39 </div>
40
41 <Button onclick={handleSave}>Save settings</Button>
42</div>
43
44<AlertDialog.Root bind:open={showResetDialog}>
45 <AlertDialog.Content>
46 <AlertDialog.Header>
47 <AlertDialog.Title>Reset Settings to Default</AlertDialog.Title>
48 <AlertDialog.Description>
49 Are you sure you want to reset all settings to their default values? This will reset all
50 parameters to the values provided by the server's /props endpoint and remove all your custom
51 configurations.
52 </AlertDialog.Description>
53 </AlertDialog.Header>
54 <AlertDialog.Footer>
55 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
56 <AlertDialog.Action onclick={handleConfirmReset}>Reset to Default</AlertDialog.Action>
57 </AlertDialog.Footer>
58 </AlertDialog.Content>
59</AlertDialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte
new file mode 100644
index 0000000..1c8b411
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte
@@ -0,0 +1,317 @@
1<script lang="ts">
2 import { Download, Upload, Trash2 } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import { DialogConversationSelection } from '$lib/components/app';
5 import { createMessageCountMap } from '$lib/utils';
6 import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
7 import { toast } from 'svelte-sonner';
8 import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
9
10 let exportedConversations = $state<DatabaseConversation[]>([]);
11 let importedConversations = $state<DatabaseConversation[]>([]);
12 let showExportSummary = $state(false);
13 let showImportSummary = $state(false);
14
15 let showExportDialog = $state(false);
16 let showImportDialog = $state(false);
17 let availableConversations = $state<DatabaseConversation[]>([]);
18 let messageCountMap = $state<Map<string, number>>(new Map());
19 let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
20 []
21 );
22
23 // Delete functionality state
24 let showDeleteDialog = $state(false);
25
26 async function handleExportClick() {
27 try {
28 const allConversations = conversations();
29 if (allConversations.length === 0) {
30 toast.info('No conversations to export');
31 return;
32 }
33
34 const conversationsWithMessages = await Promise.all(
35 allConversations.map(async (conv: DatabaseConversation) => {
36 const messages = await conversationsStore.getConversationMessages(conv.id);
37 return { conv, messages };
38 })
39 );
40
41 messageCountMap = createMessageCountMap(conversationsWithMessages);
42 availableConversations = allConversations;
43 showExportDialog = true;
44 } catch (err) {
45 console.error('Failed to load conversations:', err);
46 alert('Failed to load conversations');
47 }
48 }
49
50 async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
51 try {
52 const allData: ExportedConversations = await Promise.all(
53 selectedConversations.map(async (conv) => {
54 const messages = await conversationsStore.getConversationMessages(conv.id);
55 return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
56 })
57 );
58
59 const blob = new Blob([JSON.stringify(allData, null, 2)], {
60 type: 'application/json'
61 });
62 const url = URL.createObjectURL(blob);
63 const a = document.createElement('a');
64
65 a.href = url;
66 a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
67 document.body.appendChild(a);
68 a.click();
69 document.body.removeChild(a);
70 URL.revokeObjectURL(url);
71
72 exportedConversations = selectedConversations;
73 showExportSummary = true;
74 showImportSummary = false;
75 showExportDialog = false;
76 } catch (err) {
77 console.error('Export failed:', err);
78 alert('Failed to export conversations');
79 }
80 }
81
82 async function handleImportClick() {
83 try {
84 const input = document.createElement('input');
85
86 input.type = 'file';
87 input.accept = '.json';
88
89 input.onchange = async (e) => {
90 const file = (e.target as HTMLInputElement)?.files?.[0];
91 if (!file) return;
92
93 try {
94 const text = await file.text();
95 const parsedData = JSON.parse(text);
96 let importedData: ExportedConversations;
97
98 if (Array.isArray(parsedData)) {
99 importedData = parsedData;
100 } else if (
101 parsedData &&
102 typeof parsedData === 'object' &&
103 'conv' in parsedData &&
104 'messages' in parsedData
105 ) {
106 // Single conversation object
107 importedData = [parsedData];
108 } else {
109 throw new Error(
110 'Invalid file format: expected array of conversations or single conversation object'
111 );
112 }
113
114 fullImportData = importedData;
115 availableConversations = importedData.map(
116 (item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
117 );
118 messageCountMap = createMessageCountMap(importedData);
119 showImportDialog = true;
120 } catch (err: unknown) {
121 const message = err instanceof Error ? err.message : 'Unknown error';
122
123 console.error('Failed to parse file:', err);
124 alert(`Failed to parse file: ${message}`);
125 }
126 };
127
128 input.click();
129 } catch (err) {
130 console.error('Import failed:', err);
131 alert('Failed to import conversations');
132 }
133 }
134
135 async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
136 try {
137 const selectedIds = new Set(selectedConversations.map((c) => c.id));
138 const selectedData = $state
139 .snapshot(fullImportData)
140 .filter((item) => selectedIds.has(item.conv.id));
141
142 await conversationsStore.importConversationsData(selectedData);
143
144 importedConversations = selectedConversations;
145 showImportSummary = true;
146 showExportSummary = false;
147 showImportDialog = false;
148 } catch (err) {
149 console.error('Import failed:', err);
150 alert('Failed to import conversations. Please check the file format.');
151 }
152 }
153
154 async function handleDeleteAllClick() {
155 try {
156 const allConversations = conversations();
157
158 if (allConversations.length === 0) {
159 toast.info('No conversations to delete');
160 return;
161 }
162
163 showDeleteDialog = true;
164 } catch (err) {
165 console.error('Failed to load conversations for deletion:', err);
166 toast.error('Failed to load conversations');
167 }
168 }
169
170 async function handleDeleteAllConfirm() {
171 try {
172 await conversationsStore.deleteAll();
173
174 showDeleteDialog = false;
175 } catch (err) {
176 console.error('Failed to delete conversations:', err);
177 }
178 }
179
180 function handleDeleteAllCancel() {
181 showDeleteDialog = false;
182 }
183</script>
184
185<div class="space-y-6">
186 <div class="space-y-4">
187 <div class="grid">
188 <h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
189
190 <p class="mb-4 text-sm text-muted-foreground">
191 Download all your conversations as a JSON file. This includes all messages, attachments, and
192 conversation history.
193 </p>
194
195 <Button
196 class="w-full justify-start justify-self-start md:w-auto"
197 onclick={handleExportClick}
198 variant="outline"
199 >
200 <Download class="mr-2 h-4 w-4" />
201
202 Export conversations
203 </Button>
204
205 {#if showExportSummary && exportedConversations.length > 0}
206 <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
207 <h5 class="mb-2 text-sm font-medium">
208 Exported {exportedConversations.length} conversation{exportedConversations.length === 1
209 ? ''
210 : 's'}
211 </h5>
212
213 <ul class="space-y-1 text-sm text-muted-foreground">
214 {#each exportedConversations.slice(0, 10) as conv (conv.id)}
215 <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
216 {/each}
217
218 {#if exportedConversations.length > 10}
219 <li class="italic">
220 ... and {exportedConversations.length - 10} more
221 </li>
222 {/if}
223 </ul>
224 </div>
225 {/if}
226 </div>
227
228 <div class="grid border-t border-border/30 pt-4">
229 <h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
230
231 <p class="mb-4 text-sm text-muted-foreground">
232 Import one or more conversations from a previously exported JSON file. This will merge with
233 your existing conversations.
234 </p>
235
236 <Button
237 class="w-full justify-start justify-self-start md:w-auto"
238 onclick={handleImportClick}
239 variant="outline"
240 >
241 <Upload class="mr-2 h-4 w-4" />
242 Import conversations
243 </Button>
244
245 {#if showImportSummary && importedConversations.length > 0}
246 <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
247 <h5 class="mb-2 text-sm font-medium">
248 Imported {importedConversations.length} conversation{importedConversations.length === 1
249 ? ''
250 : 's'}
251 </h5>
252
253 <ul class="space-y-1 text-sm text-muted-foreground">
254 {#each importedConversations.slice(0, 10) as conv (conv.id)}
255 <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
256 {/each}
257
258 {#if importedConversations.length > 10}
259 <li class="italic">
260 ... and {importedConversations.length - 10} more
261 </li>
262 {/if}
263 </ul>
264 </div>
265 {/if}
266 </div>
267
268 <div class="grid border-t border-border/30 pt-4">
269 <h4 class="mb-2 text-sm font-medium text-destructive">Delete All Conversations</h4>
270
271 <p class="mb-4 text-sm text-muted-foreground">
272 Permanently delete all conversations and their messages. This action cannot be undone.
273 Consider exporting your conversations first if you want to keep a backup.
274 </p>
275
276 <Button
277 class="text-destructive-foreground w-full justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto"
278 onclick={handleDeleteAllClick}
279 variant="destructive"
280 >
281 <Trash2 class="mr-2 h-4 w-4" />
282
283 Delete all conversations
284 </Button>
285 </div>
286 </div>
287</div>
288
289<DialogConversationSelection
290 conversations={availableConversations}
291 {messageCountMap}
292 mode="export"
293 bind:open={showExportDialog}
294 onCancel={() => (showExportDialog = false)}
295 onConfirm={handleExportConfirm}
296/>
297
298<DialogConversationSelection
299 conversations={availableConversations}
300 {messageCountMap}
301 mode="import"
302 bind:open={showImportDialog}
303 onCancel={() => (showImportDialog = false)}
304 onConfirm={handleImportConfirm}
305/>
306
307<DialogConfirmation
308 bind:open={showDeleteDialog}
309 title="Delete all conversations"
310 description="Are you sure you want to delete all conversations? This action cannot be undone and will permanently remove all your conversations and messages."
311 confirmText="Delete All"
312 cancelText="Cancel"
313 variant="destructive"
314 icon={Trash2}
315 onConfirm={handleDeleteAllConfirm}
316 onCancel={handleDeleteAllCancel}
317/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte
new file mode 100644
index 0000000..b566985
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte
@@ -0,0 +1,18 @@
1<script lang="ts">
2 import { Wrench } from '@lucide/svelte';
3 import { Badge } from '$lib/components/ui/badge';
4
5 interface Props {
6 class?: string;
7 }
8
9 let { class: className = '' }: Props = $props();
10</script>
11
12<Badge
13 variant="secondary"
14 class="h-5 bg-orange-100 px-1.5 py-0.5 text-xs text-orange-800 dark:bg-orange-900 dark:text-orange-200 {className}"
15>
16 <Wrench class="mr-1 h-3 w-3" />
17 Custom
18</Badge>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
new file mode 100644
index 0000000..aa0c27f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
@@ -0,0 +1,211 @@
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 { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
11 import { chatStore } from '$lib/stores/chat.svelte';
12 import { getPreviewText } from '$lib/utils/text';
13 import ChatSidebarActions from './ChatSidebarActions.svelte';
14
15 const sidebar = Sidebar.useSidebar();
16
17 let currentChatId = $derived(page.params.id);
18 let isSearchModeActive = $state(false);
19 let searchQuery = $state('');
20 let showDeleteDialog = $state(false);
21 let showEditDialog = $state(false);
22 let selectedConversation = $state<DatabaseConversation | null>(null);
23 let editedName = $state('');
24 let selectedConversationNamePreview = $derived.by(() =>
25 selectedConversation ? getPreviewText(selectedConversation.name) : ''
26 );
27
28 let filteredConversations = $derived.by(() => {
29 if (searchQuery.trim().length > 0) {
30 return conversations().filter((conversation: { name: string }) =>
31 conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
32 );
33 }
34
35 return conversations();
36 });
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
46 async function handleEditConversation(id: string) {
47 const conversation = conversations().find((conv) => conv.id === id);
48 if (conversation) {
49 selectedConversation = conversation;
50 editedName = conversation.name;
51 showEditDialog = true;
52 }
53 }
54
55 function handleConfirmDelete() {
56 if (selectedConversation) {
57 showDeleteDialog = false;
58
59 setTimeout(() => {
60 conversationsStore.deleteConversation(selectedConversation.id);
61 selectedConversation = null;
62 }, 100); // Wait for animation to finish
63 }
64 }
65
66 function handleConfirmEdit() {
67 if (!editedName.trim() || !selectedConversation) return;
68
69 showEditDialog = false;
70
71 conversationsStore.updateConversationName(selectedConversation.id, editedName);
72 selectedConversation = null;
73 }
74
75 export function handleMobileSidebarItemClick() {
76 if (sidebar.isMobile) {
77 sidebar.toggle();
78 }
79 }
80
81 export function activateSearchMode() {
82 isSearchModeActive = true;
83 }
84
85 export function editActiveConversation() {
86 if (currentChatId) {
87 const activeConversation = filteredConversations.find((conv) => conv.id === currentChatId);
88
89 if (activeConversation) {
90 const event = new CustomEvent('edit-active-conversation', {
91 detail: { conversationId: currentChatId }
92 });
93 document.dispatchEvent(event);
94 }
95 }
96 }
97
98 async function selectConversation(id: string) {
99 if (isSearchModeActive) {
100 isSearchModeActive = false;
101 searchQuery = '';
102 }
103
104 await goto(`#/chat/${id}`);
105 }
106
107 function handleStopGeneration(id: string) {
108 chatStore.stopGenerationForChat(id);
109 }
110</script>
111
112<ScrollArea class="h-[100vh]">
113 <Sidebar.Header class=" top-0 z-10 gap-6 bg-sidebar/50 px-4 py-4 pb-2 backdrop-blur-lg md:sticky">
114 <a href="#/" onclick={handleMobileSidebarItemClick}>
115 <h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
116 </a>
117
118 <ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
119 </Sidebar.Header>
120
121 <Sidebar.Group class="mt-4 space-y-2 p-0 px-4">
122 {#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
123 <Sidebar.GroupLabel>
124 {isSearchModeActive ? 'Search results' : 'Conversations'}
125 </Sidebar.GroupLabel>
126 {/if}
127
128 <Sidebar.GroupContent>
129 <Sidebar.Menu>
130 {#each filteredConversations as conversation (conversation.id)}
131 <Sidebar.MenuItem class="mb-1">
132 <ChatSidebarConversationItem
133 conversation={{
134 id: conversation.id,
135 name: conversation.name,
136 lastModified: conversation.lastModified,
137 currNode: conversation.currNode
138 }}
139 {handleMobileSidebarItemClick}
140 isActive={currentChatId === conversation.id}
141 onSelect={selectConversation}
142 onEdit={handleEditConversation}
143 onDelete={handleDeleteConversation}
144 onStop={handleStopGeneration}
145 />
146 </Sidebar.MenuItem>
147 {/each}
148
149 {#if filteredConversations.length === 0}
150 <div class="px-2 py-4 text-center">
151 <p class="mb-4 p-4 text-sm text-muted-foreground">
152 {searchQuery.length > 0
153 ? 'No results found'
154 : isSearchModeActive
155 ? 'Start typing to see results'
156 : 'No conversations yet'}
157 </p>
158 </div>
159 {/if}
160 </Sidebar.Menu>
161 </Sidebar.GroupContent>
162 </Sidebar.Group>
163</ScrollArea>
164
165<DialogConfirmation
166 bind:open={showDeleteDialog}
167 title="Delete Conversation"
168 description={selectedConversation
169 ? `Are you sure you want to delete "${selectedConversationNamePreview}"? This action cannot be undone and will permanently remove all messages in this conversation.`
170 : ''}
171 confirmText="Delete"
172 cancelText="Cancel"
173 variant="destructive"
174 icon={Trash2}
175 onConfirm={handleConfirmDelete}
176 onCancel={() => {
177 showDeleteDialog = false;
178 selectedConversation = null;
179 }}
180/>
181
182<AlertDialog.Root bind:open={showEditDialog}>
183 <AlertDialog.Content>
184 <AlertDialog.Header>
185 <AlertDialog.Title>Edit Conversation Name</AlertDialog.Title>
186 <AlertDialog.Description>
187 <Input
188 class="mt-4 text-foreground"
189 onkeydown={(e) => {
190 if (e.key === 'Enter') {
191 e.preventDefault();
192 handleConfirmEdit();
193 }
194 }}
195 placeholder="Enter a new name"
196 type="text"
197 bind:value={editedName}
198 />
199 </AlertDialog.Description>
200 </AlertDialog.Header>
201 <AlertDialog.Footer>
202 <AlertDialog.Cancel
203 onclick={() => {
204 showEditDialog = false;
205 selectedConversation = null;
206 }}>Cancel</AlertDialog.Cancel
207 >
208 <AlertDialog.Action onclick={handleConfirmEdit}>Save</AlertDialog.Action>
209 </AlertDialog.Footer>
210 </AlertDialog.Content>
211</AlertDialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte
new file mode 100644
index 0000000..30d1f9d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte
@@ -0,0 +1,81 @@
1<script lang="ts">
2 import { Search, SquarePen, X } from '@lucide/svelte';
3 import { KeyboardShortcutInfo } from '$lib/components/app';
4 import { Button } from '$lib/components/ui/button';
5 import { Input } from '$lib/components/ui/input';
6
7 interface Props {
8 handleMobileSidebarItemClick: () => void;
9 isSearchModeActive: boolean;
10 searchQuery: string;
11 }
12
13 let {
14 handleMobileSidebarItemClick,
15 isSearchModeActive = $bindable(),
16 searchQuery = $bindable()
17 }: Props = $props();
18
19 let searchInput: HTMLInputElement | null = $state(null);
20
21 function handleSearchModeDeactivate() {
22 isSearchModeActive = false;
23 searchQuery = '';
24 }
25
26 $effect(() => {
27 if (isSearchModeActive) {
28 searchInput?.focus();
29 }
30 });
31</script>
32
33<div class="space-y-0.5">
34 {#if isSearchModeActive}
35 <div class="relative">
36 <Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
37
38 <Input
39 bind:ref={searchInput}
40 bind:value={searchQuery}
41 onkeydown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
42 placeholder="Search conversations..."
43 class="pl-8"
44 />
45
46 <X
47 class="cursor-pointertext-muted-foreground absolute top-2.5 right-2 h-4 w-4"
48 onclick={handleSearchModeDeactivate}
49 />
50 </div>
51 {:else}
52 <Button
53 class="w-full justify-between hover:[&>kbd]:opacity-100"
54 href="?new_chat=true#/"
55 onclick={handleMobileSidebarItemClick}
56 variant="ghost"
57 >
58 <div class="flex items-center gap-2">
59 <SquarePen class="h-4 w-4" />
60 New chat
61 </div>
62
63 <KeyboardShortcutInfo keys={['shift', 'cmd', 'o']} />
64 </Button>
65
66 <Button
67 class="w-full justify-between hover:[&>kbd]:opacity-100"
68 onclick={() => {
69 isSearchModeActive = true;
70 }}
71 variant="ghost"
72 >
73 <div class="flex items-center gap-2">
74 <Search class="h-4 w-4" />
75 Search conversations
76 </div>
77
78 <KeyboardShortcutInfo keys={['cmd', 'k']} />
79 </Button>
80 {/if}
81</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte
new file mode 100644
index 0000000..bf2fa4f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte
@@ -0,0 +1,200 @@
1<script lang="ts">
2 import { Trash2, Pencil, MoreHorizontal, Download, Loader2, Square } from '@lucide/svelte';
3 import { ActionDropdown } from '$lib/components/app';
4 import * as Tooltip from '$lib/components/ui/tooltip';
5 import { getAllLoadingChats } from '$lib/stores/chat.svelte';
6 import { conversationsStore } from '$lib/stores/conversations.svelte';
7 import { onMount } from 'svelte';
8
9 interface Props {
10 isActive?: boolean;
11 conversation: DatabaseConversation;
12 handleMobileSidebarItemClick?: () => void;
13 onDelete?: (id: string) => void;
14 onEdit?: (id: string) => void;
15 onSelect?: (id: string) => void;
16 onStop?: (id: string) => void;
17 }
18
19 let {
20 conversation,
21 handleMobileSidebarItemClick,
22 onDelete,
23 onEdit,
24 onSelect,
25 onStop,
26 isActive = false
27 }: Props = $props();
28
29 let renderActionsDropdown = $state(false);
30 let dropdownOpen = $state(false);
31
32 let isLoading = $derived(getAllLoadingChats().includes(conversation.id));
33
34 function handleEdit(event: Event) {
35 event.stopPropagation();
36 onEdit?.(conversation.id);
37 }
38
39 function handleDelete(event: Event) {
40 event.stopPropagation();
41 onDelete?.(conversation.id);
42 }
43
44 function handleStop(event: Event) {
45 event.stopPropagation();
46 onStop?.(conversation.id);
47 }
48
49 function handleGlobalEditEvent(event: Event) {
50 const customEvent = event as CustomEvent<{ conversationId: string }>;
51
52 if (customEvent.detail.conversationId === conversation.id && isActive) {
53 handleEdit(event);
54 }
55 }
56
57 function handleMouseLeave() {
58 if (!dropdownOpen) {
59 renderActionsDropdown = false;
60 }
61 }
62
63 function handleMouseOver() {
64 renderActionsDropdown = true;
65 }
66
67 function handleSelect() {
68 onSelect?.(conversation.id);
69 }
70
71 $effect(() => {
72 if (!dropdownOpen) {
73 renderActionsDropdown = false;
74 }
75 });
76
77 onMount(() => {
78 document.addEventListener('edit-active-conversation', handleGlobalEditEvent as EventListener);
79
80 return () => {
81 document.removeEventListener(
82 'edit-active-conversation',
83 handleGlobalEditEvent as EventListener
84 );
85 };
86 });
87</script>
88
89<!-- svelte-ignore a11y_mouse_events_have_key_events -->
90<button
91 class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive
92 ? 'bg-foreground/5 text-accent-foreground'
93 : ''}"
94 onclick={handleSelect}
95 onmouseover={handleMouseOver}
96 onmouseleave={handleMouseLeave}
97>
98 <div class="flex min-w-0 flex-1 items-center gap-2">
99 {#if isLoading}
100 <Tooltip.Root>
101 <Tooltip.Trigger>
102 <div
103 class="stop-button flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
104 onclick={handleStop}
105 onkeydown={(e) => e.key === 'Enter' && handleStop(e)}
106 role="button"
107 tabindex="0"
108 aria-label="Stop generation"
109 >
110 <Loader2 class="loading-icon h-3.5 w-3.5 animate-spin" />
111
112 <Square class="stop-icon hidden h-3 w-3 fill-current text-destructive" />
113 </div>
114 </Tooltip.Trigger>
115
116 <Tooltip.Content>
117 <p>Stop generation</p>
118 </Tooltip.Content>
119 </Tooltip.Root>
120 {/if}
121
122 <!-- svelte-ignore a11y_click_events_have_key_events -->
123 <!-- svelte-ignore a11y_no_static_element_interactions -->
124 <span class="truncate text-sm font-medium" onclick={handleMobileSidebarItemClick}>
125 {conversation.name}
126 </span>
127 </div>
128
129 {#if renderActionsDropdown}
130 <div class="actions flex items-center">
131 <ActionDropdown
132 triggerIcon={MoreHorizontal}
133 triggerTooltip="More actions"
134 bind:open={dropdownOpen}
135 actions={[
136 {
137 icon: Pencil,
138 label: 'Edit',
139 onclick: handleEdit,
140 shortcut: ['shift', 'cmd', 'e']
141 },
142 {
143 icon: Download,
144 label: 'Export',
145 onclick: (e) => {
146 e.stopPropagation();
147 conversationsStore.downloadConversation(conversation.id);
148 },
149 shortcut: ['shift', 'cmd', 's']
150 },
151 {
152 icon: Trash2,
153 label: 'Delete',
154 onclick: handleDelete,
155 variant: 'destructive',
156 shortcut: ['shift', 'cmd', 'd'],
157 separator: true
158 }
159 ]}
160 />
161 </div>
162 {/if}
163</button>
164
165<style>
166 button {
167 :global([data-slot='dropdown-menu-trigger']:not([data-state='open'])) {
168 opacity: 0;
169 }
170
171 &:is(:hover) :global([data-slot='dropdown-menu-trigger']) {
172 opacity: 1;
173 }
174 @media (max-width: 768px) {
175 :global([data-slot='dropdown-menu-trigger']) {
176 opacity: 1 !important;
177 }
178 }
179
180 .stop-button {
181 :global(.stop-icon) {
182 display: none;
183 }
184
185 :global(.loading-icon) {
186 display: block;
187 }
188 }
189
190 &:is(:hover) .stop-button {
191 :global(.stop-icon) {
192 display: block;
193 }
194
195 :global(.loading-icon) {
196 display: none;
197 }
198 }
199 }
200</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte
new file mode 100644
index 0000000..afc9847
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte
@@ -0,0 +1,19 @@
1<script lang="ts">
2 import { SearchInput } from '$lib/components/app';
3
4 interface Props {
5 value?: string;
6 placeholder?: string;
7 onInput?: (value: string) => void;
8 class?: string;
9 }
10
11 let {
12 value = $bindable(''),
13 placeholder = 'Search conversations...',
14 onInput,
15 class: className
16 }: Props = $props();
17</script>
18
19<SearchInput bind:value {placeholder} {onInput} class="mb-4 {className}" />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts
new file mode 100644
index 0000000..4b9b876
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts
@@ -0,0 +1,9 @@
1import { useSidebar } from '$lib/components/ui/sidebar';
2
3const sidebar = useSidebar();
4
5export function handleMobileSidebarItemClick() {
6 if (sidebar.isMobile) {
7 sidebar.toggle();
8 }
9}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte
new file mode 100644
index 0000000..012ba00
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte
@@ -0,0 +1,67 @@
1<script lang="ts">
2 import * as Dialog from '$lib/components/ui/dialog';
3 import { ChatAttachmentPreview } from '$lib/components/app';
4 import { formatFileSize } from '$lib/utils';
5
6 interface Props {
7 open: boolean;
8 onOpenChange?: (open: boolean) => void;
9 // Either an uploaded file or a stored attachment
10 uploadedFile?: ChatUploadedFile;
11 attachment?: DatabaseMessageExtra;
12 // For uploaded files
13 preview?: string;
14 name?: string;
15 size?: number;
16 textContent?: string;
17 // For vision modality check
18 activeModelId?: string;
19 }
20
21 let {
22 open = $bindable(),
23 onOpenChange,
24 uploadedFile,
25 attachment,
26 preview,
27 name,
28 size,
29 textContent,
30 activeModelId
31 }: Props = $props();
32
33 let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state();
34
35 let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
36
37 let displaySize = $derived(uploadedFile?.size || size);
38
39 $effect(() => {
40 if (open && chatAttachmentPreviewRef) {
41 chatAttachmentPreviewRef.reset();
42 }
43 });
44</script>
45
46<Dialog.Root bind:open {onOpenChange}>
47 <Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
48 <Dialog.Header>
49 <Dialog.Title class="pr-8">{displayName}</Dialog.Title>
50 <Dialog.Description>
51 {#if displaySize}
52 {formatFileSize(displaySize)}
53 {/if}
54 </Dialog.Description>
55 </Dialog.Header>
56
57 <ChatAttachmentPreview
58 bind:this={chatAttachmentPreviewRef}
59 {uploadedFile}
60 {attachment}
61 {preview}
62 name={displayName}
63 {textContent}
64 {activeModelId}
65 />
66 </Dialog.Content>
67</Dialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte
new file mode 100644
index 0000000..33ab0fe
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte
@@ -0,0 +1,54 @@
1<script lang="ts">
2 import * as Dialog from '$lib/components/ui/dialog';
3 import { ChatAttachmentsViewAll } from '$lib/components/app';
4
5 interface Props {
6 open?: boolean;
7 uploadedFiles?: ChatUploadedFile[];
8 attachments?: DatabaseMessageExtra[];
9 readonly?: boolean;
10 onFileRemove?: (fileId: string) => void;
11 imageHeight?: string;
12 imageWidth?: string;
13 imageClass?: string;
14 activeModelId?: string;
15 }
16
17 let {
18 open = $bindable(false),
19 uploadedFiles = [],
20 attachments = [],
21 readonly = false,
22 onFileRemove,
23 imageHeight = 'h-24',
24 imageWidth = 'w-auto',
25 imageClass = '',
26 activeModelId
27 }: Props = $props();
28
29 let totalCount = $derived(uploadedFiles.length + attachments.length);
30</script>
31
32<Dialog.Root bind:open>
33 <Dialog.Portal>
34 <Dialog.Overlay />
35
36 <Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col">
37 <Dialog.Header>
38 <Dialog.Title>All Attachments ({totalCount})</Dialog.Title>
39 <Dialog.Description>View and manage all attached files</Dialog.Description>
40 </Dialog.Header>
41
42 <ChatAttachmentsViewAll
43 {uploadedFiles}
44 {attachments}
45 {readonly}
46 {onFileRemove}
47 {imageHeight}
48 {imageWidth}
49 {imageClass}
50 {activeModelId}
51 />
52 </Dialog.Content>
53 </Dialog.Portal>
54</Dialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte
new file mode 100644
index 0000000..b4340e8
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte
@@ -0,0 +1,70 @@
1<script lang="ts">
2 import * as AlertDialog from '$lib/components/ui/alert-dialog';
3 import { AlertTriangle, TimerOff } from '@lucide/svelte';
4
5 interface Props {
6 open: boolean;
7 type: 'timeout' | 'server';
8 message: string;
9 contextInfo?: { n_prompt_tokens: number; n_ctx: number };
10 onOpenChange?: (open: boolean) => void;
11 }
12
13 let { open = $bindable(), type, message, contextInfo, onOpenChange }: Props = $props();
14
15 const isTimeout = $derived(type === 'timeout');
16 const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
17 const description = $derived(
18 isTimeout
19 ? 'The request did not receive a response from the server before timing out.'
20 : 'The server responded with an error message. Review the details below.'
21 );
22 const iconClass = $derived(isTimeout ? 'text-destructive' : 'text-amber-500');
23 const badgeClass = $derived(
24 isTimeout
25 ? 'border-destructive/40 bg-destructive/10 text-destructive'
26 : 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
27 );
28
29 function handleOpenChange(newOpen: boolean) {
30 open = newOpen;
31 onOpenChange?.(newOpen);
32 }
33</script>
34
35<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
36 <AlertDialog.Content>
37 <AlertDialog.Header>
38 <AlertDialog.Title class="flex items-center gap-2">
39 {#if isTimeout}
40 <TimerOff class={`h-5 w-5 ${iconClass}`} />
41 {:else}
42 <AlertTriangle class={`h-5 w-5 ${iconClass}`} />
43 {/if}
44
45 {title}
46 </AlertDialog.Title>
47
48 <AlertDialog.Description>
49 {description}
50 </AlertDialog.Description>
51 </AlertDialog.Header>
52
53 <div class={`rounded-lg border px-4 py-3 text-sm ${badgeClass}`}>
54 <p class="font-medium">{message}</p>
55 {#if contextInfo}
56 <div class="mt-2 space-y-1 text-xs opacity-80">
57 <p>
58 <span class="font-medium">Prompt tokens:</span>
59 {contextInfo.n_prompt_tokens.toLocaleString()}
60 </p>
61 <p><span class="font-medium">Context size:</span> {contextInfo.n_ctx.toLocaleString()}</p>
62 </div>
63 {/if}
64 </div>
65
66 <AlertDialog.Footer>
67 <AlertDialog.Action onclick={() => handleOpenChange(false)}>Close</AlertDialog.Action>
68 </AlertDialog.Footer>
69 </AlertDialog.Content>
70</AlertDialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte
new file mode 100644
index 0000000..e9aaa10
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte
@@ -0,0 +1,37 @@
1<script lang="ts">
2 import * as Dialog from '$lib/components/ui/dialog';
3 import { ChatSettings } from '$lib/components/app';
4
5 interface Props {
6 onOpenChange?: (open: boolean) => void;
7 open?: boolean;
8 }
9
10 let { onOpenChange, open = false }: Props = $props();
11
12 let chatSettingsRef: ChatSettings | undefined = $state();
13
14 function handleClose() {
15 onOpenChange?.(false);
16 }
17
18 function handleSave() {
19 onOpenChange?.(false);
20 }
21
22 $effect(() => {
23 if (open && chatSettingsRef) {
24 chatSettingsRef.reset();
25 }
26 });
27</script>
28
29<Dialog.Root {open} onOpenChange={handleClose}>
30 <Dialog.Content
31 class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
32 md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
33 style="max-width: 48rem;"
34 >
35 <ChatSettings bind:this={chatSettingsRef} onSave={handleSave} />
36 </Dialog.Content>
37</Dialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte
new file mode 100644
index 0000000..b5175a9
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte
@@ -0,0 +1,72 @@
1<script lang="ts">
2 import * as AlertDialog from '$lib/components/ui/alert-dialog';
3 import type { Component } from 'svelte';
4
5 interface Props {
6 open: boolean;
7 title: string;
8 description: string;
9 confirmText?: string;
10 cancelText?: string;
11 variant?: 'default' | 'destructive';
12 icon?: Component;
13 onConfirm: () => void;
14 onCancel: () => void;
15 onKeydown?: (event: KeyboardEvent) => void;
16 }
17
18 let {
19 open = $bindable(),
20 title,
21 description,
22 confirmText = 'Confirm',
23 cancelText = 'Cancel',
24 variant = 'default',
25 icon,
26 onConfirm,
27 onCancel,
28 onKeydown
29 }: Props = $props();
30
31 function handleKeydown(event: KeyboardEvent) {
32 if (event.key === 'Enter') {
33 event.preventDefault();
34 onConfirm();
35 }
36 onKeydown?.(event);
37 }
38
39 function handleOpenChange(newOpen: boolean) {
40 if (!newOpen) {
41 onCancel();
42 }
43 }
44</script>
45
46<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
47 <AlertDialog.Content onkeydown={handleKeydown}>
48 <AlertDialog.Header>
49 <AlertDialog.Title class="flex items-center gap-2">
50 {#if icon}
51 {@const IconComponent = icon}
52 <IconComponent class="h-5 w-5 {variant === 'destructive' ? 'text-destructive' : ''}" />
53 {/if}
54 {title}
55 </AlertDialog.Title>
56
57 <AlertDialog.Description>
58 {description}
59 </AlertDialog.Description>
60 </AlertDialog.Header>
61
62 <AlertDialog.Footer>
63 <AlertDialog.Cancel onclick={onCancel}>{cancelText}</AlertDialog.Cancel>
64 <AlertDialog.Action
65 onclick={onConfirm}
66 class={variant === 'destructive' ? 'bg-destructive text-white hover:bg-destructive/80' : ''}
67 >
68 {confirmText}
69 </AlertDialog.Action>
70 </AlertDialog.Footer>
71 </AlertDialog.Content>
72</AlertDialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte
new file mode 100644
index 0000000..1f8ea64
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte
@@ -0,0 +1,68 @@
1<script lang="ts">
2 import * as Dialog from '$lib/components/ui/dialog';
3 import { ConversationSelection } from '$lib/components/app';
4
5 interface Props {
6 conversations: DatabaseConversation[];
7 messageCountMap?: Map<string, number>;
8 mode: 'export' | 'import';
9 onCancel: () => void;
10 onConfirm: (selectedConversations: DatabaseConversation[]) => void;
11 open?: boolean;
12 }
13
14 let {
15 conversations,
16 messageCountMap = new Map(),
17 mode,
18 onCancel,
19 onConfirm,
20 open = $bindable(false)
21 }: Props = $props();
22
23 let conversationSelectionRef: ConversationSelection | undefined = $state();
24
25 let previousOpen = $state(false);
26
27 $effect(() => {
28 if (open && !previousOpen && conversationSelectionRef) {
29 conversationSelectionRef.reset();
30 } else if (!open && previousOpen) {
31 onCancel();
32 }
33
34 previousOpen = open;
35 });
36</script>
37
38<Dialog.Root bind:open>
39 <Dialog.Portal>
40 <Dialog.Overlay class="z-[1000000]" />
41
42 <Dialog.Content class="z-[1000001] max-w-2xl">
43 <Dialog.Header>
44 <Dialog.Title>
45 Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
46 </Dialog.Title>
47 <Dialog.Description>
48 {#if mode === 'export'}
49 Choose which conversations you want to export. Selected conversations will be downloaded
50 as a JSON file.
51 {:else}
52 Choose which conversations you want to import. Selected conversations will be merged
53 with your existing conversations.
54 {/if}
55 </Dialog.Description>
56 </Dialog.Header>
57
58 <ConversationSelection
59 bind:this={conversationSelectionRef}
60 {conversations}
61 {messageCountMap}
62 {mode}
63 {onCancel}
64 {onConfirm}
65 />
66 </Dialog.Content>
67 </Dialog.Portal>
68</Dialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte
new file mode 100644
index 0000000..4a9ecce
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte
@@ -0,0 +1,46 @@
1<script lang="ts">
2 import * as AlertDialog from '$lib/components/ui/alert-dialog';
3 import { Button } from '$lib/components/ui/button';
4
5 interface Props {
6 open: boolean;
7 currentTitle: string;
8 newTitle: string;
9 onConfirm: () => void;
10 onCancel: () => void;
11 }
12
13 let { open = $bindable(), currentTitle, newTitle, onConfirm, onCancel }: Props = $props();
14</script>
15
16<AlertDialog.Root bind:open>
17 <AlertDialog.Content>
18 <AlertDialog.Header>
19 <AlertDialog.Title>Update Conversation Title?</AlertDialog.Title>
20
21 <AlertDialog.Description>
22 Do you want to update the conversation title to match the first message content?
23 </AlertDialog.Description>
24 </AlertDialog.Header>
25
26 <div class="space-y-4 pt-2 pb-6">
27 <div class="space-y-2">
28 <p class="text-sm font-medium text-muted-foreground">Current title:</p>
29
30 <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{currentTitle}</p>
31 </div>
32
33 <div class="space-y-2">
34 <p class="text-sm font-medium text-muted-foreground">New title would be:</p>
35
36 <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{newTitle}</p>
37 </div>
38 </div>
39
40 <AlertDialog.Footer>
41 <Button variant="outline" onclick={onCancel}>Keep Current Title</Button>
42
43 <Button onclick={onConfirm}>Update Title</Button>
44 </AlertDialog.Footer>
45 </AlertDialog.Content>
46</AlertDialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte
new file mode 100644
index 0000000..f875b0a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte
@@ -0,0 +1,61 @@
1<script lang="ts">
2 import * as AlertDialog from '$lib/components/ui/alert-dialog';
3 import { FileX } from '@lucide/svelte';
4
5 interface Props {
6 open: boolean;
7 emptyFiles: string[];
8 onOpenChange?: (open: boolean) => void;
9 }
10
11 let { open = $bindable(), emptyFiles, onOpenChange }: Props = $props();
12
13 function handleOpenChange(newOpen: boolean) {
14 open = newOpen;
15 onOpenChange?.(newOpen);
16 }
17</script>
18
19<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
20 <AlertDialog.Content>
21 <AlertDialog.Header>
22 <AlertDialog.Title class="flex items-center gap-2">
23 <FileX class="h-5 w-5 text-destructive" />
24
25 Empty Files Detected
26 </AlertDialog.Title>
27
28 <AlertDialog.Description>
29 The following files are empty and have been removed from your attachments:
30 </AlertDialog.Description>
31 </AlertDialog.Header>
32
33 <div class="space-y-3 text-sm">
34 <div class="rounded-lg bg-muted p-3">
35 <div class="mb-2 font-medium">Empty Files:</div>
36
37 <ul class="list-inside list-disc space-y-1 text-muted-foreground">
38 {#each emptyFiles as fileName (fileName)}
39 <li class="font-mono text-sm">{fileName}</li>
40 {/each}
41 </ul>
42 </div>
43
44 <div>
45 <div class="mb-2 font-medium">What happened:</div>
46
47 <ul class="list-inside list-disc space-y-1 text-muted-foreground">
48 <li>Empty files cannot be processed or sent to the AI model</li>
49
50 <li>These files have been automatically removed from your attachments</li>
51
52 <li>You can try uploading files with content instead</li>
53 </ul>
54 </div>
55 </div>
56
57 <AlertDialog.Footer>
58 <AlertDialog.Action onclick={() => handleOpenChange(false)}>Got it</AlertDialog.Action>
59 </AlertDialog.Footer>
60 </AlertDialog.Content>
61</AlertDialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte
new file mode 100644
index 0000000..dfea47c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte
@@ -0,0 +1,211 @@
1<script lang="ts">
2 import * as Dialog from '$lib/components/ui/dialog';
3 import * as Table from '$lib/components/ui/table';
4 import { BadgeModality, CopyToClipboardIcon } from '$lib/components/app';
5 import { serverStore } from '$lib/stores/server.svelte';
6 import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
7 import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
8
9 interface Props {
10 open?: boolean;
11 onOpenChange?: (open: boolean) => void;
12 }
13
14 let { open = $bindable(), onOpenChange }: Props = $props();
15
16 let serverProps = $derived(serverStore.props);
17 let modelName = $derived(modelsStore.singleModelName);
18 let models = $derived(modelOptions());
19 let isLoadingModels = $derived(modelsLoading());
20
21 // Get the first model for single-model mode display
22 let firstModel = $derived(models[0] ?? null);
23
24 // Get modalities from modelStore using the model ID from the first model
25 let modalities = $derived.by(() => {
26 if (!firstModel?.id) return [];
27 return modelsStore.getModelModalitiesArray(firstModel.id);
28 });
29
30 // Ensure models are fetched when dialog opens
31 $effect(() => {
32 if (open && models.length === 0) {
33 modelsStore.fetch();
34 }
35 });
36</script>
37
38<Dialog.Root bind:open {onOpenChange}>
39 <Dialog.Content class="@container z-9999 !max-w-[60rem] max-w-full">
40 <style>
41 @container (max-width: 56rem) {
42 .resizable-text-container {
43 max-width: calc(100vw - var(--threshold));
44 }
45 }
46 </style>
47
48 <Dialog.Header>
49 <Dialog.Title>Model Information</Dialog.Title>
50 <Dialog.Description>Current model details and capabilities</Dialog.Description>
51 </Dialog.Header>
52
53 <div class="space-y-6 py-4">
54 {#if isLoadingModels}
55 <div class="flex items-center justify-center py-8">
56 <div class="text-sm text-muted-foreground">Loading model information...</div>
57 </div>
58 {:else if firstModel}
59 {@const modelMeta = firstModel.meta}
60
61 {#if serverProps}
62 <Table.Root>
63 <Table.Header>
64 <Table.Row>
65 <Table.Head class="w-[10rem]">Model</Table.Head>
66
67 <Table.Head>
68 <div class="inline-flex items-center gap-2">
69 <span
70 class="resizable-text-container min-w-0 flex-1 truncate"
71 style:--threshold="12rem"
72 >
73 {modelName}
74 </span>
75
76 <CopyToClipboardIcon
77 text={modelName || ''}
78 canCopy={!!modelName}
79 ariaLabel="Copy model name to clipboard"
80 />
81 </div>
82 </Table.Head>
83 </Table.Row>
84 </Table.Header>
85 <Table.Body>
86 <!-- Model Path -->
87 <Table.Row>
88 <Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell>
89
90 <Table.Cell
91 class="inline-flex h-10 items-center gap-2 align-middle font-mono text-xs"
92 >
93 <span
94 class="resizable-text-container min-w-0 flex-1 truncate"
95 style:--threshold="14rem"
96 >
97 {serverProps.model_path}
98 </span>
99
100 <CopyToClipboardIcon
101 text={serverProps.model_path}
102 ariaLabel="Copy model path to clipboard"
103 />
104 </Table.Cell>
105 </Table.Row>
106
107 <!-- Context Size -->
108 <Table.Row>
109 <Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
110 <Table.Cell
111 >{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
112 >
113 </Table.Row>
114
115 <!-- Training Context -->
116 {#if modelMeta?.n_ctx_train}
117 <Table.Row>
118 <Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
119 <Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
120 </Table.Row>
121 {/if}
122
123 <!-- Model Size -->
124 {#if modelMeta?.size}
125 <Table.Row>
126 <Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
127 <Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
128 </Table.Row>
129 {/if}
130
131 <!-- Parameters -->
132 {#if modelMeta?.n_params}
133 <Table.Row>
134 <Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
135 <Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
136 </Table.Row>
137 {/if}
138
139 <!-- Embedding Size -->
140 {#if modelMeta?.n_embd}
141 <Table.Row>
142 <Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
143 <Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
144 </Table.Row>
145 {/if}
146
147 <!-- Vocabulary Size -->
148 {#if modelMeta?.n_vocab}
149 <Table.Row>
150 <Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
151 <Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
152 </Table.Row>
153 {/if}
154
155 <!-- Vocabulary Type -->
156 {#if modelMeta?.vocab_type}
157 <Table.Row>
158 <Table.Cell class="align-middle font-medium">Vocabulary Type</Table.Cell>
159 <Table.Cell class="align-middle capitalize">{modelMeta.vocab_type}</Table.Cell>
160 </Table.Row>
161 {/if}
162
163 <!-- Total Slots -->
164 <Table.Row>
165 <Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
166 <Table.Cell>{serverProps.total_slots}</Table.Cell>
167 </Table.Row>
168
169 <!-- Modalities -->
170 {#if modalities.length > 0}
171 <Table.Row>
172 <Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
173 <Table.Cell>
174 <div class="flex flex-wrap gap-1">
175 <BadgeModality {modalities} />
176 </div>
177 </Table.Cell>
178 </Table.Row>
179 {/if}
180
181 <!-- Build Info -->
182 <Table.Row>
183 <Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
184 <Table.Cell class="align-middle font-mono text-xs"
185 >{serverProps.build_info}</Table.Cell
186 >
187 </Table.Row>
188
189 <!-- Chat Template -->
190 {#if serverProps.chat_template}
191 <Table.Row>
192 <Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
193 <Table.Cell class="py-10">
194 <div class="max-h-120 overflow-y-auto rounded-md bg-muted p-4">
195 <pre
196 class="font-mono text-xs whitespace-pre-wrap">{serverProps.chat_template}</pre>
197 </div>
198 </Table.Cell>
199 </Table.Row>
200 {/if}
201 </Table.Body>
202 </Table.Root>
203 {/if}
204 {:else if !isLoadingModels}
205 <div class="flex items-center justify-center py-8">
206 <div class="text-sm text-muted-foreground">No model information available</div>
207 </div>
208 {/if}
209 </div>
210 </Dialog.Content>
211</Dialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte
new file mode 100644
index 0000000..a6c2029
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte
@@ -0,0 +1,76 @@
1<script lang="ts">
2 import * as AlertDialog from '$lib/components/ui/alert-dialog';
3 import { AlertTriangle, ArrowRight } from '@lucide/svelte';
4 import { goto } from '$app/navigation';
5 import { page } from '$app/state';
6
7 interface Props {
8 open: boolean;
9 modelName: string;
10 availableModels?: string[];
11 onOpenChange?: (open: boolean) => void;
12 }
13
14 let { open = $bindable(), modelName, availableModels = [], onOpenChange }: Props = $props();
15
16 function handleOpenChange(newOpen: boolean) {
17 open = newOpen;
18 onOpenChange?.(newOpen);
19 }
20
21 function handleSelectModel(model: string) {
22 // Build URL with selected model, preserving other params
23 const url = new URL(page.url);
24 url.searchParams.set('model', model);
25
26 handleOpenChange(false);
27 goto(url.toString());
28 }
29</script>
30
31<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
32 <AlertDialog.Content class="max-w-lg">
33 <AlertDialog.Header>
34 <AlertDialog.Title class="flex items-center gap-2">
35 <AlertTriangle class="h-5 w-5 text-amber-500" />
36 Model Not Available
37 </AlertDialog.Title>
38
39 <AlertDialog.Description>
40 The requested model could not be found. Select an available model to continue.
41 </AlertDialog.Description>
42 </AlertDialog.Header>
43
44 <div class="space-y-3">
45 <div class="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm">
46 <p class="font-medium text-amber-600 dark:text-amber-400">
47 Requested: <code class="rounded bg-amber-500/20 px-1.5 py-0.5">{modelName}</code>
48 </p>
49 </div>
50
51 {#if availableModels.length > 0}
52 <div class="text-sm">
53 <p class="mb-2 font-medium text-muted-foreground">Select an available model:</p>
54 <div class="max-h-48 space-y-1 overflow-y-auto rounded-md border p-1">
55 {#each availableModels as model (model)}
56 <button
57 type="button"
58 class="group flex w-full items-center justify-between gap-2 rounded-sm px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
59 onclick={() => handleSelectModel(model)}
60 >
61 <span class="min-w-0 truncate font-mono text-xs">{model}</span>
62 <ArrowRight
63 class="h-4 w-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
64 />
65 </button>
66 {/each}
67 </div>
68 </div>
69 {/if}
70 </div>
71
72 <AlertDialog.Footer>
73 <AlertDialog.Action onclick={() => handleOpenChange(false)}>Cancel</AlertDialog.Action>
74 </AlertDialog.Footer>
75 </AlertDialog.Content>
76</AlertDialog.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/index.ts b/llama.cpp/tools/server/webui/src/lib/components/app/index.ts
new file mode 100644
index 0000000..8631d4f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/index.ts
@@ -0,0 +1,75 @@
1// Chat
2
3export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
4export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
5export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
6export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
7export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
8
9export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
10export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
11export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
12export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
13export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
14export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
15export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
16export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
17
18export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
19export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
20export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
21export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
22export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
23export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
24export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
25export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
26
27export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
28export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
29export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
30
31export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
32export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
33export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
34export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
35export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
36
37export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
38export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
39export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
40
41// Dialogs
42
43export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
44export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
45export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
46export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte';
47export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
48export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
49export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
50export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
51export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
52export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
53
54// Miscellanous
55
56export { default as ActionButton } from './misc/ActionButton.svelte';
57export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
58export { default as BadgeChatStatistic } from './misc/BadgeChatStatistic.svelte';
59export { default as BadgeInfo } from './misc/BadgeInfo.svelte';
60export { default as ModelBadge } from './models/ModelBadge.svelte';
61export { default as BadgeModality } from './misc/BadgeModality.svelte';
62export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
63export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelte';
64export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
65export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
66export { default as RemoveButton } from './misc/RemoveButton.svelte';
67export { default as SearchInput } from './misc/SearchInput.svelte';
68export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
69export { default as ModelsSelector } from './models/ModelsSelector.svelte';
70
71// Server
72
73export { default as ServerStatus } from './server/ServerStatus.svelte';
74export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
75export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte
new file mode 100644
index 0000000..411a8b6
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte
@@ -0,0 +1,47 @@
1<script lang="ts">
2 import { Button } from '$lib/components/ui/button';
3 import * as Tooltip from '$lib/components/ui/tooltip';
4 import type { Component } from 'svelte';
5
6 interface Props {
7 icon: Component;
8 tooltip: string;
9 variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
10 size?: 'default' | 'sm' | 'lg' | 'icon';
11 class?: string;
12 disabled?: boolean;
13 onclick: () => void;
14 'aria-label'?: string;
15 }
16
17 let {
18 icon,
19 tooltip,
20 variant = 'ghost',
21 size = 'sm',
22 class: className = '',
23 disabled = false,
24 onclick,
25 'aria-label': ariaLabel
26 }: Props = $props();
27</script>
28
29<Tooltip.Root>
30 <Tooltip.Trigger>
31 <Button
32 {variant}
33 {size}
34 {disabled}
35 {onclick}
36 class="h-6 w-6 p-0 {className} flex"
37 aria-label={ariaLabel || tooltip}
38 >
39 {@const IconComponent = icon}
40 <IconComponent class="h-3 w-3" />
41 </Button>
42 </Tooltip.Trigger>
43
44 <Tooltip.Content>
45 <p>{tooltip}</p>
46 </Tooltip.Content>
47</Tooltip.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte
new file mode 100644
index 0000000..83d856d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ActionDropdown.svelte
@@ -0,0 +1,86 @@
1<script lang="ts">
2 import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
3 import * as Tooltip from '$lib/components/ui/tooltip';
4 import { KeyboardShortcutInfo } from '$lib/components/app';
5 import type { Component } from 'svelte';
6
7 interface ActionItem {
8 icon: Component;
9 label: string;
10 onclick: (event: Event) => void;
11 variant?: 'default' | 'destructive';
12 disabled?: boolean;
13 shortcut?: string[];
14 separator?: boolean;
15 }
16
17 interface Props {
18 triggerIcon: Component;
19 triggerTooltip?: string;
20 triggerClass?: string;
21 actions: ActionItem[];
22 align?: 'start' | 'center' | 'end';
23 open?: boolean;
24 }
25
26 let {
27 triggerIcon,
28 triggerTooltip,
29 triggerClass = '',
30 actions,
31 align = 'end',
32 open = $bindable(false)
33 }: Props = $props();
34</script>
35
36<DropdownMenu.Root bind:open>
37 <DropdownMenu.Trigger
38 class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}"
39 onclick={(e) => e.stopPropagation()}
40 >
41 {#if triggerTooltip}
42 <Tooltip.Root>
43 <Tooltip.Trigger>
44 {@render iconComponent(triggerIcon, 'h-3 w-3')}
45 <span class="sr-only">{triggerTooltip}</span>
46 </Tooltip.Trigger>
47 <Tooltip.Content>
48 <p>{triggerTooltip}</p>
49 </Tooltip.Content>
50 </Tooltip.Root>
51 {:else}
52 {@render iconComponent(triggerIcon, 'h-3 w-3')}
53 {/if}
54 </DropdownMenu.Trigger>
55
56 <DropdownMenu.Content {align} class="z-[999999] w-48">
57 {#each actions as action, index (action.label)}
58 {#if action.separator && index > 0}
59 <DropdownMenu.Separator />
60 {/if}
61
62 <DropdownMenu.Item
63 onclick={action.onclick}
64 variant={action.variant}
65 disabled={action.disabled}
66 class="flex items-center justify-between hover:[&>kbd]:opacity-100"
67 >
68 <div class="flex items-center gap-2">
69 {@render iconComponent(
70 action.icon,
71 `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
72 )}
73 {action.label}
74 </div>
75
76 {#if action.shortcut}
77 <KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
78 {/if}
79 </DropdownMenu.Item>
80 {/each}
81 </DropdownMenu.Content>
82</DropdownMenu.Root>
83
84{#snippet iconComponent(IconComponent: Component, className: string)}
85 <IconComponent class={className} />
86{/snippet}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte
new file mode 100644
index 0000000..a2b28d2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte
@@ -0,0 +1,44 @@
1<script lang="ts">
2 import { BadgeInfo } from '$lib/components/app';
3 import * as Tooltip from '$lib/components/ui/tooltip';
4 import { copyToClipboard } from '$lib/utils';
5 import type { Component } from 'svelte';
6
7 interface Props {
8 class?: string;
9 icon: Component;
10 value: string | number;
11 tooltipLabel?: string;
12 }
13
14 let { class: className = '', icon: Icon, value, tooltipLabel }: Props = $props();
15
16 function handleClick() {
17 void copyToClipboard(String(value));
18 }
19</script>
20
21{#if tooltipLabel}
22 <Tooltip.Root>
23 <Tooltip.Trigger>
24 <BadgeInfo class={className} onclick={handleClick}>
25 {#snippet icon()}
26 <Icon class="h-3 w-3" />
27 {/snippet}
28
29 {value}
30 </BadgeInfo>
31 </Tooltip.Trigger>
32 <Tooltip.Content>
33 <p>{tooltipLabel}</p>
34 </Tooltip.Content>
35 </Tooltip.Root>
36{:else}
37 <BadgeInfo class={className} onclick={handleClick}>
38 {#snippet icon()}
39 <Icon class="h-3 w-3" />
40 {/snippet}
41
42 {value}
43 </BadgeInfo>
44{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte
new file mode 100644
index 0000000..c70af6f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte
@@ -0,0 +1,27 @@
1<script lang="ts">
2 import { cn } from '$lib/components/ui/utils';
3 import type { Snippet } from 'svelte';
4
5 interface Props {
6 children: Snippet;
7 class?: string;
8 icon?: Snippet;
9 onclick?: () => void;
10 }
11
12 let { children, class: className = '', icon, onclick }: Props = $props();
13</script>
14
15<button
16 class={cn(
17 'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75',
18 className
19 )}
20 {onclick}
21>
22 {#if icon}
23 {@render icon()}
24 {/if}
25
26 {@render children()}
27</button>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte
new file mode 100644
index 0000000..a0d5e86
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte
@@ -0,0 +1,39 @@
1<script lang="ts">
2 import { ModelModality } from '$lib/enums';
3 import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants/icons';
4 import { cn } from '$lib/components/ui/utils';
5
6 type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO;
7
8 interface Props {
9 modalities: ModelModality[];
10 class?: string;
11 }
12
13 let { modalities, class: className = '' }: Props = $props();
14
15 // Filter to only modalities that have icons (VISION, AUDIO)
16 const displayableModalities = $derived(
17 modalities.filter(
18 (m): m is DisplayableModality => m === ModelModality.VISION || m === ModelModality.AUDIO
19 )
20 );
21</script>
22
23{#each displayableModalities as modality, index (index)}
24 {@const IconComponent = MODALITY_ICONS[modality]}
25 {@const label = MODALITY_LABELS[modality]}
26
27 <span
28 class={cn(
29 'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
30 className
31 )}
32 >
33 {#if IconComponent}
34 <IconComponent class="h-3 w-3" />
35 {/if}
36
37 {label}
38 </span>
39{/each}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte
new file mode 100644
index 0000000..702519f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte
@@ -0,0 +1,93 @@
1<script lang="ts">
2 import { Dialog as DialogPrimitive } from 'bits-ui';
3 import XIcon from '@lucide/svelte/icons/x';
4
5 interface Props {
6 open: boolean;
7 code: string;
8 language: string;
9 onOpenChange?: (open: boolean) => void;
10 }
11
12 let { open = $bindable(), code, language, onOpenChange }: Props = $props();
13
14 let iframeRef = $state<HTMLIFrameElement | null>(null);
15
16 $effect(() => {
17 if (!iframeRef) return;
18
19 if (open) {
20 iframeRef.srcdoc = code;
21 } else {
22 iframeRef.srcdoc = '';
23 }
24 });
25
26 function handleOpenChange(nextOpen: boolean) {
27 open = nextOpen;
28 onOpenChange?.(nextOpen);
29 }
30</script>
31
32<DialogPrimitive.Root {open} onOpenChange={handleOpenChange}>
33 <DialogPrimitive.Portal>
34 <DialogPrimitive.Overlay class="code-preview-overlay" />
35
36 <DialogPrimitive.Content class="code-preview-content">
37 <iframe
38 bind:this={iframeRef}
39 title="Preview {language}"
40 sandbox="allow-scripts"
41 class="code-preview-iframe"
42 ></iframe>
43
44 <DialogPrimitive.Close
45 class="code-preview-close absolute top-4 right-4 border-none bg-transparent text-white opacity-70 mix-blend-difference transition-opacity hover:opacity-100 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-8"
46 aria-label="Close preview"
47 >
48 <XIcon />
49 <span class="sr-only">Close preview</span>
50 </DialogPrimitive.Close>
51 </DialogPrimitive.Content>
52 </DialogPrimitive.Portal>
53</DialogPrimitive.Root>
54
55<style lang="postcss">
56 :global(.code-preview-overlay) {
57 position: fixed;
58 inset: 0;
59 background-color: transparent;
60 z-index: 100000;
61 }
62
63 :global(.code-preview-content) {
64 position: fixed;
65 inset: 0;
66 top: 0 !important;
67 left: 0 !important;
68 width: 100dvw;
69 height: 100dvh;
70 margin: 0;
71 padding: 0;
72 border: none;
73 border-radius: 0;
74 background-color: transparent;
75 box-shadow: none;
76 display: block;
77 overflow: hidden;
78 transform: none !important;
79 z-index: 100001;
80 }
81
82 :global(.code-preview-iframe) {
83 display: block;
84 width: 100dvw;
85 height: 100dvh;
86 border: 0;
87 }
88
89 :global(.code-preview-close) {
90 position: absolute;
91 z-index: 100002;
92 }
93</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte
new file mode 100644
index 0000000..e2095e0
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte
@@ -0,0 +1,205 @@
1<script lang="ts">
2 import { Search, X } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4 import { Input } from '$lib/components/ui/input';
5 import { Checkbox } from '$lib/components/ui/checkbox';
6 import { ScrollArea } from '$lib/components/ui/scroll-area';
7 import { SvelteSet } from 'svelte/reactivity';
8
9 interface Props {
10 conversations: DatabaseConversation[];
11 messageCountMap?: Map<string, number>;
12 mode: 'export' | 'import';
13 onCancel: () => void;
14 onConfirm: (selectedConversations: DatabaseConversation[]) => void;
15 }
16
17 let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props();
18
19 let searchQuery = $state('');
20 let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
21 let lastClickedId = $state<string | null>(null);
22
23 let filteredConversations = $derived(
24 conversations.filter((conv) => {
25 const name = conv.name || 'Untitled conversation';
26 return name.toLowerCase().includes(searchQuery.toLowerCase());
27 })
28 );
29
30 let allSelected = $derived(
31 filteredConversations.length > 0 &&
32 filteredConversations.every((conv) => selectedIds.has(conv.id))
33 );
34
35 let someSelected = $derived(
36 filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
37 );
38
39 function toggleConversation(id: string, shiftKey: boolean = false) {
40 const newSet = new SvelteSet(selectedIds);
41
42 if (shiftKey && lastClickedId !== null) {
43 const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
44 const currentIndex = filteredConversations.findIndex((c) => c.id === id);
45
46 if (lastIndex !== -1 && currentIndex !== -1) {
47 const start = Math.min(lastIndex, currentIndex);
48 const end = Math.max(lastIndex, currentIndex);
49
50 const shouldSelect = !newSet.has(id);
51
52 for (let i = start; i <= end; i++) {
53 if (shouldSelect) {
54 newSet.add(filteredConversations[i].id);
55 } else {
56 newSet.delete(filteredConversations[i].id);
57 }
58 }
59
60 selectedIds = newSet;
61 return;
62 }
63 }
64
65 if (newSet.has(id)) {
66 newSet.delete(id);
67 } else {
68 newSet.add(id);
69 }
70
71 selectedIds = newSet;
72 lastClickedId = id;
73 }
74
75 function toggleAll() {
76 if (allSelected) {
77 const newSet = new SvelteSet(selectedIds);
78
79 filteredConversations.forEach((conv) => newSet.delete(conv.id));
80 selectedIds = newSet;
81 } else {
82 const newSet = new SvelteSet(selectedIds);
83
84 filteredConversations.forEach((conv) => newSet.add(conv.id));
85 selectedIds = newSet;
86 }
87 }
88
89 function handleConfirm() {
90 const selected = conversations.filter((conv) => selectedIds.has(conv.id));
91 onConfirm(selected);
92 }
93
94 function handleCancel() {
95 selectedIds = new SvelteSet(conversations.map((c) => c.id));
96 searchQuery = '';
97 lastClickedId = null;
98
99 onCancel();
100 }
101
102 export function reset() {
103 selectedIds = new SvelteSet(conversations.map((c) => c.id));
104 searchQuery = '';
105 lastClickedId = null;
106 }
107</script>
108
109<div class="space-y-4">
110 <div class="relative">
111 <Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
112
113 <Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
114
115 {#if searchQuery}
116 <button
117 class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
118 onclick={() => (searchQuery = '')}
119 type="button"
120 >
121 <X class="h-4 w-4" />
122 </button>
123 {/if}
124 </div>
125
126 <div class="flex items-center justify-between text-sm text-muted-foreground">
127 <span>
128 {selectedIds.size} of {conversations.length} selected
129 {#if searchQuery}
130 ({filteredConversations.length} shown)
131 {/if}
132 </span>
133 </div>
134
135 <div class="overflow-hidden rounded-md border">
136 <ScrollArea class="h-[400px]">
137 <table class="w-full">
138 <thead class="sticky top-0 z-10 bg-muted">
139 <tr class="border-b">
140 <th class="w-12 p-3 text-left">
141 <Checkbox
142 checked={allSelected}
143 indeterminate={someSelected}
144 onCheckedChange={toggleAll}
145 />
146 </th>
147
148 <th class="p-3 text-left text-sm font-medium">Conversation Name</th>
149
150 <th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
151 </tr>
152 </thead>
153 <tbody>
154 {#if filteredConversations.length === 0}
155 <tr>
156 <td colspan="3" class="p-8 text-center text-sm text-muted-foreground">
157 {#if searchQuery}
158 No conversations found matching "{searchQuery}"
159 {:else}
160 No conversations available
161 {/if}
162 </td>
163 </tr>
164 {:else}
165 {#each filteredConversations as conv (conv.id)}
166 <tr
167 class="cursor-pointer border-b transition-colors hover:bg-muted/50"
168 onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
169 >
170 <td class="p-3">
171 <Checkbox
172 checked={selectedIds.has(conv.id)}
173 onclick={(e) => {
174 e.preventDefault();
175 e.stopPropagation();
176 toggleConversation(conv.id, e.shiftKey);
177 }}
178 />
179 </td>
180
181 <td class="p-3 text-sm">
182 <div class="max-w-[17rem] truncate" title={conv.name || 'Untitled conversation'}>
183 {conv.name || 'Untitled conversation'}
184 </div>
185 </td>
186
187 <td class="p-3 text-sm text-muted-foreground">
188 {messageCountMap.get(conv.id) ?? 0}
189 </td>
190 </tr>
191 {/each}
192 {/if}
193 </tbody>
194 </table>
195 </ScrollArea>
196 </div>
197
198 <div class="flex justify-end gap-2">
199 <Button variant="outline" onclick={handleCancel}>Cancel</Button>
200
201 <Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
202 {mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
203 </Button>
204 </div>
205</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte
new file mode 100644
index 0000000..bf6cd4f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte
@@ -0,0 +1,18 @@
1<script lang="ts">
2 import { Copy } from '@lucide/svelte';
3 import { copyToClipboard } from '$lib/utils';
4
5 interface Props {
6 ariaLabel?: string;
7 canCopy?: boolean;
8 text: string;
9 }
10
11 let { ariaLabel = 'Copy to clipboard', canCopy = true, text }: Props = $props();
12</script>
13
14<Copy
15 class="h-3 w-3 flex-shrink-0 cursor-{canCopy ? 'pointer' : 'not-allowed'}"
16 aria-label={ariaLabel}
17 onclick={() => canCopy && copyToClipboard(text)}
18/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte
new file mode 100644
index 0000000..5b7522f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte
@@ -0,0 +1,31 @@
1<script lang="ts">
2 import { ArrowBigUp } from '@lucide/svelte';
3
4 interface Props {
5 keys: string[];
6 variant?: 'default' | 'destructive';
7 class?: string;
8 }
9
10 let { keys, variant = 'default', class: className = '' }: Props = $props();
11
12 let baseClasses =
13 'px-1 pointer-events-none inline-flex select-none items-center gap-0.5 font-sans text-md font-medium opacity-0 transition-opacity -my-1';
14 let variantClasses = variant === 'destructive' ? 'text-destructive' : 'text-muted-foreground';
15</script>
16
17<kbd class="{baseClasses} {variantClasses} {className}">
18 {#each keys as key, index (index)}
19 {#if key === 'shift'}
20 <ArrowBigUp class="h-1 w-1 {variant === 'destructive' ? 'text-destructive' : ''} -mr-1" />
21 {:else if key === 'cmd'}
22 <span class={variant === 'destructive' ? 'text-destructive' : ''}>⌘</span>
23 {:else}
24 {key.toUpperCase()}
25 {/if}
26
27 {#if index < keys.length - 1}
28 <span> </span>
29 {/if}
30 {/each}
31</kbd>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
new file mode 100644
index 0000000..cb3ae17
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
@@ -0,0 +1,870 @@
1<script lang="ts">
2 import { remark } from 'remark';
3 import remarkBreaks from 'remark-breaks';
4 import remarkGfm from 'remark-gfm';
5 import remarkMath from 'remark-math';
6 import rehypeHighlight from 'rehype-highlight';
7 import remarkRehype from 'remark-rehype';
8 import rehypeKatex from 'rehype-katex';
9 import rehypeStringify from 'rehype-stringify';
10 import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
11 import type { Root as MdastRoot } from 'mdast';
12 import { browser } from '$app/environment';
13 import { onDestroy, tick } from 'svelte';
14 import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
15 import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
16 import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
17 import { remarkLiteralHtml } from '$lib/markdown/literal-html';
18 import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
19 import '$styles/katex-custom.scss';
20 import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
21 import githubLightCss from 'highlight.js/styles/github.css?inline';
22 import { mode } from 'mode-watcher';
23 import CodePreviewDialog from './CodePreviewDialog.svelte';
24
25 interface Props {
26 content: string;
27 class?: string;
28 }
29
30 interface MarkdownBlock {
31 id: string;
32 html: string;
33 }
34
35 let { content, class: className = '' }: Props = $props();
36
37 let containerRef = $state<HTMLDivElement>();
38 let renderedBlocks = $state<MarkdownBlock[]>([]);
39 let unstableBlockHtml = $state('');
40 let previewDialogOpen = $state(false);
41 let previewCode = $state('');
42 let previewLanguage = $state('text');
43
44 let pendingMarkdown: string | null = null;
45 let isProcessing = false;
46
47 const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
48
49 let processor = $derived(() => {
50 return remark()
51 .use(remarkGfm) // GitHub Flavored Markdown
52 .use(remarkMath) // Parse $inline$ and $$block$$ math
53 .use(remarkBreaks) // Convert line breaks to <br>
54 .use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
55 .use(remarkRehype) // Convert Markdown AST to rehype
56 .use(rehypeKatex) // Render math using KaTeX
57 .use(rehypeHighlight) // Add syntax highlighting
58 .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
59 .use(rehypeEnhanceLinks) // Add target="_blank" to links
60 .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
61 .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
62 });
63
64 /**
65 * Removes click event listeners from copy and preview buttons.
66 * Called on component destroy.
67 */
68 function cleanupEventListeners() {
69 if (!containerRef) return;
70
71 const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
72 const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn');
73
74 for (const button of copyButtons) {
75 button.removeEventListener('click', handleCopyClick);
76 }
77
78 for (const button of previewButtons) {
79 button.removeEventListener('click', handlePreviewClick);
80 }
81 }
82
83 /**
84 * Removes this component's highlight.js theme style from the document head.
85 * Called on component destroy to clean up injected styles.
86 */
87 function cleanupHighlightTheme() {
88 if (!browser) return;
89
90 const existingTheme = document.getElementById(themeStyleId);
91 existingTheme?.remove();
92 }
93
94 /**
95 * Loads the appropriate highlight.js theme based on dark/light mode.
96 * Injects a scoped style element into the document head.
97 * @param isDark - Whether to load the dark theme (true) or light theme (false)
98 */
99 function loadHighlightTheme(isDark: boolean) {
100 if (!browser) return;
101
102 const existingTheme = document.getElementById(themeStyleId);
103 existingTheme?.remove();
104
105 const style = document.createElement('style');
106 style.id = themeStyleId;
107 style.textContent = isDark ? githubDarkCss : githubLightCss;
108
109 document.head.appendChild(style);
110 }
111
112 /**
113 * Extracts code information from a button click target within a code block.
114 * @param target - The clicked button element
115 * @returns Object with rawCode and language, or null if extraction fails
116 */
117 function getCodeInfoFromTarget(target: HTMLElement) {
118 const wrapper = target.closest('.code-block-wrapper');
119
120 if (!wrapper) {
121 console.error('No wrapper found');
122 return null;
123 }
124
125 const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
126
127 if (!codeElement) {
128 console.error('No code element found in wrapper');
129 return null;
130 }
131
132 const rawCode = codeElement.textContent ?? '';
133
134 const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
135 const language = languageLabel?.textContent?.trim() || 'text';
136
137 return { rawCode, language };
138 }
139
140 /**
141 * Generates a unique identifier for a HAST node based on its position.
142 * Used for stable block identification during incremental rendering.
143 * @param node - The HAST root content node
144 * @param indexFallback - Fallback index if position is unavailable
145 * @returns Unique string identifier for the node
146 */
147 function getHastNodeId(node: HastRootContent, indexFallback: number): string {
148 const position = node.position;
149
150 if (position?.start?.offset != null && position?.end?.offset != null) {
151 return `hast-${position.start.offset}-${position.end.offset}`;
152 }
153
154 return `${node.type}-${indexFallback}`;
155 }
156
157 /**
158 * Handles click events on copy buttons within code blocks.
159 * Copies the raw code content to the clipboard.
160 * @param event - The click event from the copy button
161 */
162 async function handleCopyClick(event: Event) {
163 event.preventDefault();
164 event.stopPropagation();
165
166 const target = event.currentTarget as HTMLButtonElement | null;
167
168 if (!target) {
169 return;
170 }
171
172 const info = getCodeInfoFromTarget(target);
173
174 if (!info) {
175 return;
176 }
177
178 try {
179 await copyCodeToClipboard(info.rawCode);
180 } catch (error) {
181 console.error('Failed to copy code:', error);
182 }
183 }
184
185 /**
186 * Handles preview dialog open state changes.
187 * Clears preview content when dialog is closed.
188 * @param open - Whether the dialog is being opened or closed
189 */
190 function handlePreviewDialogOpenChange(open: boolean) {
191 previewDialogOpen = open;
192
193 if (!open) {
194 previewCode = '';
195 previewLanguage = 'text';
196 }
197 }
198
199 /**
200 * Handles click events on preview buttons within HTML code blocks.
201 * Opens a preview dialog with the rendered HTML content.
202 * @param event - The click event from the preview button
203 */
204 function handlePreviewClick(event: Event) {
205 event.preventDefault();
206 event.stopPropagation();
207
208 const target = event.currentTarget as HTMLButtonElement | null;
209
210 if (!target) {
211 return;
212 }
213
214 const info = getCodeInfoFromTarget(target);
215
216 if (!info) {
217 return;
218 }
219
220 previewCode = info.rawCode;
221 previewLanguage = info.language;
222 previewDialogOpen = true;
223 }
224
225 /**
226 * Processes markdown content into stable and unstable HTML blocks.
227 * Uses incremental rendering: stable blocks are cached, unstable block is re-rendered.
228 * @param markdown - The raw markdown string to process
229 */
230 async function processMarkdown(markdown: string) {
231 if (!markdown) {
232 renderedBlocks = [];
233 unstableBlockHtml = '';
234 return;
235 }
236
237 const normalized = preprocessLaTeX(markdown);
238 const processorInstance = processor();
239 const ast = processorInstance.parse(normalized) as MdastRoot;
240 const processedRoot = (await processorInstance.run(ast)) as HastRoot;
241 const processedChildren = processedRoot.children ?? [];
242 const stableCount = Math.max(processedChildren.length - 1, 0);
243 const nextBlocks: MarkdownBlock[] = [];
244
245 for (let index = 0; index < stableCount; index++) {
246 const hastChild = processedChildren[index];
247 const id = getHastNodeId(hastChild, index);
248 const existing = renderedBlocks[index];
249
250 if (existing && existing.id === id) {
251 nextBlocks.push(existing);
252 continue;
253 }
254
255 const html = stringifyProcessedNode(
256 processorInstance,
257 processedRoot,
258 processedChildren[index]
259 );
260
261 nextBlocks.push({ id, html });
262 }
263
264 let unstableHtml = '';
265
266 if (processedChildren.length > stableCount) {
267 const unstableChild = processedChildren[stableCount];
268 unstableHtml = stringifyProcessedNode(processorInstance, processedRoot, unstableChild);
269 }
270
271 renderedBlocks = nextBlocks;
272 await tick(); // Force DOM sync before updating unstable HTML block
273 unstableBlockHtml = unstableHtml;
274 }
275
276 /**
277 * Attaches click event listeners to copy and preview buttons in code blocks.
278 * Uses data-listener-bound attribute to prevent duplicate bindings.
279 */
280 function setupCodeBlockActions() {
281 if (!containerRef) return;
282
283 const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
284
285 for (const wrapper of wrappers) {
286 const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
287 const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
288
289 if (copyButton && copyButton.dataset.listenerBound !== 'true') {
290 copyButton.dataset.listenerBound = 'true';
291 copyButton.addEventListener('click', handleCopyClick);
292 }
293
294 if (previewButton && previewButton.dataset.listenerBound !== 'true') {
295 previewButton.dataset.listenerBound = 'true';
296 previewButton.addEventListener('click', handlePreviewClick);
297 }
298 }
299 }
300
301 /**
302 * Converts a single HAST node to an enhanced HTML string.
303 * Applies link and code block enhancements to the output.
304 * @param processorInstance - The remark/rehype processor instance
305 * @param processedRoot - The full processed HAST root (for context)
306 * @param child - The specific HAST child node to stringify
307 * @returns Enhanced HTML string representation of the node
308 */
309 function stringifyProcessedNode(
310 processorInstance: ReturnType<typeof processor>,
311 processedRoot: HastRoot,
312 child: unknown
313 ) {
314 const root: HastRoot = {
315 ...(processedRoot as HastRoot),
316 children: [child as never]
317 };
318
319 return processorInstance.stringify(root);
320 }
321
322 /**
323 * Queues markdown for processing with coalescing support.
324 * Only processes the latest markdown when multiple updates arrive quickly.
325 * @param markdown - The markdown content to render
326 */
327 async function updateRenderedBlocks(markdown: string) {
328 pendingMarkdown = markdown;
329
330 if (isProcessing) {
331 return;
332 }
333
334 isProcessing = true;
335
336 try {
337 while (pendingMarkdown !== null) {
338 const nextMarkdown = pendingMarkdown;
339 pendingMarkdown = null;
340
341 await processMarkdown(nextMarkdown);
342 }
343 } catch (error) {
344 console.error('Failed to process markdown:', error);
345 renderedBlocks = [];
346 unstableBlockHtml = markdown.replace(/\n/g, '<br>');
347 } finally {
348 isProcessing = false;
349 }
350 }
351
352 $effect(() => {
353 const currentMode = mode.current;
354 const isDark = currentMode === 'dark';
355
356 loadHighlightTheme(isDark);
357 });
358
359 $effect(() => {
360 updateRenderedBlocks(content);
361 });
362
363 $effect(() => {
364 const hasRenderedBlocks = renderedBlocks.length > 0;
365 const hasUnstableBlock = Boolean(unstableBlockHtml);
366
367 if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
368 setupCodeBlockActions();
369 }
370 });
371
372 onDestroy(() => {
373 cleanupEventListeners();
374 cleanupHighlightTheme();
375 });
376</script>
377
378<div bind:this={containerRef} class={className}>
379 {#each renderedBlocks as block (block.id)}
380 <div class="markdown-block" data-block-id={block.id}>
381 <!-- eslint-disable-next-line no-at-html-tags -->
382 {@html block.html}
383 </div>
384 {/each}
385
386 {#if unstableBlockHtml}
387 <div class="markdown-block markdown-block--unstable" data-block-id="unstable">
388 <!-- eslint-disable-next-line no-at-html-tags -->
389 {@html unstableBlockHtml}
390 </div>
391 {/if}
392</div>
393
394<CodePreviewDialog
395 open={previewDialogOpen}
396 code={previewCode}
397 language={previewLanguage}
398 onOpenChange={handlePreviewDialogOpenChange}
399/>
400
401<style>
402 .markdown-block,
403 .markdown-block--unstable {
404 display: contents;
405 }
406
407 /* Base typography styles */
408 div :global(p:not(:last-child)) {
409 margin-bottom: 1rem;
410 line-height: 1.75;
411 }
412
413 div :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
414 margin-top: 0;
415 }
416
417 /* Headers with consistent spacing */
418 div :global(h1) {
419 font-size: 1.875rem;
420 font-weight: 700;
421 line-height: 1.2;
422 margin: 1.5rem 0 0.75rem 0;
423 }
424
425 div :global(h2) {
426 font-size: 1.5rem;
427 font-weight: 600;
428 line-height: 1.3;
429 margin: 1.25rem 0 0.5rem 0;
430 }
431
432 div :global(h3) {
433 font-size: 1.25rem;
434 font-weight: 600;
435 margin: 1.5rem 0 0.5rem 0;
436 line-height: 1.4;
437 }
438
439 div :global(h4) {
440 font-size: 1.125rem;
441 font-weight: 600;
442 margin: 0.75rem 0 0.25rem 0;
443 }
444
445 div :global(h5) {
446 font-size: 1rem;
447 font-weight: 600;
448 margin: 0.5rem 0 0.25rem 0;
449 }
450
451 div :global(h6) {
452 font-size: 0.875rem;
453 font-weight: 600;
454 margin: 0.5rem 0 0.25rem 0;
455 }
456
457 /* Text formatting */
458 div :global(strong) {
459 font-weight: 600;
460 }
461
462 div :global(em) {
463 font-style: italic;
464 }
465
466 div :global(del) {
467 text-decoration: line-through;
468 opacity: 0.7;
469 }
470
471 /* Inline code */
472 div :global(code:not(pre code)) {
473 background: var(--muted);
474 color: var(--muted-foreground);
475 padding: 0.125rem 0.375rem;
476 border-radius: 0.375rem;
477 font-size: 0.875rem;
478 font-family:
479 ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
480 'Liberation Mono', Menlo, monospace;
481 }
482
483 /* Links */
484 div :global(a) {
485 color: var(--primary);
486 text-decoration: underline;
487 text-underline-offset: 2px;
488 transition: color 0.2s ease;
489 }
490
491 div :global(a:hover) {
492 color: var(--primary);
493 }
494
495 /* Lists */
496 div :global(ul) {
497 list-style-type: disc;
498 margin-left: 1.5rem;
499 margin-bottom: 1rem;
500 }
501
502 div :global(ol) {
503 list-style-type: decimal;
504 margin-left: 1.5rem;
505 margin-bottom: 1rem;
506 }
507
508 div :global(li) {
509 margin-bottom: 0.25rem;
510 padding-left: 0.5rem;
511 }
512
513 div :global(li::marker) {
514 color: var(--muted-foreground);
515 }
516
517 /* Nested lists */
518 div :global(ul ul) {
519 list-style-type: circle;
520 margin-top: 0.25rem;
521 margin-bottom: 0.25rem;
522 }
523
524 div :global(ol ol) {
525 list-style-type: lower-alpha;
526 margin-top: 0.25rem;
527 margin-bottom: 0.25rem;
528 }
529
530 /* Task lists */
531 div :global(.task-list-item) {
532 list-style: none;
533 margin-left: 0;
534 padding-left: 0;
535 }
536
537 div :global(.task-list-item-checkbox) {
538 margin-right: 0.5rem;
539 margin-top: 0.125rem;
540 }
541
542 /* Blockquotes */
543 div :global(blockquote) {
544 border-left: 4px solid var(--border);
545 padding: 0.5rem 1rem;
546 margin: 1.5rem 0;
547 font-style: italic;
548 color: var(--muted-foreground);
549 background: var(--muted);
550 border-radius: 0 0.375rem 0.375rem 0;
551 }
552
553 /* Tables */
554 div :global(table) {
555 width: 100%;
556 margin: 1.5rem 0;
557 border-collapse: collapse;
558 border: 1px solid var(--border);
559 border-radius: 0.375rem;
560 overflow: hidden;
561 }
562
563 div :global(th) {
564 background: hsl(var(--muted) / 0.3);
565 border: 1px solid var(--border);
566 padding: 0.5rem 0.75rem;
567 text-align: left;
568 font-weight: 600;
569 }
570
571 div :global(td) {
572 border: 1px solid var(--border);
573 padding: 0.5rem 0.75rem;
574 }
575
576 div :global(tr:nth-child(even)) {
577 background: hsl(var(--muted) / 0.1);
578 }
579
580 /* User message markdown should keep table borders visible on light primary backgrounds */
581 div.markdown-user-content :global(table),
582 div.markdown-user-content :global(th),
583 div.markdown-user-content :global(td),
584 div.markdown-user-content :global(.table-wrapper) {
585 border-color: currentColor;
586 }
587
588 /* Horizontal rules */
589 div :global(hr) {
590 border: none;
591 border-top: 1px solid var(--border);
592 margin: 1.5rem 0;
593 }
594
595 /* Images */
596 div :global(img) {
597 border-radius: 0.5rem;
598 box-shadow:
599 0 1px 3px 0 rgb(0 0 0 / 0.1),
600 0 1px 2px -1px rgb(0 0 0 / 0.1);
601 margin: 1.5rem 0;
602 max-width: 100%;
603 height: auto;
604 }
605
606 /* Code blocks */
607
608 div :global(.code-block-wrapper) {
609 margin: 1.5rem 0;
610 border-radius: 0.75rem;
611 overflow: hidden;
612 border: 1px solid var(--border);
613 background: var(--code-background);
614 }
615
616 div :global(.code-block-header) {
617 display: flex;
618 justify-content: space-between;
619 align-items: center;
620 padding: 0.5rem 1rem;
621 background: hsl(var(--muted) / 0.5);
622 border-bottom: 1px solid var(--border);
623 font-size: 0.875rem;
624 }
625
626 div :global(.code-language) {
627 color: var(--code-foreground);
628 font-weight: 500;
629 font-family:
630 ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
631 'Liberation Mono', Menlo, monospace;
632 text-transform: uppercase;
633 font-size: 0.75rem;
634 letter-spacing: 0.05em;
635 }
636
637 div :global(.code-block-actions) {
638 display: flex;
639 align-items: center;
640 gap: 0.5rem;
641 }
642
643 div :global(.copy-code-btn),
644 div :global(.preview-code-btn) {
645 display: flex;
646 align-items: center;
647 justify-content: center;
648 padding: 0;
649 background: transparent;
650 color: var(--code-foreground);
651 cursor: pointer;
652 transition: all 0.2s ease;
653 }
654
655 div :global(.copy-code-btn:hover),
656 div :global(.preview-code-btn:hover) {
657 transform: scale(1.05);
658 }
659
660 div :global(.copy-code-btn:active),
661 div :global(.preview-code-btn:active) {
662 transform: scale(0.95);
663 }
664
665 div :global(.code-block-wrapper pre) {
666 background: transparent;
667 padding: 1rem;
668 margin: 0;
669 overflow-x: auto;
670 border-radius: 0;
671 border: none;
672 font-size: 0.875rem;
673 line-height: 1.5;
674 }
675
676 div :global(pre) {
677 background: var(--muted);
678 margin: 1.5rem 0;
679 overflow-x: auto;
680 border-radius: 1rem;
681 border: none;
682 }
683
684 div :global(code) {
685 background: transparent;
686 color: var(--code-foreground);
687 }
688
689 /* Mentions and hashtags */
690 div :global(.mention) {
691 color: hsl(var(--primary));
692 font-weight: 500;
693 text-decoration: none;
694 }
695
696 div :global(.mention:hover) {
697 text-decoration: underline;
698 }
699
700 div :global(.hashtag) {
701 color: hsl(var(--primary));
702 font-weight: 500;
703 text-decoration: none;
704 }
705
706 div :global(.hashtag:hover) {
707 text-decoration: underline;
708 }
709
710 /* Advanced table enhancements */
711 div :global(table) {
712 transition: all 0.2s ease;
713 }
714
715 div :global(table:hover) {
716 box-shadow:
717 0 4px 6px -1px rgb(0 0 0 / 0.1),
718 0 2px 4px -2px rgb(0 0 0 / 0.1);
719 }
720
721 div :global(th:hover),
722 div :global(td:hover) {
723 background: var(--muted);
724 }
725
726 /* Disable hover effects when rendering user messages */
727 .markdown-user-content :global(a),
728 .markdown-user-content :global(a:hover) {
729 color: var(--primary-foreground);
730 }
731
732 .markdown-user-content :global(table:hover) {
733 box-shadow: none;
734 }
735
736 .markdown-user-content :global(th:hover),
737 .markdown-user-content :global(td:hover) {
738 background: inherit;
739 }
740
741 /* Enhanced blockquotes */
742 div :global(blockquote) {
743 transition: all 0.2s ease;
744 position: relative;
745 }
746
747 div :global(blockquote:hover) {
748 border-left-width: 6px;
749 background: var(--muted);
750 transform: translateX(2px);
751 }
752
753 div :global(blockquote::before) {
754 content: '"';
755 position: absolute;
756 top: -0.5rem;
757 left: 0.5rem;
758 font-size: 3rem;
759 color: var(--muted-foreground);
760 font-family: serif;
761 line-height: 1;
762 }
763
764 /* Enhanced images */
765 div :global(img) {
766 transition: all 0.3s ease;
767 cursor: pointer;
768 }
769
770 div :global(img:hover) {
771 transform: scale(1.02);
772 box-shadow:
773 0 10px 15px -3px rgb(0 0 0 / 0.1),
774 0 4px 6px -4px rgb(0 0 0 / 0.1);
775 }
776
777 /* Image zoom overlay */
778 div :global(.image-zoom-overlay) {
779 position: fixed;
780 top: 0;
781 left: 0;
782 right: 0;
783 bottom: 0;
784 background: rgba(0, 0, 0, 0.8);
785 display: flex;
786 align-items: center;
787 justify-content: center;
788 z-index: 1000;
789 cursor: pointer;
790 }
791
792 div :global(.image-zoom-overlay img) {
793 max-width: 90vw;
794 max-height: 90vh;
795 border-radius: 0.5rem;
796 box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
797 }
798
799 /* Enhanced horizontal rules */
800 div :global(hr) {
801 border: none;
802 height: 2px;
803 background: linear-gradient(to right, transparent, var(--border), transparent);
804 margin: 2rem 0;
805 position: relative;
806 }
807
808 div :global(hr::after) {
809 content: '';
810 position: absolute;
811 top: 50%;
812 left: 50%;
813 transform: translate(-50%, -50%);
814 width: 1rem;
815 height: 1rem;
816 background: var(--border);
817 border-radius: 50%;
818 }
819
820 /* Scrollable tables */
821 div :global(.table-wrapper) {
822 overflow-x: auto;
823 margin: 1.5rem 0;
824 border-radius: 0.5rem;
825 border: 1px solid var(--border);
826 }
827
828 div :global(.table-wrapper table) {
829 margin: 0;
830 border: none;
831 }
832
833 /* Responsive adjustments */
834 @media (max-width: 640px) {
835 div :global(h1) {
836 font-size: 1.5rem;
837 }
838
839 div :global(h2) {
840 font-size: 1.25rem;
841 }
842
843 div :global(h3) {
844 font-size: 1.125rem;
845 }
846
847 div :global(table) {
848 font-size: 0.875rem;
849 }
850
851 div :global(th),
852 div :global(td) {
853 padding: 0.375rem 0.5rem;
854 }
855
856 div :global(.table-wrapper) {
857 margin: 0.5rem -1rem;
858 border-radius: 0;
859 border-left: none;
860 border-right: none;
861 }
862 }
863
864 /* Dark mode adjustments */
865 @media (prefers-color-scheme: dark) {
866 div :global(blockquote:hover) {
867 background: var(--muted);
868 }
869 }
870</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte
new file mode 100644
index 0000000..1736855
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte
@@ -0,0 +1,26 @@
1<script lang="ts">
2 import { X } from '@lucide/svelte';
3 import { Button } from '$lib/components/ui/button';
4
5 interface Props {
6 id: string;
7 onRemove?: (id: string) => void;
8 class?: string;
9 }
10
11 let { id, onRemove, class: className = '' }: Props = $props();
12</script>
13
14<Button
15 type="button"
16 variant="ghost"
17 size="sm"
18 class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}"
19 onclick={(e) => {
20 e.stopPropagation();
21 onRemove?.(id);
22 }}
23 aria-label="Remove file"
24>
25 <X class="h-3 w-3" />
26</Button>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte
new file mode 100644
index 0000000..15cd6ab
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte
@@ -0,0 +1,73 @@
1<script lang="ts">
2 import { Input } from '$lib/components/ui/input';
3 import { Search, X } from '@lucide/svelte';
4
5 interface Props {
6 value?: string;
7 placeholder?: string;
8 onInput?: (value: string) => void;
9 onClose?: () => void;
10 onKeyDown?: (event: KeyboardEvent) => void;
11 class?: string;
12 id?: string;
13 ref?: HTMLInputElement | null;
14 }
15
16 let {
17 value = $bindable(''),
18 placeholder = 'Search...',
19 onInput,
20 onClose,
21 onKeyDown,
22 class: className,
23 id,
24 ref = $bindable(null)
25 }: Props = $props();
26
27 let showClearButton = $derived(!!value || !!onClose);
28
29 function handleInput(event: Event) {
30 const target = event.target as HTMLInputElement;
31
32 value = target.value;
33 onInput?.(target.value);
34 }
35
36 function handleClear() {
37 if (value) {
38 value = '';
39 onInput?.('');
40 ref?.focus();
41 } else {
42 onClose?.();
43 }
44 }
45</script>
46
47<div class="relative {className}">
48 <Search
49 class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
50 />
51
52 <Input
53 {id}
54 bind:value
55 bind:ref
56 class="pl-9 {showClearButton ? 'pr-9' : ''}"
57 oninput={handleInput}
58 onkeydown={onKeyDown}
59 {placeholder}
60 type="search"
61 />
62
63 {#if showClearButton}
64 <button
65 type="button"
66 class="absolute top-1/2 right-3 -translate-y-1/2 transform text-muted-foreground transition-colors hover:text-foreground"
67 onclick={handleClear}
68 aria-label={value ? 'Clear search' : 'Close'}
69 >
70 <X class="h-4 w-4" />
71 </button>
72 {/if}
73</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte
new file mode 100644
index 0000000..bc42f9d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte
@@ -0,0 +1,97 @@
1<script lang="ts">
2 import hljs from 'highlight.js';
3 import { browser } from '$app/environment';
4 import { mode } from 'mode-watcher';
5
6 import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
7 import githubLightCss from 'highlight.js/styles/github.css?inline';
8
9 interface Props {
10 code: string;
11 language?: string;
12 class?: string;
13 maxHeight?: string;
14 maxWidth?: string;
15 }
16
17 let {
18 code,
19 language = 'text',
20 class: className = '',
21 maxHeight = '60vh',
22 maxWidth = ''
23 }: Props = $props();
24
25 let highlightedHtml = $state('');
26
27 function loadHighlightTheme(isDark: boolean) {
28 if (!browser) return;
29
30 const existingThemes = document.querySelectorAll('style[data-highlight-theme-preview]');
31 existingThemes.forEach((style) => style.remove());
32
33 const style = document.createElement('style');
34 style.setAttribute('data-highlight-theme-preview', 'true');
35 style.textContent = isDark ? githubDarkCss : githubLightCss;
36
37 document.head.appendChild(style);
38 }
39
40 $effect(() => {
41 const currentMode = mode.current;
42 const isDark = currentMode === 'dark';
43
44 loadHighlightTheme(isDark);
45 });
46
47 $effect(() => {
48 if (!code) {
49 highlightedHtml = '';
50 return;
51 }
52
53 try {
54 // Check if the language is supported
55 const lang = language.toLowerCase();
56 const isSupported = hljs.getLanguage(lang);
57
58 if (isSupported) {
59 const result = hljs.highlight(code, { language: lang });
60 highlightedHtml = result.value;
61 } else {
62 // Try auto-detection or fallback to plain text
63 const result = hljs.highlightAuto(code);
64 highlightedHtml = result.value;
65 }
66 } catch {
67 // Fallback to escaped plain text
68 highlightedHtml = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
69 }
70 });
71</script>
72
73<div
74 class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}"
75 style="max-height: {maxHeight}; max-width: {maxWidth};"
76>
77 <!-- Needs to be formatted as single line for proper rendering -->
78 <pre class="m-0 overflow-x-auto p-4"><code class="hljs text-sm leading-relaxed"
79 >{@html highlightedHtml}</code
80 ></pre>
81</div>
82
83<style>
84 .code-preview-wrapper {
85 font-family:
86 ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
87 'Liberation Mono', Menlo, monospace;
88 }
89
90 .code-preview-wrapper pre {
91 background: transparent;
92 }
93
94 .code-preview-wrapper code {
95 background: transparent;
96 }
97</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte
new file mode 100644
index 0000000..bea1bf6
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte
@@ -0,0 +1,56 @@
1<script lang="ts">
2 import { Package } from '@lucide/svelte';
3 import { BadgeInfo, CopyToClipboardIcon } from '$lib/components/app';
4 import { modelsStore } from '$lib/stores/models.svelte';
5 import { serverStore } from '$lib/stores/server.svelte';
6 import * as Tooltip from '$lib/components/ui/tooltip';
7
8 interface Props {
9 class?: string;
10 model?: string;
11 onclick?: () => void;
12 showCopyIcon?: boolean;
13 showTooltip?: boolean;
14 }
15
16 let {
17 class: className = '',
18 model: modelProp,
19 onclick,
20 showCopyIcon = false,
21 showTooltip = false
22 }: Props = $props();
23
24 let model = $derived(modelProp || modelsStore.singleModelName);
25 let isModelMode = $derived(serverStore.isModelMode);
26</script>
27
28{#snippet badgeContent()}
29 <BadgeInfo class={className} {onclick}>
30 {#snippet icon()}
31 <Package class="h-3 w-3" />
32 {/snippet}
33
34 {model}
35
36 {#if showCopyIcon}
37 <CopyToClipboardIcon text={model || ''} ariaLabel="Copy model name" />
38 {/if}
39 </BadgeInfo>
40{/snippet}
41
42{#if model && isModelMode}
43 {#if showTooltip}
44 <Tooltip.Root>
45 <Tooltip.Trigger>
46 {@render badgeContent()}
47 </Tooltip.Trigger>
48
49 <Tooltip.Content>
50 {onclick ? 'Click for model details' : model}
51 </Tooltip.Content>
52 </Tooltip.Root>
53 {:else}
54 {@render badgeContent()}
55 {/if}
56{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte
new file mode 100644
index 0000000..efc9cd4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte
@@ -0,0 +1,555 @@
1<script lang="ts">
2 import { onMount, tick } from 'svelte';
3 import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
4 import * as Tooltip from '$lib/components/ui/tooltip';
5 import * as Popover from '$lib/components/ui/popover';
6 import { cn } from '$lib/components/ui/utils';
7 import {
8 modelsStore,
9 modelOptions,
10 modelsLoading,
11 modelsUpdating,
12 selectedModelId,
13 routerModels,
14 propsCacheVersion,
15 singleModelName
16 } from '$lib/stores/models.svelte';
17 import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
18 import { ServerModelStatus } from '$lib/enums';
19 import { isRouterMode } from '$lib/stores/server.svelte';
20 import { DialogModelInformation, SearchInput } from '$lib/components/app';
21 import type { ModelOption } from '$lib/types/models';
22
23 interface Props {
24 class?: string;
25 currentModel?: string | null;
26 /** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
27 onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
28 disabled?: boolean;
29 forceForegroundText?: boolean;
30 /** When true, user's global selection takes priority over currentModel (for form selector) */
31 useGlobalSelection?: boolean;
32 /**
33 * When provided, only consider modalities from messages BEFORE this message.
34 * Used for regeneration - allows selecting models that don't support modalities
35 * used in later messages.
36 */
37 upToMessageId?: string;
38 }
39
40 let {
41 class: className = '',
42 currentModel = null,
43 onModelChange,
44 disabled = false,
45 forceForegroundText = false,
46 useGlobalSelection = false,
47 upToMessageId
48 }: Props = $props();
49
50 let options = $derived(modelOptions());
51 let loading = $derived(modelsLoading());
52 let updating = $derived(modelsUpdating());
53 let activeId = $derived(selectedModelId());
54 let isRouter = $derived(isRouterMode());
55 let serverModel = $derived(singleModelName());
56
57 // Reactive router models state - needed for proper reactivity of status checks
58 let currentRouterModels = $derived(routerModels());
59
60 let requiredModalities = $derived(
61 upToMessageId ? conversationsStore.getModalitiesUpToMessage(upToMessageId) : usedModalities()
62 );
63
64 function getModelStatus(modelId: string): ServerModelStatus | null {
65 const model = currentRouterModels.find((m) => m.id === modelId);
66 return (model?.status?.value as ServerModelStatus) ?? null;
67 }
68
69 /**
70 * Checks if a model supports all modalities used in the conversation.
71 * Returns true if the model can be selected, false if it should be disabled.
72 */
73 function isModelCompatible(option: ModelOption): boolean {
74 void propsCacheVersion();
75
76 const modelModalities = modelsStore.getModelModalities(option.model);
77
78 if (!modelModalities) {
79 const status = getModelStatus(option.model);
80
81 if (status === ServerModelStatus.LOADED) {
82 if (requiredModalities.vision || requiredModalities.audio) return false;
83 }
84
85 return true;
86 }
87
88 if (requiredModalities.vision && !modelModalities.vision) return false;
89 if (requiredModalities.audio && !modelModalities.audio) return false;
90
91 return true;
92 }
93
94 /**
95 * Gets missing modalities for a model.
96 * Returns object with vision/audio booleans indicating what's missing.
97 */
98 function getMissingModalities(option: ModelOption): { vision: boolean; audio: boolean } | null {
99 void propsCacheVersion();
100
101 const modelModalities = modelsStore.getModelModalities(option.model);
102
103 if (!modelModalities) {
104 const status = getModelStatus(option.model);
105
106 if (status === ServerModelStatus.LOADED) {
107 const missing = {
108 vision: requiredModalities.vision,
109 audio: requiredModalities.audio
110 };
111
112 if (missing.vision || missing.audio) return missing;
113 }
114
115 return null;
116 }
117
118 const missing = {
119 vision: requiredModalities.vision && !modelModalities.vision,
120 audio: requiredModalities.audio && !modelModalities.audio
121 };
122
123 if (!missing.vision && !missing.audio) return null;
124
125 return missing;
126 }
127
128 let isHighlightedCurrentModelActive = $derived(
129 !isRouter || !currentModel
130 ? false
131 : (() => {
132 const currentOption = options.find((option) => option.model === currentModel);
133
134 return currentOption ? currentOption.id === activeId : false;
135 })()
136 );
137
138 let isCurrentModelInCache = $derived(() => {
139 if (!isRouter || !currentModel) return true;
140
141 return options.some((option) => option.model === currentModel);
142 });
143
144 let searchTerm = $state('');
145 let searchInputRef = $state<HTMLInputElement | null>(null);
146 let highlightedIndex = $state<number>(-1);
147
148 let filteredOptions: ModelOption[] = $derived(
149 (() => {
150 const term = searchTerm.trim().toLowerCase();
151 if (!term) return options;
152
153 return options.filter(
154 (option) =>
155 option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term)
156 );
157 })()
158 );
159
160 // Get indices of compatible options for keyboard navigation
161 let compatibleIndices = $derived(
162 filteredOptions
163 .map((option, index) => (isModelCompatible(option) ? index : -1))
164 .filter((i) => i !== -1)
165 );
166
167 // Reset highlighted index when search term changes
168 $effect(() => {
169 void searchTerm;
170 highlightedIndex = -1;
171 });
172
173 let isOpen = $state(false);
174 let showModelDialog = $state(false);
175
176 onMount(() => {
177 modelsStore.fetch().catch((error) => {
178 console.error('Unable to load models:', error);
179 });
180 });
181
182 // Handle changes to the model selector pop-down or the model dialog, depending on if the server is in
183 // router mode or not.
184 function handleOpenChange(open: boolean) {
185 if (loading || updating) return;
186
187 if (isRouter) {
188 if (open) {
189 isOpen = true;
190 searchTerm = '';
191 highlightedIndex = -1;
192
193 // Focus search input after popover opens
194 tick().then(() => {
195 requestAnimationFrame(() => searchInputRef?.focus());
196 });
197
198 modelsStore.fetchRouterModels().then(() => {
199 modelsStore.fetchModalitiesForLoadedModels();
200 });
201 } else {
202 isOpen = false;
203 searchTerm = '';
204 highlightedIndex = -1;
205 }
206 } else {
207 showModelDialog = open;
208 }
209 }
210
211 export function open() {
212 handleOpenChange(true);
213 }
214
215 function handleSearchKeyDown(event: KeyboardEvent) {
216 if (event.isComposing) return;
217
218 if (event.key === 'ArrowDown') {
219 event.preventDefault();
220 if (compatibleIndices.length === 0) return;
221
222 const currentPos = compatibleIndices.indexOf(highlightedIndex);
223 if (currentPos === -1 || currentPos === compatibleIndices.length - 1) {
224 highlightedIndex = compatibleIndices[0];
225 } else {
226 highlightedIndex = compatibleIndices[currentPos + 1];
227 }
228 } else if (event.key === 'ArrowUp') {
229 event.preventDefault();
230 if (compatibleIndices.length === 0) return;
231
232 const currentPos = compatibleIndices.indexOf(highlightedIndex);
233 if (currentPos === -1 || currentPos === 0) {
234 highlightedIndex = compatibleIndices[compatibleIndices.length - 1];
235 } else {
236 highlightedIndex = compatibleIndices[currentPos - 1];
237 }
238 } else if (event.key === 'Enter') {
239 event.preventDefault();
240 if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
241 const option = filteredOptions[highlightedIndex];
242 if (isModelCompatible(option)) {
243 handleSelect(option.id);
244 }
245 } else if (compatibleIndices.length > 0) {
246 // No selection - highlight first compatible option
247 highlightedIndex = compatibleIndices[0];
248 }
249 }
250 }
251
252 async function handleSelect(modelId: string) {
253 const option = options.find((opt) => opt.id === modelId);
254 if (!option) return;
255
256 let shouldCloseMenu = true;
257
258 if (onModelChange) {
259 // If callback provided, use it (for regenerate functionality)
260 const result = await onModelChange(option.id, option.model);
261
262 // If callback returns false, keep menu open (validation failed)
263 if (result === false) {
264 shouldCloseMenu = false;
265 }
266 } else {
267 // Update global selection
268 await modelsStore.selectModelById(option.id);
269
270 // Load the model if not already loaded (router mode)
271 if (isRouter && getModelStatus(option.model) !== ServerModelStatus.LOADED) {
272 try {
273 await modelsStore.loadModel(option.model);
274 } catch (error) {
275 console.error('Failed to load model:', error);
276 }
277 }
278 }
279
280 if (shouldCloseMenu) {
281 handleOpenChange(false);
282
283 // Focus the chat textarea after model selection
284 requestAnimationFrame(() => {
285 const textarea = document.querySelector<HTMLTextAreaElement>(
286 '[data-slot="chat-form"] textarea'
287 );
288 textarea?.focus();
289 });
290 }
291 }
292
293 function getDisplayOption(): ModelOption | undefined {
294 if (!isRouter) {
295 if (serverModel) {
296 return {
297 id: 'current',
298 model: serverModel,
299 name: serverModel.split('/').pop() || serverModel,
300 capabilities: [] // Empty array for single model mode
301 };
302 }
303
304 return undefined;
305 }
306
307 // When useGlobalSelection is true (form selector), prioritize user selection
308 // Otherwise (message display), prioritize currentModel
309 if (useGlobalSelection && activeId) {
310 const selected = options.find((option) => option.id === activeId);
311 if (selected) return selected;
312 }
313
314 // Show currentModel (from message payload or conversation)
315 if (currentModel) {
316 if (!isCurrentModelInCache()) {
317 return {
318 id: 'not-in-cache',
319 model: currentModel,
320 name: currentModel.split('/').pop() || currentModel,
321 capabilities: []
322 };
323 }
324
325 return options.find((option) => option.model === currentModel);
326 }
327
328 // Fallback to user selection (for new chats before first message)
329 if (activeId) {
330 return options.find((option) => option.id === activeId);
331 }
332
333 // No selection - return undefined to show "Select model"
334 return undefined;
335 }
336</script>
337
338<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
339 {#if loading && options.length === 0 && isRouter}
340 <div class="flex items-center gap-2 text-xs text-muted-foreground">
341 <Loader2 class="h-3.5 w-3.5 animate-spin" />
342 Loading models…
343 </div>
344 {:else if options.length === 0 && isRouter}
345 <p class="text-xs text-muted-foreground">No models available.</p>
346 {:else}
347 {@const selectedOption = getDisplayOption()}
348
349 {#if isRouter}
350 <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
351 <Popover.Trigger
352 class={cn(
353 `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
354 !isCurrentModelInCache()
355 ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
356 : forceForegroundText
357 ? 'text-foreground'
358 : isHighlightedCurrentModelActive
359 ? 'text-foreground'
360 : 'text-muted-foreground',
361 isOpen ? 'text-foreground' : ''
362 )}
363 style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
364 disabled={disabled || updating}
365 >
366 <Package class="h-3.5 w-3.5" />
367
368 <span class="truncate font-medium">
369 {selectedOption?.model || 'Select model'}
370 </span>
371
372 {#if updating}
373 <Loader2 class="h-3 w-3.5 animate-spin" />
374 {:else}
375 <ChevronDown class="h-3 w-3.5" />
376 {/if}
377 </Popover.Trigger>
378
379 <Popover.Content
380 class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
381 align="end"
382 sideOffset={8}
383 collisionPadding={16}
384 >
385 <div class="flex max-h-[50dvh] flex-col overflow-hidden">
386 <div
387 class="order-1 shrink-0 border-b p-4 group-data-[side=top]/popover-content:order-2 group-data-[side=top]/popover-content:border-t group-data-[side=top]/popover-content:border-b-0"
388 >
389 <SearchInput
390 id="model-search"
391 placeholder="Search models..."
392 bind:value={searchTerm}
393 bind:ref={searchInputRef}
394 onClose={() => handleOpenChange(false)}
395 onKeyDown={handleSearchKeyDown}
396 />
397 </div>
398 <div
399 class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
400 >
401 {#if !isCurrentModelInCache() && currentModel}
402 <!-- Show unavailable model as first option (disabled) -->
403 <button
404 type="button"
405 class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
406 role="option"
407 aria-selected="true"
408 aria-disabled="true"
409 disabled
410 >
411 <span class="truncate">{selectedOption?.name || currentModel}</span>
412 <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
413 </button>
414 <div class="my-1 h-px bg-border"></div>
415 {/if}
416 {#if filteredOptions.length === 0}
417 <p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
418 {/if}
419 {#each filteredOptions as option, index (option.id)}
420 {@const status = getModelStatus(option.model)}
421 {@const isLoaded = status === ServerModelStatus.LOADED}
422 {@const isLoading = status === ServerModelStatus.LOADING}
423 {@const isSelected = currentModel === option.model || activeId === option.id}
424 {@const isCompatible = isModelCompatible(option)}
425 {@const isHighlighted = index === highlightedIndex}
426 {@const missingModalities = getMissingModalities(option)}
427
428 <div
429 class={cn(
430 'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
431 isCompatible
432 ? 'cursor-pointer hover:bg-muted focus:bg-muted'
433 : 'cursor-not-allowed opacity-50',
434 isSelected || isHighlighted
435 ? 'bg-accent text-accent-foreground'
436 : isCompatible
437 ? 'hover:bg-accent hover:text-accent-foreground'
438 : '',
439 isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
440 )}
441 role="option"
442 aria-selected={isSelected || isHighlighted}
443 aria-disabled={!isCompatible}
444 tabindex={isCompatible ? 0 : -1}
445 onclick={() => isCompatible && handleSelect(option.id)}
446 onmouseenter={() => (highlightedIndex = index)}
447 onkeydown={(e) => {
448 if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
449 e.preventDefault();
450 handleSelect(option.id);
451 }
452 }}
453 >
454 <span class="min-w-0 flex-1 truncate">{option.model}</span>
455
456 {#if missingModalities}
457 <span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
458 {#if missingModalities.vision}
459 <Tooltip.Root>
460 <Tooltip.Trigger>
461 <EyeOff class="h-3.5 w-3.5" />
462 </Tooltip.Trigger>
463 <Tooltip.Content class="z-[9999]">
464 <p>No vision support</p>
465 </Tooltip.Content>
466 </Tooltip.Root>
467 {/if}
468 {#if missingModalities.audio}
469 <Tooltip.Root>
470 <Tooltip.Trigger>
471 <MicOff class="h-3.5 w-3.5" />
472 </Tooltip.Trigger>
473 <Tooltip.Content class="z-[9999]">
474 <p>No audio support</p>
475 </Tooltip.Content>
476 </Tooltip.Root>
477 {/if}
478 </span>
479 {/if}
480
481 {#if isLoading}
482 <Tooltip.Root>
483 <Tooltip.Trigger>
484 <Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
485 </Tooltip.Trigger>
486 <Tooltip.Content class="z-[9999]">
487 <p>Loading model...</p>
488 </Tooltip.Content>
489 </Tooltip.Root>
490 {:else if isLoaded}
491 <Tooltip.Root>
492 <Tooltip.Trigger>
493 <button
494 type="button"
495 class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
496 onclick={(e) => {
497 e.stopPropagation();
498 modelsStore.unloadModel(option.model);
499 }}
500 >
501 <span
502 class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
503 ></span>
504 <Power
505 class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
506 />
507 </button>
508 </Tooltip.Trigger>
509 <Tooltip.Content class="z-[9999]">
510 <p>Unload model</p>
511 </Tooltip.Content>
512 </Tooltip.Root>
513 {:else}
514 <span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
515 {/if}
516 </div>
517 {/each}
518 </div>
519 </div>
520 </Popover.Content>
521 </Popover.Root>
522 {:else}
523 <button
524 class={cn(
525 `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
526 !isCurrentModelInCache()
527 ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
528 : forceForegroundText
529 ? 'text-foreground'
530 : isHighlightedCurrentModelActive
531 ? 'text-foreground'
532 : 'text-muted-foreground',
533 isOpen ? 'text-foreground' : ''
534 )}
535 style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
536 onclick={() => handleOpenChange(true)}
537 disabled={disabled || updating}
538 >
539 <Package class="h-3.5 w-3.5" />
540
541 <span class="truncate font-medium">
542 {selectedOption?.model}
543 </span>
544
545 {#if updating}
546 <Loader2 class="h-3 w-3.5 animate-spin" />
547 {/if}
548 </button>
549 {/if}
550 {/if}
551</div>
552
553{#if showModelDialog && !isRouter}
554 <DialogModelInformation bind:open={showModelDialog} />
555{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte
new file mode 100644
index 0000000..fa4c284
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte
@@ -0,0 +1,282 @@
1<script lang="ts">
2 import { base } from '$app/paths';
3 import { AlertTriangle, RefreshCw, Key, CheckCircle, XCircle } from '@lucide/svelte';
4 import { goto } from '$app/navigation';
5 import { Button } from '$lib/components/ui/button';
6 import { Input } from '$lib/components/ui/input';
7 import Label from '$lib/components/ui/label/label.svelte';
8 import { serverStore, serverLoading } from '$lib/stores/server.svelte';
9 import { config, settingsStore } from '$lib/stores/settings.svelte';
10 import { fade, fly, scale } from 'svelte/transition';
11
12 interface Props {
13 class?: string;
14 error: string;
15 onRetry?: () => void;
16 showRetry?: boolean;
17 showTroubleshooting?: boolean;
18 }
19
20 let {
21 class: className = '',
22 error,
23 onRetry,
24 showRetry = true,
25 showTroubleshooting = false
26 }: Props = $props();
27
28 let isServerLoading = $derived(serverLoading());
29 let isAccessDeniedError = $derived(
30 error.toLowerCase().includes('access denied') ||
31 error.toLowerCase().includes('invalid api key') ||
32 error.toLowerCase().includes('unauthorized') ||
33 error.toLowerCase().includes('401') ||
34 error.toLowerCase().includes('403')
35 );
36
37 let apiKeyInput = $state('');
38 let showApiKeyInput = $state(false);
39 let apiKeyState = $state<'idle' | 'validating' | 'success' | 'error'>('idle');
40 let apiKeyError = $state('');
41
42 function handleRetryConnection() {
43 if (onRetry) {
44 onRetry();
45 } else {
46 serverStore.fetch();
47 }
48 }
49
50 function handleShowApiKeyInput() {
51 showApiKeyInput = true;
52 // Pre-fill with current API key if it exists
53 const currentConfig = config();
54 apiKeyInput = currentConfig.apiKey?.toString() || '';
55 }
56
57 async function handleSaveApiKey() {
58 if (!apiKeyInput.trim()) return;
59
60 apiKeyState = 'validating';
61 apiKeyError = '';
62
63 try {
64 // Update the API key in settings first
65 settingsStore.updateConfig('apiKey', apiKeyInput.trim());
66
67 // Test the API key by making a real request to the server
68 const response = await fetch(`${base}/props`, {
69 headers: {
70 'Content-Type': 'application/json',
71 Authorization: `Bearer ${apiKeyInput.trim()}`
72 }
73 });
74
75 if (response.ok) {
76 // API key is valid - User Story B
77 apiKeyState = 'success';
78
79 // Show success state briefly, then navigate to home
80 setTimeout(() => {
81 goto(`#/`);
82 }, 1000);
83 } else {
84 // API key is invalid - User Story A
85 apiKeyState = 'error';
86
87 if (response.status === 401 || response.status === 403) {
88 apiKeyError = 'Invalid API key - please check and try again';
89 } else {
90 apiKeyError = `Authentication failed (${response.status})`;
91 }
92
93 // Reset to idle state after showing error (don't reload UI)
94 setTimeout(() => {
95 apiKeyState = 'idle';
96 }, 3000);
97 }
98 } catch (error) {
99 // Network or other errors - User Story A
100 apiKeyState = 'error';
101
102 if (error instanceof Error) {
103 if (error.message.includes('fetch')) {
104 apiKeyError = 'Cannot connect to server - check if server is running';
105 } else {
106 apiKeyError = error.message;
107 }
108 } else {
109 apiKeyError = 'Connection error - please try again';
110 }
111
112 // Reset to idle state after showing error (don't reload UI)
113 setTimeout(() => {
114 apiKeyState = 'idle';
115 }, 3000);
116 }
117 }
118
119 function handleApiKeyKeydown(event: KeyboardEvent) {
120 if (event.key === 'Enter') {
121 handleSaveApiKey();
122 }
123 }
124</script>
125
126<div class="flex h-full items-center justify-center {className}">
127 <div class="w-full max-w-md px-4 text-center">
128 <div class="mb-6" in:fade={{ duration: 300 }}>
129 <div
130 class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10"
131 >
132 <AlertTriangle class="h-8 w-8 text-destructive" />
133 </div>
134
135 <h2 class="mb-2 text-xl font-semibold">Server Connection Error</h2>
136
137 <p class="mb-4 text-sm text-muted-foreground">
138 {error}
139 </p>
140 </div>
141
142 {#if isAccessDeniedError && !showApiKeyInput}
143 <div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4">
144 <Button onclick={handleShowApiKeyInput} variant="outline" class="w-full">
145 <Key class="h-4 w-4" />
146 Enter API Key
147 </Button>
148 </div>
149 {/if}
150
151 {#if showApiKeyInput}
152 <div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4 space-y-3 text-left">
153 <div class="space-y-2">
154 <Label for="api-key-input" class="text-sm font-medium">API Key</Label>
155
156 <div class="relative">
157 <Input
158 id="api-key-input"
159 placeholder="Enter your API key..."
160 bind:value={apiKeyInput}
161 onkeydown={handleApiKeyKeydown}
162 class="w-full pr-10 {apiKeyState === 'error'
163 ? 'border-destructive'
164 : apiKeyState === 'success'
165 ? 'border-green-500'
166 : ''}"
167 disabled={apiKeyState === 'validating'}
168 />
169 {#if apiKeyState === 'validating'}
170 <div class="absolute top-1/2 right-3 -translate-y-1/2">
171 <RefreshCw class="h-4 w-4 animate-spin text-muted-foreground" />
172 </div>
173 {:else if apiKeyState === 'success'}
174 <div
175 class="absolute top-1/2 right-3 -translate-y-1/2"
176 in:scale={{ duration: 200, start: 0.8 }}
177 >
178 <CheckCircle class="h-4 w-4 text-green-500" />
179 </div>
180 {:else if apiKeyState === 'error'}
181 <div
182 class="absolute top-1/2 right-3 -translate-y-1/2"
183 in:scale={{ duration: 200, start: 0.8 }}
184 >
185 <XCircle class="h-4 w-4 text-destructive" />
186 </div>
187 {/if}
188 </div>
189 {#if apiKeyError}
190 <p class="text-sm text-destructive" in:fly={{ y: -10, duration: 200 }}>
191 {apiKeyError}
192 </p>
193 {/if}
194 {#if apiKeyState === 'success'}
195 <p class="text-sm text-green-600" in:fly={{ y: -10, duration: 200 }}>
196 ✓ API key validated successfully! Connecting...
197 </p>
198 {/if}
199 </div>
200 <div class="flex gap-2">
201 <Button
202 onclick={handleSaveApiKey}
203 disabled={!apiKeyInput.trim() ||
204 apiKeyState === 'validating' ||
205 apiKeyState === 'success'}
206 class="flex-1"
207 >
208 {#if apiKeyState === 'validating'}
209 <RefreshCw class="h-4 w-4 animate-spin" />
210 Validating...
211 {:else if apiKeyState === 'success'}
212 Success!
213 {:else}
214 Save & Retry
215 {/if}
216 </Button>
217 <Button
218 onclick={() => {
219 showApiKeyInput = false;
220 apiKeyState = 'idle';
221 apiKeyError = '';
222 }}
223 variant="outline"
224 class="flex-1"
225 disabled={apiKeyState === 'validating'}
226 >
227 Cancel
228 </Button>
229 </div>
230 </div>
231 {/if}
232
233 {#if showRetry}
234 <div in:fly={{ y: 10, duration: 300, delay: 200 }}>
235 <Button onclick={handleRetryConnection} disabled={isServerLoading} class="w-full">
236 {#if isServerLoading}
237 <RefreshCw class="h-4 w-4 animate-spin" />
238
239 Connecting...
240 {:else}
241 <RefreshCw class="h-4 w-4" />
242
243 Retry Connection
244 {/if}
245 </Button>
246 </div>
247 {/if}
248
249 {#if showTroubleshooting}
250 <div class="mt-4 text-left" in:fly={{ y: 10, duration: 300, delay: 400 }}>
251 <details class="text-sm">
252 <summary class="cursor-pointer text-muted-foreground hover:text-foreground">
253 Troubleshooting
254 </summary>
255
256 <div class="mt-2 space-y-3 text-xs text-muted-foreground">
257 <div class="space-y-2">
258 <p class="mb-4 font-medium">Start the llama-server:</p>
259
260 <div class="rounded bg-muted/50 px-2 py-1 font-mono text-xs">
261 <p>llama-server -hf ggml-org/gemma-3-4b-it-GGUF</p>
262 </div>
263
264 <p>or</p>
265
266 <div class="rounded bg-muted/50 px-2 py-1 font-mono text-xs">
267 <p class="mt-1">llama-server -m locally-stored-model.gguf</p>
268 </div>
269 </div>
270 <ul class="list-disc space-y-1 pl-4">
271 <li>Check that the server is accessible at the correct URL</li>
272
273 <li>Verify your network connection</li>
274
275 <li>Check server logs for any error messages</li>
276 </ul>
277 </div>
278 </details>
279 </div>
280 {/if}
281 </div>
282</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerLoadingSplash.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerLoadingSplash.svelte
new file mode 100644
index 0000000..505325d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerLoadingSplash.svelte
@@ -0,0 +1,33 @@
1<script lang="ts">
2 import { Server } from '@lucide/svelte';
3 import { ServerStatus } from '$lib/components/app';
4 import { fade } from 'svelte/transition';
5
6 interface Props {
7 class?: string;
8 message?: string;
9 }
10
11 let { class: className = '', message = 'Initializing connection to llama.cpp server...' }: Props =
12 $props();
13</script>
14
15<div class="flex h-full items-center justify-center {className}">
16 <div class="text-center">
17 <div class="mb-4" in:fade={{ duration: 300 }}>
18 <div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
19 <Server class="h-8 w-8 animate-pulse text-muted-foreground" />
20 </div>
21
22 <h2 class="mb-2 text-xl font-semibold">Connecting to Server</h2>
23
24 <p class="text-sm text-muted-foreground">
25 {message}
26 </p>
27 </div>
28
29 <div class="mt-4">
30 <ServerStatus class="justify-center" />
31 </div>
32 </div>
33</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte
new file mode 100644
index 0000000..d9f6d4a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte
@@ -0,0 +1,65 @@
1<script lang="ts">
2 import { AlertTriangle, Server } from '@lucide/svelte';
3 import { Badge } from '$lib/components/ui/badge';
4 import { Button } from '$lib/components/ui/button';
5 import { serverProps, serverLoading, serverError } from '$lib/stores/server.svelte';
6 import { singleModelName } from '$lib/stores/models.svelte';
7
8 interface Props {
9 class?: string;
10 showActions?: boolean;
11 }
12
13 let { class: className = '', showActions = false }: Props = $props();
14
15 let error = $derived(serverError());
16 let loading = $derived(serverLoading());
17 let model = $derived(singleModelName());
18 let serverData = $derived(serverProps());
19
20 function getStatusColor() {
21 if (loading) return 'bg-yellow-500';
22 if (error) return 'bg-red-500';
23 if (serverData) return 'bg-green-500';
24
25 return 'bg-gray-500';
26 }
27
28 function getStatusText() {
29 if (loading) return 'Connecting...';
30 if (error) return 'Connection Error';
31 if (serverData) return 'Connected';
32
33 return 'Unknown';
34 }
35</script>
36
37<div class="flex items-center space-x-3 {className}">
38 <div class="flex items-center space-x-2">
39 <div class="h-2 w-2 rounded-full {getStatusColor()}"></div>
40
41 <span class="text-sm text-muted-foreground">{getStatusText()}</span>
42 </div>
43
44 {#if serverData && !error}
45 <Badge variant="outline" class="text-xs">
46 <Server class="mr-1 h-3 w-3" />
47
48 {model || 'Unknown Model'}
49 </Badge>
50
51 {#if serverData.default_generation_settings.n_ctx}
52 <Badge variant="secondary" class="text-xs">
53 ctx: {serverData.default_generation_settings.n_ctx.toLocaleString()}
54 </Badge>
55 {/if}
56 {/if}
57
58 {#if showActions && error}
59 <Button variant="outline" size="sm" class="text-destructive">
60 <AlertTriangle class="h-4 w-4" />
61
62 {error}
63 </Button>
64 {/if}
65</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
new file mode 100644
index 0000000..162107e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
@@ -0,0 +1,18 @@
1<script lang="ts">
2 import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
3 import { buttonVariants } from '$lib/components/ui/button/index.js';
4 import { cn } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 ...restProps
10 }: AlertDialogPrimitive.ActionProps = $props();
11</script>
12
13<AlertDialogPrimitive.Action
14 bind:ref
15 data-slot="alert-dialog-action"
16 class={cn(buttonVariants(), className)}
17 {...restProps}
18/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
new file mode 100644
index 0000000..6b3f354
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
@@ -0,0 +1,18 @@
1<script lang="ts">
2 import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
3 import { buttonVariants } from '$lib/components/ui/button/index.js';
4 import { cn } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 ...restProps
10 }: AlertDialogPrimitive.CancelProps = $props();
11</script>
12
13<AlertDialogPrimitive.Cancel
14 bind:ref
15 data-slot="alert-dialog-cancel"
16 class={cn(buttonVariants({ variant: 'outline' }), className)}
17 {...restProps}
18/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
new file mode 100644
index 0000000..2398dae
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
@@ -0,0 +1,35 @@
1<script lang="ts">
2 import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
3 import AlertDialogOverlay from './alert-dialog-overlay.svelte';
4 import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 portalProps,
10 ...restProps
11 }: WithoutChild<AlertDialogPrimitive.ContentProps> & {
12 portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
13 } = $props();
14</script>
15
16<AlertDialogPrimitive.Portal {...portalProps}>
17 <AlertDialogOverlay />
18 <AlertDialogPrimitive.Content
19 bind:ref
20 data-slot="alert-dialog-content"
21 class={cn(
22 'fixed z-[999999] grid w-full gap-4 border bg-background p-6 shadow-lg duration-200',
23 // Mobile: Bottom sheet behavior
24 'right-0 bottom-0 left-0 max-h-[100dvh] translate-x-0 translate-y-0 overflow-y-auto rounded-t-lg',
25 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-bottom-full',
26 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-bottom-full',
27 // Desktop: Centered dialog behavior
28 'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-h-[100vh] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg',
29 'sm:data-[state=closed]:slide-out-to-bottom-0 sm:data-[state=closed]:zoom-out-95',
30 'sm:data-[state=open]:slide-in-from-bottom-0 sm:data-[state=open]:zoom-in-95',
31 className
32 )}
33 {...restProps}
34 />
35</AlertDialogPrimitive.Portal>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
new file mode 100644
index 0000000..84735d8
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
@@ -0,0 +1,17 @@
1<script lang="ts">
2 import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: AlertDialogPrimitive.DescriptionProps = $props();
10</script>
11
12<AlertDialogPrimitive.Description
13 bind:ref
14 data-slot="alert-dialog-description"
15 class={cn('text-sm text-muted-foreground', className)}
16 {...restProps}
17/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
new file mode 100644
index 0000000..da0f7be
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
@@ -0,0 +1,23 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="alert-dialog-footer"
16 class={cn(
17 'mt-6 flex flex-row gap-2 sm:mt-0 sm:justify-end [&>*]:flex-1 sm:[&>*]:flex-none',
18 className
19 )}
20 {...restProps}
21>
22 {@render children?.()}
23</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
new file mode 100644
index 0000000..fa6539d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="alert-dialog-header"
16 class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
new file mode 100644
index 0000000..71f166d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: AlertDialogPrimitive.OverlayProps = $props();
10</script>
11
12<AlertDialogPrimitive.Overlay
13 bind:ref
14 data-slot="alert-dialog-overlay"
15 class={cn(
16 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
17 className
18 )}
19 {...restProps}
20/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
new file mode 100644
index 0000000..4c610aa
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
@@ -0,0 +1,17 @@
1<script lang="ts">
2 import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: AlertDialogPrimitive.TitleProps = $props();
10</script>
11
12<AlertDialogPrimitive.Title
13 bind:ref
14 data-slot="alert-dialog-title"
15 class={cn('text-lg font-semibold', className)}
16 {...restProps}
17/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte
new file mode 100644
index 0000000..51a3da1
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
5</script>
6
7<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/index.ts
new file mode 100644
index 0000000..a4439bc
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert-dialog/index.ts
@@ -0,0 +1,39 @@
1import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
2import Trigger from './alert-dialog-trigger.svelte';
3import Title from './alert-dialog-title.svelte';
4import Action from './alert-dialog-action.svelte';
5import Cancel from './alert-dialog-cancel.svelte';
6import Footer from './alert-dialog-footer.svelte';
7import Header from './alert-dialog-header.svelte';
8import Overlay from './alert-dialog-overlay.svelte';
9import Content from './alert-dialog-content.svelte';
10import Description from './alert-dialog-description.svelte';
11
12const Root = AlertDialogPrimitive.Root;
13const Portal = AlertDialogPrimitive.Portal;
14
15export {
16 Root,
17 Title,
18 Action,
19 Cancel,
20 Portal,
21 Footer,
22 Header,
23 Trigger,
24 Overlay,
25 Content,
26 Description,
27 //
28 Root as AlertDialog,
29 Title as AlertDialogTitle,
30 Action as AlertDialogAction,
31 Cancel as AlertDialogCancel,
32 Portal as AlertDialogPortal,
33 Footer as AlertDialogFooter,
34 Header as AlertDialogHeader,
35 Trigger as AlertDialogTrigger,
36 Overlay as AlertDialogOverlay,
37 Content as AlertDialogContent,
38 Description as AlertDialogDescription
39};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte
new file mode 100644
index 0000000..440d006
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte
@@ -0,0 +1,23 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="alert-description"
16 class={cn(
17 'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
18 className
19 )}
20 {...restProps}
21>
22 {@render children?.()}
23</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte
new file mode 100644
index 0000000..0721aeb
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="alert-title"
16 class={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert.svelte
new file mode 100644
index 0000000..7d79e4b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/alert.svelte
@@ -0,0 +1,44 @@
1<script lang="ts" module>
2 import { type VariantProps, tv } from 'tailwind-variants';
3
4 export const alertVariants = tv({
5 base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
6 variants: {
7 variant: {
8 default: 'bg-card text-card-foreground',
9 destructive:
10 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current'
11 }
12 },
13 defaultVariants: {
14 variant: 'default'
15 }
16 });
17
18 export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
19</script>
20
21<script lang="ts">
22 import type { HTMLAttributes } from 'svelte/elements';
23 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
24
25 let {
26 ref = $bindable(null),
27 class: className,
28 variant = 'default',
29 children,
30 ...restProps
31 }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
32 variant?: AlertVariant;
33 } = $props();
34</script>
35
36<div
37 bind:this={ref}
38 data-slot="alert"
39 class={cn(alertVariants({ variant }), className)}
40 {...restProps}
41 role="alert"
42>
43 {@render children?.()}
44</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/alert/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/index.ts
new file mode 100644
index 0000000..5e0f854
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/alert/index.ts
@@ -0,0 +1,14 @@
1import Root from './alert.svelte';
2import Description from './alert-description.svelte';
3import Title from './alert-title.svelte';
4export { alertVariants, type AlertVariant } from './alert.svelte';
5
6export {
7 Root,
8 Description,
9 Title,
10 //
11 Root as Alert,
12 Description as AlertDescription,
13 Title as AlertTitle
14};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/badge/badge.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/badge/badge.svelte
new file mode 100644
index 0000000..4d15145
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/badge/badge.svelte
@@ -0,0 +1,49 @@
1<script lang="ts" module>
2 import { type VariantProps, tv } from 'tailwind-variants';
3
4 export const badgeVariants = tv({
5 base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
6 variants: {
7 variant: {
8 default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
9 secondary:
10 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
11 destructive:
12 'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
13 outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
14 }
15 },
16 defaultVariants: {
17 variant: 'default'
18 }
19 });
20
21 export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
22</script>
23
24<script lang="ts">
25 import type { HTMLAnchorAttributes } from 'svelte/elements';
26 import { cn, type WithElementRef } from '$lib/components/ui/utils';
27
28 let {
29 ref = $bindable(null),
30 href,
31 class: className,
32 variant = 'default',
33 children,
34 ...restProps
35 }: WithElementRef<HTMLAnchorAttributes> & {
36 variant?: BadgeVariant;
37 } = $props();
38</script>
39
40<svelte:element
41 this={href ? 'a' : 'span'}
42 bind:this={ref}
43 data-slot="badge"
44 {href}
45 class={cn(badgeVariants({ variant }), className)}
46 {...restProps}
47>
48 {@render children?.()}
49</svelte:element>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/badge/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/badge/index.ts
new file mode 100644
index 0000000..f05fb87
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/badge/index.ts
@@ -0,0 +1,2 @@
1export { default as Badge } from './badge.svelte';
2export { badgeVariants, type BadgeVariant } from './badge.svelte';
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/button/button.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/button/button.svelte
new file mode 100644
index 0000000..d12c8de
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/button/button.svelte
@@ -0,0 +1,87 @@
1<script lang="ts" module>
2 import { cn, type WithElementRef } from '$lib/components/ui/utils';
3 import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
4 import { type VariantProps, tv } from 'tailwind-variants';
5
6 export const buttonVariants = tv({
7 base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
8 variants: {
9 variant: {
10 default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
11 destructive:
12 'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
13 outline:
14 'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
15 secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
16 ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
17 link: 'text-primary underline-offset-4 hover:underline'
18 },
19 size: {
20 default: 'h-9 px-4 py-2 has-[>svg]:px-3',
21 sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
22 lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
23 icon: 'size-9'
24 }
25 },
26 defaultVariants: {
27 variant: 'default',
28 size: 'default'
29 }
30 });
31
32 export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
33 export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
34
35 export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
36 WithElementRef<HTMLAnchorAttributes> & {
37 variant?: ButtonVariant;
38 size?: ButtonSize;
39 };
40</script>
41
42<script lang="ts">
43 let {
44 class: className,
45 variant = 'default',
46 size = 'default',
47 ref = $bindable(null),
48 href = undefined,
49 type = 'button',
50 disabled,
51 children,
52 ...restProps
53 }: ButtonProps = $props();
54</script>
55
56{#if href}
57 <a
58 bind:this={ref}
59 data-slot="button"
60 class={cn(buttonVariants({ variant, size }), className)}
61 href={disabled ? undefined : href}
62 aria-disabled={disabled}
63 role={disabled ? 'link' : undefined}
64 tabindex={disabled ? -1 : undefined}
65 {...restProps}
66 >
67 {@render children?.()}
68 </a>
69{:else}
70 <button
71 bind:this={ref}
72 data-slot="button"
73 class={cn(buttonVariants({ variant, size }), className)}
74 {type}
75 {disabled}
76 {...restProps}
77 >
78 {@render children?.()}
79 </button>
80{/if}
81
82<style>
83 a,
84 button {
85 cursor: pointer;
86 }
87</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/button/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/button/index.ts
new file mode 100644
index 0000000..5414d9d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/button/index.ts
@@ -0,0 +1,17 @@
1import Root, {
2 type ButtonProps,
3 type ButtonSize,
4 type ButtonVariant,
5 buttonVariants
6} from './button.svelte';
7
8export {
9 Root,
10 type ButtonProps as Props,
11 //
12 Root as Button,
13 buttonVariants,
14 type ButtonProps,
15 type ButtonSize,
16 type ButtonVariant
17};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-action.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-action.svelte
new file mode 100644
index 0000000..0d4e965
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-action.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="card-action"
16 class={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-content.svelte
new file mode 100644
index 0000000..c68f613
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-content.svelte
@@ -0,0 +1,15 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div bind:this={ref} data-slot="card-content" class={cn('px-6', className)} {...restProps}>
14 {@render children?.()}
15</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-description.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-description.svelte
new file mode 100644
index 0000000..81578df
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-description.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
11</script>
12
13<p
14 bind:this={ref}
15 data-slot="card-description"
16 class={cn('text-sm text-muted-foreground', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</p>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-footer.svelte
new file mode 100644
index 0000000..0366459
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-footer.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="card-footer"
16 class={cn('flex items-center px-6 [.border-t]:pt-6', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-header.svelte
new file mode 100644
index 0000000..74ab163
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-header.svelte
@@ -0,0 +1,23 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="card-header"
16 class={cn(
17 '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
18 className
19 )}
20 {...restProps}
21>
22 {@render children?.()}
23</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-title.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-title.svelte
new file mode 100644
index 0000000..8dfc062
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card-title.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="card-title"
16 class={cn('leading-none font-semibold', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/card.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card.svelte
new file mode 100644
index 0000000..c40d143
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/card.svelte
@@ -0,0 +1,23 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="card"
16 class={cn(
17 'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
18 className
19 )}
20 {...restProps}
21>
22 {@render children?.()}
23</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/card/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/card/index.ts
new file mode 100644
index 0000000..77d3674
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/card/index.ts
@@ -0,0 +1,25 @@
1import Root from './card.svelte';
2import Content from './card-content.svelte';
3import Description from './card-description.svelte';
4import Footer from './card-footer.svelte';
5import Header from './card-header.svelte';
6import Title from './card-title.svelte';
7import Action from './card-action.svelte';
8
9export {
10 Root,
11 Content,
12 Description,
13 Footer,
14 Header,
15 Title,
16 Action,
17 //
18 Root as Card,
19 Content as CardContent,
20 Description as CardDescription,
21 Footer as CardFooter,
22 Header as CardHeader,
23 Title as CardTitle,
24 Action as CardAction
25};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/checkbox.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/checkbox.svelte
new file mode 100644
index 0000000..aafa071
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/checkbox.svelte
@@ -0,0 +1,36 @@
1<script lang="ts">
2 import { Checkbox as CheckboxPrimitive } from 'bits-ui';
3 import CheckIcon from '@lucide/svelte/icons/check';
4 import MinusIcon from '@lucide/svelte/icons/minus';
5 import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
6
7 let {
8 ref = $bindable(null),
9 checked = $bindable(false),
10 indeterminate = $bindable(false),
11 class: className,
12 ...restProps
13 }: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
14</script>
15
16<CheckboxPrimitive.Root
17 bind:ref
18 data-slot="checkbox"
19 class={cn(
20 'peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary',
21 className
22 )}
23 bind:checked
24 bind:indeterminate
25 {...restProps}
26>
27 {#snippet children({ checked, indeterminate })}
28 <div data-slot="checkbox-indicator" class="text-current transition-none">
29 {#if checked}
30 <CheckIcon class="size-3.5" />
31 {:else if indeterminate}
32 <MinusIcon class="size-3.5" />
33 {/if}
34 </div>
35 {/snippet}
36</CheckboxPrimitive.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/index.ts
new file mode 100644
index 0000000..5c27671
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/checkbox/index.ts
@@ -0,0 +1,6 @@
1import Root from './checkbox.svelte';
2export {
3 Root,
4 //
5 Root as Checkbox
6};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-content.svelte
new file mode 100644
index 0000000..59b068c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-content.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
5</script>
6
7<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-trigger.svelte
new file mode 100644
index 0000000..c88ceba
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible-trigger.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
5</script>
6
7<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible.svelte
new file mode 100644
index 0000000..7a8c5da
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/collapsible.svelte
@@ -0,0 +1,11 @@
1<script lang="ts">
2 import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
3
4 let {
5 ref = $bindable(null),
6 open = $bindable(false),
7 ...restProps
8 }: CollapsiblePrimitive.RootProps = $props();
9</script>
10
11<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/index.ts
new file mode 100644
index 0000000..8181f64
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/collapsible/index.ts
@@ -0,0 +1,13 @@
1import Root from './collapsible.svelte';
2import Trigger from './collapsible-trigger.svelte';
3import Content from './collapsible-content.svelte';
4
5export {
6 Root,
7 Content,
8 Trigger,
9 //
10 Root as Collapsible,
11 Content as CollapsibleContent,
12 Trigger as CollapsibleTrigger
13};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-close.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-close.svelte
new file mode 100644
index 0000000..e8a96a7
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-close.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Dialog as DialogPrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
5</script>
6
7<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-content.svelte
new file mode 100644
index 0000000..74df0ea
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-content.svelte
@@ -0,0 +1,43 @@
1<script lang="ts">
2 import { Dialog as DialogPrimitive } from 'bits-ui';
3 import XIcon from '@lucide/svelte/icons/x';
4 import type { Snippet } from 'svelte';
5 import * as Dialog from './index.js';
6 import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils';
7
8 let {
9 ref = $bindable(null),
10 class: className,
11 portalProps,
12 children,
13 showCloseButton = true,
14 ...restProps
15 }: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
16 portalProps?: DialogPrimitive.PortalProps;
17 children: Snippet;
18 showCloseButton?: boolean;
19 } = $props();
20</script>
21
22<Dialog.Portal {...portalProps}>
23 <Dialog.Overlay />
24 <DialogPrimitive.Content
25 bind:ref
26 data-slot="dialog-content"
27 class={cn(
28 `fixed top-[50%] left-[50%] z-50 grid max-h-[100dvh] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border border-border/30 bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg md:max-h-[100vh]`,
29 className
30 )}
31 {...restProps}
32 >
33 {@render children?.()}
34 {#if showCloseButton}
35 <DialogPrimitive.Close
36 class="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
37 >
38 <XIcon />
39 <span class="sr-only">Close</span>
40 </DialogPrimitive.Close>
41 {/if}
42 </DialogPrimitive.Content>
43</Dialog.Portal>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-description.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-description.svelte
new file mode 100644
index 0000000..6c0c192
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-description.svelte
@@ -0,0 +1,17 @@
1<script lang="ts">
2 import { Dialog as DialogPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: DialogPrimitive.DescriptionProps = $props();
10</script>
11
12<DialogPrimitive.Description
13 bind:ref
14 data-slot="dialog-description"
15 class={cn('text-sm text-muted-foreground', className)}
16 {...restProps}
17/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-footer.svelte
new file mode 100644
index 0000000..abf948f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-footer.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="dialog-footer"
16 class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-header.svelte
new file mode 100644
index 0000000..7ba9ba1
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-header.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="dialog-header"
16 class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-overlay.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-overlay.svelte
new file mode 100644
index 0000000..a6e9a10
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-overlay.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { Dialog as DialogPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: DialogPrimitive.OverlayProps = $props();
10</script>
11
12<DialogPrimitive.Overlay
13 bind:ref
14 data-slot="dialog-overlay"
15 class={cn(
16 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
17 className
18 )}
19 {...restProps}
20/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-title.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-title.svelte
new file mode 100644
index 0000000..e8c99c5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-title.svelte
@@ -0,0 +1,17 @@
1<script lang="ts">
2 import { Dialog as DialogPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: DialogPrimitive.TitleProps = $props();
10</script>
11
12<DialogPrimitive.Title
13 bind:ref
14 data-slot="dialog-title"
15 class={cn('text-lg leading-none font-semibold', className)}
16 {...restProps}
17/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-trigger.svelte
new file mode 100644
index 0000000..ac04d9f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/dialog-trigger.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Dialog as DialogPrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
5</script>
6
7<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/index.ts
new file mode 100644
index 0000000..d9e5fb8
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dialog/index.ts
@@ -0,0 +1,37 @@
1import { Dialog as DialogPrimitive } from 'bits-ui';
2
3import Title from './dialog-title.svelte';
4import Footer from './dialog-footer.svelte';
5import Header from './dialog-header.svelte';
6import Overlay from './dialog-overlay.svelte';
7import Content from './dialog-content.svelte';
8import Description from './dialog-description.svelte';
9import Trigger from './dialog-trigger.svelte';
10import Close from './dialog-close.svelte';
11
12const Root = DialogPrimitive.Root;
13const Portal = DialogPrimitive.Portal;
14
15export {
16 Root,
17 Title,
18 Portal,
19 Footer,
20 Header,
21 Trigger,
22 Overlay,
23 Content,
24 Description,
25 Close,
26 //
27 Root as Dialog,
28 Title as DialogTitle,
29 Portal as DialogPortal,
30 Footer as DialogFooter,
31 Header as DialogHeader,
32 Trigger as DialogTrigger,
33 Overlay as DialogOverlay,
34 Content as DialogContent,
35 Description as DialogDescription,
36 Close as DialogClose
37};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
new file mode 100644
index 0000000..e71acef
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
@@ -0,0 +1,41 @@
1<script lang="ts">
2 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3 import CheckIcon from '@lucide/svelte/icons/check';
4 import MinusIcon from '@lucide/svelte/icons/minus';
5 import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
6 import type { Snippet } from 'svelte';
7
8 let {
9 ref = $bindable(null),
10 checked = $bindable(false),
11 indeterminate = $bindable(false),
12 class: className,
13 children: childrenProp,
14 ...restProps
15 }: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
16 children?: Snippet;
17 } = $props();
18</script>
19
20<DropdownMenuPrimitive.CheckboxItem
21 bind:ref
22 bind:checked
23 bind:indeterminate
24 data-slot="dropdown-menu-checkbox-item"
25 class={cn(
26 "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
27 className
28 )}
29 {...restProps}
30>
31 {#snippet children({ checked, indeterminate })}
32 <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
33 {#if indeterminate}
34 <MinusIcon class="size-4" />
35 {:else}
36 <CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
37 {/if}
38 </span>
39 {@render childrenProp?.()}
40 {/snippet}
41</DropdownMenuPrimitive.CheckboxItem>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
new file mode 100644
index 0000000..869c38e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
@@ -0,0 +1,27 @@
1<script lang="ts">
2 import { cn } from '$lib/components/ui/utils.js';
3 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
4
5 let {
6 ref = $bindable(null),
7 sideOffset = 4,
8 portalProps,
9 class: className,
10 ...restProps
11 }: DropdownMenuPrimitive.ContentProps & {
12 portalProps?: DropdownMenuPrimitive.PortalProps;
13 } = $props();
14</script>
15
16<DropdownMenuPrimitive.Portal {...portalProps}>
17 <DropdownMenuPrimitive.Content
18 bind:ref
19 data-slot="dropdown-menu-content"
20 {sideOffset}
21 class={cn(
22 'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
23 className
24 )}
25 {...restProps}
26 />
27</DropdownMenuPrimitive.Portal>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
new file mode 100644
index 0000000..f217966
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
@@ -0,0 +1,22 @@
1<script lang="ts">
2 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4 import type { ComponentProps } from 'svelte';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 inset,
10 ...restProps
11 }: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
12 inset?: boolean;
13 } = $props();
14</script>
15
16<DropdownMenuPrimitive.GroupHeading
17 bind:ref
18 data-slot="dropdown-menu-group-heading"
19 data-inset={inset}
20 class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
21 {...restProps}
22/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte
new file mode 100644
index 0000000..261ab7e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
5</script>
6
7<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte
new file mode 100644
index 0000000..1ac5615
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte
@@ -0,0 +1,27 @@
1<script lang="ts">
2 import { cn } from '$lib/components/ui/utils.js';
3 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 inset,
9 variant = 'default',
10 ...restProps
11 }: DropdownMenuPrimitive.ItemProps & {
12 inset?: boolean;
13 variant?: 'default' | 'destructive';
14 } = $props();
15</script>
16
17<DropdownMenuPrimitive.Item
18 bind:ref
19 data-slot="dropdown-menu-item"
20 data-inset={inset}
21 data-variant={variant}
22 class={cn(
23 "relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive",
24 className
25 )}
26 {...restProps}
27/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte
new file mode 100644
index 0000000..15b546e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte
@@ -0,0 +1,24 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 inset,
9 children,
10 ...restProps
11 }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
12 inset?: boolean;
13 } = $props();
14</script>
15
16<div
17 bind:this={ref}
18 data-slot="dropdown-menu-label"
19 data-inset={inset}
20 class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
21 {...restProps}
22>
23 {@render children?.()}
24</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte
new file mode 100644
index 0000000..3e98749
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte
@@ -0,0 +1,16 @@
1<script lang="ts">
2 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3
4 let {
5 ref = $bindable(null),
6 value = $bindable(),
7 ...restProps
8 }: DropdownMenuPrimitive.RadioGroupProps = $props();
9</script>
10
11<DropdownMenuPrimitive.RadioGroup
12 bind:ref
13 bind:value
14 data-slot="dropdown-menu-radio-group"
15 {...restProps}
16/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
new file mode 100644
index 0000000..97ba772
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
@@ -0,0 +1,31 @@
1<script lang="ts">
2 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3 import CircleIcon from '@lucide/svelte/icons/circle';
4 import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 children: childrenProp,
10 ...restProps
11 }: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
12</script>
13
14<DropdownMenuPrimitive.RadioItem
15 bind:ref
16 data-slot="dropdown-menu-radio-item"
17 class={cn(
18 "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
19 className
20 )}
21 {...restProps}
22>
23 {#snippet children({ checked })}
24 <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
25 {#if checked}
26 <CircleIcon class="size-2 fill-current" />
27 {/if}
28 </span>
29 {@render childrenProp?.({ checked })}
30 {/snippet}
31</DropdownMenuPrimitive.RadioItem>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte
new file mode 100644
index 0000000..17b64ac
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte
@@ -0,0 +1,17 @@
1<script lang="ts">
2 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: DropdownMenuPrimitive.SeparatorProps = $props();
10</script>
11
12<DropdownMenuPrimitive.Separator
13 bind:ref
14 data-slot="dropdown-menu-separator"
15 class={cn('-mx-1 my-1 h-px bg-border/20', className)}
16 {...restProps}
17/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
new file mode 100644
index 0000000..c3ccc21
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
11</script>
12
13<span
14 bind:this={ref}
15 data-slot="dropdown-menu-shortcut"
16 class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</span>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
new file mode 100644
index 0000000..3ceb165
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: DropdownMenuPrimitive.SubContentProps = $props();
10</script>
11
12<DropdownMenuPrimitive.SubContent
13 bind:ref
14 data-slot="dropdown-menu-sub-content"
15 class={cn(
16 'z-50 min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
17 className
18 )}
19 {...restProps}
20/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
new file mode 100644
index 0000000..550a789
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
@@ -0,0 +1,29 @@
1<script lang="ts">
2 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3 import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
4 import { cn } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 inset,
10 children,
11 ...restProps
12 }: DropdownMenuPrimitive.SubTriggerProps & {
13 inset?: boolean;
14 } = $props();
15</script>
16
17<DropdownMenuPrimitive.SubTrigger
18 bind:ref
19 data-slot="dropdown-menu-sub-trigger"
20 data-inset={inset}
21 class={cn(
22 "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
23 className
24 )}
25 {...restProps}
26>
27 {@render children?.()}
28 <ChevronRightIcon class="ml-auto size-4" />
29</DropdownMenuPrimitive.SubTrigger>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte
new file mode 100644
index 0000000..032b645
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
5</script>
6
7<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/index.ts
new file mode 100644
index 0000000..aeb398e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/dropdown-menu/index.ts
@@ -0,0 +1,49 @@
1import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
2import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
3import Content from './dropdown-menu-content.svelte';
4import Group from './dropdown-menu-group.svelte';
5import Item from './dropdown-menu-item.svelte';
6import Label from './dropdown-menu-label.svelte';
7import RadioGroup from './dropdown-menu-radio-group.svelte';
8import RadioItem from './dropdown-menu-radio-item.svelte';
9import Separator from './dropdown-menu-separator.svelte';
10import Shortcut from './dropdown-menu-shortcut.svelte';
11import Trigger from './dropdown-menu-trigger.svelte';
12import SubContent from './dropdown-menu-sub-content.svelte';
13import SubTrigger from './dropdown-menu-sub-trigger.svelte';
14import GroupHeading from './dropdown-menu-group-heading.svelte';
15const Sub = DropdownMenuPrimitive.Sub;
16const Root = DropdownMenuPrimitive.Root;
17
18export {
19 CheckboxItem,
20 Content,
21 Root as DropdownMenu,
22 CheckboxItem as DropdownMenuCheckboxItem,
23 Content as DropdownMenuContent,
24 Group as DropdownMenuGroup,
25 Item as DropdownMenuItem,
26 Label as DropdownMenuLabel,
27 RadioGroup as DropdownMenuRadioGroup,
28 RadioItem as DropdownMenuRadioItem,
29 Separator as DropdownMenuSeparator,
30 Shortcut as DropdownMenuShortcut,
31 Sub as DropdownMenuSub,
32 SubContent as DropdownMenuSubContent,
33 SubTrigger as DropdownMenuSubTrigger,
34 Trigger as DropdownMenuTrigger,
35 GroupHeading as DropdownMenuGroupHeading,
36 Group,
37 GroupHeading,
38 Item,
39 Label,
40 RadioGroup,
41 RadioItem,
42 Root,
43 Separator,
44 Shortcut,
45 Sub,
46 SubContent,
47 SubTrigger,
48 Trigger
49};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/input/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/input/index.ts
new file mode 100644
index 0000000..15c0933
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/input/index.ts
@@ -0,0 +1,7 @@
1import Root from './input.svelte';
2
3export {
4 Root,
5 //
6 Root as Input
7};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/input/input.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/input/input.svelte
new file mode 100644
index 0000000..889b720
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/input/input.svelte
@@ -0,0 +1,51 @@
1<script lang="ts">
2 import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils';
4
5 type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
6
7 type Props = WithElementRef<
8 Omit<HTMLInputAttributes, 'type'> &
9 ({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
10 >;
11
12 let {
13 ref = $bindable(null),
14 value = $bindable(),
15 type,
16 files = $bindable(),
17 class: className,
18 ...restProps
19 }: Props = $props();
20</script>
21
22{#if type === 'file'}
23 <input
24 bind:this={ref}
25 data-slot="input"
26 class={cn(
27 'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
28 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
29 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
30 className
31 )}
32 type="file"
33 bind:files
34 bind:value
35 {...restProps}
36 />
37{:else}
38 <input
39 bind:this={ref}
40 data-slot="input"
41 class={cn(
42 'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
43 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
44 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
45 className
46 )}
47 {type}
48 bind:value
49 {...restProps}
50 />
51{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/label/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/label/index.ts
new file mode 100644
index 0000000..808d141
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/label/index.ts
@@ -0,0 +1,7 @@
1import Root from './label.svelte';
2
3export {
4 Root,
5 //
6 Root as Label
7};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/label/label.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/label/label.svelte
new file mode 100644
index 0000000..9da4ae3
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/label/label.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { Label as LabelPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: LabelPrimitive.RootProps = $props();
10</script>
11
12<LabelPrimitive.Root
13 bind:ref
14 data-slot="label"
15 class={cn(
16 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
17 className
18 )}
19 {...restProps}
20/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/index.ts
new file mode 100644
index 0000000..c5937fb
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/index.ts
@@ -0,0 +1,19 @@
1import Root from './popover.svelte';
2import Close from './popover-close.svelte';
3import Content from './popover-content.svelte';
4import Trigger from './popover-trigger.svelte';
5import Portal from './popover-portal.svelte';
6
7export {
8 Root,
9 Content,
10 Trigger,
11 Close,
12 Portal,
13 //
14 Root as Popover,
15 Content as PopoverContent,
16 Trigger as PopoverTrigger,
17 Close as PopoverClose,
18 Portal as PopoverPortal
19};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte
new file mode 100644
index 0000000..dc4dec4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Popover as PopoverPrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
5</script>
6
7<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte
new file mode 100644
index 0000000..2d3513d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte
@@ -0,0 +1,37 @@
1<script lang="ts">
2 import { Popover as PopoverPrimitive } from 'bits-ui';
3 import PopoverPortal from './popover-portal.svelte';
4 import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
5 import type { ComponentProps } from 'svelte';
6
7 let {
8 ref = $bindable(null),
9 class: className,
10 sideOffset = 4,
11 side,
12 align = 'center',
13 collisionPadding = 8,
14 avoidCollisions = true,
15 portalProps,
16 ...restProps
17 }: PopoverPrimitive.ContentProps & {
18 portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
19 } = $props();
20</script>
21
22<PopoverPortal {...portalProps}>
23 <PopoverPrimitive.Content
24 bind:ref
25 data-slot="popover-content"
26 {sideOffset}
27 {side}
28 {align}
29 {collisionPadding}
30 {avoidCollisions}
31 class={cn(
32 'z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
33 className
34 )}
35 {...restProps}
36 />
37</PopoverPortal>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte
new file mode 100644
index 0000000..25efb87
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Popover as PopoverPrimitive } from 'bits-ui';
3
4 let { ...restProps }: PopoverPrimitive.PortalProps = $props();
5</script>
6
7<PopoverPrimitive.Portal {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte
new file mode 100644
index 0000000..5ef3d0e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte
@@ -0,0 +1,17 @@
1<script lang="ts">
2 import { cn } from '$lib/components/ui/utils.js';
3 import { Popover as PopoverPrimitive } from 'bits-ui';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: PopoverPrimitive.TriggerProps = $props();
10</script>
11
12<PopoverPrimitive.Trigger
13 bind:ref
14 data-slot="popover-trigger"
15 class={cn('', className)}
16 {...restProps}
17/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover.svelte
new file mode 100644
index 0000000..f39b867
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/popover/popover.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Popover as PopoverPrimitive } from 'bits-ui';
3
4 let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
5</script>
6
7<PopoverPrimitive.Root bind:open {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/index.ts
new file mode 100644
index 0000000..d546806
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/index.ts
@@ -0,0 +1,10 @@
1import Scrollbar from './scroll-area-scrollbar.svelte';
2import Root from './scroll-area.svelte';
3
4export {
5 Root,
6 Scrollbar,
7 //,
8 Root as ScrollArea,
9 Scrollbar as ScrollAreaScrollbar
10};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte
new file mode 100644
index 0000000..3f0d00d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte
@@ -0,0 +1,31 @@
1<script lang="ts">
2 import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
3 import { cn, type WithoutChild } from '$lib/components/ui/utils';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 orientation = 'vertical',
9 children,
10 ...restProps
11 }: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
12</script>
13
14<ScrollAreaPrimitive.Scrollbar
15 bind:ref
16 data-slot="scroll-area-scrollbar"
17 {orientation}
18 class={cn(
19 'flex touch-none p-px transition-colors select-none',
20 orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
21 orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
22 className
23 )}
24 {...restProps}
25>
26 {@render children?.()}
27 <ScrollAreaPrimitive.Thumb
28 data-slot="scroll-area-thumb"
29 class="relative flex-1 rounded-full bg-border"
30 />
31</ScrollAreaPrimitive.Scrollbar>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area.svelte
new file mode 100644
index 0000000..ba6f838
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/scroll-area/scroll-area.svelte
@@ -0,0 +1,40 @@
1<script lang="ts">
2 import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
3 import { Scrollbar } from './index.js';
4 import { cn, type WithoutChild } from '$lib/components/ui/utils';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 orientation = 'vertical',
10 scrollbarXClasses = '',
11 scrollbarYClasses = '',
12 children,
13 ...restProps
14 }: WithoutChild<ScrollAreaPrimitive.RootProps> & {
15 orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
16 scrollbarXClasses?: string | undefined;
17 scrollbarYClasses?: string | undefined;
18 } = $props();
19</script>
20
21<ScrollAreaPrimitive.Root
22 bind:ref
23 data-slot="scroll-area"
24 class={cn('relative', className)}
25 {...restProps}
26>
27 <ScrollAreaPrimitive.Viewport
28 data-slot="scroll-area-viewport"
29 class="size-full rounded-[inherit] ring-ring/10 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 dark:ring-ring/20 dark:outline-ring/40"
30 >
31 {@render children?.()}
32 </ScrollAreaPrimitive.Viewport>
33 {#if orientation === 'vertical' || orientation === 'both'}
34 <Scrollbar orientation="vertical" class={scrollbarYClasses} />
35 {/if}
36 {#if orientation === 'horizontal' || orientation === 'both'}
37 <Scrollbar orientation="horizontal" class={scrollbarXClasses} />
38 {/if}
39 <ScrollAreaPrimitive.Corner />
40</ScrollAreaPrimitive.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/select/index.ts
new file mode 100644
index 0000000..bfa73d9
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/index.ts
@@ -0,0 +1,37 @@
1import { Select as SelectPrimitive } from 'bits-ui';
2
3import Group from './select-group.svelte';
4import Label from './select-label.svelte';
5import Item from './select-item.svelte';
6import Content from './select-content.svelte';
7import Trigger from './select-trigger.svelte';
8import Separator from './select-separator.svelte';
9import ScrollDownButton from './select-scroll-down-button.svelte';
10import ScrollUpButton from './select-scroll-up-button.svelte';
11import GroupHeading from './select-group-heading.svelte';
12
13const Root = SelectPrimitive.Root;
14
15export {
16 Root,
17 Group,
18 Label,
19 Item,
20 Content,
21 Trigger,
22 Separator,
23 ScrollDownButton,
24 ScrollUpButton,
25 GroupHeading,
26 //
27 Root as Select,
28 Group as SelectGroup,
29 Label as SelectLabel,
30 Item as SelectItem,
31 Content as SelectContent,
32 Trigger as SelectTrigger,
33 Separator as SelectSeparator,
34 ScrollDownButton as SelectScrollDownButton,
35 ScrollUpButton as SelectScrollUpButton,
36 GroupHeading as SelectGroupHeading
37};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-content.svelte
new file mode 100644
index 0000000..4050628
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-content.svelte
@@ -0,0 +1,111 @@
1<script lang="ts">
2 import { onDestroy, onMount } from 'svelte';
3 import { Select as SelectPrimitive } from 'bits-ui';
4 import SelectScrollUpButton from './select-scroll-up-button.svelte';
5 import SelectScrollDownButton from './select-scroll-down-button.svelte';
6 import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
7
8 let {
9 ref = $bindable(null),
10 class: className,
11 sideOffset = 4,
12 portalProps,
13 children,
14 ...restProps
15 }: WithoutChild<SelectPrimitive.ContentProps> & {
16 portalProps?: SelectPrimitive.PortalProps;
17 } = $props();
18
19 let cleanupInternalListeners: (() => void) | undefined;
20
21 onMount(() => {
22 const listenerOptions: AddEventListenerOptions = { passive: false };
23
24 const blockOutsideWheel = (event: WheelEvent) => {
25 if (!ref) {
26 return;
27 }
28
29 const target = event.target as Node | null;
30
31 if (!target || !ref.contains(target)) {
32 event.preventDefault();
33 event.stopPropagation();
34 }
35 };
36
37 const blockOutsideTouchMove = (event: TouchEvent) => {
38 if (!ref) {
39 return;
40 }
41
42 const target = event.target as Node | null;
43
44 if (!target || !ref.contains(target)) {
45 event.preventDefault();
46 event.stopPropagation();
47 }
48 };
49
50 document.addEventListener('wheel', blockOutsideWheel, listenerOptions);
51 document.addEventListener('touchmove', blockOutsideTouchMove, listenerOptions);
52
53 return () => {
54 document.removeEventListener('wheel', blockOutsideWheel, listenerOptions);
55 document.removeEventListener('touchmove', blockOutsideTouchMove, listenerOptions);
56 };
57 });
58
59 $effect(() => {
60 const element = ref;
61
62 cleanupInternalListeners?.();
63
64 if (!element) {
65 return;
66 }
67
68 const stopWheelPropagation = (event: WheelEvent) => {
69 event.stopPropagation();
70 };
71
72 const stopTouchPropagation = (event: TouchEvent) => {
73 event.stopPropagation();
74 };
75
76 element.addEventListener('wheel', stopWheelPropagation);
77 element.addEventListener('touchmove', stopTouchPropagation);
78
79 cleanupInternalListeners = () => {
80 element.removeEventListener('wheel', stopWheelPropagation);
81 element.removeEventListener('touchmove', stopTouchPropagation);
82 };
83 });
84
85 onDestroy(() => {
86 cleanupInternalListeners?.();
87 });
88</script>
89
90<SelectPrimitive.Portal {...portalProps}>
91 <SelectPrimitive.Content
92 bind:ref
93 {sideOffset}
94 data-slot="select-content"
95 class={cn(
96 'relative z-[var(--layer-popover,1000000)] max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-right-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-left-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
97 className
98 )}
99 {...restProps}
100 >
101 <SelectScrollUpButton />
102 <SelectPrimitive.Viewport
103 class={cn(
104 'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1'
105 )}
106 >
107 {@render children?.()}
108 </SelectPrimitive.Viewport>
109 <SelectScrollDownButton />
110 </SelectPrimitive.Content>
111</SelectPrimitive.Portal>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group-heading.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group-heading.svelte
new file mode 100644
index 0000000..77c2042
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group-heading.svelte
@@ -0,0 +1,21 @@
1<script lang="ts">
2 import { Select as SelectPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4 import type { ComponentProps } from 'svelte';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 children,
10 ...restProps
11 }: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
12</script>
13
14<SelectPrimitive.GroupHeading
15 bind:ref
16 data-slot="select-group-heading"
17 class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
18 {...restProps}
19>
20 {@render children?.()}
21</SelectPrimitive.GroupHeading>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group.svelte
new file mode 100644
index 0000000..2520795
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-group.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Select as SelectPrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
5</script>
6
7<SelectPrimitive.Group data-slot="select-group" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-item.svelte
new file mode 100644
index 0000000..02543c1
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-item.svelte
@@ -0,0 +1,38 @@
1<script lang="ts">
2 import CheckIcon from '@lucide/svelte/icons/check';
3 import { Select as SelectPrimitive } from 'bits-ui';
4 import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 value,
10 label,
11 children: childrenProp,
12 ...restProps
13 }: WithoutChild<SelectPrimitive.ItemProps> = $props();
14</script>
15
16<SelectPrimitive.Item
17 bind:ref
18 {value}
19 data-slot="select-item"
20 class={cn(
21 "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
22 className
23 )}
24 {...restProps}
25>
26 {#snippet children({ selected, highlighted })}
27 <span class="absolute right-2 flex size-3.5 items-center justify-center">
28 {#if selected}
29 <CheckIcon class="size-4" />
30 {/if}
31 </span>
32 {#if childrenProp}
33 {@render childrenProp({ selected, highlighted })}
34 {:else}
35 {label || value}
36 {/if}
37 {/snippet}
38</SelectPrimitive.Item>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-label.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-label.svelte
new file mode 100644
index 0000000..e2b830c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-label.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="select-label"
16 class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-down-button.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-down-button.svelte
new file mode 100644
index 0000000..9256dd8
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-down-button.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
3 import { Select as SelectPrimitive } from 'bits-ui';
4 import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 ...restProps
10 }: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
11</script>
12
13<SelectPrimitive.ScrollDownButton
14 bind:ref
15 data-slot="select-scroll-down-button"
16 class={cn('flex cursor-default items-center justify-center py-1', className)}
17 {...restProps}
18>
19 <ChevronDownIcon class="size-4" />
20</SelectPrimitive.ScrollDownButton>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-up-button.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-up-button.svelte
new file mode 100644
index 0000000..552e527
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-scroll-up-button.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
3 import { Select as SelectPrimitive } from 'bits-ui';
4 import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 ...restProps
10 }: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
11</script>
12
13<SelectPrimitive.ScrollUpButton
14 bind:ref
15 data-slot="select-scroll-up-button"
16 class={cn('flex cursor-default items-center justify-center py-1', className)}
17 {...restProps}
18>
19 <ChevronUpIcon class="size-4" />
20</SelectPrimitive.ScrollUpButton>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-separator.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-separator.svelte
new file mode 100644
index 0000000..7daaa8d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-separator.svelte
@@ -0,0 +1,18 @@
1<script lang="ts">
2 import type { Separator as SeparatorPrimitive } from 'bits-ui';
3 import { Separator } from '$lib/components/ui/separator/index.js';
4 import { cn } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 ...restProps
10 }: SeparatorPrimitive.RootProps = $props();
11</script>
12
13<Separator
14 bind:ref
15 data-slot="select-separator"
16 class={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
17 {...restProps}
18/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte
new file mode 100644
index 0000000..5bc28ee
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/select/select-trigger.svelte
@@ -0,0 +1,40 @@
1<script lang="ts">
2 import { Select as SelectPrimitive } from 'bits-ui';
3 import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
4 import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 children,
10 size = 'default',
11 variant = 'default',
12 ...restProps
13 }: WithoutChild<SelectPrimitive.TriggerProps> & {
14 size?: 'sm' | 'default';
15 variant?: 'default' | 'plain';
16 } = $props();
17
18 const baseClasses = $derived(
19 variant === 'plain'
20 ? "group inline-flex w-full items-center justify-end gap-2 whitespace-nowrap px-0 py-0 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground"
21 : "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground"
22 );
23
24 const chevronClasses = $derived(
25 variant === 'plain'
26 ? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
27 : 'size-4 opacity-50'
28 );
29</script>
30
31<SelectPrimitive.Trigger
32 bind:ref
33 data-slot="select-trigger"
34 data-size={size}
35 class={cn(baseClasses, className)}
36 {...restProps}
37>
38 {@render children?.()}
39 <ChevronDownIcon class={chevronClasses} />
40</SelectPrimitive.Trigger>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/separator/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/separator/index.ts
new file mode 100644
index 0000000..768efac
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/separator/index.ts
@@ -0,0 +1,7 @@
1import Root from './separator.svelte';
2
3export {
4 Root,
5 //
6 Root as Separator
7};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/separator/separator.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/separator/separator.svelte
new file mode 100644
index 0000000..00307fd
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/separator/separator.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { Separator as SeparatorPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: SeparatorPrimitive.RootProps = $props();
10</script>
11
12<SeparatorPrimitive.Root
13 bind:ref
14 data-slot="separator"
15 class={cn(
16 'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
17 className
18 )}
19 {...restProps}
20/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/index.ts
new file mode 100644
index 0000000..139e2d2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/index.ts
@@ -0,0 +1,36 @@
1import { Dialog as SheetPrimitive } from 'bits-ui';
2import Trigger from './sheet-trigger.svelte';
3import Close from './sheet-close.svelte';
4import Overlay from './sheet-overlay.svelte';
5import Content from './sheet-content.svelte';
6import Header from './sheet-header.svelte';
7import Footer from './sheet-footer.svelte';
8import Title from './sheet-title.svelte';
9import Description from './sheet-description.svelte';
10
11const Root = SheetPrimitive.Root;
12const Portal = SheetPrimitive.Portal;
13
14export {
15 Root,
16 Close,
17 Trigger,
18 Portal,
19 Overlay,
20 Content,
21 Header,
22 Footer,
23 Title,
24 Description,
25 //
26 Root as Sheet,
27 Close as SheetClose,
28 Trigger as SheetTrigger,
29 Portal as SheetPortal,
30 Overlay as SheetOverlay,
31 Content as SheetContent,
32 Header as SheetHeader,
33 Footer as SheetFooter,
34 Title as SheetTitle,
35 Description as SheetDescription
36};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-close.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-close.svelte
new file mode 100644
index 0000000..b0180c0
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-close.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Dialog as SheetPrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
5</script>
6
7<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-content.svelte
new file mode 100644
index 0000000..f16a0e0
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-content.svelte
@@ -0,0 +1,60 @@
1<script lang="ts" module>
2 import { tv, type VariantProps } from 'tailwind-variants';
3 export const sheetVariants = tv({
4 base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
5 variants: {
6 side: {
7 top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
8 bottom:
9 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
10 left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
11 right:
12 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm'
13 }
14 },
15 defaultVariants: {
16 side: 'right'
17 }
18 });
19
20 export type Side = VariantProps<typeof sheetVariants>['side'];
21</script>
22
23<script lang="ts">
24 import { Dialog as SheetPrimitive } from 'bits-ui';
25 import XIcon from '@lucide/svelte/icons/x';
26 import type { Snippet } from 'svelte';
27 import SheetOverlay from './sheet-overlay.svelte';
28 import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
29
30 let {
31 ref = $bindable(null),
32 class: className,
33 side = 'right',
34 portalProps,
35 children,
36 ...restProps
37 }: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
38 portalProps?: SheetPrimitive.PortalProps;
39 side?: Side;
40 children: Snippet;
41 } = $props();
42</script>
43
44<SheetPrimitive.Portal {...portalProps}>
45 <SheetOverlay />
46 <SheetPrimitive.Content
47 bind:ref
48 data-slot="sheet-content"
49 class={cn(sheetVariants({ side }), className)}
50 {...restProps}
51 >
52 {@render children?.()}
53 <SheetPrimitive.Close
54 class="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
55 >
56 <XIcon class="size-4" />
57 <span class="sr-only">Close</span>
58 </SheetPrimitive.Close>
59 </SheetPrimitive.Content>
60</SheetPrimitive.Portal>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-description.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-description.svelte
new file mode 100644
index 0000000..ef4d58f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-description.svelte
@@ -0,0 +1,17 @@
1<script lang="ts">
2 import { Dialog as SheetPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: SheetPrimitive.DescriptionProps = $props();
10</script>
11
12<SheetPrimitive.Description
13 bind:ref
14 data-slot="sheet-description"
15 class={cn('text-sm text-muted-foreground', className)}
16 {...restProps}
17/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-footer.svelte
new file mode 100644
index 0000000..4e1b927
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-footer.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="sheet-footer"
16 class={cn('mt-auto flex flex-col gap-2 p-4', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-header.svelte
new file mode 100644
index 0000000..6c6c1ec
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-header.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="sheet-header"
16 class={cn('flex flex-col gap-1.5 p-4', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-overlay.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-overlay.svelte
new file mode 100644
index 0000000..a6a064f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-overlay.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { Dialog as SheetPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: SheetPrimitive.OverlayProps = $props();
10</script>
11
12<SheetPrimitive.Overlay
13 bind:ref
14 data-slot="sheet-overlay"
15 class={cn(
16 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
17 className
18 )}
19 {...restProps}
20/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-title.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-title.svelte
new file mode 100644
index 0000000..0efcc7a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-title.svelte
@@ -0,0 +1,17 @@
1<script lang="ts">
2 import { Dialog as SheetPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: SheetPrimitive.TitleProps = $props();
10</script>
11
12<SheetPrimitive.Title
13 bind:ref
14 data-slot="sheet-title"
15 class={cn('font-semibold text-foreground', className)}
16 {...restProps}
17/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-trigger.svelte
new file mode 100644
index 0000000..d95719a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sheet/sheet-trigger.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Dialog as SheetPrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
5</script>
6
7<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/constants.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/constants.ts
new file mode 100644
index 0000000..c7e827b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/constants.ts
@@ -0,0 +1,6 @@
1export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
2export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
3export const SIDEBAR_WIDTH = '18rem';
4export const SIDEBAR_WIDTH_MOBILE = '18rem';
5export const SIDEBAR_WIDTH_ICON = '3rem';
6export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/context.svelte.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/context.svelte.ts
new file mode 100644
index 0000000..6fa2aa3
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/context.svelte.ts
@@ -0,0 +1,79 @@
1import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
2import { getContext, setContext } from 'svelte';
3import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js';
4
5type Getter<T> = () => T;
6
7export type SidebarStateProps = {
8 /**
9 * A getter function that returns the current open state of the sidebar.
10 * We use a getter function here to support `bind:open` on the `Sidebar.Provider`
11 * component.
12 */
13 open: Getter<boolean>;
14
15 /**
16 * A function that sets the open state of the sidebar. To support `bind:open`, we need
17 * a source of truth for changing the open state to ensure it will be synced throughout
18 * the sub-components and any `bind:` references.
19 */
20 setOpen: (open: boolean) => void;
21};
22
23class SidebarState {
24 readonly props: SidebarStateProps;
25 open = $derived.by(() => this.props.open());
26 openMobile = $state(false);
27 setOpen: SidebarStateProps['setOpen'];
28 #isMobile: IsMobile;
29 state = $derived.by(() => (this.open ? 'expanded' : 'collapsed'));
30
31 constructor(props: SidebarStateProps) {
32 this.setOpen = props.setOpen;
33 this.#isMobile = new IsMobile();
34 this.props = props;
35 }
36
37 // Convenience getter for checking if the sidebar is mobile
38 // without this, we would need to use `sidebar.isMobile.current` everywhere
39 get isMobile() {
40 return this.#isMobile.current;
41 }
42
43 // Event handler to apply to the `<svelte:window>`
44 handleShortcutKeydown = (e: KeyboardEvent) => {
45 if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
46 e.preventDefault();
47 this.toggle();
48 }
49 };
50
51 setOpenMobile = (value: boolean) => {
52 this.openMobile = value;
53 };
54
55 toggle = () => {
56 return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open);
57 };
58}
59
60const SYMBOL_KEY = 'scn-sidebar';
61
62/**
63 * Instantiates a new `SidebarState` instance and sets it in the context.
64 *
65 * @param props The constructor props for the `SidebarState` class.
66 * @returns The `SidebarState` instance.
67 */
68export function setSidebar(props: SidebarStateProps): SidebarState {
69 return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
70}
71
72/**
73 * Retrieves the `SidebarState` instance from the context. This is a class instance,
74 * so you cannot destructure it.
75 * @returns The `SidebarState` instance.
76 */
77export function useSidebar(): SidebarState {
78 return getContext(Symbol.for(SYMBOL_KEY));
79}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/index.ts
new file mode 100644
index 0000000..280e640
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/index.ts
@@ -0,0 +1,75 @@
1import { useSidebar } from './context.svelte.js';
2import Content from './sidebar-content.svelte';
3import Footer from './sidebar-footer.svelte';
4import GroupAction from './sidebar-group-action.svelte';
5import GroupContent from './sidebar-group-content.svelte';
6import GroupLabel from './sidebar-group-label.svelte';
7import Group from './sidebar-group.svelte';
8import Header from './sidebar-header.svelte';
9import Input from './sidebar-input.svelte';
10import Inset from './sidebar-inset.svelte';
11import MenuAction from './sidebar-menu-action.svelte';
12import MenuBadge from './sidebar-menu-badge.svelte';
13import MenuButton from './sidebar-menu-button.svelte';
14import MenuItem from './sidebar-menu-item.svelte';
15import MenuSkeleton from './sidebar-menu-skeleton.svelte';
16import MenuSubButton from './sidebar-menu-sub-button.svelte';
17import MenuSubItem from './sidebar-menu-sub-item.svelte';
18import MenuSub from './sidebar-menu-sub.svelte';
19import Menu from './sidebar-menu.svelte';
20import Provider from './sidebar-provider.svelte';
21import Rail from './sidebar-rail.svelte';
22import Separator from './sidebar-separator.svelte';
23import Trigger from './sidebar-trigger.svelte';
24import Root from './sidebar.svelte';
25
26export {
27 Content,
28 Footer,
29 Group,
30 GroupAction,
31 GroupContent,
32 GroupLabel,
33 Header,
34 Input,
35 Inset,
36 Menu,
37 MenuAction,
38 MenuBadge,
39 MenuButton,
40 MenuItem,
41 MenuSkeleton,
42 MenuSub,
43 MenuSubButton,
44 MenuSubItem,
45 Provider,
46 Rail,
47 Root,
48 Separator,
49 //
50 Root as Sidebar,
51 Content as SidebarContent,
52 Footer as SidebarFooter,
53 Group as SidebarGroup,
54 GroupAction as SidebarGroupAction,
55 GroupContent as SidebarGroupContent,
56 GroupLabel as SidebarGroupLabel,
57 Header as SidebarHeader,
58 Input as SidebarInput,
59 Inset as SidebarInset,
60 Menu as SidebarMenu,
61 MenuAction as SidebarMenuAction,
62 MenuBadge as SidebarMenuBadge,
63 MenuButton as SidebarMenuButton,
64 MenuItem as SidebarMenuItem,
65 MenuSkeleton as SidebarMenuSkeleton,
66 MenuSub as SidebarMenuSub,
67 MenuSubButton as SidebarMenuSubButton,
68 MenuSubItem as SidebarMenuSubItem,
69 Provider as SidebarProvider,
70 Rail as SidebarRail,
71 Separator as SidebarSeparator,
72 Trigger as SidebarTrigger,
73 Trigger,
74 useSidebar
75};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-content.svelte
new file mode 100644
index 0000000..0e5f75e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-content.svelte
@@ -0,0 +1,24 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="sidebar-content"
16 data-sidebar="content"
17 class={cn(
18 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
19 className
20 )}
21 {...restProps}
22>
23 {@render children?.()}
24</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-footer.svelte
new file mode 100644
index 0000000..67be0a4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-footer.svelte
@@ -0,0 +1,21 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="sidebar-footer"
16 data-sidebar="footer"
17 class={cn('flex flex-col gap-2 p-2', className)}
18 {...restProps}
19>
20 {@render children?.()}
21</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-action.svelte
new file mode 100644
index 0000000..027a711
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-action.svelte
@@ -0,0 +1,36 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { Snippet } from 'svelte';
4 import type { HTMLButtonAttributes } from 'svelte/elements';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 children,
10 child,
11 ...restProps
12 }: WithElementRef<HTMLButtonAttributes> & {
13 child?: Snippet<[{ props: Record<string, unknown> }]>;
14 } = $props();
15
16 const mergedProps = $derived({
17 class: cn(
18 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground outline-hidden absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
19 // Increases the hit area of the button on mobile.
20 'after:absolute after:-inset-2 md:after:hidden',
21 'group-data-[collapsible=icon]:hidden',
22 className
23 ),
24 'data-slot': 'sidebar-group-action',
25 'data-sidebar': 'group-action',
26 ...restProps
27 });
28</script>
29
30{#if child}
31 {@render child({ props: mergedProps })}
32{:else}
33 <button bind:this={ref} {...mergedProps}>
34 {@render children?.()}
35 </button>
36{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-content.svelte
new file mode 100644
index 0000000..9e018fb
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-content.svelte
@@ -0,0 +1,21 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="sidebar-group-content"
16 data-sidebar="group-content"
17 class={cn('w-full text-sm', className)}
18 {...restProps}
19>
20 {@render children?.()}
21</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-label.svelte
new file mode 100644
index 0000000..79f47d7
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group-label.svelte
@@ -0,0 +1,34 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { Snippet } from 'svelte';
4 import type { HTMLAttributes } from 'svelte/elements';
5
6 let {
7 ref = $bindable(null),
8 children,
9 child,
10 class: className,
11 ...restProps
12 }: WithElementRef<HTMLAttributes<HTMLElement>> & {
13 child?: Snippet<[{ props: Record<string, unknown> }]>;
14 } = $props();
15
16 const mergedProps = $derived({
17 class: cn(
18 'text-sidebar-foreground/70 ring-sidebar-ring outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
19 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
20 className
21 ),
22 'data-slot': 'sidebar-group-label',
23 'data-sidebar': 'group-label',
24 ...restProps
25 });
26</script>
27
28{#if child}
29 {@render child({ props: mergedProps })}
30{:else}
31 <div bind:this={ref} {...mergedProps}>
32 {@render children?.()}
33 </div>
34{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group.svelte
new file mode 100644
index 0000000..eed5ace
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-group.svelte
@@ -0,0 +1,21 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="sidebar-group"
16 data-sidebar="group"
17 class={cn('relative flex w-full min-w-0 flex-col p-2', className)}
18 {...restProps}
19>
20 {@render children?.()}
21</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-header.svelte
new file mode 100644
index 0000000..0651550
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-header.svelte
@@ -0,0 +1,21 @@
1<script lang="ts">
2 import type { HTMLAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="sidebar-header"
16 data-sidebar="header"
17 class={cn('flex flex-col gap-2 p-2', className)}
18 {...restProps}
19>
20 {@render children?.()}
21</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-input.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-input.svelte
new file mode 100644
index 0000000..fa57473
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-input.svelte
@@ -0,0 +1,21 @@
1<script lang="ts">
2 import type { ComponentProps } from 'svelte';
3 import { Input } from '$lib/components/ui/input/index.js';
4 import { cn } from '$lib/components/ui/utils.js';
5
6 let {
7 ref = $bindable(null),
8 value = $bindable(''),
9 class: className,
10 ...restProps
11 }: ComponentProps<typeof Input> = $props();
12</script>
13
14<Input
15 bind:ref
16 bind:value
17 data-slot="sidebar-input"
18 data-sidebar="input"
19 class={cn('h-8 w-full bg-background shadow-none', className)}
20 {...restProps}
21/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-inset.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-inset.svelte
new file mode 100644
index 0000000..f55d2f4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-inset.svelte
@@ -0,0 +1,24 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
11</script>
12
13<main
14 bind:this={ref}
15 data-slot="sidebar-inset"
16 class={cn(
17 'relative flex w-full flex-1 flex-col',
18 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
19 className
20 )}
21 {...restProps}
22>
23 {@render children?.()}
24</main>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
new file mode 100644
index 0000000..ded1ffd
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
@@ -0,0 +1,43 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { Snippet } from 'svelte';
4 import type { HTMLButtonAttributes } from 'svelte/elements';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 showOnHover = false,
10 children,
11 child,
12 ...restProps
13 }: WithElementRef<HTMLButtonAttributes> & {
14 child?: Snippet<[{ props: Record<string, unknown> }]>;
15 showOnHover?: boolean;
16 } = $props();
17
18 const mergedProps = $derived({
19 class: cn(
20 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground outline-hidden absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
21 // Increases the hit area of the button on mobile.
22 'after:absolute after:-inset-2 md:after:hidden',
23 'peer-data-[size=sm]/menu-button:top-1',
24 'peer-data-[size=default]/menu-button:top-1.5',
25 'peer-data-[size=lg]/menu-button:top-2.5',
26 'group-data-[collapsible=icon]:hidden',
27 showOnHover &&
28 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
29 className
30 ),
31 'data-slot': 'sidebar-menu-action',
32 'data-sidebar': 'menu-action',
33 ...restProps
34 });
35</script>
36
37{#if child}
38 {@render child({ props: mergedProps })}
39{:else}
40 <button bind:this={ref} {...mergedProps}>
41 {@render children?.()}
42 </button>
43{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
new file mode 100644
index 0000000..f4525a1
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
@@ -0,0 +1,29 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
11</script>
12
13<div
14 bind:this={ref}
15 data-slot="sidebar-menu-badge"
16 data-sidebar="menu-badge"
17 class={cn(
18 'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none',
19 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
20 'peer-data-[size=sm]/menu-button:top-1',
21 'peer-data-[size=default]/menu-button:top-1.5',
22 'peer-data-[size=lg]/menu-button:top-2.5',
23 'group-data-[collapsible=icon]:hidden',
24 className
25 )}
26 {...restProps}
27>
28 {@render children?.()}
29</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
new file mode 100644
index 0000000..2ce0305
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
@@ -0,0 +1,106 @@
1<script lang="ts" module>
2 import { tv, type VariantProps } from 'tailwind-variants';
3
4 export const sidebarMenuButtonVariants = tv({
5 base: 'peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
6 variants: {
7 variant: {
8 default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
9 outline:
10 'bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]'
11 },
12 size: {
13 default: 'h-8 text-sm',
14 sm: 'h-7 text-xs',
15 lg: 'group-data-[collapsible=icon]:p-0! h-12 text-sm'
16 }
17 },
18 defaultVariants: {
19 variant: 'default',
20 size: 'default'
21 }
22 });
23
24 export type SidebarMenuButtonVariant = VariantProps<typeof sidebarMenuButtonVariants>['variant'];
25 export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>['size'];
26</script>
27
28<script lang="ts">
29 import * as Tooltip from '$lib/components/ui/tooltip/index.js';
30 import {
31 cn,
32 type WithElementRef,
33 type WithoutChildrenOrChild
34 } from '$lib/components/ui/utils.js';
35 import { mergeProps } from 'bits-ui';
36 import type { ComponentProps, Snippet } from 'svelte';
37 import type { HTMLAttributes } from 'svelte/elements';
38 import { useSidebar } from './context.svelte.js';
39
40 let {
41 ref = $bindable(null),
42 class: className,
43 children,
44 child,
45 variant = 'default',
46 size = 'default',
47 isActive = false,
48 tooltipContent,
49 tooltipContentProps,
50 ...restProps
51 }: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
52 isActive?: boolean;
53 variant?: SidebarMenuButtonVariant;
54 size?: SidebarMenuButtonSize;
55 tooltipContent?: Snippet | string;
56 tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>;
57 child?: Snippet<[{ props: Record<string, unknown> }]>;
58 } = $props();
59
60 const sidebar = useSidebar();
61
62 const buttonProps = $derived({
63 class: cn(sidebarMenuButtonVariants({ variant, size }), className),
64 'data-slot': 'sidebar-menu-button',
65 'data-sidebar': 'menu-button',
66 'data-size': size,
67 'data-active': isActive,
68 ...restProps
69 });
70</script>
71
72{#snippet Button({ props }: { props?: Record<string, unknown> })}
73 {@const mergedProps = mergeProps(buttonProps, props)}
74 {#if child}
75 {@render child({ props: mergedProps })}
76 {:else}
77 <button bind:this={ref} {...mergedProps}>
78 {@render children?.()}
79 </button>
80 {/if}
81{/snippet}
82
83{#if !tooltipContent}
84 {@render Button({})}
85{:else}
86 <Tooltip.Root>
87 <Tooltip.Trigger>
88 {#snippet child({ props })}
89 {@render Button({ props })}
90 {/snippet}
91 </Tooltip.Trigger>
92
93 <Tooltip.Content
94 side="right"
95 align="center"
96 hidden={sidebar.state !== 'collapsed' || sidebar.isMobile}
97 {...tooltipContentProps}
98 >
99 {#if typeof tooltipContent === 'string'}
100 {tooltipContent}
101 {:else if tooltipContent}
102 {@render tooltipContent()}
103 {/if}
104 </Tooltip.Content>
105 </Tooltip.Root>
106{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
new file mode 100644
index 0000000..5adbedd
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
@@ -0,0 +1,21 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLLIElement>, HTMLLIElement> = $props();
11</script>
12
13<li
14 bind:this={ref}
15 data-slot="sidebar-menu-item"
16 data-sidebar="menu-item"
17 class={cn('group/menu-item relative', className)}
18 {...restProps}
19>
20 {@render children?.()}
21</li>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
new file mode 100644
index 0000000..2b2acd6
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
@@ -0,0 +1,36 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import { Skeleton } from '$lib/components/ui/skeleton/index.js';
4 import type { HTMLAttributes } from 'svelte/elements';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 showIcon = false,
10 children,
11 ...restProps
12 }: WithElementRef<HTMLAttributes<HTMLElement>> & {
13 showIcon?: boolean;
14 } = $props();
15
16 // Random width between 50% and 90%
17 const width = `${Math.floor(Math.random() * 40) + 50}%`;
18</script>
19
20<div
21 bind:this={ref}
22 data-slot="sidebar-menu-skeleton"
23 data-sidebar="menu-skeleton"
24 class={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
25 {...restProps}
26>
27 {#if showIcon}
28 <Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
29 {/if}
30 <Skeleton
31 class="h-4 max-w-(--skeleton-width) flex-1"
32 data-sidebar="menu-skeleton-text"
33 style="--skeleton-width: {width};"
34 />
35 {@render children?.()}
36</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
new file mode 100644
index 0000000..dabfe0f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
@@ -0,0 +1,43 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { Snippet } from 'svelte';
4 import type { HTMLAnchorAttributes } from 'svelte/elements';
5
6 let {
7 ref = $bindable(null),
8 children,
9 child,
10 class: className,
11 size = 'md',
12 isActive = false,
13 ...restProps
14 }: WithElementRef<HTMLAnchorAttributes> & {
15 child?: Snippet<[{ props: Record<string, unknown> }]>;
16 size?: 'sm' | 'md';
17 isActive?: boolean;
18 } = $props();
19
20 const mergedProps = $derived({
21 class: cn(
22 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground outline-hidden flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
23 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
24 size === 'sm' && 'text-xs',
25 size === 'md' && 'text-sm',
26 'group-data-[collapsible=icon]:hidden',
27 className
28 ),
29 'data-slot': 'sidebar-menu-sub-button',
30 'data-sidebar': 'menu-sub-button',
31 'data-size': size,
32 'data-active': isActive,
33 ...restProps
34 });
35</script>
36
37{#if child}
38 {@render child({ props: mergedProps })}
39{:else}
40 <a bind:this={ref} {...mergedProps}>
41 {@render children?.()}
42 </a>
43{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
new file mode 100644
index 0000000..cca870e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
@@ -0,0 +1,21 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 children,
8 class: className,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
11</script>
12
13<li
14 bind:this={ref}
15 data-slot="sidebar-menu-sub-item"
16 data-sidebar="menu-sub-item"
17 class={cn('group/menu-sub-item relative', className)}
18 {...restProps}
19>
20 {@render children?.()}
21</li>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
new file mode 100644
index 0000000..5458ced
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
@@ -0,0 +1,25 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
11</script>
12
13<ul
14 bind:this={ref}
15 data-slot="sidebar-menu-sub"
16 data-sidebar="menu-sub"
17 class={cn(
18 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
19 'group-data-[collapsible=icon]:hidden',
20 className
21 )}
22 {...restProps}
23>
24 {@render children?.()}
25</ul>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu.svelte
new file mode 100644
index 0000000..fee96ed
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-menu.svelte
@@ -0,0 +1,21 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLUListElement>, HTMLUListElement> = $props();
11</script>
12
13<ul
14 bind:this={ref}
15 data-slot="sidebar-menu"
16 data-sidebar="menu"
17 class={cn('flex w-full min-w-0 flex-col gap-1', className)}
18 {...restProps}
19>
20 {@render children?.()}
21</ul>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte
new file mode 100644
index 0000000..364235a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte
@@ -0,0 +1,50 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4 import {
5 SIDEBAR_COOKIE_MAX_AGE,
6 SIDEBAR_COOKIE_NAME,
7 SIDEBAR_WIDTH,
8 SIDEBAR_WIDTH_ICON
9 } from './constants.js';
10 import { setSidebar } from './context.svelte.js';
11
12 let {
13 ref = $bindable(null),
14 open = $bindable(true),
15 onOpenChange = () => {},
16 class: className,
17 style,
18 children,
19 ...restProps
20 }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
21 open?: boolean;
22 onOpenChange?: (open: boolean) => void;
23 } = $props();
24
25 const sidebar = setSidebar({
26 open: () => open,
27 setOpen: (value: boolean) => {
28 open = value;
29 onOpenChange(value);
30
31 // This sets the cookie to keep the sidebar state.
32 document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
33 }
34 });
35</script>
36
37<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
38
39<div
40 data-slot="sidebar-wrapper"
41 style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
42 class={cn(
43 'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
44 className
45 )}
46 bind:this={ref}
47 {...restProps}
48>
49 {@render children?.()}
50</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-rail.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-rail.svelte
new file mode 100644
index 0000000..cde9307
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-rail.svelte
@@ -0,0 +1,36 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4 import { useSidebar } from './context.svelte.js';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 children,
10 ...restProps
11 }: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
12
13 const sidebar = useSidebar();
14</script>
15
16<button
17 bind:this={ref}
18 data-sidebar="rail"
19 data-slot="sidebar-rail"
20 aria-label="Toggle Sidebar"
21 tabIndex={-1}
22 onclick={sidebar.toggle}
23 title="Toggle Sidebar"
24 class={cn(
25 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-[calc(1/2*100%-1px)] after:w-[2px] hover:after:bg-sidebar-border sm:flex',
26 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
27 '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
28 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
29 '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
30 '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
31 className
32 )}
33 {...restProps}
34>
35 {@render children?.()}
36</button>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-separator.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-separator.svelte
new file mode 100644
index 0000000..8fc2065
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-separator.svelte
@@ -0,0 +1,19 @@
1<script lang="ts">
2 import { Separator } from '$lib/components/ui/separator/index.js';
3 import { cn } from '$lib/components/ui/utils.js';
4 import type { ComponentProps } from 'svelte';
5
6 let {
7 ref = $bindable(null),
8 class: className,
9 ...restProps
10 }: ComponentProps<typeof Separator> = $props();
11</script>
12
13<Separator
14 bind:ref
15 data-slot="sidebar-separator"
16 data-sidebar="separator"
17 class={cn('bg-sidebar-border', className)}
18 {...restProps}
19/>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-trigger.svelte
new file mode 100644
index 0000000..29d3a9c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar-trigger.svelte
@@ -0,0 +1,35 @@
1<script lang="ts">
2 import { Button } from '$lib/components/ui/button/index.js';
3 import { cn } from '$lib/components/ui/utils.js';
4 import PanelLeftIcon from '@lucide/svelte/icons/panel-left';
5 import type { ComponentProps } from 'svelte';
6 import { useSidebar } from './context.svelte.js';
7
8 let {
9 ref = $bindable(null),
10 class: className,
11 onclick,
12 ...restProps
13 }: ComponentProps<typeof Button> & {
14 onclick?: (e: MouseEvent) => void;
15 } = $props();
16
17 const sidebar = useSidebar();
18</script>
19
20<Button
21 data-sidebar="trigger"
22 data-slot="sidebar-trigger"
23 variant="ghost"
24 size="icon"
25 class={cn('size-7', className)}
26 type="button"
27 onclick={(e) => {
28 onclick?.(e);
29 sidebar.toggle();
30 }}
31 {...restProps}
32>
33 <PanelLeftIcon />
34 <span class="sr-only">Toggle Sidebar</span>
35</Button>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar.svelte
new file mode 100644
index 0000000..e2c4a75
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/sidebar/sidebar.svelte
@@ -0,0 +1,101 @@
1<script lang="ts">
2 import * as Sheet from '$lib/components/ui/sheet/index.js';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4 import type { HTMLAttributes } from 'svelte/elements';
5 import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
6 import { useSidebar } from './context.svelte.js';
7
8 let {
9 ref = $bindable(null),
10 side = 'left',
11 variant = 'sidebar',
12 collapsible = 'offcanvas',
13 class: className,
14 children,
15 ...restProps
16 }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
17 side?: 'left' | 'right';
18 variant?: 'sidebar' | 'floating' | 'inset';
19 collapsible?: 'offcanvas' | 'icon' | 'none';
20 } = $props();
21
22 const sidebar = useSidebar();
23</script>
24
25{#if collapsible === 'none'}
26 <div
27 class={cn(
28 'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
29 className
30 )}
31 bind:this={ref}
32 {...restProps}
33 >
34 {@render children?.()}
35 </div>
36{:else if sidebar.isMobile}
37 <Sheet.Root bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}>
38 <Sheet.Content
39 data-sidebar="sidebar"
40 data-slot="sidebar"
41 data-mobile="true"
42 class="z-99999 w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground sm:z-99 [&>button]:hidden"
43 style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
44 {side}
45 >
46 <Sheet.Header class="sr-only">
47 <Sheet.Title>Sidebar</Sheet.Title>
48 <Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
49 </Sheet.Header>
50 <div class="flex h-full w-full flex-col">
51 {@render children?.()}
52 </div>
53 </Sheet.Content>
54 </Sheet.Root>
55{:else}
56 <div
57 bind:this={ref}
58 class="group peer hidden text-sidebar-foreground md:block"
59 data-state={sidebar.state}
60 data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
61 data-variant={variant}
62 data-side={side}
63 data-slot="sidebar"
64 >
65 <!-- This is what handles the sidebar gap on desktop -->
66 <div
67 data-slot="sidebar-gap"
68 class={cn(
69 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
70 'group-data-[collapsible=offcanvas]:w-0',
71 'group-data-[side=right]:rotate-180',
72 variant === 'floating' || variant === 'inset'
73 ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
74 : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
75 )}
76 ></div>
77 <div
78 data-slot="sidebar-container"
79 class={cn(
80 'fixed inset-y-0 z-999 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:z-0 md:flex',
81 side === 'left'
82 ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
83 : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
84 // Adjust the padding for floating and inset variants.
85 variant === 'floating' || variant === 'inset'
86 ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
87 : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
88 className
89 )}
90 {...restProps}
91 >
92 <div
93 data-sidebar="sidebar"
94 data-slot="sidebar-inner"
95 class="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
96 >
97 {@render children?.()}
98 </div>
99 </div>
100 </div>
101{/if}
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/index.ts
new file mode 100644
index 0000000..3120ce1
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/index.ts
@@ -0,0 +1,7 @@
1import Root from './skeleton.svelte';
2
3export {
4 Root,
5 //
6 Root as Skeleton
7};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/skeleton.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/skeleton.svelte
new file mode 100644
index 0000000..62b6f80
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/skeleton/skeleton.svelte
@@ -0,0 +1,17 @@
1<script lang="ts">
2 import { cn, type WithElementRef, type WithoutChildren } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 ...restProps
9 }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
10</script>
11
12<div
13 bind:this={ref}
14 data-slot="skeleton"
15 class={cn('animate-pulse rounded-md bg-accent', className)}
16 {...restProps}
17></div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/switch/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/switch/index.ts
new file mode 100644
index 0000000..129f8f5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/switch/index.ts
@@ -0,0 +1,7 @@
1import Root from './switch.svelte';
2
3export {
4 Root,
5 //
6 Root as Switch
7};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/switch/switch.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/switch/switch.svelte
new file mode 100644
index 0000000..5a5975e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/switch/switch.svelte
@@ -0,0 +1,29 @@
1<script lang="ts">
2 import { Switch as SwitchPrimitive } from 'bits-ui';
3 import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 checked = $bindable(false),
9 ...restProps
10 }: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
11</script>
12
13<SwitchPrimitive.Root
14 bind:ref
15 bind:checked
16 data-slot="switch"
17 class={cn(
18 'peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
19 className
20 )}
21 {...restProps}
22>
23 <SwitchPrimitive.Thumb
24 data-slot="switch-thumb"
25 class={cn(
26 'pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground'
27 )}
28 />
29</SwitchPrimitive.Root>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/table/index.ts
new file mode 100644
index 0000000..99239ae
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/index.ts
@@ -0,0 +1,28 @@
1import Root from './table.svelte';
2import Body from './table-body.svelte';
3import Caption from './table-caption.svelte';
4import Cell from './table-cell.svelte';
5import Footer from './table-footer.svelte';
6import Head from './table-head.svelte';
7import Header from './table-header.svelte';
8import Row from './table-row.svelte';
9
10export {
11 Root,
12 Body,
13 Caption,
14 Cell,
15 Footer,
16 Head,
17 Header,
18 Row,
19 //
20 Root as Table,
21 Body as TableBody,
22 Caption as TableCaption,
23 Cell as TableCell,
24 Footer as TableFooter,
25 Head as TableHead,
26 Header as TableHeader,
27 Row as TableRow
28};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-body.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-body.svelte
new file mode 100644
index 0000000..f8df65c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-body.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11</script>
12
13<tbody
14 bind:this={ref}
15 data-slot="table-body"
16 class={cn('[&_tr:last-child]:border-0', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</tbody>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-caption.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-caption.svelte
new file mode 100644
index 0000000..0fdcc64
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-caption.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
11</script>
12
13<caption
14 bind:this={ref}
15 data-slot="table-caption"
16 class={cn('mt-4 text-sm text-muted-foreground', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</caption>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-cell.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-cell.svelte
new file mode 100644
index 0000000..4506fdf
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-cell.svelte
@@ -0,0 +1,23 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLTdAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLTdAttributes> = $props();
11</script>
12
13<td
14 bind:this={ref}
15 data-slot="table-cell"
16 class={cn(
17 'bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0',
18 className
19 )}
20 {...restProps}
21>
22 {@render children?.()}
23</td>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-footer.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-footer.svelte
new file mode 100644
index 0000000..77e4a64
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-footer.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11</script>
12
13<tfoot
14 bind:this={ref}
15 data-slot="table-footer"
16 class={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</tfoot>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-head.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-head.svelte
new file mode 100644
index 0000000..c1c57ad
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-head.svelte
@@ -0,0 +1,23 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLThAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLThAttributes> = $props();
11</script>
12
13<th
14 bind:this={ref}
15 data-slot="table-head"
16 class={cn(
17 'h-10 bg-clip-padding px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pe-0',
18 className
19 )}
20 {...restProps}
21>
22 {@render children?.()}
23</th>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-header.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-header.svelte
new file mode 100644
index 0000000..eb36673
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-header.svelte
@@ -0,0 +1,20 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11</script>
12
13<thead
14 bind:this={ref}
15 data-slot="table-header"
16 class={cn('[&_tr]:border-b', className)}
17 {...restProps}
18>
19 {@render children?.()}
20</thead>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-row.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-row.svelte
new file mode 100644
index 0000000..4131d36
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table-row.svelte
@@ -0,0 +1,23 @@
1<script lang="ts">
2 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
3 import type { HTMLAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
11</script>
12
13<tr
14 bind:this={ref}
15 data-slot="table-row"
16 class={cn(
17 'border-b transition-colors data-[state=selected]:bg-muted hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50',
18 className
19 )}
20 {...restProps}
21>
22 {@render children?.()}
23</tr>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/table/table.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table.svelte
new file mode 100644
index 0000000..c11a6a6
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/table/table.svelte
@@ -0,0 +1,22 @@
1<script lang="ts">
2 import type { HTMLTableAttributes } from 'svelte/elements';
3 import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 children,
9 ...restProps
10 }: WithElementRef<HTMLTableAttributes> = $props();
11</script>
12
13<div data-slot="table-container" class="relative w-full overflow-x-auto">
14 <table
15 bind:this={ref}
16 data-slot="table"
17 class={cn('w-full caption-bottom text-sm', className)}
18 {...restProps}
19 >
20 {@render children?.()}
21 </table>
22</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/index.ts
new file mode 100644
index 0000000..9ccb3bf
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/index.ts
@@ -0,0 +1,7 @@
1import Root from './textarea.svelte';
2
3export {
4 Root,
5 //
6 Root as Textarea
7};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/textarea.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/textarea.svelte
new file mode 100644
index 0000000..bf83882
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/textarea/textarea.svelte
@@ -0,0 +1,22 @@
1<script lang="ts">
2 import { cn, type WithElementRef, type WithoutChildren } from '$lib/components/ui/utils';
3 import type { HTMLTextareaAttributes } from 'svelte/elements';
4
5 let {
6 ref = $bindable(null),
7 value = $bindable(),
8 class: className,
9 ...restProps
10 }: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
11</script>
12
13<textarea
14 bind:this={ref}
15 data-slot="textarea"
16 class={cn(
17 'flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40',
18 className
19 )}
20 bind:value
21 {...restProps}
22></textarea>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/index.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/index.ts
new file mode 100644
index 0000000..273d831
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/index.ts
@@ -0,0 +1,21 @@
1import { Tooltip as TooltipPrimitive } from 'bits-ui';
2import Trigger from './tooltip-trigger.svelte';
3import Content from './tooltip-content.svelte';
4
5const Root = TooltipPrimitive.Root;
6const Provider = TooltipPrimitive.Provider;
7const Portal = TooltipPrimitive.Portal;
8
9export {
10 Root,
11 Trigger,
12 Content,
13 Provider,
14 Portal,
15 //
16 Root as Tooltip,
17 Content as TooltipContent,
18 Trigger as TooltipTrigger,
19 Provider as TooltipProvider,
20 Portal as TooltipPortal
21};
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte
new file mode 100644
index 0000000..72ea93a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte
@@ -0,0 +1,47 @@
1<script lang="ts">
2 import { Tooltip as TooltipPrimitive } from 'bits-ui';
3 import { cn } from '$lib/components/ui/utils.js';
4
5 let {
6 ref = $bindable(null),
7 class: className,
8 sideOffset = 0,
9 side = 'top',
10 children,
11 arrowClasses,
12 ...restProps
13 }: TooltipPrimitive.ContentProps & {
14 arrowClasses?: string;
15 } = $props();
16</script>
17
18<TooltipPrimitive.Portal>
19 <TooltipPrimitive.Content
20 bind:ref
21 data-slot="tooltip-content"
22 {sideOffset}
23 {side}
24 class={cn(
25 'z-50 w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
26 className
27 )}
28 {...restProps}
29 >
30 {@render children?.()}
31 <TooltipPrimitive.Arrow>
32 {#snippet child({ props })}
33 <div
34 class={cn(
35 'z-50 size-2.5 rotate-45 rounded-[2px] bg-primary',
36 'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
37 'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
38 'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
39 'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
40 arrowClasses
41 )}
42 {...props}
43 ></div>
44 {/snippet}
45 </TooltipPrimitive.Arrow>
46 </TooltipPrimitive.Content>
47</TooltipPrimitive.Portal>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-trigger.svelte
new file mode 100644
index 0000000..5631d1b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/tooltip/tooltip-trigger.svelte
@@ -0,0 +1,7 @@
1<script lang="ts">
2 import { Tooltip as TooltipPrimitive } from 'bits-ui';
3
4 let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
5</script>
6
7<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/ui/utils.ts b/llama.cpp/tools/server/webui/src/lib/components/ui/utils.ts
new file mode 100644
index 0000000..f92bfcb
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/ui/utils.ts
@@ -0,0 +1,13 @@
1import { clsx, type ClassValue } from 'clsx';
2import { twMerge } from 'tailwind-merge';
3
4export function cn(...inputs: ClassValue[]) {
5 return twMerge(clsx(inputs));
6}
7
8// eslint-disable-next-line @typescript-eslint/no-explicit-any
9export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
10// eslint-disable-next-line @typescript-eslint/no-explicit-any
11export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
12export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
13export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/auto-scroll.ts b/llama.cpp/tools/server/webui/src/lib/constants/auto-scroll.ts
new file mode 100644
index 0000000..098f435
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/auto-scroll.ts
@@ -0,0 +1,3 @@
1export const AUTO_SCROLL_INTERVAL = 100;
2export const INITIAL_SCROLL_DELAY = 50;
3export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/binary-detection.ts b/llama.cpp/tools/server/webui/src/lib/constants/binary-detection.ts
new file mode 100644
index 0000000..a4440fd
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/binary-detection.ts
@@ -0,0 +1,14 @@
1export interface BinaryDetectionOptions {
2 /** Number of characters to check from the beginning of the file */
3 prefixLength: number;
4 /** Maximum ratio of suspicious characters allowed (0.0 to 1.0) */
5 suspiciousCharThresholdRatio: number;
6 /** Maximum absolute number of null bytes allowed */
7 maxAbsoluteNullBytes: number;
8}
9
10export const DEFAULT_BINARY_DETECTION_OPTIONS: BinaryDetectionOptions = {
11 prefixLength: 1024 * 10, // Check the first 10KB of the string
12 suspiciousCharThresholdRatio: 0.15, // Allow up to 15% suspicious chars
13 maxAbsoluteNullBytes: 2
14};
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/default-context.ts b/llama.cpp/tools/server/webui/src/lib/constants/default-context.ts
new file mode 100644
index 0000000..78f3111
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/default-context.ts
@@ -0,0 +1 @@
export const DEFAULT_CONTEXT = 4096;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/floating-ui-constraints.ts b/llama.cpp/tools/server/webui/src/lib/constants/floating-ui-constraints.ts
new file mode 100644
index 0000000..003fc77
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/floating-ui-constraints.ts
@@ -0,0 +1,2 @@
1export const VIEWPORT_GUTTER = 8;
2export const MENU_OFFSET = 6;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/icons.ts b/llama.cpp/tools/server/webui/src/lib/constants/icons.ts
new file mode 100644
index 0000000..1e88ab5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/icons.ts
@@ -0,0 +1,32 @@
1/**
2 * Icon mappings for file types and model modalities
3 * Centralized configuration to ensure consistent icon usage across the app
4 */
5
6import {
7 File as FileIcon,
8 FileText as FileTextIcon,
9 Image as ImageIcon,
10 Eye as VisionIcon,
11 Mic as AudioIcon
12} from '@lucide/svelte';
13import { FileTypeCategory, ModelModality } from '$lib/enums';
14
15export const FILE_TYPE_ICONS = {
16 [FileTypeCategory.IMAGE]: ImageIcon,
17 [FileTypeCategory.AUDIO]: AudioIcon,
18 [FileTypeCategory.TEXT]: FileTextIcon,
19 [FileTypeCategory.PDF]: FileIcon
20} as const;
21
22export const DEFAULT_FILE_ICON = FileIcon;
23
24export const MODALITY_ICONS = {
25 [ModelModality.VISION]: VisionIcon,
26 [ModelModality.AUDIO]: AudioIcon
27} as const;
28
29export const MODALITY_LABELS = {
30 [ModelModality.VISION]: 'Vision',
31 [ModelModality.AUDIO]: 'Audio'
32} as const;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/input-classes.ts b/llama.cpp/tools/server/webui/src/lib/constants/input-classes.ts
new file mode 100644
index 0000000..a541cfc
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/input-classes.ts
@@ -0,0 +1,6 @@
1export const INPUT_CLASSES = `
2 bg-muted/70 dark:bg-muted/85
3 border border-border/30 focus-within:border-border dark:border-border/20 dark:focus-within:border-border
4 outline-none
5 text-foreground
6`;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/latex-protection.ts b/llama.cpp/tools/server/webui/src/lib/constants/latex-protection.ts
new file mode 100644
index 0000000..27c88e7
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/latex-protection.ts
@@ -0,0 +1,35 @@
1/**
2 * Matches common Markdown code blocks to exclude them from further processing (e.g. LaTeX).
3 * - Fenced: ```...```
4 * - Inline: `...` (does NOT support nested backticks or multi-backtick syntax)
5 *
6 * Note: This pattern does not handle advanced cases like:
7 * `` `code with `backticks` `` or \\``...\\``
8 */
9export const CODE_BLOCK_REGEXP = /(```[\s\S]*?```|`[^`\n]+`)/g;
10
11/**
12 * Matches LaTeX math delimiters \(...\) and \[...\] only when not preceded by a backslash (i.e., not escaped),
13 * while also capturing code blocks (```, `...`) so they can be skipped during processing.
14 *
15 * Uses negative lookbehind `(?<!\\)` to avoid matching \\( or \\[.
16 * Using the look‑behind pattern `(?<!\\)` we skip matches
17 * that are preceded by a backslash, e.g.
18 * `Definitions\\(also called macros)` (title of chapter 20 in The TeXbook)
19 * or `\\[4pt]` (LaTeX line-break).
20 *
21 * group 1: code-block
22 * group 2: square-bracket
23 * group 3: round-bracket
24 */
25export const LATEX_MATH_AND_CODE_PATTERN =
26 /(```[\S\s]*?```|`.*?`)|(?<!\\)\\\[([\S\s]*?[^\\])\\]|(?<!\\)\\\((.*?)\\\)/g;
27
28/** Regex to capture the content of a $$...\\\\...$$ block (display-formula with line-break) */
29export const LATEX_LINEBREAK_REGEXP = /\$\$([\s\S]*?\\\\[\s\S]*?)\$\$/;
30
31/** map from mchem-regexp to replacement */
32export const MHCHEM_PATTERN_MAP: readonly [RegExp, string][] = [
33 [/(\s)\$\\ce{/g, '$1$\\\\ce{'],
34 [/(\s)\$\\pu{/g, '$1$\\\\pu{']
35] as const;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/literal-html.ts b/llama.cpp/tools/server/webui/src/lib/constants/literal-html.ts
new file mode 100644
index 0000000..ed1b0cf
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/literal-html.ts
@@ -0,0 +1,15 @@
1export const LINE_BREAK = /\r?\n/;
2
3export const PHRASE_PARENTS = new Set([
4 'paragraph',
5 'heading',
6 'emphasis',
7 'strong',
8 'delete',
9 'link',
10 'linkReference',
11 'tableCell'
12]);
13
14export const NBSP = '\u00a0';
15export const TAB_AS_SPACES = NBSP.repeat(4);
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/localstorage-keys.ts b/llama.cpp/tools/server/webui/src/lib/constants/localstorage-keys.ts
new file mode 100644
index 0000000..919b6ea
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/localstorage-keys.ts
@@ -0,0 +1,2 @@
1export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
2export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/max-bundle-size.ts b/llama.cpp/tools/server/webui/src/lib/constants/max-bundle-size.ts
new file mode 100644
index 0000000..e04348f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/max-bundle-size.ts
@@ -0,0 +1 @@
export const MAX_BUNDLE_SIZE = 2 * 1024 * 1024;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/precision.ts b/llama.cpp/tools/server/webui/src/lib/constants/precision.ts
new file mode 100644
index 0000000..8df5c4f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/precision.ts
@@ -0,0 +1,2 @@
1export const PRECISION_MULTIPLIER = 1000000;
2export const PRECISION_DECIMAL_PLACES = 6;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/processing-info.ts b/llama.cpp/tools/server/webui/src/lib/constants/processing-info.ts
new file mode 100644
index 0000000..7264392
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/processing-info.ts
@@ -0,0 +1 @@
export const PROCESSING_INFO_TIMEOUT = 2000;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/settings-config.ts b/llama.cpp/tools/server/webui/src/lib/constants/settings-config.ts
new file mode 100644
index 0000000..cac48a5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/settings-config.ts
@@ -0,0 +1,117 @@
1export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> = {
2 // Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value.
3 // Do not use nested objects, keep it single level. Prefix the key if you need to group them.
4 apiKey: '',
5 systemMessage: '',
6 showSystemMessage: true,
7 theme: 'system',
8 showThoughtInProgress: false,
9 showToolCalls: false,
10 disableReasoningFormat: false,
11 keepStatsVisible: false,
12 showMessageStats: true,
13 askForTitleConfirmation: false,
14 pasteLongTextToFileLen: 2500,
15 copyTextAttachmentsAsPlainText: false,
16 pdfAsImage: false,
17 disableAutoScroll: false,
18 renderUserContentAsMarkdown: false,
19 alwaysShowSidebarOnDesktop: false,
20 autoShowSidebarOnNewChat: true,
21 autoMicOnEmpty: false,
22 // make sure these default values are in sync with `common.h`
23 samplers: 'top_k;typ_p;top_p;min_p;temperature',
24 backend_sampling: false,
25 temperature: 0.8,
26 dynatemp_range: 0.0,
27 dynatemp_exponent: 1.0,
28 top_k: 40,
29 top_p: 0.95,
30 min_p: 0.05,
31 xtc_probability: 0.0,
32 xtc_threshold: 0.1,
33 typ_p: 1.0,
34 repeat_last_n: 64,
35 repeat_penalty: 1.0,
36 presence_penalty: 0.0,
37 frequency_penalty: 0.0,
38 dry_multiplier: 0.0,
39 dry_base: 1.75,
40 dry_allowed_length: 2,
41 dry_penalty_last_n: -1,
42 max_tokens: -1,
43 custom: '', // custom json-stringified object
44 // experimental features
45 pyInterpreterEnabled: false,
46 enableContinueGeneration: false
47};
48
49export const SETTING_CONFIG_INFO: Record<string, string> = {
50 apiKey: 'Set the API Key if you are using <code>--api-key</code> option for the server.',
51 systemMessage: 'The starting message that defines how model should behave.',
52 showSystemMessage: 'Display the system message at the top of each conversation.',
53 theme:
54 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
55 pasteLongTextToFileLen:
56 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
57 copyTextAttachmentsAsPlainText:
58 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
59 samplers:
60 'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
61 backend_sampling:
62 'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.',
63 temperature:
64 'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.',
65 dynatemp_range:
66 'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.',
67 dynatemp_exponent:
68 'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.',
69 top_k: 'Keeps only k top tokens.',
70 top_p: 'Limits tokens to those that together have a cumulative probability of at least p',
71 min_p:
72 'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.',
73 xtc_probability:
74 'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.',
75 xtc_threshold:
76 'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.',
77 typ_p: 'Sorts and limits tokens based on the difference between log-probability and entropy.',
78 repeat_last_n: 'Last n tokens to consider for penalizing repetition',
79 repeat_penalty: 'Controls the repetition of token sequences in the generated text',
80 presence_penalty: 'Limits tokens based on whether they appear in the output or not.',
81 frequency_penalty: 'Limits tokens based on how often they appear in the output.',
82 dry_multiplier:
83 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.',
84 dry_base:
85 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.',
86 dry_allowed_length:
87 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.',
88 dry_penalty_last_n:
89 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
90 max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).',
91 custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
92 showThoughtInProgress: 'Expand thought process by default when generating messages.',
93 showToolCalls:
94 'Display tool call labels and payloads from Harmony-compatible delta.tool_calls data below assistant messages.',
95 disableReasoningFormat:
96 'Show raw LLM output without backend parsing and frontend Markdown rendering to inspect streaming across different models.',
97 keepStatsVisible: 'Keep processing statistics visible after generation finishes.',
98 showMessageStats:
99 'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
100 askForTitleConfirmation:
101 'Ask for confirmation before automatically changing conversation title when editing the first message.',
102 pdfAsImage:
103 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
104 disableAutoScroll:
105 'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
106 renderUserContentAsMarkdown: 'Render user messages using markdown formatting in the chat.',
107 alwaysShowSidebarOnDesktop:
108 'Always keep the sidebar visible on desktop instead of auto-hiding it.',
109 autoShowSidebarOnNewChat:
110 'Automatically show sidebar when starting a new chat. Disable to keep the sidebar hidden until you click on it.',
111 autoMicOnEmpty:
112 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
113 pyInterpreterEnabled:
114 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
115 enableContinueGeneration:
116 'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.'
117};
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/supported-file-types.ts b/llama.cpp/tools/server/webui/src/lib/constants/supported-file-types.ts
new file mode 100644
index 0000000..0d955ad
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/supported-file-types.ts
@@ -0,0 +1,217 @@
1/**
2 * Comprehensive dictionary of all supported file types in webui
3 * Organized by category with TypeScript enums for better type safety
4 */
5
6import {
7 FileExtensionAudio,
8 FileExtensionImage,
9 FileExtensionPdf,
10 FileExtensionText,
11 FileTypeAudio,
12 FileTypeImage,
13 FileTypePdf,
14 FileTypeText,
15 MimeTypeAudio,
16 MimeTypeImage,
17 MimeTypeApplication,
18 MimeTypeText
19} from '$lib/enums';
20
21// File type configuration using enums
22export const AUDIO_FILE_TYPES = {
23 [FileTypeAudio.MP3]: {
24 extensions: [FileExtensionAudio.MP3],
25 mimeTypes: [MimeTypeAudio.MP3_MPEG, MimeTypeAudio.MP3]
26 },
27 [FileTypeAudio.WAV]: {
28 extensions: [FileExtensionAudio.WAV],
29 mimeTypes: [MimeTypeAudio.WAV]
30 }
31} as const;
32
33export const IMAGE_FILE_TYPES = {
34 [FileTypeImage.JPEG]: {
35 extensions: [FileExtensionImage.JPG, FileExtensionImage.JPEG],
36 mimeTypes: [MimeTypeImage.JPEG]
37 },
38 [FileTypeImage.PNG]: {
39 extensions: [FileExtensionImage.PNG],
40 mimeTypes: [MimeTypeImage.PNG]
41 },
42 [FileTypeImage.GIF]: {
43 extensions: [FileExtensionImage.GIF],
44 mimeTypes: [MimeTypeImage.GIF]
45 },
46 [FileTypeImage.WEBP]: {
47 extensions: [FileExtensionImage.WEBP],
48 mimeTypes: [MimeTypeImage.WEBP]
49 },
50 [FileTypeImage.SVG]: {
51 extensions: [FileExtensionImage.SVG],
52 mimeTypes: [MimeTypeImage.SVG]
53 }
54} as const;
55
56export const PDF_FILE_TYPES = {
57 [FileTypePdf.PDF]: {
58 extensions: [FileExtensionPdf.PDF],
59 mimeTypes: [MimeTypeApplication.PDF]
60 }
61} as const;
62
63export const TEXT_FILE_TYPES = {
64 [FileTypeText.PLAIN_TEXT]: {
65 extensions: [FileExtensionText.TXT],
66 mimeTypes: [MimeTypeText.PLAIN]
67 },
68 [FileTypeText.MARKDOWN]: {
69 extensions: [FileExtensionText.MD],
70 mimeTypes: [MimeTypeText.MARKDOWN]
71 },
72 [FileTypeText.ASCIIDOC]: {
73 extensions: [FileExtensionText.ADOC],
74 mimeTypes: [MimeTypeText.ASCIIDOC]
75 },
76 [FileTypeText.JAVASCRIPT]: {
77 extensions: [FileExtensionText.JS],
78 mimeTypes: [MimeTypeText.JAVASCRIPT, MimeTypeText.JAVASCRIPT_APP]
79 },
80 [FileTypeText.TYPESCRIPT]: {
81 extensions: [FileExtensionText.TS],
82 mimeTypes: [MimeTypeText.TYPESCRIPT]
83 },
84 [FileTypeText.JSX]: {
85 extensions: [FileExtensionText.JSX],
86 mimeTypes: [MimeTypeText.JSX]
87 },
88 [FileTypeText.TSX]: {
89 extensions: [FileExtensionText.TSX],
90 mimeTypes: [MimeTypeText.TSX]
91 },
92 [FileTypeText.CSS]: {
93 extensions: [FileExtensionText.CSS],
94 mimeTypes: [MimeTypeText.CSS]
95 },
96 [FileTypeText.HTML]: {
97 extensions: [FileExtensionText.HTML, FileExtensionText.HTM],
98 mimeTypes: [MimeTypeText.HTML]
99 },
100 [FileTypeText.JSON]: {
101 extensions: [FileExtensionText.JSON],
102 mimeTypes: [MimeTypeText.JSON]
103 },
104 [FileTypeText.XML]: {
105 extensions: [FileExtensionText.XML],
106 mimeTypes: [MimeTypeText.XML_TEXT, MimeTypeText.XML_APP]
107 },
108 [FileTypeText.YAML]: {
109 extensions: [FileExtensionText.YAML, FileExtensionText.YML],
110 mimeTypes: [MimeTypeText.YAML_TEXT, MimeTypeText.YAML_APP]
111 },
112 [FileTypeText.CSV]: {
113 extensions: [FileExtensionText.CSV],
114 mimeTypes: [MimeTypeText.CSV]
115 },
116 [FileTypeText.LOG]: {
117 extensions: [FileExtensionText.LOG],
118 mimeTypes: [MimeTypeText.PLAIN]
119 },
120 [FileTypeText.PYTHON]: {
121 extensions: [FileExtensionText.PY],
122 mimeTypes: [MimeTypeText.PYTHON]
123 },
124 [FileTypeText.JAVA]: {
125 extensions: [FileExtensionText.JAVA],
126 mimeTypes: [MimeTypeText.JAVA]
127 },
128 [FileTypeText.CPP]: {
129 extensions: [
130 FileExtensionText.CPP,
131 FileExtensionText.C,
132 FileExtensionText.H,
133 FileExtensionText.HPP
134 ],
135 mimeTypes: [MimeTypeText.CPP_SRC, MimeTypeText.CPP_HDR, MimeTypeText.C_SRC, MimeTypeText.C_HDR]
136 },
137 [FileTypeText.PHP]: {
138 extensions: [FileExtensionText.PHP],
139 mimeTypes: [MimeTypeText.PHP]
140 },
141 [FileTypeText.RUBY]: {
142 extensions: [FileExtensionText.RB],
143 mimeTypes: [MimeTypeText.RUBY]
144 },
145 [FileTypeText.GO]: {
146 extensions: [FileExtensionText.GO],
147 mimeTypes: [MimeTypeText.GO]
148 },
149 [FileTypeText.RUST]: {
150 extensions: [FileExtensionText.RS],
151 mimeTypes: [MimeTypeText.RUST]
152 },
153 [FileTypeText.SHELL]: {
154 extensions: [FileExtensionText.SH, FileExtensionText.BAT],
155 mimeTypes: [MimeTypeText.SHELL, MimeTypeText.BAT]
156 },
157 [FileTypeText.SQL]: {
158 extensions: [FileExtensionText.SQL],
159 mimeTypes: [MimeTypeText.SQL]
160 },
161 [FileTypeText.R]: {
162 extensions: [FileExtensionText.R],
163 mimeTypes: [MimeTypeText.R]
164 },
165 [FileTypeText.SCALA]: {
166 extensions: [FileExtensionText.SCALA],
167 mimeTypes: [MimeTypeText.SCALA]
168 },
169 [FileTypeText.KOTLIN]: {
170 extensions: [FileExtensionText.KT],
171 mimeTypes: [MimeTypeText.KOTLIN]
172 },
173 [FileTypeText.SWIFT]: {
174 extensions: [FileExtensionText.SWIFT],
175 mimeTypes: [MimeTypeText.SWIFT]
176 },
177 [FileTypeText.DART]: {
178 extensions: [FileExtensionText.DART],
179 mimeTypes: [MimeTypeText.DART]
180 },
181 [FileTypeText.VUE]: {
182 extensions: [FileExtensionText.VUE],
183 mimeTypes: [MimeTypeText.VUE]
184 },
185 [FileTypeText.SVELTE]: {
186 extensions: [FileExtensionText.SVELTE],
187 mimeTypes: [MimeTypeText.SVELTE]
188 },
189 [FileTypeText.LATEX]: {
190 extensions: [FileExtensionText.TEX],
191 mimeTypes: [MimeTypeText.LATEX, MimeTypeText.TEX, MimeTypeText.TEX_APP]
192 },
193 [FileTypeText.BIBTEX]: {
194 extensions: [FileExtensionText.BIB],
195 mimeTypes: [MimeTypeText.BIBTEX]
196 },
197 [FileTypeText.CUDA]: {
198 extensions: [FileExtensionText.CU, FileExtensionText.CUH],
199 mimeTypes: [MimeTypeText.CUDA]
200 },
201 [FileTypeText.VULKAN]: {
202 extensions: [FileExtensionText.COMP],
203 mimeTypes: [MimeTypeText.PLAIN]
204 },
205 [FileTypeText.HASKELL]: {
206 extensions: [FileExtensionText.HS],
207 mimeTypes: [MimeTypeText.HASKELL]
208 },
209 [FileTypeText.CSHARP]: {
210 extensions: [FileExtensionText.CS],
211 mimeTypes: [MimeTypeText.CSHARP]
212 },
213 [FileTypeText.PROPERTIES]: {
214 extensions: [FileExtensionText.PROPERTIES],
215 mimeTypes: [MimeTypeText.PROPERTIES]
216 }
217} as const;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/table-html-restorer.ts b/llama.cpp/tools/server/webui/src/lib/constants/table-html-restorer.ts
new file mode 100644
index 0000000..e5d5b12
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/table-html-restorer.ts
@@ -0,0 +1,20 @@
1/**
2 * Matches <br>, <br/>, <br /> tags (case-insensitive).
3 * Used to detect line breaks in table cell text content.
4 */
5export const BR_PATTERN = /<br\s*\/?\s*>/gi;
6
7/**
8 * Matches a complete <ul>...</ul> block.
9 * Captures the inner content (group 1) for further <li> extraction.
10 * Case-insensitive, allows multiline content.
11 */
12export const LIST_PATTERN = /^<ul>([\s\S]*)<\/ul>$/i;
13
14/**
15 * Matches individual <li>...</li> elements within a list.
16 * Captures the inner content (group 1) of each list item.
17 * Non-greedy to handle multiple consecutive items.
18 * Case-insensitive, allows multiline content.
19 */
20export const LI_PATTERN = /<li>([\s\S]*?)<\/li>/gi;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/tooltip-config.ts b/llama.cpp/tools/server/webui/src/lib/constants/tooltip-config.ts
new file mode 100644
index 0000000..3c30c8c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/tooltip-config.ts
@@ -0,0 +1 @@
export const TOOLTIP_DELAY_DURATION = 100;
diff --git a/llama.cpp/tools/server/webui/src/lib/constants/viewport.ts b/llama.cpp/tools/server/webui/src/lib/constants/viewport.ts
new file mode 100644
index 0000000..26e202c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/constants/viewport.ts
@@ -0,0 +1 @@
export const DEFAULT_MOBILE_BREAKPOINT = 768;
diff --git a/llama.cpp/tools/server/webui/src/lib/enums/attachment.ts b/llama.cpp/tools/server/webui/src/lib/enums/attachment.ts
new file mode 100644
index 0000000..7c7d0da
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/enums/attachment.ts
@@ -0,0 +1,10 @@
1/**
2 * Attachment type enum for database message extras
3 */
4export enum AttachmentType {
5 AUDIO = 'AUDIO',
6 IMAGE = 'IMAGE',
7 PDF = 'PDF',
8 TEXT = 'TEXT',
9 LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility
10}
diff --git a/llama.cpp/tools/server/webui/src/lib/enums/chat.ts b/llama.cpp/tools/server/webui/src/lib/enums/chat.ts
new file mode 100644
index 0000000..2b9eb7b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/enums/chat.ts
@@ -0,0 +1,4 @@
1export enum ChatMessageStatsView {
2 GENERATION = 'generation',
3 READING = 'reading'
4}
diff --git a/llama.cpp/tools/server/webui/src/lib/enums/files.ts b/llama.cpp/tools/server/webui/src/lib/enums/files.ts
new file mode 100644
index 0000000..a4f079d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/enums/files.ts
@@ -0,0 +1,206 @@
1/**
2 * Comprehensive dictionary of all supported file types in webui
3 * Organized by category with TypeScript enums for better type safety
4 */
5
6// File type category enum
7export enum FileTypeCategory {
8 IMAGE = 'image',
9 AUDIO = 'audio',
10 PDF = 'pdf',
11 TEXT = 'text'
12}
13
14// Specific file type enums for each category
15export enum FileTypeImage {
16 JPEG = 'jpeg',
17 PNG = 'png',
18 GIF = 'gif',
19 WEBP = 'webp',
20 SVG = 'svg'
21}
22
23export enum FileTypeAudio {
24 MP3 = 'mp3',
25 WAV = 'wav',
26 WEBM = 'webm'
27}
28
29export enum FileTypePdf {
30 PDF = 'pdf'
31}
32
33export enum FileTypeText {
34 PLAIN_TEXT = 'plainText',
35 MARKDOWN = 'md',
36 ASCIIDOC = 'asciidoc',
37 JAVASCRIPT = 'js',
38 TYPESCRIPT = 'ts',
39 JSX = 'jsx',
40 TSX = 'tsx',
41 CSS = 'css',
42 HTML = 'html',
43 JSON = 'json',
44 XML = 'xml',
45 YAML = 'yaml',
46 CSV = 'csv',
47 LOG = 'log',
48 PYTHON = 'python',
49 JAVA = 'java',
50 CPP = 'cpp',
51 PHP = 'php',
52 RUBY = 'ruby',
53 GO = 'go',
54 RUST = 'rust',
55 SHELL = 'shell',
56 SQL = 'sql',
57 R = 'r',
58 SCALA = 'scala',
59 KOTLIN = 'kotlin',
60 SWIFT = 'swift',
61 DART = 'dart',
62 VUE = 'vue',
63 SVELTE = 'svelte',
64 LATEX = 'latex',
65 BIBTEX = 'bibtex',
66 CUDA = 'cuda',
67 VULKAN = 'vulkan',
68 HASKELL = 'haskell',
69 CSHARP = 'csharp',
70 PROPERTIES = 'properties'
71}
72
73// File extension enums
74export enum FileExtensionImage {
75 JPG = '.jpg',
76 JPEG = '.jpeg',
77 PNG = '.png',
78 GIF = '.gif',
79 WEBP = '.webp',
80 SVG = '.svg'
81}
82
83export enum FileExtensionAudio {
84 MP3 = '.mp3',
85 WAV = '.wav'
86}
87
88export enum FileExtensionPdf {
89 PDF = '.pdf'
90}
91
92export enum FileExtensionText {
93 TXT = '.txt',
94 MD = '.md',
95 ADOC = '.adoc',
96 JS = '.js',
97 TS = '.ts',
98 JSX = '.jsx',
99 TSX = '.tsx',
100 CSS = '.css',
101 HTML = '.html',
102 HTM = '.htm',
103 JSON = '.json',
104 XML = '.xml',
105 YAML = '.yaml',
106 YML = '.yml',
107 CSV = '.csv',
108 LOG = '.log',
109 PY = '.py',
110 JAVA = '.java',
111 CPP = '.cpp',
112 C = '.c',
113 H = '.h',
114 PHP = '.php',
115 RB = '.rb',
116 GO = '.go',
117 RS = '.rs',
118 SH = '.sh',
119 BAT = '.bat',
120 SQL = '.sql',
121 R = '.r',
122 SCALA = '.scala',
123 KT = '.kt',
124 SWIFT = '.swift',
125 DART = '.dart',
126 VUE = '.vue',
127 SVELTE = '.svelte',
128 TEX = '.tex',
129 BIB = '.bib',
130 CU = '.cu',
131 CUH = '.cuh',
132 COMP = '.comp',
133 HPP = '.hpp',
134 HS = '.hs',
135 PROPERTIES = '.properties',
136 CS = '.cs'
137}
138
139// MIME type enums
140export enum MimeTypeApplication {
141 PDF = 'application/pdf'
142}
143
144export enum MimeTypeAudio {
145 MP3_MPEG = 'audio/mpeg',
146 MP3 = 'audio/mp3',
147 MP4 = 'audio/mp4',
148 WAV = 'audio/wav',
149 WEBM = 'audio/webm',
150 WEBM_OPUS = 'audio/webm;codecs=opus'
151}
152
153export enum MimeTypeImage {
154 JPEG = 'image/jpeg',
155 PNG = 'image/png',
156 GIF = 'image/gif',
157 WEBP = 'image/webp',
158 SVG = 'image/svg+xml'
159}
160
161export enum MimeTypeText {
162 PLAIN = 'text/plain',
163 MARKDOWN = 'text/markdown',
164 ASCIIDOC = 'text/asciidoc',
165 JAVASCRIPT = 'text/javascript',
166 JAVASCRIPT_APP = 'application/javascript',
167 TYPESCRIPT = 'text/typescript',
168 JSX = 'text/jsx',
169 TSX = 'text/tsx',
170 CSS = 'text/css',
171 HTML = 'text/html',
172 JSON = 'application/json',
173 XML_TEXT = 'text/xml',
174 XML_APP = 'application/xml',
175 YAML_TEXT = 'text/yaml',
176 YAML_APP = 'application/yaml',
177 CSV = 'text/csv',
178 PYTHON = 'text/x-python',
179 JAVA = 'text/x-java-source',
180 CPP_HDR = 'text/x-c++hdr',
181 CPP_SRC = 'text/x-c++src',
182 CSHARP = 'text/x-csharp',
183 HASKELL = 'text/x-haskell',
184 C_SRC = 'text/x-csrc',
185 C_HDR = 'text/x-chdr',
186 PHP = 'text/x-php',
187 RUBY = 'text/x-ruby',
188 GO = 'text/x-go',
189 RUST = 'text/x-rust',
190 SHELL = 'text/x-shellscript',
191 BAT = 'application/x-bat',
192 SQL = 'text/x-sql',
193 R = 'text/x-r',
194 SCALA = 'text/x-scala',
195 KOTLIN = 'text/x-kotlin',
196 SWIFT = 'text/x-swift',
197 DART = 'text/x-dart',
198 VUE = 'text/x-vue',
199 SVELTE = 'text/x-svelte',
200 TEX = 'text/x-tex',
201 TEX_APP = 'application/x-tex',
202 LATEX = 'application/x-latex',
203 BIBTEX = 'text/x-bibtex',
204 CUDA = 'text/x-cuda',
205 PROPERTIES = 'text/properties'
206}
diff --git a/llama.cpp/tools/server/webui/src/lib/enums/index.ts b/llama.cpp/tools/server/webui/src/lib/enums/index.ts
new file mode 100644
index 0000000..83c86ca
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/enums/index.ts
@@ -0,0 +1,23 @@
1export { AttachmentType } from './attachment';
2
3export { ChatMessageStatsView } from './chat';
4
5export {
6 FileTypeCategory,
7 FileTypeImage,
8 FileTypeAudio,
9 FileTypePdf,
10 FileTypeText,
11 FileExtensionImage,
12 FileExtensionAudio,
13 FileExtensionPdf,
14 FileExtensionText,
15 MimeTypeApplication,
16 MimeTypeAudio,
17 MimeTypeImage,
18 MimeTypeText
19} from './files';
20
21export { ModelModality } from './model';
22
23export { ServerRole, ServerModelStatus } from './server';
diff --git a/llama.cpp/tools/server/webui/src/lib/enums/model.ts b/llama.cpp/tools/server/webui/src/lib/enums/model.ts
new file mode 100644
index 0000000..7729ecf
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/enums/model.ts
@@ -0,0 +1,5 @@
1export enum ModelModality {
2 TEXT = 'TEXT',
3 AUDIO = 'AUDIO',
4 VISION = 'VISION'
5}
diff --git a/llama.cpp/tools/server/webui/src/lib/enums/server.ts b/llama.cpp/tools/server/webui/src/lib/enums/server.ts
new file mode 100644
index 0000000..7f30eab
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/enums/server.ts
@@ -0,0 +1,20 @@
1/**
2 * Server role enum - used for single/multi-model mode
3 */
4export enum ServerRole {
5 /** Single model mode - server running with a specific model loaded */
6 MODEL = 'model',
7 /** Router mode - server managing multiple model instances */
8 ROUTER = 'router'
9}
10
11/**
12 * Model status enum - matches tools/server/server-models.h from C++ server
13 * Used as the `value` field in the status object from /models endpoint
14 */
15export enum ServerModelStatus {
16 UNLOADED = 'unloaded',
17 LOADING = 'loading',
18 LOADED = 'loaded',
19 FAILED = 'failed'
20}
diff --git a/llama.cpp/tools/server/webui/src/lib/hooks/is-mobile.svelte.ts b/llama.cpp/tools/server/webui/src/lib/hooks/is-mobile.svelte.ts
new file mode 100644
index 0000000..22c74f4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/hooks/is-mobile.svelte.ts
@@ -0,0 +1,8 @@
1import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants/viewport';
2import { MediaQuery } from 'svelte/reactivity';
3
4export class IsMobile extends MediaQuery {
5 constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
6 super(`max-width: ${breakpoint - 1}px`);
7 }
8}
diff --git a/llama.cpp/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts b/llama.cpp/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts
new file mode 100644
index 0000000..bb66615
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts
@@ -0,0 +1,118 @@
1import { modelsStore } from '$lib/stores/models.svelte';
2import { isRouterMode } from '$lib/stores/server.svelte';
3import { toast } from 'svelte-sonner';
4
5interface UseModelChangeValidationOptions {
6 /**
7 * Function to get required modalities for validation.
8 * For ChatForm: () => usedModalities() - all messages
9 * For ChatMessageAssistant: () => getModalitiesUpToMessage(messageId) - messages before
10 */
11 getRequiredModalities: () => ModelModalities;
12
13 /**
14 * Optional callback to execute after successful validation.
15 * For ChatForm: undefined - just select model
16 * For ChatMessageAssistant: (modelName) => onRegenerate(modelName)
17 */
18 onSuccess?: (modelName: string) => void;
19
20 /**
21 * Optional callback for rollback on validation failure.
22 * For ChatForm: (previousId) => selectModelById(previousId)
23 * For ChatMessageAssistant: undefined - no rollback needed
24 */
25 onValidationFailure?: (previousModelId: string | null) => Promise<void>;
26}
27
28export function useModelChangeValidation(options: UseModelChangeValidationOptions) {
29 const { getRequiredModalities, onSuccess, onValidationFailure } = options;
30
31 let previousSelectedModelId: string | null = null;
32 const isRouter = $derived(isRouterMode());
33
34 async function handleModelChange(modelId: string, modelName: string): Promise<boolean> {
35 try {
36 // Store previous selection for potential rollback
37 if (onValidationFailure) {
38 previousSelectedModelId = modelsStore.selectedModelId;
39 }
40
41 // Load model if not already loaded (router mode only)
42 let hasLoadedModel = false;
43 const isModelLoadedBefore = modelsStore.isModelLoaded(modelName);
44
45 if (isRouter && !isModelLoadedBefore) {
46 try {
47 await modelsStore.loadModel(modelName);
48 hasLoadedModel = true;
49 } catch {
50 toast.error(`Failed to load model "${modelName}"`);
51 return false;
52 }
53 }
54
55 // Fetch model props to validate modalities
56 const props = await modelsStore.fetchModelProps(modelName);
57
58 if (props?.modalities) {
59 const requiredModalities = getRequiredModalities();
60
61 // Check if model supports required modalities
62 const missingModalities: string[] = [];
63 if (requiredModalities.vision && !props.modalities.vision) {
64 missingModalities.push('vision');
65 }
66 if (requiredModalities.audio && !props.modalities.audio) {
67 missingModalities.push('audio');
68 }
69
70 if (missingModalities.length > 0) {
71 toast.error(
72 `Model "${modelName}" doesn't support required modalities: ${missingModalities.join(', ')}. Please select a different model.`
73 );
74
75 // Unload the model if we just loaded it
76 if (isRouter && hasLoadedModel) {
77 try {
78 await modelsStore.unloadModel(modelName);
79 } catch (error) {
80 console.error('Failed to unload incompatible model:', error);
81 }
82 }
83
84 // Execute rollback callback if provided
85 if (onValidationFailure && previousSelectedModelId) {
86 await onValidationFailure(previousSelectedModelId);
87 }
88
89 return false;
90 }
91 }
92
93 // Select the model (validation passed)
94 await modelsStore.selectModelById(modelId);
95
96 // Execute success callback if provided
97 if (onSuccess) {
98 onSuccess(modelName);
99 }
100
101 return true;
102 } catch (error) {
103 console.error('Failed to change model:', error);
104 toast.error('Failed to validate model capabilities');
105
106 // Execute rollback callback on error if provided
107 if (onValidationFailure && previousSelectedModelId) {
108 await onValidationFailure(previousSelectedModelId);
109 }
110
111 return false;
112 }
113 }
114
115 return {
116 handleModelChange
117 };
118}
diff --git a/llama.cpp/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts b/llama.cpp/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
new file mode 100644
index 0000000..c06cf28
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
@@ -0,0 +1,262 @@
1import { activeProcessingState } from '$lib/stores/chat.svelte';
2import { config } from '$lib/stores/settings.svelte';
3
4export interface LiveProcessingStats {
5 tokensProcessed: number;
6 totalTokens: number;
7 timeMs: number;
8 tokensPerSecond: number;
9 etaSecs?: number;
10}
11
12export interface LiveGenerationStats {
13 tokensGenerated: number;
14 timeMs: number;
15 tokensPerSecond: number;
16}
17
18export interface UseProcessingStateReturn {
19 readonly processingState: ApiProcessingState | null;
20 getProcessingDetails(): string[];
21 getProcessingMessage(): string;
22 getPromptProgressText(): string | null;
23 getLiveProcessingStats(): LiveProcessingStats | null;
24 getLiveGenerationStats(): LiveGenerationStats | null;
25 shouldShowDetails(): boolean;
26 startMonitoring(): void;
27 stopMonitoring(): void;
28}
29
30/**
31 * useProcessingState - Reactive processing state hook
32 *
33 * This hook provides reactive access to the processing state of the server.
34 * It directly reads from chatStore's reactive state and provides
35 * formatted processing details for UI display.
36 *
37 * **Features:**
38 * - Real-time processing state via direct reactive state binding
39 * - Context and output token tracking
40 * - Tokens per second calculation
41 * - Automatic updates when streaming data arrives
42 * - Supports multiple concurrent conversations
43 *
44 * @returns Hook interface with processing state and control methods
45 */
46export function useProcessingState(): UseProcessingStateReturn {
47 let isMonitoring = $state(false);
48 let lastKnownState = $state<ApiProcessingState | null>(null);
49 let lastKnownProcessingStats = $state<LiveProcessingStats | null>(null);
50
51 // Derive processing state reactively from chatStore's direct state
52 const processingState = $derived.by(() => {
53 if (!isMonitoring) {
54 return lastKnownState;
55 }
56 // Read directly from the reactive state export
57 return activeProcessingState();
58 });
59
60 // Track last known state for keepStatsVisible functionality
61 $effect(() => {
62 if (processingState && isMonitoring) {
63 lastKnownState = processingState;
64 }
65 });
66
67 // Track last known processing stats for when promptProgress disappears
68 $effect(() => {
69 if (processingState?.promptProgress) {
70 const { processed, total, time_ms, cache } = processingState.promptProgress;
71 const actualProcessed = processed - cache;
72 const actualTotal = total - cache;
73
74 if (actualProcessed > 0 && time_ms > 0) {
75 const tokensPerSecond = actualProcessed / (time_ms / 1000);
76 lastKnownProcessingStats = {
77 tokensProcessed: actualProcessed,
78 totalTokens: actualTotal,
79 timeMs: time_ms,
80 tokensPerSecond
81 };
82 }
83 }
84 });
85
86 function getETASecs(done: number, total: number, elapsedMs: number): number | undefined {
87 const elapsedSecs = elapsedMs / 1000;
88 const progressETASecs =
89 done === 0 || elapsedSecs < 0.5
90 ? undefined // can be the case for the 0% progress report
91 : elapsedSecs * (total / done - 1);
92 return progressETASecs;
93 }
94
95 function startMonitoring(): void {
96 if (isMonitoring) return;
97 isMonitoring = true;
98 }
99
100 function stopMonitoring(): void {
101 if (!isMonitoring) return;
102 isMonitoring = false;
103
104 // Only clear last known state if keepStatsVisible is disabled
105 const currentConfig = config();
106 if (!currentConfig.keepStatsVisible) {
107 lastKnownState = null;
108 lastKnownProcessingStats = null;
109 }
110 }
111
112 function getProcessingMessage(): string {
113 if (!processingState) {
114 return 'Processing...';
115 }
116
117 switch (processingState.status) {
118 case 'initializing':
119 return 'Initializing...';
120 case 'preparing':
121 if (processingState.progressPercent !== undefined) {
122 return `Processing (${processingState.progressPercent}%)`;
123 }
124 return 'Preparing response...';
125 case 'generating':
126 return '';
127 default:
128 return 'Processing...';
129 }
130 }
131
132 function getProcessingDetails(): string[] {
133 // Use current processing state or fall back to last known state
134 const stateToUse = processingState || lastKnownState;
135 if (!stateToUse) {
136 return [];
137 }
138
139 const details: string[] = [];
140
141 // Always show context info when we have valid data
142 if (stateToUse.contextUsed >= 0 && stateToUse.contextTotal > 0) {
143 const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100);
144
145 details.push(
146 `Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)`
147 );
148 }
149
150 if (stateToUse.outputTokensUsed > 0) {
151 // Handle infinite max_tokens (-1) case
152 if (stateToUse.outputTokensMax <= 0) {
153 details.push(`Output: ${stateToUse.outputTokensUsed}/∞`);
154 } else {
155 const outputPercent = Math.round(
156 (stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100
157 );
158
159 details.push(
160 `Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)`
161 );
162 }
163 }
164
165 if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
166 details.push(`${stateToUse.tokensPerSecond.toFixed(1)} tokens/sec`);
167 }
168
169 if (stateToUse.speculative) {
170 details.push('Speculative decoding enabled');
171 }
172
173 return details;
174 }
175
176 function shouldShowDetails(): boolean {
177 return processingState !== null && processingState.status !== 'idle';
178 }
179
180 /**
181 * Returns a short progress message with percent
182 */
183 function getPromptProgressText(): string | null {
184 if (!processingState?.promptProgress) return null;
185
186 const { processed, total, cache } = processingState.promptProgress;
187
188 const actualProcessed = processed - cache;
189 const actualTotal = total - cache;
190 const percent = Math.round((actualProcessed / actualTotal) * 100);
191 const eta = getETASecs(actualProcessed, actualTotal, processingState.promptProgress.time_ms);
192
193 if (eta !== undefined) {
194 const etaSecs = Math.ceil(eta);
195 return `Processing ${percent}% (ETA: ${etaSecs}s)`;
196 }
197
198 return `Processing ${percent}%`;
199 }
200
201 /**
202 * Returns live processing statistics for display (prompt processing phase)
203 * Returns last known stats when promptProgress becomes unavailable
204 */
205 function getLiveProcessingStats(): LiveProcessingStats | null {
206 if (processingState?.promptProgress) {
207 const { processed, total, time_ms, cache } = processingState.promptProgress;
208
209 const actualProcessed = processed - cache;
210 const actualTotal = total - cache;
211
212 if (actualProcessed > 0 && time_ms > 0) {
213 const tokensPerSecond = actualProcessed / (time_ms / 1000);
214
215 return {
216 tokensProcessed: actualProcessed,
217 totalTokens: actualTotal,
218 timeMs: time_ms,
219 tokensPerSecond
220 };
221 }
222 }
223
224 // Return last known stats if promptProgress is no longer available
225 return lastKnownProcessingStats;
226 }
227
228 /**
229 * Returns live generation statistics for display (token generation phase)
230 */
231 function getLiveGenerationStats(): LiveGenerationStats | null {
232 if (!processingState) return null;
233
234 const { tokensDecoded, tokensPerSecond } = processingState;
235
236 if (tokensDecoded <= 0) return null;
237
238 // Calculate time from tokens and speed
239 const timeMs =
240 tokensPerSecond && tokensPerSecond > 0 ? (tokensDecoded / tokensPerSecond) * 1000 : 0;
241
242 return {
243 tokensGenerated: tokensDecoded,
244 timeMs,
245 tokensPerSecond: tokensPerSecond || 0
246 };
247 }
248
249 return {
250 get processingState() {
251 return processingState;
252 },
253 getProcessingDetails,
254 getProcessingMessage,
255 getPromptProgressText,
256 getLiveProcessingStats,
257 getLiveGenerationStats,
258 shouldShowDetails,
259 startMonitoring,
260 stopMonitoring
261 };
262}
diff --git a/llama.cpp/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts b/llama.cpp/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts
new file mode 100644
index 0000000..6f0e03e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/markdown/enhance-code-blocks.ts
@@ -0,0 +1,162 @@
1/**
2 * Rehype plugin to enhance code blocks with wrapper, header, and action buttons.
3 *
4 * Wraps <pre><code> elements with a container that includes:
5 * - Language label
6 * - Copy button
7 * - Preview button (for HTML code blocks)
8 *
9 * This operates directly on the HAST tree for better performance,
10 * avoiding the need to stringify and re-parse HTML.
11 */
12
13import type { Plugin } from 'unified';
14import type { Root, Element, ElementContent } from 'hast';
15import { visit } from 'unist-util-visit';
16
17declare global {
18 interface Window {
19 idxCodeBlock?: number;
20 }
21}
22
23const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
24
25const PREVIEW_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye lucide-eye-icon"><path d="M2.062 12.345a1 1 0 0 1 0-.69C3.5 7.73 7.36 5 12 5s8.5 2.73 9.938 6.655a1 1 0 0 1 0 .69C20.5 16.27 16.64 19 12 19s-8.5-2.73-9.938-6.655"/><circle cx="12" cy="12" r="3"/></svg>`;
26
27/**
28 * Creates an SVG element node from raw SVG string.
29 * Since we can't parse HTML in HAST directly, we use the raw property.
30 */
31function createRawHtmlElement(html: string): Element {
32 return {
33 type: 'element',
34 tagName: 'span',
35 properties: {},
36 children: [{ type: 'raw', value: html } as unknown as ElementContent]
37 };
38}
39
40function createCopyButton(codeId: string): Element {
41 return {
42 type: 'element',
43 tagName: 'button',
44 properties: {
45 className: ['copy-code-btn'],
46 'data-code-id': codeId,
47 title: 'Copy code',
48 type: 'button'
49 },
50 children: [createRawHtmlElement(COPY_ICON_SVG)]
51 };
52}
53
54function createPreviewButton(codeId: string): Element {
55 return {
56 type: 'element',
57 tagName: 'button',
58 properties: {
59 className: ['preview-code-btn'],
60 'data-code-id': codeId,
61 title: 'Preview code',
62 type: 'button'
63 },
64 children: [createRawHtmlElement(PREVIEW_ICON_SVG)]
65 };
66}
67
68function createHeader(language: string, codeId: string): Element {
69 const actions: Element[] = [createCopyButton(codeId)];
70
71 if (language.toLowerCase() === 'html') {
72 actions.push(createPreviewButton(codeId));
73 }
74
75 return {
76 type: 'element',
77 tagName: 'div',
78 properties: { className: ['code-block-header'] },
79 children: [
80 {
81 type: 'element',
82 tagName: 'span',
83 properties: { className: ['code-language'] },
84 children: [{ type: 'text', value: language }]
85 },
86 {
87 type: 'element',
88 tagName: 'div',
89 properties: { className: ['code-block-actions'] },
90 children: actions
91 }
92 ]
93 };
94}
95
96function createWrapper(header: Element, preElement: Element): Element {
97 return {
98 type: 'element',
99 tagName: 'div',
100 properties: { className: ['code-block-wrapper'] },
101 children: [header, preElement]
102 };
103}
104
105function extractLanguage(codeElement: Element): string {
106 const className = codeElement.properties?.className;
107 if (!Array.isArray(className)) return 'text';
108
109 for (const cls of className) {
110 if (typeof cls === 'string' && cls.startsWith('language-')) {
111 return cls.replace('language-', '');
112 }
113 }
114
115 return 'text';
116}
117
118/**
119 * Generates a unique code block ID using a global counter.
120 */
121function generateCodeId(): string {
122 if (typeof window !== 'undefined') {
123 return `code-${(window.idxCodeBlock = (window.idxCodeBlock ?? 0) + 1)}`;
124 }
125 // Fallback for SSR - use timestamp + random
126 return `code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
127}
128
129/**
130 * Rehype plugin to enhance code blocks with wrapper, header, and action buttons.
131 * This plugin wraps <pre><code> elements with a container that includes:
132 * - Language label
133 * - Copy button
134 * - Preview button (for HTML code blocks)
135 */
136export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => {
137 return (tree: Root) => {
138 visit(tree, 'element', (node: Element, index, parent) => {
139 if (node.tagName !== 'pre' || !parent || index === undefined) return;
140
141 const codeElement = node.children.find(
142 (child): child is Element => child.type === 'element' && child.tagName === 'code'
143 );
144
145 if (!codeElement) return;
146
147 const language = extractLanguage(codeElement);
148 const codeId = generateCodeId();
149
150 codeElement.properties = {
151 ...codeElement.properties,
152 'data-code-id': codeId
153 };
154
155 const header = createHeader(language, codeId);
156 const wrapper = createWrapper(header, node);
157
158 // Replace pre with wrapper in parent
159 (parent.children as ElementContent[])[index] = wrapper;
160 });
161 };
162};
diff --git a/llama.cpp/tools/server/webui/src/lib/markdown/enhance-links.ts b/llama.cpp/tools/server/webui/src/lib/markdown/enhance-links.ts
new file mode 100644
index 0000000..b5fbcbd
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/markdown/enhance-links.ts
@@ -0,0 +1,33 @@
1/**
2 * Rehype plugin to enhance links with security attributes.
3 *
4 * Adds target="_blank" and rel="noopener noreferrer" to all anchor elements,
5 * ensuring external links open in new tabs safely.
6 */
7
8import type { Plugin } from 'unified';
9import type { Root, Element } from 'hast';
10import { visit } from 'unist-util-visit';
11
12/**
13 * Rehype plugin that adds security attributes to all links.
14 * This plugin ensures external links open in new tabs safely by adding:
15 * - target="_blank"
16 * - rel="noopener noreferrer"
17 */
18export const rehypeEnhanceLinks: Plugin<[], Root> = () => {
19 return (tree: Root) => {
20 visit(tree, 'element', (node: Element) => {
21 if (node.tagName !== 'a') return;
22
23 const props = node.properties ?? {};
24
25 // Only modify if href exists
26 if (!props.href) return;
27
28 props.target = '_blank';
29 props.rel = 'noopener noreferrer';
30 node.properties = props;
31 });
32 };
33};
diff --git a/llama.cpp/tools/server/webui/src/lib/markdown/literal-html.ts b/llama.cpp/tools/server/webui/src/lib/markdown/literal-html.ts
new file mode 100644
index 0000000..d4ace01
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/markdown/literal-html.ts
@@ -0,0 +1,121 @@
1import type { Plugin } from 'unified';
2import { visit } from 'unist-util-visit';
3import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast';
4import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants/literal-html';
5
6/**
7 * remark plugin that rewrites raw HTML nodes into plain-text equivalents.
8 *
9 * remark parses inline HTML into `html` nodes even when we do not want to render
10 * them. We turn each of those nodes into regular text (plus `<br>` break markers)
11 * so the downstream rehype pipeline escapes the characters instead of executing
12 * them. Leading spaces and tab characters are converted to non‑breaking spaces to
13 * keep indentation identical to the original author input.
14 */
15
16function preserveIndent(line: string): string {
17 let index = 0;
18 let output = '';
19
20 while (index < line.length) {
21 const char = line[index];
22
23 if (char === ' ') {
24 output += NBSP;
25 index += 1;
26 continue;
27 }
28
29 if (char === '\t') {
30 output += TAB_AS_SPACES;
31 index += 1;
32 continue;
33 }
34
35 break;
36 }
37
38 return output + line.slice(index);
39}
40
41function createLiteralChildren(value: string): PhrasingContent[] {
42 const lines = value.split(LINE_BREAK);
43 const nodes: PhrasingContent[] = [];
44
45 for (const [lineIndex, rawLine] of lines.entries()) {
46 if (lineIndex > 0) {
47 nodes.push({ type: 'break' } as Break as unknown as PhrasingContent);
48 }
49
50 nodes.push({
51 type: 'text',
52 value: preserveIndent(rawLine)
53 } as Text as unknown as PhrasingContent);
54 }
55
56 if (!nodes.length) {
57 nodes.push({ type: 'text', value: '' } as Text as unknown as PhrasingContent);
58 }
59
60 return nodes;
61}
62
63export const remarkLiteralHtml: Plugin<[], Root> = () => {
64 return (tree) => {
65 visit(tree, 'html', (node, index, parent) => {
66 if (!parent || typeof index !== 'number') {
67 return;
68 }
69
70 const replacement = createLiteralChildren(node.value);
71
72 if (!PHRASE_PARENTS.has(parent.type as string)) {
73 const paragraph: Paragraph = {
74 type: 'paragraph',
75 children: replacement as Paragraph['children'],
76 data: { literalHtml: true }
77 };
78
79 const siblings = parent.children as unknown as Content[];
80 siblings.splice(index, 1, paragraph as unknown as Content);
81
82 if (index > 0) {
83 const previous = siblings[index - 1] as Paragraph | undefined;
84
85 if (
86 previous?.type === 'paragraph' &&
87 (previous.data as { literalHtml?: boolean } | undefined)?.literalHtml
88 ) {
89 const prevChildren = previous.children as unknown as PhrasingContent[];
90
91 if (prevChildren.length) {
92 const lastChild = prevChildren[prevChildren.length - 1];
93
94 if (lastChild.type !== 'break') {
95 prevChildren.push({
96 type: 'break'
97 } as Break as unknown as PhrasingContent);
98 }
99 }
100
101 prevChildren.push(...(paragraph.children as unknown as PhrasingContent[]));
102
103 siblings.splice(index, 1);
104
105 return index;
106 }
107 }
108
109 return index + 1;
110 }
111
112 (parent.children as unknown as PhrasingContent[]).splice(
113 index,
114 1,
115 ...(replacement as unknown as PhrasingContent[])
116 );
117
118 return index + replacement.length;
119 });
120 };
121};
diff --git a/llama.cpp/tools/server/webui/src/lib/markdown/table-html-restorer.ts b/llama.cpp/tools/server/webui/src/lib/markdown/table-html-restorer.ts
new file mode 100644
index 0000000..918aa46
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/markdown/table-html-restorer.ts
@@ -0,0 +1,181 @@
1/**
2 * Rehype plugin to restore limited HTML elements inside Markdown table cells.
3 *
4 * ## Problem
5 * The remark/rehype pipeline neutralizes inline HTML as literal text
6 * (remarkLiteralHtml) so that XML/HTML snippets in LLM responses display
7 * as-is instead of being rendered. This causes <br> and <ul> markup in
8 * table cells to show as plain text.
9 *
10 * ## Solution
11 * This plugin traverses the HAST post-conversion, parses whitelisted HTML
12 * patterns from text nodes, and replaces them with actual HAST element nodes
13 * that will be rendered as real HTML.
14 *
15 * ## Supported HTML
16 * - `<br>` / `<br/>` / `<br />` - Line breaks (inline)
17 * - `<ul><li>...</li></ul>` - Unordered lists (block)
18 *
19 * ## Key Implementation Details
20 *
21 * ### 1. Sibling Combination (Critical)
22 * The Markdown pipeline may fragment content across multiple text nodes and `<br>`
23 * elements. For example, `<ul><li>a</li></ul>` might arrive as:
24 * - Text: `"<ul>"`
25 * - Element: `<br>`
26 * - Text: `"<li>a</li></ul>"`
27 *
28 * We must combine consecutive text nodes and `<br>` elements into a single string
29 * before attempting to parse list markup. Without this, list detection fails.
30 *
31 * ### 2. visitParents for Deep Traversal
32 * Table cell content may be wrapped in intermediate elements (e.g., `<p>` tags).
33 * Using `visitParents` instead of direct child iteration ensures we find text
34 * nodes at any depth within the cell.
35 *
36 * ### 3. Reference Comparison for No-Op Detection
37 * When checking if `<br>` expansion changed anything, we compare:
38 * `expanded.length !== 1 || expanded[0] !== textNode`
39 *
40 * This catches both cases:
41 * - Multiple nodes created (text was split)
42 * - Single NEW node created (original had only `<br>`, now it's an element)
43 *
44 * A simple `length > 1` check would miss the single `<br>` case.
45 *
46 * ### 4. Strict List Validation
47 * `parseList()` rejects malformed markup by checking for garbage text between
48 * `<li>` elements. This prevents creating broken DOM from partial matches like
49 * `<ul>garbage<li>a</li></ul>`.
50 *
51 * ### 5. Newline Substitution for `<br>` in Combined String
52 * When combining siblings, existing `<br>` elements become `\n` in the combined
53 * string. This allows list content to span visual lines while still being parsed
54 * as a single unit.
55 *
56 * @example
57 * // Input Markdown:
58 * // | Feature | Notes |
59 * // |---------|-------|
60 * // | Multi-line | First<br>Second |
61 * // | List | <ul><li>A</li><li>B</li></ul> |
62 * //
63 * // Without this plugin: <br> and <ul> render as literal text
64 * // With this plugin: <br> becomes line break, <ul> becomes actual list
65 */
66
67import type { Plugin } from 'unified';
68import type { Element, ElementContent, Root, Text } from 'hast';
69import { visit } from 'unist-util-visit';
70import { visitParents } from 'unist-util-visit-parents';
71import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants/table-html-restorer';
72
73/**
74 * Expands text containing `<br>` tags into an array of text nodes and br elements.
75 */
76function expandBrTags(value: string): ElementContent[] {
77 const matches = [...value.matchAll(BR_PATTERN)];
78 if (!matches.length) return [{ type: 'text', value } as Text];
79
80 const result: ElementContent[] = [];
81 let cursor = 0;
82
83 for (const m of matches) {
84 if (m.index! > cursor) {
85 result.push({ type: 'text', value: value.slice(cursor, m.index) } as Text);
86 }
87 result.push({ type: 'element', tagName: 'br', properties: {}, children: [] } as Element);
88 cursor = m.index! + m[0].length;
89 }
90
91 if (cursor < value.length) {
92 result.push({ type: 'text', value: value.slice(cursor) } as Text);
93 }
94
95 return result;
96}
97
98/**
99 * Parses a `<ul><li>...</li></ul>` string into a HAST element.
100 * Returns null if the markup is malformed or contains unexpected content.
101 */
102function parseList(value: string): Element | null {
103 const match = value.trim().match(LIST_PATTERN);
104 if (!match) return null;
105
106 const body = match[1];
107 const items: ElementContent[] = [];
108 let cursor = 0;
109
110 for (const liMatch of body.matchAll(LI_PATTERN)) {
111 // Reject if there's non-whitespace between list items
112 if (body.slice(cursor, liMatch.index!).trim()) return null;
113
114 items.push({
115 type: 'element',
116 tagName: 'li',
117 properties: {},
118 children: expandBrTags(liMatch[1] ?? '')
119 } as Element);
120
121 cursor = liMatch.index! + liMatch[0].length;
122 }
123
124 // Reject if no items found or trailing garbage exists
125 if (!items.length || body.slice(cursor).trim()) return null;
126
127 return { type: 'element', tagName: 'ul', properties: {}, children: items } as Element;
128}
129
130/**
131 * Processes a single table cell, restoring HTML elements from text content.
132 */
133function processCell(cell: Element) {
134 visitParents(cell, 'text', (textNode: Text, ancestors) => {
135 const parent = ancestors[ancestors.length - 1];
136 if (!parent || parent.type !== 'element') return;
137
138 const parentEl = parent as Element;
139 const siblings = parentEl.children as ElementContent[];
140 const startIndex = siblings.indexOf(textNode as ElementContent);
141 if (startIndex === -1) return;
142
143 // Combine consecutive text nodes and <br> elements into one string
144 let combined = '';
145 let endIndex = startIndex;
146
147 for (let i = startIndex; i < siblings.length; i++) {
148 const sib = siblings[i];
149 if (sib.type === 'text') {
150 combined += (sib as Text).value;
151 endIndex = i;
152 } else if (sib.type === 'element' && (sib as Element).tagName === 'br') {
153 combined += '\n';
154 endIndex = i;
155 } else {
156 break;
157 }
158 }
159
160 // Try parsing as list first (replaces entire combined range)
161 const list = parseList(combined);
162 if (list) {
163 siblings.splice(startIndex, endIndex - startIndex + 1, list);
164 return;
165 }
166
167 // Otherwise, just expand <br> tags in this text node
168 const expanded = expandBrTags(textNode.value);
169 if (expanded.length !== 1 || expanded[0] !== textNode) {
170 siblings.splice(startIndex, 1, ...expanded);
171 }
172 });
173}
174
175export const rehypeRestoreTableHtml: Plugin<[], Root> = () => (tree) => {
176 visit(tree, 'element', (node: Element) => {
177 if (node.tagName === 'td' || node.tagName === 'th') {
178 processCell(node);
179 }
180 });
181};
diff --git a/llama.cpp/tools/server/webui/src/lib/services/chat.ts b/llama.cpp/tools/server/webui/src/lib/services/chat.ts
new file mode 100644
index 0000000..02fc638
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/services/chat.ts
@@ -0,0 +1,784 @@
1import { getJsonHeaders } from '$lib/utils';
2import { AttachmentType } from '$lib/enums';
3
4/**
5 * ChatService - Low-level API communication layer for Chat Completions
6 *
7 * **Terminology - Chat vs Conversation:**
8 * - **Chat**: The active interaction space with the Chat Completions API. This service
9 * handles the real-time communication with the AI backend - sending messages, receiving
10 * streaming responses, and managing request lifecycles. "Chat" is ephemeral and runtime-focused.
11 * - **Conversation**: The persistent database entity storing all messages and metadata.
12 * Managed by ConversationsService/Store, conversations persist across sessions.
13 *
14 * This service handles direct communication with the llama-server's Chat Completions API.
15 * It provides the network layer abstraction for AI model interactions while remaining
16 * stateless and focused purely on API communication.
17 *
18 * **Architecture & Relationships:**
19 * - **ChatService** (this class): Stateless API communication layer
20 * - Handles HTTP requests/responses with the llama-server
21 * - Manages streaming and non-streaming response parsing
22 * - Provides per-conversation request abortion capabilities
23 * - Converts database messages to API format
24 * - Handles error translation for server responses
25 *
26 * - **chatStore**: Uses ChatService for all AI model communication
27 * - **conversationsStore**: Provides message context for API requests
28 *
29 * **Key Responsibilities:**
30 * - Message format conversion (DatabaseMessage → API format)
31 * - Streaming response handling with real-time callbacks
32 * - Reasoning content extraction and processing
33 * - File attachment processing (images, PDFs, audio, text)
34 * - Request lifecycle management (abort via AbortSignal)
35 */
36export class ChatService {
37 // ─────────────────────────────────────────────────────────────────────────────
38 // Messaging
39 // ─────────────────────────────────────────────────────────────────────────────
40
41 /**
42 * Sends a chat completion request to the llama.cpp server.
43 * Supports both streaming and non-streaming responses with comprehensive parameter configuration.
44 * Automatically converts database messages with attachments to the appropriate API format.
45 *
46 * @param messages - Array of chat messages to send to the API (supports both ApiChatMessageData and DatabaseMessage with attachments)
47 * @param options - Configuration options for the chat completion request. See `SettingsChatServiceOptions` type for details.
48 * @returns {Promise<string | void>} that resolves to the complete response string (non-streaming) or void (streaming)
49 * @throws {Error} if the request fails or is aborted
50 */
51 static async sendMessage(
52 messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
53 options: SettingsChatServiceOptions = {},
54 conversationId?: string,
55 signal?: AbortSignal
56 ): Promise<string | void> {
57 const {
58 stream,
59 onChunk,
60 onComplete,
61 onError,
62 onReasoningChunk,
63 onToolCallChunk,
64 onModel,
65 onTimings,
66 // Generation parameters
67 temperature,
68 max_tokens,
69 // Sampling parameters
70 dynatemp_range,
71 dynatemp_exponent,
72 top_k,
73 top_p,
74 min_p,
75 xtc_probability,
76 xtc_threshold,
77 typ_p,
78 // Penalty parameters
79 repeat_last_n,
80 repeat_penalty,
81 presence_penalty,
82 frequency_penalty,
83 dry_multiplier,
84 dry_base,
85 dry_allowed_length,
86 dry_penalty_last_n,
87 // Other parameters
88 samplers,
89 backend_sampling,
90 custom,
91 timings_per_token,
92 // Config options
93 disableReasoningFormat
94 } = options;
95
96 const normalizedMessages: ApiChatMessageData[] = messages
97 .map((msg) => {
98 if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
99 const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
100 return ChatService.convertDbMessageToApiChatMessageData(dbMsg);
101 } else {
102 return msg as ApiChatMessageData;
103 }
104 })
105 .filter((msg) => {
106 // Filter out empty system messages
107 if (msg.role === 'system') {
108 const content = typeof msg.content === 'string' ? msg.content : '';
109
110 return content.trim().length > 0;
111 }
112
113 return true;
114 });
115
116 const requestBody: ApiChatCompletionRequest = {
117 messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
118 role: msg.role,
119 content: msg.content
120 })),
121 stream,
122 return_progress: stream ? true : undefined
123 };
124
125 // Include model in request if provided (required in ROUTER mode)
126 if (options.model) {
127 requestBody.model = options.model;
128 }
129
130 requestBody.reasoning_format = disableReasoningFormat ? 'none' : 'auto';
131
132 if (temperature !== undefined) requestBody.temperature = temperature;
133 if (max_tokens !== undefined) {
134 // Set max_tokens to -1 (infinite) when explicitly configured as 0 or null
135 requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1;
136 }
137
138 if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range;
139 if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent;
140 if (top_k !== undefined) requestBody.top_k = top_k;
141 if (top_p !== undefined) requestBody.top_p = top_p;
142 if (min_p !== undefined) requestBody.min_p = min_p;
143 if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability;
144 if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold;
145 if (typ_p !== undefined) requestBody.typ_p = typ_p;
146
147 if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n;
148 if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty;
149 if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty;
150 if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty;
151 if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier;
152 if (dry_base !== undefined) requestBody.dry_base = dry_base;
153 if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length;
154 if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n;
155
156 if (samplers !== undefined) {
157 requestBody.samplers =
158 typeof samplers === 'string'
159 ? samplers.split(';').filter((s: string) => s.trim())
160 : samplers;
161 }
162
163 if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling;
164
165 if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
166
167 if (custom) {
168 try {
169 const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
170 Object.assign(requestBody, customParams);
171 } catch (error) {
172 console.warn('Failed to parse custom parameters:', error);
173 }
174 }
175
176 try {
177 const response = await fetch(`./v1/chat/completions`, {
178 method: 'POST',
179 headers: getJsonHeaders(),
180 body: JSON.stringify(requestBody),
181 signal
182 });
183
184 if (!response.ok) {
185 const error = await ChatService.parseErrorResponse(response);
186 if (onError) {
187 onError(error);
188 }
189 throw error;
190 }
191
192 if (stream) {
193 await ChatService.handleStreamResponse(
194 response,
195 onChunk,
196 onComplete,
197 onError,
198 onReasoningChunk,
199 onToolCallChunk,
200 onModel,
201 onTimings,
202 conversationId,
203 signal
204 );
205 return;
206 } else {
207 return ChatService.handleNonStreamResponse(
208 response,
209 onComplete,
210 onError,
211 onToolCallChunk,
212 onModel
213 );
214 }
215 } catch (error) {
216 if (error instanceof Error && error.name === 'AbortError') {
217 console.log('Chat completion request was aborted');
218 return;
219 }
220
221 let userFriendlyError: Error;
222
223 if (error instanceof Error) {
224 if (error.name === 'TypeError' && error.message.includes('fetch')) {
225 userFriendlyError = new Error(
226 'Unable to connect to server - please check if the server is running'
227 );
228 userFriendlyError.name = 'NetworkError';
229 } else if (error.message.includes('ECONNREFUSED')) {
230 userFriendlyError = new Error('Connection refused - server may be offline');
231 userFriendlyError.name = 'NetworkError';
232 } else if (error.message.includes('ETIMEDOUT')) {
233 userFriendlyError = new Error('Request timed out - the server took too long to respond');
234 userFriendlyError.name = 'TimeoutError';
235 } else {
236 userFriendlyError = error;
237 }
238 } else {
239 userFriendlyError = new Error('Unknown error occurred while sending message');
240 }
241
242 console.error('Error in sendMessage:', error);
243 if (onError) {
244 onError(userFriendlyError);
245 }
246 throw userFriendlyError;
247 }
248 }
249
250 // ─────────────────────────────────────────────────────────────────────────────
251 // Streaming
252 // ─────────────────────────────────────────────────────────────────────────────
253
254 /**
255 * Handles streaming response from the chat completion API
256 * @param response - The Response object from the fetch request
257 * @param onChunk - Optional callback invoked for each content chunk received
258 * @param onComplete - Optional callback invoked when the stream is complete with full response
259 * @param onError - Optional callback invoked if an error occurs during streaming
260 * @param onReasoningChunk - Optional callback invoked for each reasoning content chunk
261 * @param conversationId - Optional conversation ID for per-conversation state tracking
262 * @returns {Promise<void>} Promise that resolves when streaming is complete
263 * @throws {Error} if the stream cannot be read or parsed
264 */
265 private static async handleStreamResponse(
266 response: Response,
267 onChunk?: (chunk: string) => void,
268 onComplete?: (
269 response: string,
270 reasoningContent?: string,
271 timings?: ChatMessageTimings,
272 toolCalls?: string
273 ) => void,
274 onError?: (error: Error) => void,
275 onReasoningChunk?: (chunk: string) => void,
276 onToolCallChunk?: (chunk: string) => void,
277 onModel?: (model: string) => void,
278 onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
279 conversationId?: string,
280 abortSignal?: AbortSignal
281 ): Promise<void> {
282 const reader = response.body?.getReader();
283
284 if (!reader) {
285 throw new Error('No response body');
286 }
287
288 const decoder = new TextDecoder();
289 let aggregatedContent = '';
290 let fullReasoningContent = '';
291 let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
292 let lastTimings: ChatMessageTimings | undefined;
293 let streamFinished = false;
294 let modelEmitted = false;
295 let toolCallIndexOffset = 0;
296 let hasOpenToolCallBatch = false;
297
298 const finalizeOpenToolCallBatch = () => {
299 if (!hasOpenToolCallBatch) {
300 return;
301 }
302
303 toolCallIndexOffset = aggregatedToolCalls.length;
304 hasOpenToolCallBatch = false;
305 };
306
307 const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => {
308 if (!toolCalls || toolCalls.length === 0) {
309 return;
310 }
311
312 aggregatedToolCalls = ChatService.mergeToolCallDeltas(
313 aggregatedToolCalls,
314 toolCalls,
315 toolCallIndexOffset
316 );
317
318 if (aggregatedToolCalls.length === 0) {
319 return;
320 }
321
322 hasOpenToolCallBatch = true;
323
324 const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
325
326 if (!serializedToolCalls) {
327 return;
328 }
329
330 if (!abortSignal?.aborted) {
331 onToolCallChunk?.(serializedToolCalls);
332 }
333 };
334
335 try {
336 let chunk = '';
337 while (true) {
338 if (abortSignal?.aborted) break;
339
340 const { done, value } = await reader.read();
341 if (done) break;
342
343 if (abortSignal?.aborted) break;
344
345 chunk += decoder.decode(value, { stream: true });
346 const lines = chunk.split('\n');
347 chunk = lines.pop() || '';
348
349 for (const line of lines) {
350 if (abortSignal?.aborted) break;
351
352 if (line.startsWith('data: ')) {
353 const data = line.slice(6);
354 if (data === '[DONE]') {
355 streamFinished = true;
356 continue;
357 }
358
359 try {
360 const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
361 const content = parsed.choices[0]?.delta?.content;
362 const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
363 const toolCalls = parsed.choices[0]?.delta?.tool_calls;
364 const timings = parsed.timings;
365 const promptProgress = parsed.prompt_progress;
366
367 const chunkModel = ChatService.extractModelName(parsed);
368 if (chunkModel && !modelEmitted) {
369 modelEmitted = true;
370 onModel?.(chunkModel);
371 }
372
373 if (promptProgress) {
374 ChatService.notifyTimings(undefined, promptProgress, onTimings);
375 }
376
377 if (timings) {
378 ChatService.notifyTimings(timings, promptProgress, onTimings);
379 lastTimings = timings;
380 }
381
382 if (content) {
383 finalizeOpenToolCallBatch();
384 aggregatedContent += content;
385 if (!abortSignal?.aborted) {
386 onChunk?.(content);
387 }
388 }
389
390 if (reasoningContent) {
391 finalizeOpenToolCallBatch();
392 fullReasoningContent += reasoningContent;
393 if (!abortSignal?.aborted) {
394 onReasoningChunk?.(reasoningContent);
395 }
396 }
397
398 processToolCallDelta(toolCalls);
399 } catch (e) {
400 console.error('Error parsing JSON chunk:', e);
401 }
402 }
403 }
404
405 if (abortSignal?.aborted) break;
406 }
407
408 if (abortSignal?.aborted) return;
409
410 if (streamFinished) {
411 finalizeOpenToolCallBatch();
412
413 const finalToolCalls =
414 aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined;
415
416 onComplete?.(
417 aggregatedContent,
418 fullReasoningContent || undefined,
419 lastTimings,
420 finalToolCalls
421 );
422 }
423 } catch (error) {
424 const err = error instanceof Error ? error : new Error('Stream error');
425
426 onError?.(err);
427
428 throw err;
429 } finally {
430 reader.releaseLock();
431 }
432 }
433
434 /**
435 * Handles non-streaming response from the chat completion API.
436 * Parses the JSON response and extracts the generated content.
437 *
438 * @param response - The fetch Response object containing the JSON data
439 * @param onComplete - Optional callback invoked when response is successfully parsed
440 * @param onError - Optional callback invoked if an error occurs during parsing
441 * @returns {Promise<string>} Promise that resolves to the generated content string
442 * @throws {Error} if the response cannot be parsed or is malformed
443 */
444 private static async handleNonStreamResponse(
445 response: Response,
446 onComplete?: (
447 response: string,
448 reasoningContent?: string,
449 timings?: ChatMessageTimings,
450 toolCalls?: string
451 ) => void,
452 onError?: (error: Error) => void,
453 onToolCallChunk?: (chunk: string) => void,
454 onModel?: (model: string) => void
455 ): Promise<string> {
456 try {
457 const responseText = await response.text();
458
459 if (!responseText.trim()) {
460 const noResponseError = new Error('No response received from server. Please try again.');
461 throw noResponseError;
462 }
463
464 const data: ApiChatCompletionResponse = JSON.parse(responseText);
465
466 const responseModel = ChatService.extractModelName(data);
467 if (responseModel) {
468 onModel?.(responseModel);
469 }
470
471 const content = data.choices[0]?.message?.content || '';
472 const reasoningContent = data.choices[0]?.message?.reasoning_content;
473 const toolCalls = data.choices[0]?.message?.tool_calls;
474
475 if (reasoningContent) {
476 console.log('Full reasoning content:', reasoningContent);
477 }
478
479 let serializedToolCalls: string | undefined;
480
481 if (toolCalls && toolCalls.length > 0) {
482 const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls);
483
484 if (mergedToolCalls.length > 0) {
485 serializedToolCalls = JSON.stringify(mergedToolCalls);
486 if (serializedToolCalls) {
487 onToolCallChunk?.(serializedToolCalls);
488 }
489 }
490 }
491
492 if (!content.trim() && !serializedToolCalls) {
493 const noResponseError = new Error('No response received from server. Please try again.');
494 throw noResponseError;
495 }
496
497 onComplete?.(content, reasoningContent, undefined, serializedToolCalls);
498
499 return content;
500 } catch (error) {
501 const err = error instanceof Error ? error : new Error('Parse error');
502
503 onError?.(err);
504
505 throw err;
506 }
507 }
508
509 /**
510 * Merges tool call deltas into an existing array of tool calls.
511 * Handles both existing and new tool calls, updating existing ones and adding new ones.
512 *
513 * @param existing - The existing array of tool calls to merge into
514 * @param deltas - The array of tool call deltas to merge
515 * @param indexOffset - Optional offset to apply to the index of new tool calls
516 * @returns {ApiChatCompletionToolCall[]} The merged array of tool calls
517 */
518 private static mergeToolCallDeltas(
519 existing: ApiChatCompletionToolCall[],
520 deltas: ApiChatCompletionToolCallDelta[],
521 indexOffset = 0
522 ): ApiChatCompletionToolCall[] {
523 const result = existing.map((call) => ({
524 ...call,
525 function: call.function ? { ...call.function } : undefined
526 }));
527
528 for (const delta of deltas) {
529 const index =
530 typeof delta.index === 'number' && delta.index >= 0
531 ? delta.index + indexOffset
532 : result.length;
533
534 while (result.length <= index) {
535 result.push({ function: undefined });
536 }
537
538 const target = result[index]!;
539
540 if (delta.id) {
541 target.id = delta.id;
542 }
543
544 if (delta.type) {
545 target.type = delta.type;
546 }
547
548 if (delta.function) {
549 const fn = target.function ? { ...target.function } : {};
550
551 if (delta.function.name) {
552 fn.name = delta.function.name;
553 }
554
555 if (delta.function.arguments) {
556 fn.arguments = (fn.arguments ?? '') + delta.function.arguments;
557 }
558
559 target.function = fn;
560 }
561 }
562
563 return result;
564 }
565
566 // ─────────────────────────────────────────────────────────────────────────────
567 // Conversion
568 // ─────────────────────────────────────────────────────────────────────────────
569
570 /**
571 * Converts a database message with attachments to API chat message format.
572 * Processes various attachment types (images, text files, PDFs) and formats them
573 * as content parts suitable for the chat completion API.
574 *
575 * @param message - Database message object with optional extra attachments
576 * @param message.content - The text content of the message
577 * @param message.role - The role of the message sender (user, assistant, system)
578 * @param message.extra - Optional array of message attachments (images, files, etc.)
579 * @returns {ApiChatMessageData} object formatted for the chat completion API
580 * @static
581 */
582 static convertDbMessageToApiChatMessageData(
583 message: DatabaseMessage & { extra?: DatabaseMessageExtra[] }
584 ): ApiChatMessageData {
585 if (!message.extra || message.extra.length === 0) {
586 return {
587 role: message.role as 'user' | 'assistant' | 'system',
588 content: message.content
589 };
590 }
591
592 const contentParts: ApiChatMessageContentPart[] = [];
593
594 if (message.content) {
595 contentParts.push({
596 type: 'text',
597 text: message.content
598 });
599 }
600
601 const imageFiles = message.extra.filter(
602 (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
603 extra.type === AttachmentType.IMAGE
604 );
605
606 for (const image of imageFiles) {
607 contentParts.push({
608 type: 'image_url',
609 image_url: { url: image.base64Url }
610 });
611 }
612
613 const textFiles = message.extra.filter(
614 (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
615 extra.type === AttachmentType.TEXT
616 );
617
618 for (const textFile of textFiles) {
619 contentParts.push({
620 type: 'text',
621 text: `\n\n--- File: ${textFile.name} ---\n${textFile.content}`
622 });
623 }
624
625 // Handle legacy 'context' type from old webui (pasted content)
626 const legacyContextFiles = message.extra.filter(
627 (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext =>
628 extra.type === AttachmentType.LEGACY_CONTEXT
629 );
630
631 for (const legacyContextFile of legacyContextFiles) {
632 contentParts.push({
633 type: 'text',
634 text: `\n\n--- File: ${legacyContextFile.name} ---\n${legacyContextFile.content}`
635 });
636 }
637
638 const audioFiles = message.extra.filter(
639 (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile =>
640 extra.type === AttachmentType.AUDIO
641 );
642
643 for (const audio of audioFiles) {
644 contentParts.push({
645 type: 'input_audio',
646 input_audio: {
647 data: audio.base64Data,
648 format: audio.mimeType.includes('wav') ? 'wav' : 'mp3'
649 }
650 });
651 }
652
653 const pdfFiles = message.extra.filter(
654 (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
655 extra.type === AttachmentType.PDF
656 );
657
658 for (const pdfFile of pdfFiles) {
659 if (pdfFile.processedAsImages && pdfFile.images) {
660 for (let i = 0; i < pdfFile.images.length; i++) {
661 contentParts.push({
662 type: 'image_url',
663 image_url: { url: pdfFile.images[i] }
664 });
665 }
666 } else {
667 contentParts.push({
668 type: 'text',
669 text: `\n\n--- PDF File: ${pdfFile.name} ---\n${pdfFile.content}`
670 });
671 }
672 }
673
674 return {
675 role: message.role as 'user' | 'assistant' | 'system',
676 content: contentParts
677 };
678 }
679
680 // ─────────────────────────────────────────────────────────────────────────────
681 // Utilities
682 // ─────────────────────────────────────────────────────────────────────────────
683
684 /**
685 * Parses error response and creates appropriate error with context information
686 * @param response - HTTP response object
687 * @returns Promise<Error> - Parsed error with context info if available
688 */
689 private static async parseErrorResponse(
690 response: Response
691 ): Promise<Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }> {
692 try {
693 const errorText = await response.text();
694 const errorData: ApiErrorResponse = JSON.parse(errorText);
695
696 const message = errorData.error?.message || 'Unknown server error';
697 const error = new Error(message) as Error & {
698 contextInfo?: { n_prompt_tokens: number; n_ctx: number };
699 };
700 error.name = response.status === 400 ? 'ServerError' : 'HttpError';
701
702 if (errorData.error && 'n_prompt_tokens' in errorData.error && 'n_ctx' in errorData.error) {
703 error.contextInfo = {
704 n_prompt_tokens: errorData.error.n_prompt_tokens,
705 n_ctx: errorData.error.n_ctx
706 };
707 }
708
709 return error;
710 } catch {
711 const fallback = new Error(
712 `Server error (${response.status}): ${response.statusText}`
713 ) as Error & {
714 contextInfo?: { n_prompt_tokens: number; n_ctx: number };
715 };
716 fallback.name = 'HttpError';
717 return fallback;
718 }
719 }
720
721 /**
722 * Extracts model name from Chat Completions API response data.
723 * Handles various response formats including streaming chunks and final responses.
724 *
725 * WORKAROUND: In single model mode, llama-server returns a default/incorrect model name
726 * in the response. We override it with the actual model name from serverStore.
727 *
728 * @param data - Raw response data from the Chat Completions API
729 * @returns Model name string if found, undefined otherwise
730 * @private
731 */
732 private static extractModelName(data: unknown): string | undefined {
733 const asRecord = (value: unknown): Record<string, unknown> | undefined => {
734 return typeof value === 'object' && value !== null
735 ? (value as Record<string, unknown>)
736 : undefined;
737 };
738
739 const getTrimmedString = (value: unknown): string | undefined => {
740 return typeof value === 'string' && value.trim() ? value.trim() : undefined;
741 };
742
743 const root = asRecord(data);
744 if (!root) return undefined;
745
746 // 1) root (some implementations provide `model` at the top level)
747 const rootModel = getTrimmedString(root.model);
748 if (rootModel) return rootModel;
749
750 // 2) streaming choice (delta) or final response (message)
751 const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
752 if (!firstChoice) return undefined;
753
754 // priority: delta.model (first chunk) else message.model (final response)
755 const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
756 if (deltaModel) return deltaModel;
757
758 const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
759 if (messageModel) return messageModel;
760
761 // avoid guessing from non-standard locations (metadata, etc.)
762 return undefined;
763 }
764
765 /**
766 * Calls the onTimings callback with timing data from streaming response.
767 *
768 * @param timings - Timing information from the Chat Completions API response
769 * @param promptProgress - Prompt processing progress data
770 * @param onTimingsCallback - Callback function to invoke with timing data
771 * @private
772 */
773 private static notifyTimings(
774 timings: ChatMessageTimings | undefined,
775 promptProgress: ChatMessagePromptProgress | undefined,
776 onTimingsCallback:
777 | ((timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void)
778 | undefined
779 ): void {
780 if (!onTimingsCallback || (!timings && !promptProgress)) return;
781
782 onTimingsCallback(timings, promptProgress);
783 }
784}
diff --git a/llama.cpp/tools/server/webui/src/lib/services/database.ts b/llama.cpp/tools/server/webui/src/lib/services/database.ts
new file mode 100644
index 0000000..3b24628
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/services/database.ts
@@ -0,0 +1,400 @@
1import Dexie, { type EntityTable } from 'dexie';
2import { findDescendantMessages } from '$lib/utils';
3
4class LlamacppDatabase extends Dexie {
5 conversations!: EntityTable<DatabaseConversation, string>;
6 messages!: EntityTable<DatabaseMessage, string>;
7
8 constructor() {
9 super('LlamacppWebui');
10
11 this.version(1).stores({
12 conversations: 'id, lastModified, currNode, name',
13 messages: 'id, convId, type, role, timestamp, parent, children'
14 });
15 }
16}
17
18const db = new LlamacppDatabase();
19import { v4 as uuid } from 'uuid';
20
21/**
22 * DatabaseService - Stateless IndexedDB communication layer
23 *
24 * **Terminology - Chat vs Conversation:**
25 * - **Chat**: The active interaction space with the Chat Completions API (ephemeral, runtime).
26 * - **Conversation**: The persistent database entity storing all messages and metadata.
27 * This service handles raw database operations for conversations - the lowest layer
28 * in the persistence stack.
29 *
30 * This service provides a stateless data access layer built on IndexedDB using Dexie ORM.
31 * It handles all low-level storage operations for conversations and messages with support
32 * for complex branching and message threading. All methods are static - no instance state.
33 *
34 * **Architecture & Relationships (bottom to top):**
35 * - **DatabaseService** (this class): Stateless IndexedDB operations
36 * - Lowest layer - direct Dexie/IndexedDB communication
37 * - Pure CRUD operations without business logic
38 * - Handles branching tree structure (parent-child relationships)
39 * - Provides transaction safety for multi-table operations
40 *
41 * - **ConversationsService**: Stateless business logic layer
42 * - Uses DatabaseService for all persistence operations
43 * - Adds import/export, navigation, and higher-level operations
44 *
45 * - **conversationsStore**: Reactive state management for conversations
46 * - Uses ConversationsService for database operations
47 * - Manages conversation list, active conversation, and messages in memory
48 *
49 * - **chatStore**: Active AI interaction management
50 * - Uses conversationsStore for conversation context
51 * - Directly uses DatabaseService for message CRUD during streaming
52 *
53 * **Key Features:**
54 * - **Conversation CRUD**: Create, read, update, delete conversations
55 * - **Message CRUD**: Add, update, delete messages with branching support
56 * - **Branch Operations**: Create branches, find descendants, cascade deletions
57 * - **Transaction Safety**: Atomic operations for data consistency
58 *
59 * **Database Schema:**
60 * - `conversations`: id, lastModified, currNode, name
61 * - `messages`: id, convId, type, role, timestamp, parent, children
62 *
63 * **Branching Model:**
64 * Messages form a tree structure where each message can have multiple children,
65 * enabling conversation branching and alternative response paths. The conversation's
66 * `currNode` tracks the currently active branch endpoint.
67 */
68export class DatabaseService {
69 // ─────────────────────────────────────────────────────────────────────────────
70 // Conversations
71 // ─────────────────────────────────────────────────────────────────────────────
72
73 /**
74 * Creates a new conversation.
75 *
76 * @param name - Name of the conversation
77 * @returns The created conversation
78 */
79 static async createConversation(name: string): Promise<DatabaseConversation> {
80 const conversation: DatabaseConversation = {
81 id: uuid(),
82 name,
83 lastModified: Date.now(),
84 currNode: ''
85 };
86
87 await db.conversations.add(conversation);
88 return conversation;
89 }
90
91 // ─────────────────────────────────────────────────────────────────────────────
92 // Messages
93 // ─────────────────────────────────────────────────────────────────────────────
94
95 /**
96 * Creates a new message branch by adding a message and updating parent/child relationships.
97 * Also updates the conversation's currNode to point to the new message.
98 *
99 * @param message - Message to add (without id)
100 * @param parentId - Parent message ID to attach to
101 * @returns The created message
102 */
103 static async createMessageBranch(
104 message: Omit<DatabaseMessage, 'id'>,
105 parentId: string | null
106 ): Promise<DatabaseMessage> {
107 return await db.transaction('rw', [db.conversations, db.messages], async () => {
108 // Handle null parent (root message case)
109 if (parentId !== null) {
110 const parentMessage = await db.messages.get(parentId);
111 if (!parentMessage) {
112 throw new Error(`Parent message ${parentId} not found`);
113 }
114 }
115
116 const newMessage: DatabaseMessage = {
117 ...message,
118 id: uuid(),
119 parent: parentId,
120 toolCalls: message.toolCalls ?? '',
121 children: []
122 };
123
124 await db.messages.add(newMessage);
125
126 // Update parent's children array if parent exists
127 if (parentId !== null) {
128 const parentMessage = await db.messages.get(parentId);
129 if (parentMessage) {
130 await db.messages.update(parentId, {
131 children: [...parentMessage.children, newMessage.id]
132 });
133 }
134 }
135
136 await this.updateConversation(message.convId, {
137 currNode: newMessage.id
138 });
139
140 return newMessage;
141 });
142 }
143
144 /**
145 * Creates a root message for a new conversation.
146 * Root messages are not displayed but serve as the tree root for branching.
147 *
148 * @param convId - Conversation ID
149 * @returns The created root message
150 */
151 static async createRootMessage(convId: string): Promise<string> {
152 const rootMessage: DatabaseMessage = {
153 id: uuid(),
154 convId,
155 type: 'root',
156 timestamp: Date.now(),
157 role: 'system',
158 content: '',
159 parent: null,
160 thinking: '',
161 toolCalls: '',
162 children: []
163 };
164
165 await db.messages.add(rootMessage);
166 return rootMessage.id;
167 }
168
169 /**
170 * Creates a system prompt message for a conversation.
171 *
172 * @param convId - Conversation ID
173 * @param systemPrompt - The system prompt content (must be non-empty)
174 * @param parentId - Parent message ID (typically the root message)
175 * @returns The created system message
176 * @throws Error if systemPrompt is empty
177 */
178 static async createSystemMessage(
179 convId: string,
180 systemPrompt: string,
181 parentId: string
182 ): Promise<DatabaseMessage> {
183 const trimmedPrompt = systemPrompt.trim();
184 if (!trimmedPrompt) {
185 throw new Error('Cannot create system message with empty content');
186 }
187
188 const systemMessage: DatabaseMessage = {
189 id: uuid(),
190 convId,
191 type: 'system',
192 timestamp: Date.now(),
193 role: 'system',
194 content: trimmedPrompt,
195 parent: parentId,
196 thinking: '',
197 children: []
198 };
199
200 await db.messages.add(systemMessage);
201
202 const parentMessage = await db.messages.get(parentId);
203 if (parentMessage) {
204 await db.messages.update(parentId, {
205 children: [...parentMessage.children, systemMessage.id]
206 });
207 }
208
209 return systemMessage;
210 }
211
212 /**
213 * Deletes a conversation and all its messages.
214 *
215 * @param id - Conversation ID
216 */
217 static async deleteConversation(id: string): Promise<void> {
218 await db.transaction('rw', [db.conversations, db.messages], async () => {
219 await db.conversations.delete(id);
220 await db.messages.where('convId').equals(id).delete();
221 });
222 }
223
224 /**
225 * Deletes a message and removes it from its parent's children array.
226 *
227 * @param messageId - ID of the message to delete
228 */
229 static async deleteMessage(messageId: string): Promise<void> {
230 await db.transaction('rw', db.messages, async () => {
231 const message = await db.messages.get(messageId);
232 if (!message) return;
233
234 // Remove this message from its parent's children array
235 if (message.parent) {
236 const parent = await db.messages.get(message.parent);
237 if (parent) {
238 parent.children = parent.children.filter((childId: string) => childId !== messageId);
239 await db.messages.put(parent);
240 }
241 }
242
243 // Delete the message
244 await db.messages.delete(messageId);
245 });
246 }
247
248 /**
249 * Deletes a message and all its descendant messages (cascading deletion).
250 * This removes the entire branch starting from the specified message.
251 *
252 * @param conversationId - ID of the conversation containing the message
253 * @param messageId - ID of the root message to delete (along with all descendants)
254 * @returns Array of all deleted message IDs
255 */
256 static async deleteMessageCascading(
257 conversationId: string,
258 messageId: string
259 ): Promise<string[]> {
260 return await db.transaction('rw', db.messages, async () => {
261 // Get all messages in the conversation to find descendants
262 const allMessages = await db.messages.where('convId').equals(conversationId).toArray();
263
264 // Find all descendant messages
265 const descendants = findDescendantMessages(allMessages, messageId);
266 const allToDelete = [messageId, ...descendants];
267
268 // Get the message to delete for parent cleanup
269 const message = await db.messages.get(messageId);
270 if (message && message.parent) {
271 const parent = await db.messages.get(message.parent);
272 if (parent) {
273 parent.children = parent.children.filter((childId: string) => childId !== messageId);
274 await db.messages.put(parent);
275 }
276 }
277
278 // Delete all messages in the branch
279 await db.messages.bulkDelete(allToDelete);
280
281 return allToDelete;
282 });
283 }
284
285 /**
286 * Gets all conversations, sorted by last modified time (newest first).
287 *
288 * @returns Array of conversations
289 */
290 static async getAllConversations(): Promise<DatabaseConversation[]> {
291 return await db.conversations.orderBy('lastModified').reverse().toArray();
292 }
293
294 /**
295 * Gets a conversation by ID.
296 *
297 * @param id - Conversation ID
298 * @returns The conversation if found, otherwise undefined
299 */
300 static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
301 return await db.conversations.get(id);
302 }
303
304 /**
305 * Gets all messages in a conversation, sorted by timestamp (oldest first).
306 *
307 * @param convId - Conversation ID
308 * @returns Array of messages in the conversation
309 */
310 static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
311 return await db.messages.where('convId').equals(convId).sortBy('timestamp');
312 }
313
314 /**
315 * Updates a conversation.
316 *
317 * @param id - Conversation ID
318 * @param updates - Partial updates to apply
319 * @returns Promise that resolves when the conversation is updated
320 */
321 static async updateConversation(
322 id: string,
323 updates: Partial<Omit<DatabaseConversation, 'id'>>
324 ): Promise<void> {
325 await db.conversations.update(id, {
326 ...updates,
327 lastModified: Date.now()
328 });
329 }
330
331 // ─────────────────────────────────────────────────────────────────────────────
332 // Navigation
333 // ─────────────────────────────────────────────────────────────────────────────
334
335 /**
336 * Updates the conversation's current node (active branch).
337 * This determines which conversation path is currently being viewed.
338 *
339 * @param convId - Conversation ID
340 * @param nodeId - Message ID to set as current node
341 */
342 static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
343 await this.updateConversation(convId, {
344 currNode: nodeId
345 });
346 }
347
348 /**
349 * Updates a message.
350 *
351 * @param id - Message ID
352 * @param updates - Partial updates to apply
353 * @returns Promise that resolves when the message is updated
354 */
355 static async updateMessage(
356 id: string,
357 updates: Partial<Omit<DatabaseMessage, 'id'>>
358 ): Promise<void> {
359 await db.messages.update(id, updates);
360 }
361
362 // ─────────────────────────────────────────────────────────────────────────────
363 // Import
364 // ─────────────────────────────────────────────────────────────────────────────
365
366 /**
367 * Imports multiple conversations and their messages.
368 * Skips conversations that already exist.
369 *
370 * @param data - Array of { conv, messages } objects
371 */
372 static async importConversations(
373 data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
374 ): Promise<{ imported: number; skipped: number }> {
375 let importedCount = 0;
376 let skippedCount = 0;
377
378 return await db.transaction('rw', [db.conversations, db.messages], async () => {
379 for (const item of data) {
380 const { conv, messages } = item;
381
382 const existing = await db.conversations.get(conv.id);
383 if (existing) {
384 console.warn(`Conversation "${conv.name}" already exists, skipping...`);
385 skippedCount++;
386 continue;
387 }
388
389 await db.conversations.add(conv);
390 for (const msg of messages) {
391 await db.messages.put(msg);
392 }
393
394 importedCount++;
395 }
396
397 return { imported: importedCount, skipped: skippedCount };
398 });
399 }
400}
diff --git a/llama.cpp/tools/server/webui/src/lib/services/index.ts b/llama.cpp/tools/server/webui/src/lib/services/index.ts
new file mode 100644
index 0000000..c36c64a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/services/index.ts
@@ -0,0 +1,5 @@
1export { ChatService } from './chat';
2export { DatabaseService } from './database';
3export { ModelsService } from './models';
4export { PropsService } from './props';
5export { ParameterSyncService } from './parameter-sync';
diff --git a/llama.cpp/tools/server/webui/src/lib/services/models.ts b/llama.cpp/tools/server/webui/src/lib/services/models.ts
new file mode 100644
index 0000000..eecb7fa
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/services/models.ts
@@ -0,0 +1,124 @@
1import { base } from '$app/paths';
2import { ServerModelStatus } from '$lib/enums';
3import { getJsonHeaders } from '$lib/utils';
4
5/**
6 * ModelsService - Stateless service for model management API communication
7 *
8 * This service handles communication with model-related endpoints:
9 * - `/v1/models` - OpenAI-compatible model list (MODEL + ROUTER mode)
10 * - `/models/load`, `/models/unload` - Router-specific model management (ROUTER mode only)
11 *
12 * **Responsibilities:**
13 * - List available models
14 * - Load/unload models (ROUTER mode)
15 * - Check model status (ROUTER mode)
16 *
17 * **Used by:**
18 * - modelsStore: Primary consumer for model state management
19 */
20export class ModelsService {
21 // ─────────────────────────────────────────────────────────────────────────────
22 // Listing
23 // ─────────────────────────────────────────────────────────────────────────────
24
25 /**
26 * Fetch list of models from OpenAI-compatible endpoint
27 * Works in both MODEL and ROUTER modes
28 */
29 static async list(): Promise<ApiModelListResponse> {
30 const response = await fetch(`${base}/v1/models`, {
31 headers: getJsonHeaders()
32 });
33
34 if (!response.ok) {
35 throw new Error(`Failed to fetch model list (status ${response.status})`);
36 }
37
38 return response.json() as Promise<ApiModelListResponse>;
39 }
40
41 /**
42 * Fetch list of all models with detailed metadata (ROUTER mode)
43 * Returns models with load status, paths, and other metadata
44 */
45 static async listRouter(): Promise<ApiRouterModelsListResponse> {
46 const response = await fetch(`${base}/v1/models`, {
47 headers: getJsonHeaders()
48 });
49
50 if (!response.ok) {
51 throw new Error(`Failed to fetch router models list (status ${response.status})`);
52 }
53
54 return response.json() as Promise<ApiRouterModelsListResponse>;
55 }
56
57 // ─────────────────────────────────────────────────────────────────────────────
58 // Load/Unload
59 // ─────────────────────────────────────────────────────────────────────────────
60
61 /**
62 * Load a model (ROUTER mode)
63 * POST /models/load
64 * @param modelId - Model identifier to load
65 * @param extraArgs - Optional additional arguments to pass to the model instance
66 */
67 static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
68 const payload: { model: string; extra_args?: string[] } = { model: modelId };
69 if (extraArgs && extraArgs.length > 0) {
70 payload.extra_args = extraArgs;
71 }
72
73 const response = await fetch(`${base}/models/load`, {
74 method: 'POST',
75 headers: getJsonHeaders(),
76 body: JSON.stringify(payload)
77 });
78
79 if (!response.ok) {
80 const errorData = await response.json().catch(() => ({}));
81 throw new Error(errorData.error || `Failed to load model (status ${response.status})`);
82 }
83
84 return response.json() as Promise<ApiRouterModelsLoadResponse>;
85 }
86
87 /**
88 * Unload a model (ROUTER mode)
89 * POST /models/unload
90 * @param modelId - Model identifier to unload
91 */
92 static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
93 const response = await fetch(`${base}/models/unload`, {
94 method: 'POST',
95 headers: getJsonHeaders(),
96 body: JSON.stringify({ model: modelId })
97 });
98
99 if (!response.ok) {
100 const errorData = await response.json().catch(() => ({}));
101 throw new Error(errorData.error || `Failed to unload model (status ${response.status})`);
102 }
103
104 return response.json() as Promise<ApiRouterModelsUnloadResponse>;
105 }
106
107 // ─────────────────────────────────────────────────────────────────────────────
108 // Status
109 // ─────────────────────────────────────────────────────────────────────────────
110
111 /**
112 * Check if a model is loaded based on its metadata
113 */
114 static isModelLoaded(model: ApiModelDataEntry): boolean {
115 return model.status.value === ServerModelStatus.LOADED;
116 }
117
118 /**
119 * Check if a model is currently loading
120 */
121 static isModelLoading(model: ApiModelDataEntry): boolean {
122 return model.status.value === ServerModelStatus.LOADING;
123 }
124}
diff --git a/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.spec.ts b/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.spec.ts
new file mode 100644
index 0000000..6b5c58a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.spec.ts
@@ -0,0 +1,148 @@
1import { describe, it, expect } from 'vitest';
2import { ParameterSyncService } from './parameter-sync';
3
4describe('ParameterSyncService', () => {
5 describe('roundFloatingPoint', () => {
6 it('should fix JavaScript floating-point precision issues', () => {
7 // Test the specific values from the screenshot
8 const mockServerParams = {
9 top_p: 0.949999988079071,
10 min_p: 0.009999999776482582,
11 temperature: 0.800000011920929,
12 top_k: 40,
13 samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature']
14 };
15
16 const result = ParameterSyncService.extractServerDefaults({
17 ...mockServerParams,
18 // Add other required fields to match the API type
19 n_predict: 512,
20 seed: -1,
21 dynatemp_range: 0.0,
22 dynatemp_exponent: 1.0,
23 xtc_probability: 0.0,
24 xtc_threshold: 0.1,
25 typ_p: 1.0,
26 repeat_last_n: 64,
27 repeat_penalty: 1.0,
28 presence_penalty: 0.0,
29 frequency_penalty: 0.0,
30 dry_multiplier: 0.0,
31 dry_base: 1.75,
32 dry_allowed_length: 2,
33 dry_penalty_last_n: -1,
34 mirostat: 0,
35 mirostat_tau: 5.0,
36 mirostat_eta: 0.1,
37 stop: [],
38 max_tokens: -1,
39 n_keep: 0,
40 n_discard: 0,
41 ignore_eos: false,
42 stream: true,
43 logit_bias: [],
44 n_probs: 0,
45 min_keep: 0,
46 grammar: '',
47 grammar_lazy: false,
48 grammar_triggers: [],
49 preserved_tokens: [],
50 chat_format: '',
51 reasoning_format: '',
52 reasoning_in_content: false,
53 thinking_forced_open: false,
54 'speculative.n_max': 0,
55 'speculative.n_min': 0,
56 'speculative.p_min': 0.0,
57 timings_per_token: false,
58 post_sampling_probs: false,
59 lora: [],
60 top_n_sigma: 0.0,
61 dry_sequence_breakers: []
62 } as ApiLlamaCppServerProps['default_generation_settings']['params']);
63
64 // Check that the problematic floating-point values are rounded correctly
65 expect(result.top_p).toBe(0.95);
66 expect(result.min_p).toBe(0.01);
67 expect(result.temperature).toBe(0.8);
68 expect(result.top_k).toBe(40); // Integer should remain unchanged
69 expect(result.samplers).toBe('top_k;typ_p;top_p;min_p;temperature');
70 });
71
72 it('should preserve non-numeric values', () => {
73 const mockServerParams = {
74 samplers: ['top_k', 'temperature'],
75 max_tokens: -1,
76 temperature: 0.7
77 };
78
79 const result = ParameterSyncService.extractServerDefaults({
80 ...mockServerParams,
81 // Minimal required fields
82 n_predict: 512,
83 seed: -1,
84 dynatemp_range: 0.0,
85 dynatemp_exponent: 1.0,
86 top_k: 40,
87 top_p: 0.95,
88 min_p: 0.05,
89 xtc_probability: 0.0,
90 xtc_threshold: 0.1,
91 typ_p: 1.0,
92 repeat_last_n: 64,
93 repeat_penalty: 1.0,
94 presence_penalty: 0.0,
95 frequency_penalty: 0.0,
96 dry_multiplier: 0.0,
97 dry_base: 1.75,
98 dry_allowed_length: 2,
99 dry_penalty_last_n: -1,
100 mirostat: 0,
101 mirostat_tau: 5.0,
102 mirostat_eta: 0.1,
103 stop: [],
104 n_keep: 0,
105 n_discard: 0,
106 ignore_eos: false,
107 stream: true,
108 logit_bias: [],
109 n_probs: 0,
110 min_keep: 0,
111 grammar: '',
112 grammar_lazy: false,
113 grammar_triggers: [],
114 preserved_tokens: [],
115 chat_format: '',
116 reasoning_format: '',
117 reasoning_in_content: false,
118 thinking_forced_open: false,
119 'speculative.n_max': 0,
120 'speculative.n_min': 0,
121 'speculative.p_min': 0.0,
122 timings_per_token: false,
123 post_sampling_probs: false,
124 lora: [],
125 top_n_sigma: 0.0,
126 dry_sequence_breakers: []
127 } as ApiLlamaCppServerProps['default_generation_settings']['params']);
128
129 expect(result.samplers).toBe('top_k;temperature');
130 expect(result.max_tokens).toBe(-1);
131 expect(result.temperature).toBe(0.7);
132 });
133
134 it('should merge webui settings from props when provided', () => {
135 const result = ParameterSyncService.extractServerDefaults(null, {
136 pasteLongTextToFileLen: 0,
137 pdfAsImage: true,
138 renderUserContentAsMarkdown: false,
139 theme: 'dark'
140 });
141
142 expect(result.pasteLongTextToFileLen).toBe(0);
143 expect(result.pdfAsImage).toBe(true);
144 expect(result.renderUserContentAsMarkdown).toBe(false);
145 expect(result.theme).toBeUndefined();
146 });
147 });
148});
diff --git a/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.ts b/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.ts
new file mode 100644
index 0000000..d124cf5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/services/parameter-sync.ts
@@ -0,0 +1,279 @@
1/**
2 * ParameterSyncService - Handles synchronization between server defaults and user settings
3 *
4 * This service manages the complex logic of merging server-provided default parameters
5 * with user-configured overrides, ensuring the UI reflects the actual server state
6 * while preserving user customizations.
7 *
8 * **Key Responsibilities:**
9 * - Extract syncable parameters from server props
10 * - Merge server defaults with user overrides
11 * - Track parameter sources (server, user, default)
12 * - Provide sync utilities for settings store integration
13 */
14
15import { normalizeFloatingPoint } from '$lib/utils';
16
17export type ParameterSource = 'default' | 'custom';
18export type ParameterValue = string | number | boolean;
19export type ParameterRecord = Record<string, ParameterValue>;
20
21export interface ParameterInfo {
22 value: string | number | boolean;
23 source: ParameterSource;
24 serverDefault?: string | number | boolean;
25 userOverride?: string | number | boolean;
26}
27
28export interface SyncableParameter {
29 key: string;
30 serverKey: string;
31 type: 'number' | 'string' | 'boolean';
32 canSync: boolean;
33}
34
35/**
36 * Mapping of webui setting keys to server parameter keys
37 * Only parameters that should be synced from server are included
38 */
39export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
40 { key: 'temperature', serverKey: 'temperature', type: 'number', canSync: true },
41 { key: 'top_k', serverKey: 'top_k', type: 'number', canSync: true },
42 { key: 'top_p', serverKey: 'top_p', type: 'number', canSync: true },
43 { key: 'min_p', serverKey: 'min_p', type: 'number', canSync: true },
44 { key: 'dynatemp_range', serverKey: 'dynatemp_range', type: 'number', canSync: true },
45 { key: 'dynatemp_exponent', serverKey: 'dynatemp_exponent', type: 'number', canSync: true },
46 { key: 'xtc_probability', serverKey: 'xtc_probability', type: 'number', canSync: true },
47 { key: 'xtc_threshold', serverKey: 'xtc_threshold', type: 'number', canSync: true },
48 { key: 'typ_p', serverKey: 'typ_p', type: 'number', canSync: true },
49 { key: 'repeat_last_n', serverKey: 'repeat_last_n', type: 'number', canSync: true },
50 { key: 'repeat_penalty', serverKey: 'repeat_penalty', type: 'number', canSync: true },
51 { key: 'presence_penalty', serverKey: 'presence_penalty', type: 'number', canSync: true },
52 { key: 'frequency_penalty', serverKey: 'frequency_penalty', type: 'number', canSync: true },
53 { key: 'dry_multiplier', serverKey: 'dry_multiplier', type: 'number', canSync: true },
54 { key: 'dry_base', serverKey: 'dry_base', type: 'number', canSync: true },
55 { key: 'dry_allowed_length', serverKey: 'dry_allowed_length', type: 'number', canSync: true },
56 { key: 'dry_penalty_last_n', serverKey: 'dry_penalty_last_n', type: 'number', canSync: true },
57 { key: 'max_tokens', serverKey: 'max_tokens', type: 'number', canSync: true },
58 { key: 'samplers', serverKey: 'samplers', type: 'string', canSync: true },
59 {
60 key: 'pasteLongTextToFileLen',
61 serverKey: 'pasteLongTextToFileLen',
62 type: 'number',
63 canSync: true
64 },
65 { key: 'pdfAsImage', serverKey: 'pdfAsImage', type: 'boolean', canSync: true },
66 {
67 key: 'showThoughtInProgress',
68 serverKey: 'showThoughtInProgress',
69 type: 'boolean',
70 canSync: true
71 },
72 { key: 'showToolCalls', serverKey: 'showToolCalls', type: 'boolean', canSync: true },
73 {
74 key: 'disableReasoningFormat',
75 serverKey: 'disableReasoningFormat',
76 type: 'boolean',
77 canSync: true
78 },
79 { key: 'keepStatsVisible', serverKey: 'keepStatsVisible', type: 'boolean', canSync: true },
80 { key: 'showMessageStats', serverKey: 'showMessageStats', type: 'boolean', canSync: true },
81 {
82 key: 'askForTitleConfirmation',
83 serverKey: 'askForTitleConfirmation',
84 type: 'boolean',
85 canSync: true
86 },
87 { key: 'disableAutoScroll', serverKey: 'disableAutoScroll', type: 'boolean', canSync: true },
88 {
89 key: 'renderUserContentAsMarkdown',
90 serverKey: 'renderUserContentAsMarkdown',
91 type: 'boolean',
92 canSync: true
93 },
94 { key: 'autoMicOnEmpty', serverKey: 'autoMicOnEmpty', type: 'boolean', canSync: true },
95 {
96 key: 'pyInterpreterEnabled',
97 serverKey: 'pyInterpreterEnabled',
98 type: 'boolean',
99 canSync: true
100 },
101 {
102 key: 'enableContinueGeneration',
103 serverKey: 'enableContinueGeneration',
104 type: 'boolean',
105 canSync: true
106 }
107];
108
109export class ParameterSyncService {
110 // ─────────────────────────────────────────────────────────────────────────────
111 // Extraction
112 // ─────────────────────────────────────────────────────────────────────────────
113
114 /**
115 * Round floating-point numbers to avoid JavaScript precision issues
116 */
117 private static roundFloatingPoint(value: ParameterValue): ParameterValue {
118 return normalizeFloatingPoint(value) as ParameterValue;
119 }
120
121 /**
122 * Extract server default parameters that can be synced
123 */
124 static extractServerDefaults(
125 serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
126 webuiSettings?: Record<string, string | number | boolean>
127 ): ParameterRecord {
128 const extracted: ParameterRecord = {};
129
130 if (serverParams) {
131 for (const param of SYNCABLE_PARAMETERS) {
132 if (param.canSync && param.serverKey in serverParams) {
133 const value = (serverParams as unknown as Record<string, ParameterValue>)[
134 param.serverKey
135 ];
136 if (value !== undefined) {
137 // Apply precision rounding to avoid JavaScript floating-point issues
138 extracted[param.key] = this.roundFloatingPoint(value);
139 }
140 }
141 }
142
143 // Handle samplers array conversion to string
144 if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
145 extracted.samplers = serverParams.samplers.join(';');
146 }
147 }
148
149 if (webuiSettings) {
150 for (const param of SYNCABLE_PARAMETERS) {
151 if (param.canSync && param.serverKey in webuiSettings) {
152 const value = webuiSettings[param.serverKey];
153 if (value !== undefined) {
154 extracted[param.key] = this.roundFloatingPoint(value);
155 }
156 }
157 }
158 }
159
160 return extracted;
161 }
162
163 // ─────────────────────────────────────────────────────────────────────────────
164 // Merging
165 // ─────────────────────────────────────────────────────────────────────────────
166
167 /**
168 * Merge server defaults with current user settings
169 * Returns updated settings that respect user overrides while using server defaults
170 */
171 static mergeWithServerDefaults(
172 currentSettings: ParameterRecord,
173 serverDefaults: ParameterRecord,
174 userOverrides: Set<string> = new Set()
175 ): ParameterRecord {
176 const merged = { ...currentSettings };
177
178 for (const [key, serverValue] of Object.entries(serverDefaults)) {
179 // Only update if user hasn't explicitly overridden this parameter
180 if (!userOverrides.has(key)) {
181 merged[key] = this.roundFloatingPoint(serverValue);
182 }
183 }
184
185 return merged;
186 }
187
188 // ─────────────────────────────────────────────────────────────────────────────
189 // Info
190 // ─────────────────────────────────────────────────────────────────────────────
191
192 /**
193 * Get parameter information including source and values
194 */
195 static getParameterInfo(
196 key: string,
197 currentValue: ParameterValue,
198 propsDefaults: ParameterRecord,
199 userOverrides: Set<string>
200 ): ParameterInfo {
201 const hasPropsDefault = propsDefaults[key] !== undefined;
202 const isUserOverride = userOverrides.has(key);
203
204 // Simple logic: either using default (from props) or custom (user override)
205 const source: ParameterSource = isUserOverride ? 'custom' : 'default';
206
207 return {
208 value: currentValue,
209 source,
210 serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility
211 userOverride: isUserOverride ? currentValue : undefined
212 };
213 }
214
215 /**
216 * Check if a parameter can be synced from server
217 */
218 static canSyncParameter(key: string): boolean {
219 return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
220 }
221
222 /**
223 * Get all syncable parameter keys
224 */
225 static getSyncableParameterKeys(): string[] {
226 return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
227 }
228
229 /**
230 * Validate server parameter value
231 */
232 static validateServerParameter(key: string, value: ParameterValue): boolean {
233 const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
234 if (!param) return false;
235
236 switch (param.type) {
237 case 'number':
238 return typeof value === 'number' && !isNaN(value);
239 case 'string':
240 return typeof value === 'string';
241 case 'boolean':
242 return typeof value === 'boolean';
243 default:
244 return false;
245 }
246 }
247
248 // ─────────────────────────────────────────────────────────────────────────────
249 // Diff
250 // ─────────────────────────────────────────────────────────────────────────────
251
252 /**
253 * Create a diff between current settings and server defaults
254 */
255 static createParameterDiff(
256 currentSettings: ParameterRecord,
257 serverDefaults: ParameterRecord
258 ): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
259 const diff: Record<
260 string,
261 { current: ParameterValue; server: ParameterValue; differs: boolean }
262 > = {};
263
264 for (const key of this.getSyncableParameterKeys()) {
265 const currentValue = currentSettings[key];
266 const serverValue = serverDefaults[key];
267
268 if (serverValue !== undefined) {
269 diff[key] = {
270 current: currentValue,
271 server: serverValue,
272 differs: currentValue !== serverValue
273 };
274 }
275 }
276
277 return diff;
278 }
279}
diff --git a/llama.cpp/tools/server/webui/src/lib/services/props.ts b/llama.cpp/tools/server/webui/src/lib/services/props.ts
new file mode 100644
index 0000000..01fead9
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/services/props.ts
@@ -0,0 +1,77 @@
1import { getAuthHeaders } from '$lib/utils';
2
3/**
4 * PropsService - Server properties management
5 *
6 * This service handles communication with the /props endpoint to retrieve
7 * server configuration, model information, and capabilities.
8 *
9 * **Responsibilities:**
10 * - Fetch server properties from /props endpoint
11 * - Handle API authentication
12 * - Parse and validate server response
13 *
14 * **Used by:**
15 * - serverStore: Primary consumer for server state management
16 */
17export class PropsService {
18 // ─────────────────────────────────────────────────────────────────────────────
19 // Fetching
20 // ─────────────────────────────────────────────────────────────────────────────
21
22 /**
23 * Fetches server properties from the /props endpoint
24 *
25 * @param autoload - If false, prevents automatic model loading (default: false)
26 * @returns {Promise<ApiLlamaCppServerProps>} Server properties
27 * @throws {Error} If the request fails or returns invalid data
28 */
29 static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
30 const url = new URL('./props', window.location.href);
31 if (!autoload) {
32 url.searchParams.set('autoload', 'false');
33 }
34
35 const response = await fetch(url.toString(), {
36 headers: getAuthHeaders()
37 });
38
39 if (!response.ok) {
40 throw new Error(
41 `Failed to fetch server properties: ${response.status} ${response.statusText}`
42 );
43 }
44
45 const data = await response.json();
46 return data as ApiLlamaCppServerProps;
47 }
48
49 /**
50 * Fetches server properties for a specific model (ROUTER mode)
51 *
52 * @param modelId - The model ID to fetch properties for
53 * @param autoload - If false, prevents automatic model loading (default: false)
54 * @returns {Promise<ApiLlamaCppServerProps>} Server properties for the model
55 * @throws {Error} If the request fails or returns invalid data
56 */
57 static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
58 const url = new URL('./props', window.location.href);
59 url.searchParams.set('model', modelId);
60 if (!autoload) {
61 url.searchParams.set('autoload', 'false');
62 }
63
64 const response = await fetch(url.toString(), {
65 headers: getAuthHeaders()
66 });
67
68 if (!response.ok) {
69 throw new Error(
70 `Failed to fetch model properties: ${response.status} ${response.statusText}`
71 );
72 }
73
74 const data = await response.json();
75 return data as ApiLlamaCppServerProps;
76 }
77}
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts
new file mode 100644
index 0000000..879b2f3
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts
@@ -0,0 +1,1487 @@
1import { DatabaseService, ChatService } from '$lib/services';
2import { conversationsStore } from '$lib/stores/conversations.svelte';
3import { config } from '$lib/stores/settings.svelte';
4import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
5import {
6 selectedModelName,
7 modelsStore,
8 selectedModelContextSize
9} from '$lib/stores/models.svelte';
10import {
11 normalizeModelName,
12 filterByLeafNodeId,
13 findDescendantMessages,
14 findLeafNode
15} from '$lib/utils';
16import { SvelteMap } from 'svelte/reactivity';
17import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
18
19/**
20 * chatStore - Active AI interaction and streaming state management
21 *
22 * **Terminology - Chat vs Conversation:**
23 * - **Chat**: The active interaction space with the Chat Completions API. Represents the
24 * real-time streaming session, loading states, and UI visualization of AI communication.
25 * A "chat" is ephemeral - it exists only while the user is actively interacting with the AI.
26 * - **Conversation**: The persistent database entity storing all messages and metadata.
27 * Managed by conversationsStore, conversations persist across sessions and page reloads.
28 *
29 * This store manages all active AI interactions including real-time streaming, response
30 * generation, and per-chat loading states. It handles the runtime layer between UI and
31 * AI backend, supporting concurrent streaming across multiple conversations.
32 *
33 * **Architecture & Relationships:**
34 * - **chatStore** (this class): Active AI session and streaming management
35 * - Manages real-time AI response streaming via ChatService
36 * - Tracks per-chat loading and streaming states for concurrent sessions
37 * - Handles message operations (send, edit, regenerate, branch)
38 * - Coordinates with conversationsStore for persistence
39 *
40 * - **conversationsStore**: Provides conversation data and message arrays for chat context
41 * - **ChatService**: Low-level API communication with llama.cpp server
42 * - **DatabaseService**: Message persistence and retrieval
43 *
44 * **Key Features:**
45 * - **AI Streaming**: Real-time token streaming with abort support
46 * - **Concurrent Chats**: Independent loading/streaming states per conversation
47 * - **Message Branching**: Edit, regenerate, and branch conversation trees
48 * - **Error Handling**: Timeout and server error recovery with user feedback
49 * - **Graceful Stop**: Save partial responses when stopping generation
50 *
51 * **State Management:**
52 * - Global `isLoading` and `currentResponse` for active chat UI
53 * - `chatLoadingStates` Map for per-conversation streaming tracking
54 * - `chatStreamingStates` Map for per-conversation streaming content
55 * - `processingStates` Map for per-conversation processing state (timing/context info)
56 * - Automatic state sync when switching between conversations
57 */
58class ChatStore {
59 // ─────────────────────────────────────────────────────────────────────────────
60 // State
61 // ─────────────────────────────────────────────────────────────────────────────
62
63 activeProcessingState = $state<ApiProcessingState | null>(null);
64 currentResponse = $state('');
65 errorDialogState = $state<{
66 type: 'timeout' | 'server';
67 message: string;
68 contextInfo?: { n_prompt_tokens: number; n_ctx: number };
69 } | null>(null);
70 isLoading = $state(false);
71 chatLoadingStates = new SvelteMap<string, boolean>();
72 chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
73 private abortControllers = new SvelteMap<string, AbortController>();
74 private processingStates = new SvelteMap<string, ApiProcessingState | null>();
75 private activeConversationId = $state<string | null>(null);
76 private isStreamingActive = $state(false);
77 private isEditModeActive = $state(false);
78 private addFilesHandler: ((files: File[]) => void) | null = $state(null);
79
80 // ─────────────────────────────────────────────────────────────────────────────
81 // Loading State
82 // ─────────────────────────────────────────────────────────────────────────────
83
84 private setChatLoading(convId: string, loading: boolean): void {
85 if (loading) {
86 this.chatLoadingStates.set(convId, true);
87 if (conversationsStore.activeConversation?.id === convId) this.isLoading = true;
88 } else {
89 this.chatLoadingStates.delete(convId);
90 if (conversationsStore.activeConversation?.id === convId) this.isLoading = false;
91 }
92 }
93
94 private isChatLoading(convId: string): boolean {
95 return this.chatLoadingStates.get(convId) || false;
96 }
97
98 private setChatStreaming(convId: string, response: string, messageId: string): void {
99 this.chatStreamingStates.set(convId, { response, messageId });
100 if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response;
101 }
102
103 private clearChatStreaming(convId: string): void {
104 this.chatStreamingStates.delete(convId);
105 if (conversationsStore.activeConversation?.id === convId) this.currentResponse = '';
106 }
107
108 private getChatStreaming(convId: string): { response: string; messageId: string } | undefined {
109 return this.chatStreamingStates.get(convId);
110 }
111
112 syncLoadingStateForChat(convId: string): void {
113 this.isLoading = this.isChatLoading(convId);
114 const streamingState = this.getChatStreaming(convId);
115 this.currentResponse = streamingState?.response || '';
116 }
117
118 /**
119 * Clears global UI state without affecting background streaming.
120 * Used when navigating to empty/new chat while other chats stream in background.
121 */
122 clearUIState(): void {
123 this.isLoading = false;
124 this.currentResponse = '';
125 }
126
127 // ─────────────────────────────────────────────────────────────────────────────
128 // Processing State
129 // ─────────────────────────────────────────────────────────────────────────────
130
131 /**
132 * Set the active conversation for statistics display
133 */
134 setActiveProcessingConversation(conversationId: string | null): void {
135 this.activeConversationId = conversationId;
136
137 if (conversationId) {
138 this.activeProcessingState = this.processingStates.get(conversationId) || null;
139 } else {
140 this.activeProcessingState = null;
141 }
142 }
143
144 /**
145 * Get processing state for a specific conversation
146 */
147 getProcessingState(conversationId: string): ApiProcessingState | null {
148 return this.processingStates.get(conversationId) || null;
149 }
150
151 /**
152 * Clear processing state for a specific conversation
153 */
154 clearProcessingState(conversationId: string): void {
155 this.processingStates.delete(conversationId);
156
157 if (conversationId === this.activeConversationId) {
158 this.activeProcessingState = null;
159 }
160 }
161
162 /**
163 * Get the current processing state for the active conversation (reactive)
164 * Returns the direct reactive state for UI binding
165 */
166 getActiveProcessingState(): ApiProcessingState | null {
167 return this.activeProcessingState;
168 }
169
170 /**
171 * Updates processing state with timing data from streaming response
172 */
173 updateProcessingStateFromTimings(
174 timingData: {
175 prompt_n: number;
176 prompt_ms?: number;
177 predicted_n: number;
178 predicted_per_second: number;
179 cache_n: number;
180 prompt_progress?: ChatMessagePromptProgress;
181 },
182 conversationId?: string
183 ): void {
184 const processingState = this.parseTimingData(timingData);
185
186 if (processingState === null) {
187 console.warn('Failed to parse timing data - skipping update');
188 return;
189 }
190
191 const targetId = conversationId || this.activeConversationId;
192 if (targetId) {
193 this.processingStates.set(targetId, processingState);
194
195 if (targetId === this.activeConversationId) {
196 this.activeProcessingState = processingState;
197 }
198 }
199 }
200
201 /**
202 * Get current processing state (sync version for reactive access)
203 */
204 getCurrentProcessingStateSync(): ApiProcessingState | null {
205 return this.activeProcessingState;
206 }
207
208 /**
209 * Restore processing state from last assistant message timings
210 * Call this when keepStatsVisible is enabled and we need to show last known stats
211 */
212 restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void {
213 for (let i = messages.length - 1; i >= 0; i--) {
214 const message = messages[i];
215 if (message.role === 'assistant' && message.timings) {
216 const restoredState = this.parseTimingData({
217 prompt_n: message.timings.prompt_n || 0,
218 prompt_ms: message.timings.prompt_ms,
219 predicted_n: message.timings.predicted_n || 0,
220 predicted_per_second:
221 message.timings.predicted_n && message.timings.predicted_ms
222 ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
223 : 0,
224 cache_n: message.timings.cache_n || 0
225 });
226
227 if (restoredState) {
228 this.processingStates.set(conversationId, restoredState);
229
230 if (conversationId === this.activeConversationId) {
231 this.activeProcessingState = restoredState;
232 }
233
234 return;
235 }
236 }
237 }
238 }
239
240 // ─────────────────────────────────────────────────────────────────────────────
241 // Streaming
242 // ─────────────────────────────────────────────────────────────────────────────
243
244 /**
245 * Start streaming session tracking
246 */
247 startStreaming(): void {
248 this.isStreamingActive = true;
249 }
250
251 /**
252 * Stop streaming session tracking
253 */
254 stopStreaming(): void {
255 this.isStreamingActive = false;
256 }
257
258 /**
259 * Check if currently in a streaming session
260 */
261 isStreaming(): boolean {
262 return this.isStreamingActive;
263 }
264
265 private getContextTotal(): number {
266 const activeState = this.getActiveProcessingState();
267
268 if (activeState && activeState.contextTotal > 0) {
269 return activeState.contextTotal;
270 }
271
272 if (isRouterMode()) {
273 const modelContextSize = selectedModelContextSize();
274 if (modelContextSize && modelContextSize > 0) {
275 return modelContextSize;
276 }
277 }
278
279 const propsContextSize = contextSize();
280 if (propsContextSize && propsContextSize > 0) {
281 return propsContextSize;
282 }
283
284 return DEFAULT_CONTEXT;
285 }
286
287 private parseTimingData(timingData: Record<string, unknown>): ApiProcessingState | null {
288 const promptTokens = (timingData.prompt_n as number) || 0;
289 const promptMs = (timingData.prompt_ms as number) || undefined;
290 const predictedTokens = (timingData.predicted_n as number) || 0;
291 const tokensPerSecond = (timingData.predicted_per_second as number) || 0;
292 const cacheTokens = (timingData.cache_n as number) || 0;
293 const promptProgress = timingData.prompt_progress as
294 | {
295 total: number;
296 cache: number;
297 processed: number;
298 time_ms: number;
299 }
300 | undefined;
301
302 const contextTotal = this.getContextTotal();
303 const currentConfig = config();
304 const outputTokensMax = currentConfig.max_tokens || -1;
305
306 // Note: for timings data, the n_prompt does NOT include cache tokens
307 const contextUsed = promptTokens + cacheTokens + predictedTokens;
308 const outputTokensUsed = predictedTokens;
309
310 // Note: for prompt progress, the "processed" DOES include cache tokens
311 // we need to exclude them to get the real prompt tokens processed count
312 const progressCache = promptProgress?.cache || 0;
313 const progressActualDone = (promptProgress?.processed ?? 0) - progressCache;
314 const progressActualTotal = (promptProgress?.total ?? 0) - progressCache;
315 const progressPercent = promptProgress
316 ? Math.round((progressActualDone / progressActualTotal) * 100)
317 : undefined;
318
319 return {
320 status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
321 tokensDecoded: predictedTokens,
322 tokensRemaining: outputTokensMax - predictedTokens,
323 contextUsed,
324 contextTotal,
325 outputTokensUsed,
326 outputTokensMax,
327 hasNextToken: predictedTokens > 0,
328 tokensPerSecond,
329 temperature: currentConfig.temperature ?? 0.8,
330 topP: currentConfig.top_p ?? 0.95,
331 speculative: false,
332 progressPercent,
333 promptProgress,
334 promptTokens,
335 promptMs,
336 cacheTokens
337 };
338 }
339
340 /**
341 * Gets the model used in a conversation based on the latest assistant message.
342 * Returns the model from the most recent assistant message that has a model field set.
343 *
344 * @param messages - Array of messages to search through
345 * @returns The model name or null if no model found
346 */
347 getConversationModel(messages: DatabaseMessage[]): string | null {
348 // Search backwards through messages to find most recent assistant message with model
349 for (let i = messages.length - 1; i >= 0; i--) {
350 const message = messages[i];
351 if (message.role === 'assistant' && message.model) {
352 return message.model;
353 }
354 }
355 return null;
356 }
357
358 // ─────────────────────────────────────────────────────────────────────────────
359 // Error Handling
360 // ─────────────────────────────────────────────────────────────────────────────
361
362 private isAbortError(error: unknown): boolean {
363 return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException);
364 }
365
366 private showErrorDialog(
367 type: 'timeout' | 'server',
368 message: string,
369 contextInfo?: { n_prompt_tokens: number; n_ctx: number }
370 ): void {
371 this.errorDialogState = { type, message, contextInfo };
372 }
373
374 dismissErrorDialog(): void {
375 this.errorDialogState = null;
376 }
377
378 // ─────────────────────────────────────────────────────────────────────────────
379 // Message Operations
380 // ─────────────────────────────────────────────────────────────────────────────
381
382 /**
383 * Finds a message by ID and optionally validates its role.
384 * Returns message and index, or null if not found or role doesn't match.
385 */
386 private getMessageByIdWithRole(
387 messageId: string,
388 expectedRole?: ChatRole
389 ): { message: DatabaseMessage; index: number } | null {
390 const index = conversationsStore.findMessageIndex(messageId);
391 if (index === -1) return null;
392
393 const message = conversationsStore.activeMessages[index];
394 if (expectedRole && message.role !== expectedRole) return null;
395
396 return { message, index };
397 }
398
399 async addMessage(
400 role: ChatRole,
401 content: string,
402 type: ChatMessageType = 'text',
403 parent: string = '-1',
404 extras?: DatabaseMessageExtra[]
405 ): Promise<DatabaseMessage | null> {
406 const activeConv = conversationsStore.activeConversation;
407 if (!activeConv) {
408 console.error('No active conversation when trying to add message');
409 return null;
410 }
411
412 try {
413 let parentId: string | null = null;
414
415 if (parent === '-1') {
416 const activeMessages = conversationsStore.activeMessages;
417 if (activeMessages.length > 0) {
418 parentId = activeMessages[activeMessages.length - 1].id;
419 } else {
420 const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
421 const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
422 if (!rootMessage) {
423 parentId = await DatabaseService.createRootMessage(activeConv.id);
424 } else {
425 parentId = rootMessage.id;
426 }
427 }
428 } else {
429 parentId = parent;
430 }
431
432 const message = await DatabaseService.createMessageBranch(
433 {
434 convId: activeConv.id,
435 role,
436 content,
437 type,
438 timestamp: Date.now(),
439 thinking: '',
440 toolCalls: '',
441 children: [],
442 extra: extras
443 },
444 parentId
445 );
446
447 conversationsStore.addMessageToActive(message);
448 await conversationsStore.updateCurrentNode(message.id);
449 conversationsStore.updateConversationTimestamp();
450
451 return message;
452 } catch (error) {
453 console.error('Failed to add message:', error);
454 return null;
455 }
456 }
457
458 private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage | null> {
459 const activeConv = conversationsStore.activeConversation;
460 if (!activeConv) return null;
461
462 return await DatabaseService.createMessageBranch(
463 {
464 convId: activeConv.id,
465 type: 'text',
466 role: 'assistant',
467 content: '',
468 timestamp: Date.now(),
469 thinking: '',
470 toolCalls: '',
471 children: [],
472 model: null
473 },
474 parentId || null
475 );
476 }
477
478 private async streamChatCompletion(
479 allMessages: DatabaseMessage[],
480 assistantMessage: DatabaseMessage,
481 onComplete?: (content: string) => Promise<void>,
482 onError?: (error: Error) => void,
483 modelOverride?: string | null
484 ): Promise<void> {
485 // Ensure model props are cached before streaming (for correct n_ctx in processing info)
486 if (isRouterMode()) {
487 const modelName = modelOverride || selectedModelName();
488 if (modelName && !modelsStore.getModelProps(modelName)) {
489 await modelsStore.fetchModelProps(modelName);
490 }
491 }
492
493 let streamedContent = '';
494 let streamedReasoningContent = '';
495 let streamedToolCallContent = '';
496 let resolvedModel: string | null = null;
497 let modelPersisted = false;
498
499 const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
500 if (!modelName) return;
501 const normalizedModel = normalizeModelName(modelName);
502 if (!normalizedModel || normalizedModel === resolvedModel) return;
503 resolvedModel = normalizedModel;
504 const messageIndex = conversationsStore.findMessageIndex(assistantMessage.id);
505 conversationsStore.updateMessageAtIndex(messageIndex, { model: normalizedModel });
506 if (persistImmediately && !modelPersisted) {
507 modelPersisted = true;
508 DatabaseService.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(() => {
509 modelPersisted = false;
510 resolvedModel = null;
511 });
512 }
513 };
514
515 this.startStreaming();
516 this.setActiveProcessingConversation(assistantMessage.convId);
517
518 const abortController = this.getOrCreateAbortController(assistantMessage.convId);
519
520 await ChatService.sendMessage(
521 allMessages,
522 {
523 ...this.getApiOptions(),
524 ...(modelOverride ? { model: modelOverride } : {}),
525 onChunk: (chunk: string) => {
526 streamedContent += chunk;
527 this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id);
528 const idx = conversationsStore.findMessageIndex(assistantMessage.id);
529 conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
530 },
531 onReasoningChunk: (reasoningChunk: string) => {
532 streamedReasoningContent += reasoningChunk;
533 const idx = conversationsStore.findMessageIndex(assistantMessage.id);
534 conversationsStore.updateMessageAtIndex(idx, { thinking: streamedReasoningContent });
535 },
536 onToolCallChunk: (toolCallChunk: string) => {
537 const chunk = toolCallChunk.trim();
538 if (!chunk) return;
539 streamedToolCallContent = chunk;
540 const idx = conversationsStore.findMessageIndex(assistantMessage.id);
541 conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
542 },
543 onModel: (modelName: string) => recordModel(modelName),
544 onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
545 const tokensPerSecond =
546 timings?.predicted_ms && timings?.predicted_n
547 ? (timings.predicted_n / timings.predicted_ms) * 1000
548 : 0;
549 this.updateProcessingStateFromTimings(
550 {
551 prompt_n: timings?.prompt_n || 0,
552 prompt_ms: timings?.prompt_ms,
553 predicted_n: timings?.predicted_n || 0,
554 predicted_per_second: tokensPerSecond,
555 cache_n: timings?.cache_n || 0,
556 prompt_progress: promptProgress
557 },
558 assistantMessage.convId
559 );
560 },
561 onComplete: async (
562 finalContent?: string,
563 reasoningContent?: string,
564 timings?: ChatMessageTimings,
565 toolCallContent?: string
566 ) => {
567 this.stopStreaming();
568
569 const updateData: Record<string, unknown> = {
570 content: finalContent || streamedContent,
571 thinking: reasoningContent || streamedReasoningContent,
572 toolCalls: toolCallContent || streamedToolCallContent,
573 timings
574 };
575 if (resolvedModel && !modelPersisted) {
576 updateData.model = resolvedModel;
577 }
578 await DatabaseService.updateMessage(assistantMessage.id, updateData);
579
580 const idx = conversationsStore.findMessageIndex(assistantMessage.id);
581 const uiUpdate: Partial<DatabaseMessage> = {
582 content: updateData.content as string,
583 toolCalls: updateData.toolCalls as string
584 };
585 if (timings) uiUpdate.timings = timings;
586 if (resolvedModel) uiUpdate.model = resolvedModel;
587
588 conversationsStore.updateMessageAtIndex(idx, uiUpdate);
589 await conversationsStore.updateCurrentNode(assistantMessage.id);
590
591 if (onComplete) await onComplete(streamedContent);
592 this.setChatLoading(assistantMessage.convId, false);
593 this.clearChatStreaming(assistantMessage.convId);
594 this.clearProcessingState(assistantMessage.convId);
595
596 if (isRouterMode()) {
597 modelsStore.fetchRouterModels().catch(console.error);
598 }
599 },
600 onError: (error: Error) => {
601 this.stopStreaming();
602
603 if (this.isAbortError(error)) {
604 this.setChatLoading(assistantMessage.convId, false);
605 this.clearChatStreaming(assistantMessage.convId);
606 this.clearProcessingState(assistantMessage.convId);
607
608 return;
609 }
610
611 console.error('Streaming error:', error);
612
613 this.setChatLoading(assistantMessage.convId, false);
614 this.clearChatStreaming(assistantMessage.convId);
615 this.clearProcessingState(assistantMessage.convId);
616
617 const idx = conversationsStore.findMessageIndex(assistantMessage.id);
618
619 if (idx !== -1) {
620 const failedMessage = conversationsStore.removeMessageAtIndex(idx);
621 if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error);
622 }
623
624 const contextInfo = (
625 error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
626 ).contextInfo;
627
628 this.showErrorDialog(
629 error.name === 'TimeoutError' ? 'timeout' : 'server',
630 error.message,
631 contextInfo
632 );
633
634 if (onError) onError(error);
635 }
636 },
637 assistantMessage.convId,
638 abortController.signal
639 );
640 }
641
642 async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
643 if (!content.trim() && (!extras || extras.length === 0)) return;
644 const activeConv = conversationsStore.activeConversation;
645 if (activeConv && this.isChatLoading(activeConv.id)) return;
646
647 let isNewConversation = false;
648 if (!activeConv) {
649 await conversationsStore.createConversation();
650 isNewConversation = true;
651 }
652 const currentConv = conversationsStore.activeConversation;
653 if (!currentConv) return;
654
655 this.errorDialogState = null;
656 this.setChatLoading(currentConv.id, true);
657 this.clearChatStreaming(currentConv.id);
658
659 try {
660 if (isNewConversation) {
661 const rootId = await DatabaseService.createRootMessage(currentConv.id);
662 const currentConfig = config();
663 const systemPrompt = currentConfig.systemMessage?.toString().trim();
664
665 if (systemPrompt) {
666 const systemMessage = await DatabaseService.createSystemMessage(
667 currentConv.id,
668 systemPrompt,
669 rootId
670 );
671
672 conversationsStore.addMessageToActive(systemMessage);
673 }
674 }
675
676 const userMessage = await this.addMessage('user', content, 'text', '-1', extras);
677 if (!userMessage) throw new Error('Failed to add user message');
678 if (isNewConversation && content)
679 await conversationsStore.updateConversationName(currentConv.id, content.trim());
680
681 const assistantMessage = await this.createAssistantMessage(userMessage.id);
682
683 if (!assistantMessage) throw new Error('Failed to create assistant message');
684
685 conversationsStore.addMessageToActive(assistantMessage);
686 await this.streamChatCompletion(
687 conversationsStore.activeMessages.slice(0, -1),
688 assistantMessage
689 );
690 } catch (error) {
691 if (this.isAbortError(error)) {
692 this.setChatLoading(currentConv.id, false);
693 return;
694 }
695 console.error('Failed to send message:', error);
696 this.setChatLoading(currentConv.id, false);
697 if (!this.errorDialogState) {
698 const dialogType =
699 error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'server';
700 const contextInfo = (
701 error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
702 ).contextInfo;
703
704 this.showErrorDialog(
705 dialogType,
706 error instanceof Error ? error.message : 'Unknown error',
707 contextInfo
708 );
709 }
710 }
711 }
712
713 async stopGeneration(): Promise<void> {
714 const activeConv = conversationsStore.activeConversation;
715
716 if (!activeConv) return;
717
718 await this.stopGenerationForChat(activeConv.id);
719 }
720
721 async stopGenerationForChat(convId: string): Promise<void> {
722 await this.savePartialResponseIfNeeded(convId);
723
724 this.stopStreaming();
725 this.abortRequest(convId);
726 this.setChatLoading(convId, false);
727 this.clearChatStreaming(convId);
728 this.clearProcessingState(convId);
729 }
730
731 /**
732 * Gets or creates an AbortController for a conversation
733 */
734 private getOrCreateAbortController(convId: string): AbortController {
735 let controller = this.abortControllers.get(convId);
736 if (!controller || controller.signal.aborted) {
737 controller = new AbortController();
738 this.abortControllers.set(convId, controller);
739 }
740 return controller;
741 }
742
743 /**
744 * Aborts any ongoing request for a conversation
745 */
746 private abortRequest(convId?: string): void {
747 if (convId) {
748 const controller = this.abortControllers.get(convId);
749 if (controller) {
750 controller.abort();
751 this.abortControllers.delete(convId);
752 }
753 } else {
754 for (const controller of this.abortControllers.values()) {
755 controller.abort();
756 }
757 this.abortControllers.clear();
758 }
759 }
760
761 private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
762 const conversationId = convId || conversationsStore.activeConversation?.id;
763
764 if (!conversationId) return;
765
766 const streamingState = this.chatStreamingStates.get(conversationId);
767
768 if (!streamingState || !streamingState.response.trim()) return;
769
770 const messages =
771 conversationId === conversationsStore.activeConversation?.id
772 ? conversationsStore.activeMessages
773 : await conversationsStore.getConversationMessages(conversationId);
774
775 if (!messages.length) return;
776
777 const lastMessage = messages[messages.length - 1];
778
779 if (lastMessage?.role === 'assistant') {
780 try {
781 const updateData: { content: string; thinking?: string; timings?: ChatMessageTimings } = {
782 content: streamingState.response
783 };
784 if (lastMessage.thinking?.trim()) updateData.thinking = lastMessage.thinking;
785 const lastKnownState = this.getProcessingState(conversationId);
786 if (lastKnownState) {
787 updateData.timings = {
788 prompt_n: lastKnownState.promptTokens || 0,
789 prompt_ms: lastKnownState.promptMs,
790 predicted_n: lastKnownState.tokensDecoded || 0,
791 cache_n: lastKnownState.cacheTokens || 0,
792 predicted_ms:
793 lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded
794 ? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000
795 : undefined
796 };
797 }
798
799 await DatabaseService.updateMessage(lastMessage.id, updateData);
800
801 lastMessage.content = this.currentResponse;
802
803 if (updateData.thinking) lastMessage.thinking = updateData.thinking;
804
805 if (updateData.timings) lastMessage.timings = updateData.timings;
806 } catch (error) {
807 lastMessage.content = this.currentResponse;
808 console.error('Failed to save partial response:', error);
809 }
810 }
811 }
812
813 async updateMessage(messageId: string, newContent: string): Promise<void> {
814 const activeConv = conversationsStore.activeConversation;
815 if (!activeConv) return;
816 if (this.isLoading) this.stopGeneration();
817
818 const result = this.getMessageByIdWithRole(messageId, 'user');
819 if (!result) return;
820 const { message: messageToUpdate, index: messageIndex } = result;
821 const originalContent = messageToUpdate.content;
822
823 try {
824 const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
825 const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
826 const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id;
827
828 conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent });
829 await DatabaseService.updateMessage(messageId, { content: newContent });
830
831 if (isFirstUserMessage && newContent.trim()) {
832 await conversationsStore.updateConversationTitleWithConfirmation(
833 activeConv.id,
834 newContent.trim(),
835 conversationsStore.titleUpdateConfirmationCallback
836 );
837 }
838
839 const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
840
841 for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
842
843 conversationsStore.sliceActiveMessages(messageIndex + 1);
844 conversationsStore.updateConversationTimestamp();
845
846 this.setChatLoading(activeConv.id, true);
847 this.clearChatStreaming(activeConv.id);
848
849 const assistantMessage = await this.createAssistantMessage();
850
851 if (!assistantMessage) throw new Error('Failed to create assistant message');
852
853 conversationsStore.addMessageToActive(assistantMessage);
854
855 await conversationsStore.updateCurrentNode(assistantMessage.id);
856 await this.streamChatCompletion(
857 conversationsStore.activeMessages.slice(0, -1),
858 assistantMessage,
859 undefined,
860 () => {
861 conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), {
862 content: originalContent
863 });
864 }
865 );
866 } catch (error) {
867 if (!this.isAbortError(error)) console.error('Failed to update message:', error);
868 }
869 }
870
871 // ─────────────────────────────────────────────────────────────────────────────
872 // Regeneration
873 // ─────────────────────────────────────────────────────────────────────────────
874
875 async regenerateMessage(messageId: string): Promise<void> {
876 const activeConv = conversationsStore.activeConversation;
877 if (!activeConv || this.isLoading) return;
878
879 const result = this.getMessageByIdWithRole(messageId, 'assistant');
880 if (!result) return;
881 const { index: messageIndex } = result;
882
883 try {
884 const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);
885 for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
886 conversationsStore.sliceActiveMessages(messageIndex);
887 conversationsStore.updateConversationTimestamp();
888
889 this.setChatLoading(activeConv.id, true);
890 this.clearChatStreaming(activeConv.id);
891
892 const parentMessageId =
893 conversationsStore.activeMessages.length > 0
894 ? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id
895 : undefined;
896 const assistantMessage = await this.createAssistantMessage(parentMessageId);
897 if (!assistantMessage) throw new Error('Failed to create assistant message');
898 conversationsStore.addMessageToActive(assistantMessage);
899 await this.streamChatCompletion(
900 conversationsStore.activeMessages.slice(0, -1),
901 assistantMessage
902 );
903 } catch (error) {
904 if (!this.isAbortError(error)) console.error('Failed to regenerate message:', error);
905 this.setChatLoading(activeConv?.id || '', false);
906 }
907 }
908
909 async getDeletionInfo(messageId: string): Promise<{
910 totalCount: number;
911 userMessages: number;
912 assistantMessages: number;
913 messageTypes: string[];
914 }> {
915 const activeConv = conversationsStore.activeConversation;
916 if (!activeConv)
917 return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] };
918 const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
919 const descendants = findDescendantMessages(allMessages, messageId);
920 const allToDelete = [messageId, ...descendants];
921 const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id));
922 let userMessages = 0,
923 assistantMessages = 0;
924 const messageTypes: string[] = [];
925 for (const msg of messagesToDelete) {
926 if (msg.role === 'user') {
927 userMessages++;
928 if (!messageTypes.includes('user message')) messageTypes.push('user message');
929 } else if (msg.role === 'assistant') {
930 assistantMessages++;
931 if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
932 }
933 }
934 return { totalCount: allToDelete.length, userMessages, assistantMessages, messageTypes };
935 }
936
937 async deleteMessage(messageId: string): Promise<void> {
938 const activeConv = conversationsStore.activeConversation;
939 if (!activeConv) return;
940 try {
941 const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
942 const messageToDelete = allMessages.find((m) => m.id === messageId);
943 if (!messageToDelete) return;
944
945 const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false);
946 const isInCurrentPath = currentPath.some((m) => m.id === messageId);
947
948 if (isInCurrentPath && messageToDelete.parent) {
949 const siblings = allMessages.filter(
950 (m) => m.parent === messageToDelete.parent && m.id !== messageId
951 );
952
953 if (siblings.length > 0) {
954 const latestSibling = siblings.reduce((latest, sibling) =>
955 sibling.timestamp > latest.timestamp ? sibling : latest
956 );
957 await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id));
958 } else if (messageToDelete.parent) {
959 await conversationsStore.updateCurrentNode(
960 findLeafNode(allMessages, messageToDelete.parent)
961 );
962 }
963 }
964 await DatabaseService.deleteMessageCascading(activeConv.id, messageId);
965 await conversationsStore.refreshActiveMessages();
966
967 conversationsStore.updateConversationTimestamp();
968 } catch (error) {
969 console.error('Failed to delete message:', error);
970 }
971 }
972
973 // ─────────────────────────────────────────────────────────────────────────────
974 // Editing
975 // ─────────────────────────────────────────────────────────────────────────────
976
977 clearEditMode(): void {
978 this.isEditModeActive = false;
979 this.addFilesHandler = null;
980 }
981
982 async continueAssistantMessage(messageId: string): Promise<void> {
983 const activeConv = conversationsStore.activeConversation;
984 if (!activeConv || this.isLoading) return;
985
986 const result = this.getMessageByIdWithRole(messageId, 'assistant');
987 if (!result) return;
988 const { message: msg, index: idx } = result;
989
990 if (this.isChatLoading(activeConv.id)) return;
991
992 try {
993 this.errorDialogState = null;
994 this.setChatLoading(activeConv.id, true);
995 this.clearChatStreaming(activeConv.id);
996
997 const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
998 const dbMessage = allMessages.find((m) => m.id === messageId);
999
1000 if (!dbMessage) {
1001 this.setChatLoading(activeConv.id, false);
1002
1003 return;
1004 }
1005
1006 const originalContent = dbMessage.content;
1007 const originalThinking = dbMessage.thinking || '';
1008
1009 const conversationContext = conversationsStore.activeMessages.slice(0, idx);
1010 const contextWithContinue = [
1011 ...conversationContext,
1012 { role: 'assistant' as const, content: originalContent }
1013 ];
1014
1015 let appendedContent = '',
1016 appendedThinking = '',
1017 hasReceivedContent = false;
1018
1019 const abortController = this.getOrCreateAbortController(msg.convId);
1020
1021 await ChatService.sendMessage(
1022 contextWithContinue,
1023 {
1024 ...this.getApiOptions(),
1025
1026 onChunk: (chunk: string) => {
1027 hasReceivedContent = true;
1028 appendedContent += chunk;
1029 const fullContent = originalContent + appendedContent;
1030 this.setChatStreaming(msg.convId, fullContent, msg.id);
1031 conversationsStore.updateMessageAtIndex(idx, { content: fullContent });
1032 },
1033
1034 onReasoningChunk: (reasoningChunk: string) => {
1035 hasReceivedContent = true;
1036 appendedThinking += reasoningChunk;
1037 conversationsStore.updateMessageAtIndex(idx, {
1038 thinking: originalThinking + appendedThinking
1039 });
1040 },
1041
1042 onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
1043 const tokensPerSecond =
1044 timings?.predicted_ms && timings?.predicted_n
1045 ? (timings.predicted_n / timings.predicted_ms) * 1000
1046 : 0;
1047 this.updateProcessingStateFromTimings(
1048 {
1049 prompt_n: timings?.prompt_n || 0,
1050 prompt_ms: timings?.prompt_ms,
1051 predicted_n: timings?.predicted_n || 0,
1052 predicted_per_second: tokensPerSecond,
1053 cache_n: timings?.cache_n || 0,
1054 prompt_progress: promptProgress
1055 },
1056 msg.convId
1057 );
1058 },
1059
1060 onComplete: async (
1061 finalContent?: string,
1062 reasoningContent?: string,
1063 timings?: ChatMessageTimings
1064 ) => {
1065 const fullContent = originalContent + (finalContent || appendedContent);
1066 const fullThinking = originalThinking + (reasoningContent || appendedThinking);
1067 await DatabaseService.updateMessage(msg.id, {
1068 content: fullContent,
1069 thinking: fullThinking,
1070 timestamp: Date.now(),
1071 timings
1072 });
1073 conversationsStore.updateMessageAtIndex(idx, {
1074 content: fullContent,
1075 thinking: fullThinking,
1076 timestamp: Date.now(),
1077 timings
1078 });
1079 conversationsStore.updateConversationTimestamp();
1080 this.setChatLoading(msg.convId, false);
1081 this.clearChatStreaming(msg.convId);
1082 this.clearProcessingState(msg.convId);
1083 },
1084
1085 onError: async (error: Error) => {
1086 if (this.isAbortError(error)) {
1087 if (hasReceivedContent && appendedContent) {
1088 await DatabaseService.updateMessage(msg.id, {
1089 content: originalContent + appendedContent,
1090 thinking: originalThinking + appendedThinking,
1091 timestamp: Date.now()
1092 });
1093 conversationsStore.updateMessageAtIndex(idx, {
1094 content: originalContent + appendedContent,
1095 thinking: originalThinking + appendedThinking,
1096 timestamp: Date.now()
1097 });
1098 }
1099 this.setChatLoading(msg.convId, false);
1100 this.clearChatStreaming(msg.convId);
1101 this.clearProcessingState(msg.convId);
1102 return;
1103 }
1104 console.error('Continue generation error:', error);
1105 conversationsStore.updateMessageAtIndex(idx, {
1106 content: originalContent,
1107 thinking: originalThinking
1108 });
1109 await DatabaseService.updateMessage(msg.id, {
1110 content: originalContent,
1111 thinking: originalThinking
1112 });
1113 this.setChatLoading(msg.convId, false);
1114 this.clearChatStreaming(msg.convId);
1115 this.clearProcessingState(msg.convId);
1116 this.showErrorDialog(
1117 error.name === 'TimeoutError' ? 'timeout' : 'server',
1118 error.message
1119 );
1120 }
1121 },
1122 msg.convId,
1123 abortController.signal
1124 );
1125 } catch (error) {
1126 if (!this.isAbortError(error)) console.error('Failed to continue message:', error);
1127 if (activeConv) this.setChatLoading(activeConv.id, false);
1128 }
1129 }
1130
1131 async editAssistantMessage(
1132 messageId: string,
1133 newContent: string,
1134 shouldBranch: boolean
1135 ): Promise<void> {
1136 const activeConv = conversationsStore.activeConversation;
1137 if (!activeConv || this.isLoading) return;
1138
1139 const result = this.getMessageByIdWithRole(messageId, 'assistant');
1140 if (!result) return;
1141 const { message: msg, index: idx } = result;
1142
1143 try {
1144 if (shouldBranch) {
1145 const newMessage = await DatabaseService.createMessageBranch(
1146 {
1147 convId: msg.convId,
1148 type: msg.type,
1149 timestamp: Date.now(),
1150 role: msg.role,
1151 content: newContent,
1152 thinking: msg.thinking || '',
1153 toolCalls: msg.toolCalls || '',
1154 children: [],
1155 model: msg.model
1156 },
1157 msg.parent!
1158 );
1159 await conversationsStore.updateCurrentNode(newMessage.id);
1160 } else {
1161 await DatabaseService.updateMessage(msg.id, { content: newContent });
1162 await conversationsStore.updateCurrentNode(msg.id);
1163 conversationsStore.updateMessageAtIndex(idx, {
1164 content: newContent
1165 });
1166 }
1167 conversationsStore.updateConversationTimestamp();
1168 await conversationsStore.refreshActiveMessages();
1169 } catch (error) {
1170 console.error('Failed to edit assistant message:', error);
1171 }
1172 }
1173
1174 async editUserMessagePreserveResponses(
1175 messageId: string,
1176 newContent: string,
1177 newExtras?: DatabaseMessageExtra[]
1178 ): Promise<void> {
1179 const activeConv = conversationsStore.activeConversation;
1180 if (!activeConv) return;
1181
1182 const result = this.getMessageByIdWithRole(messageId, 'user');
1183 if (!result) return;
1184 const { message: msg, index: idx } = result;
1185
1186 try {
1187 const updateData: Partial<DatabaseMessage> = {
1188 content: newContent
1189 };
1190
1191 // Update extras if provided (including empty array to clear attachments)
1192 // Deep clone to avoid Proxy objects from Svelte reactivity
1193 if (newExtras !== undefined) {
1194 updateData.extra = JSON.parse(JSON.stringify(newExtras));
1195 }
1196
1197 await DatabaseService.updateMessage(messageId, updateData);
1198 conversationsStore.updateMessageAtIndex(idx, updateData);
1199
1200 const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
1201 const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
1202
1203 if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) {
1204 await conversationsStore.updateConversationTitleWithConfirmation(
1205 activeConv.id,
1206 newContent.trim(),
1207 conversationsStore.titleUpdateConfirmationCallback
1208 );
1209 }
1210 conversationsStore.updateConversationTimestamp();
1211 } catch (error) {
1212 console.error('Failed to edit user message:', error);
1213 }
1214 }
1215
1216 async editMessageWithBranching(
1217 messageId: string,
1218 newContent: string,
1219 newExtras?: DatabaseMessageExtra[]
1220 ): Promise<void> {
1221 const activeConv = conversationsStore.activeConversation;
1222 if (!activeConv || this.isLoading) return;
1223
1224 let result = this.getMessageByIdWithRole(messageId, 'user');
1225
1226 if (!result) {
1227 result = this.getMessageByIdWithRole(messageId, 'system');
1228 }
1229
1230 if (!result) return;
1231 const { message: msg } = result;
1232
1233 try {
1234 const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
1235 const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
1236 const isFirstUserMessage =
1237 msg.role === 'user' && rootMessage && msg.parent === rootMessage.id;
1238
1239 const parentId = msg.parent || rootMessage?.id;
1240 if (!parentId) return;
1241
1242 // Use newExtras if provided, otherwise copy existing extras
1243 // Deep clone to avoid Proxy objects from Svelte reactivity
1244 const extrasToUse =
1245 newExtras !== undefined
1246 ? JSON.parse(JSON.stringify(newExtras))
1247 : msg.extra
1248 ? JSON.parse(JSON.stringify(msg.extra))
1249 : undefined;
1250
1251 const newMessage = await DatabaseService.createMessageBranch(
1252 {
1253 convId: msg.convId,
1254 type: msg.type,
1255 timestamp: Date.now(),
1256 role: msg.role,
1257 content: newContent,
1258 thinking: msg.thinking || '',
1259 toolCalls: msg.toolCalls || '',
1260 children: [],
1261 extra: extrasToUse,
1262 model: msg.model
1263 },
1264 parentId
1265 );
1266 await conversationsStore.updateCurrentNode(newMessage.id);
1267 conversationsStore.updateConversationTimestamp();
1268
1269 if (isFirstUserMessage && newContent.trim()) {
1270 await conversationsStore.updateConversationTitleWithConfirmation(
1271 activeConv.id,
1272 newContent.trim(),
1273 conversationsStore.titleUpdateConfirmationCallback
1274 );
1275 }
1276 await conversationsStore.refreshActiveMessages();
1277
1278 if (msg.role === 'user') {
1279 await this.generateResponseForMessage(newMessage.id);
1280 }
1281 } catch (error) {
1282 console.error('Failed to edit message with branching:', error);
1283 }
1284 }
1285
1286 async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
1287 const activeConv = conversationsStore.activeConversation;
1288 if (!activeConv || this.isLoading) return;
1289 try {
1290 const idx = conversationsStore.findMessageIndex(messageId);
1291 if (idx === -1) return;
1292 const msg = conversationsStore.activeMessages[idx];
1293 if (msg.role !== 'assistant') return;
1294
1295 const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
1296 const parentMessage = allMessages.find((m) => m.id === msg.parent);
1297 if (!parentMessage) return;
1298
1299 this.setChatLoading(activeConv.id, true);
1300 this.clearChatStreaming(activeConv.id);
1301
1302 const newAssistantMessage = await DatabaseService.createMessageBranch(
1303 {
1304 convId: activeConv.id,
1305 type: 'text',
1306 timestamp: Date.now(),
1307 role: 'assistant',
1308 content: '',
1309 thinking: '',
1310 toolCalls: '',
1311 children: [],
1312 model: null
1313 },
1314 parentMessage.id
1315 );
1316 await conversationsStore.updateCurrentNode(newAssistantMessage.id);
1317 conversationsStore.updateConversationTimestamp();
1318 await conversationsStore.refreshActiveMessages();
1319
1320 const conversationPath = filterByLeafNodeId(
1321 allMessages,
1322 parentMessage.id,
1323 false
1324 ) as DatabaseMessage[];
1325 // Use modelOverride if provided, otherwise use the original message's model
1326 // If neither is available, don't pass model (will use global selection)
1327 const modelToUse = modelOverride || msg.model || undefined;
1328 await this.streamChatCompletion(
1329 conversationPath,
1330 newAssistantMessage,
1331 undefined,
1332 undefined,
1333 modelToUse
1334 );
1335 } catch (error) {
1336 if (!this.isAbortError(error))
1337 console.error('Failed to regenerate message with branching:', error);
1338 this.setChatLoading(activeConv?.id || '', false);
1339 }
1340 }
1341
1342 private async generateResponseForMessage(userMessageId: string): Promise<void> {
1343 const activeConv = conversationsStore.activeConversation;
1344
1345 if (!activeConv) return;
1346
1347 this.errorDialogState = null;
1348 this.setChatLoading(activeConv.id, true);
1349 this.clearChatStreaming(activeConv.id);
1350
1351 try {
1352 const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
1353 const conversationPath = filterByLeafNodeId(
1354 allMessages,
1355 userMessageId,
1356 false
1357 ) as DatabaseMessage[];
1358 const assistantMessage = await DatabaseService.createMessageBranch(
1359 {
1360 convId: activeConv.id,
1361 type: 'text',
1362 timestamp: Date.now(),
1363 role: 'assistant',
1364 content: '',
1365 thinking: '',
1366 toolCalls: '',
1367 children: [],
1368 model: null
1369 },
1370 userMessageId
1371 );
1372 conversationsStore.addMessageToActive(assistantMessage);
1373 await this.streamChatCompletion(conversationPath, assistantMessage);
1374 } catch (error) {
1375 console.error('Failed to generate response:', error);
1376 this.setChatLoading(activeConv.id, false);
1377 }
1378 }
1379
1380 getAddFilesHandler(): ((files: File[]) => void) | null {
1381 return this.addFilesHandler;
1382 }
1383
1384 public getAllLoadingChats(): string[] {
1385 return Array.from(this.chatLoadingStates.keys());
1386 }
1387
1388 public getAllStreamingChats(): string[] {
1389 return Array.from(this.chatStreamingStates.keys());
1390 }
1391
1392 public getChatStreamingPublic(
1393 convId: string
1394 ): { response: string; messageId: string } | undefined {
1395 return this.getChatStreaming(convId);
1396 }
1397
1398 public isChatLoadingPublic(convId: string): boolean {
1399 return this.isChatLoading(convId);
1400 }
1401
1402 isEditing(): boolean {
1403 return this.isEditModeActive;
1404 }
1405
1406 setEditModeActive(handler: (files: File[]) => void): void {
1407 this.isEditModeActive = true;
1408 this.addFilesHandler = handler;
1409 }
1410
1411 // ─────────────────────────────────────────────────────────────────────────────
1412 // Utilities
1413 // ─────────────────────────────────────────────────────────────────────────────
1414
1415 private getApiOptions(): Record<string, unknown> {
1416 const currentConfig = config();
1417 const hasValue = (value: unknown): boolean =>
1418 value !== undefined && value !== null && value !== '';
1419
1420 const apiOptions: Record<string, unknown> = { stream: true, timings_per_token: true };
1421
1422 // Model selection (required in ROUTER mode)
1423 if (isRouterMode()) {
1424 const modelName = selectedModelName();
1425 if (modelName) apiOptions.model = modelName;
1426 }
1427
1428 // Config options needed by ChatService
1429 if (currentConfig.systemMessage) apiOptions.systemMessage = currentConfig.systemMessage;
1430 if (currentConfig.disableReasoningFormat) apiOptions.disableReasoningFormat = true;
1431
1432 if (hasValue(currentConfig.temperature))
1433 apiOptions.temperature = Number(currentConfig.temperature);
1434 if (hasValue(currentConfig.max_tokens))
1435 apiOptions.max_tokens = Number(currentConfig.max_tokens);
1436 if (hasValue(currentConfig.dynatemp_range))
1437 apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range);
1438 if (hasValue(currentConfig.dynatemp_exponent))
1439 apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent);
1440 if (hasValue(currentConfig.top_k)) apiOptions.top_k = Number(currentConfig.top_k);
1441 if (hasValue(currentConfig.top_p)) apiOptions.top_p = Number(currentConfig.top_p);
1442 if (hasValue(currentConfig.min_p)) apiOptions.min_p = Number(currentConfig.min_p);
1443 if (hasValue(currentConfig.xtc_probability))
1444 apiOptions.xtc_probability = Number(currentConfig.xtc_probability);
1445 if (hasValue(currentConfig.xtc_threshold))
1446 apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold);
1447 if (hasValue(currentConfig.typ_p)) apiOptions.typ_p = Number(currentConfig.typ_p);
1448 if (hasValue(currentConfig.repeat_last_n))
1449 apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n);
1450 if (hasValue(currentConfig.repeat_penalty))
1451 apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty);
1452 if (hasValue(currentConfig.presence_penalty))
1453 apiOptions.presence_penalty = Number(currentConfig.presence_penalty);
1454 if (hasValue(currentConfig.frequency_penalty))
1455 apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty);
1456 if (hasValue(currentConfig.dry_multiplier))
1457 apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier);
1458 if (hasValue(currentConfig.dry_base)) apiOptions.dry_base = Number(currentConfig.dry_base);
1459 if (hasValue(currentConfig.dry_allowed_length))
1460 apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length);
1461 if (hasValue(currentConfig.dry_penalty_last_n))
1462 apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n);
1463 if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers;
1464 if (currentConfig.backend_sampling)
1465 apiOptions.backend_sampling = currentConfig.backend_sampling;
1466 if (currentConfig.custom) apiOptions.custom = currentConfig.custom;
1467
1468 return apiOptions;
1469 }
1470}
1471
1472export const chatStore = new ChatStore();
1473
1474export const activeProcessingState = () => chatStore.activeProcessingState;
1475export const clearEditMode = () => chatStore.clearEditMode();
1476export const currentResponse = () => chatStore.currentResponse;
1477export const errorDialog = () => chatStore.errorDialogState;
1478export const getAddFilesHandler = () => chatStore.getAddFilesHandler();
1479export const getAllLoadingChats = () => chatStore.getAllLoadingChats();
1480export const getAllStreamingChats = () => chatStore.getAllStreamingChats();
1481export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId);
1482export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId);
1483export const isChatStreaming = () => chatStore.isStreaming();
1484export const isEditing = () => chatStore.isEditing();
1485export const isLoading = () => chatStore.isLoading;
1486export const setEditModeActive = (handler: (files: File[]) => void) =>
1487 chatStore.setEditModeActive(handler);
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts
new file mode 100644
index 0000000..3300eb3
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts
@@ -0,0 +1,662 @@
1import { browser } from '$app/environment';
2import { goto } from '$app/navigation';
3import { toast } from 'svelte-sonner';
4import { DatabaseService } from '$lib/services/database';
5import { config } from '$lib/stores/settings.svelte';
6import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
7import { AttachmentType } from '$lib/enums';
8
9/**
10 * conversationsStore - Persistent conversation data and lifecycle management
11 *
12 * **Terminology - Chat vs Conversation:**
13 * - **Chat**: The active interaction space with the Chat Completions API. Represents the
14 * real-time streaming session, loading states, and UI visualization of AI communication.
15 * Managed by chatStore, a "chat" is ephemeral and exists during active AI interactions.
16 * - **Conversation**: The persistent database entity storing all messages and metadata.
17 * A "conversation" survives across sessions, page reloads, and browser restarts.
18 * It contains the complete message history, branching structure, and conversation metadata.
19 *
20 * This store manages all conversation-level data and operations including creation, loading,
21 * deletion, and navigation. It maintains the list of conversations and the currently active
22 * conversation with its message history, providing reactive state for UI components.
23 *
24 * **Architecture & Relationships:**
25 * - **conversationsStore** (this class): Persistent conversation data management
26 * - Manages conversation list and active conversation state
27 * - Handles conversation CRUD operations via DatabaseService
28 * - Maintains active message array for current conversation
29 * - Coordinates branching navigation (currNode tracking)
30 *
31 * - **chatStore**: Uses conversation data as context for active AI streaming
32 * - **DatabaseService**: Low-level IndexedDB storage for conversations and messages
33 *
34 * **Key Features:**
35 * - **Conversation Lifecycle**: Create, load, update, delete conversations
36 * - **Message Management**: Active message array with branching support
37 * - **Import/Export**: JSON-based conversation backup and restore
38 * - **Branch Navigation**: Navigate between message tree branches
39 * - **Title Management**: Auto-update titles with confirmation dialogs
40 * - **Reactive State**: Svelte 5 runes for automatic UI updates
41 *
42 * **State Properties:**
43 * - `conversations`: All conversations sorted by last modified
44 * - `activeConversation`: Currently viewed conversation
45 * - `activeMessages`: Messages in current conversation path
46 * - `isInitialized`: Store initialization status
47 */
48class ConversationsStore {
49 // ─────────────────────────────────────────────────────────────────────────────
50 // State
51 // ─────────────────────────────────────────────────────────────────────────────
52
53 /** List of all conversations */
54 conversations = $state<DatabaseConversation[]>([]);
55
56 /** Currently active conversation */
57 activeConversation = $state<DatabaseConversation | null>(null);
58
59 /** Messages in the active conversation (filtered by currNode path) */
60 activeMessages = $state<DatabaseMessage[]>([]);
61
62 /** Whether the store has been initialized */
63 isInitialized = $state(false);
64
65 /** Callback for title update confirmation dialog */
66 titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
67
68 // ─────────────────────────────────────────────────────────────────────────────
69 // Modalities
70 // ─────────────────────────────────────────────────────────────────────────────
71
72 /**
73 * Modalities used in the active conversation.
74 * Computed from attachments in activeMessages.
75 * Used to filter available models - models must support all used modalities.
76 */
77 usedModalities: ModelModalities = $derived.by(() => {
78 return this.calculateModalitiesFromMessages(this.activeMessages);
79 });
80
81 /**
82 * Calculate modalities from a list of messages.
83 * Helper method used by both usedModalities and getModalitiesUpToMessage.
84 */
85 private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities {
86 const modalities: ModelModalities = { vision: false, audio: false };
87
88 for (const message of messages) {
89 if (!message.extra) continue;
90
91 for (const extra of message.extra) {
92 if (extra.type === AttachmentType.IMAGE) {
93 modalities.vision = true;
94 }
95
96 // PDF only requires vision if processed as images
97 if (extra.type === AttachmentType.PDF) {
98 const pdfExtra = extra as DatabaseMessageExtraPdfFile;
99
100 if (pdfExtra.processedAsImages) {
101 modalities.vision = true;
102 }
103 }
104
105 if (extra.type === AttachmentType.AUDIO) {
106 modalities.audio = true;
107 }
108 }
109
110 if (modalities.vision && modalities.audio) break;
111 }
112
113 return modalities;
114 }
115
116 /**
117 * Get modalities used in messages BEFORE the specified message.
118 * Used for regeneration - only consider context that was available when generating this message.
119 */
120 getModalitiesUpToMessage(messageId: string): ModelModalities {
121 const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId);
122
123 if (messageIndex === -1) {
124 return this.usedModalities;
125 }
126
127 const messagesBefore = this.activeMessages.slice(0, messageIndex);
128 return this.calculateModalitiesFromMessages(messagesBefore);
129 }
130
131 constructor() {
132 if (browser) {
133 this.initialize();
134 }
135 }
136
137 // ─────────────────────────────────────────────────────────────────────────────
138 // Lifecycle
139 // ─────────────────────────────────────────────────────────────────────────────
140
141 /**
142 * Initializes the conversations store by loading conversations from the database
143 */
144 async initialize(): Promise<void> {
145 try {
146 await this.loadConversations();
147 this.isInitialized = true;
148 } catch (error) {
149 console.error('Failed to initialize conversations store:', error);
150 }
151 }
152
153 /**
154 * Loads all conversations from the database
155 */
156 async loadConversations(): Promise<void> {
157 this.conversations = await DatabaseService.getAllConversations();
158 }
159
160 // ─────────────────────────────────────────────────────────────────────────────
161 // Conversation CRUD
162 // ─────────────────────────────────────────────────────────────────────────────
163
164 /**
165 * Creates a new conversation and navigates to it
166 * @param name - Optional name for the conversation
167 * @returns The ID of the created conversation
168 */
169 async createConversation(name?: string): Promise<string> {
170 const conversationName = name || `Chat ${new Date().toLocaleString()}`;
171 const conversation = await DatabaseService.createConversation(conversationName);
172
173 this.conversations.unshift(conversation);
174 this.activeConversation = conversation;
175 this.activeMessages = [];
176
177 await goto(`#/chat/${conversation.id}`);
178
179 return conversation.id;
180 }
181
182 /**
183 * Loads a specific conversation and its messages
184 * @param convId - The conversation ID to load
185 * @returns True if conversation was loaded successfully
186 */
187 async loadConversation(convId: string): Promise<boolean> {
188 try {
189 const conversation = await DatabaseService.getConversation(convId);
190
191 if (!conversation) {
192 return false;
193 }
194
195 this.activeConversation = conversation;
196
197 if (conversation.currNode) {
198 const allMessages = await DatabaseService.getConversationMessages(convId);
199 this.activeMessages = filterByLeafNodeId(
200 allMessages,
201 conversation.currNode,
202 false
203 ) as DatabaseMessage[];
204 } else {
205 this.activeMessages = await DatabaseService.getConversationMessages(convId);
206 }
207
208 return true;
209 } catch (error) {
210 console.error('Failed to load conversation:', error);
211 return false;
212 }
213 }
214
215 /**
216 * Clears the active conversation and messages
217 * Used when navigating away from chat or starting fresh
218 */
219 clearActiveConversation(): void {
220 this.activeConversation = null;
221 this.activeMessages = [];
222 // Active processing conversation is now managed by chatStore
223 }
224
225 // ─────────────────────────────────────────────────────────────────────────────
226 // Message Management
227 // ─────────────────────────────────────────────────────────────────────────────
228
229 /**
230 * Refreshes active messages based on currNode after branch navigation
231 */
232 async refreshActiveMessages(): Promise<void> {
233 if (!this.activeConversation) return;
234
235 const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
236
237 if (allMessages.length === 0) {
238 this.activeMessages = [];
239 return;
240 }
241
242 const leafNodeId =
243 this.activeConversation.currNode ||
244 allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
245
246 const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
247
248 this.activeMessages.length = 0;
249 this.activeMessages.push(...currentPath);
250 }
251
252 /**
253 * Updates the name of a conversation
254 * @param convId - The conversation ID to update
255 * @param name - The new name for the conversation
256 */
257 async updateConversationName(convId: string, name: string): Promise<void> {
258 try {
259 await DatabaseService.updateConversation(convId, { name });
260
261 const convIndex = this.conversations.findIndex((c) => c.id === convId);
262
263 if (convIndex !== -1) {
264 this.conversations[convIndex].name = name;
265 }
266
267 if (this.activeConversation?.id === convId) {
268 this.activeConversation.name = name;
269 }
270 } catch (error) {
271 console.error('Failed to update conversation name:', error);
272 }
273 }
274
275 /**
276 * Updates conversation title with optional confirmation dialog based on settings
277 * @param convId - The conversation ID to update
278 * @param newTitle - The new title content
279 * @param onConfirmationNeeded - Callback when user confirmation is needed
280 * @returns True if title was updated, false if cancelled
281 */
282 async updateConversationTitleWithConfirmation(
283 convId: string,
284 newTitle: string,
285 onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
286 ): Promise<boolean> {
287 try {
288 const currentConfig = config();
289
290 if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
291 const conversation = await DatabaseService.getConversation(convId);
292 if (!conversation) return false;
293
294 const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
295 if (!shouldUpdate) return false;
296 }
297
298 await this.updateConversationName(convId, newTitle);
299 return true;
300 } catch (error) {
301 console.error('Failed to update conversation title with confirmation:', error);
302 return false;
303 }
304 }
305
306 // ─────────────────────────────────────────────────────────────────────────────
307 // Navigation
308 // ─────────────────────────────────────────────────────────────────────────────
309
310 /**
311 * Updates the current node of the active conversation
312 * @param nodeId - The new current node ID
313 */
314 async updateCurrentNode(nodeId: string): Promise<void> {
315 if (!this.activeConversation) return;
316
317 await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
318 this.activeConversation.currNode = nodeId;
319 }
320
321 /**
322 * Updates conversation lastModified timestamp and moves it to top of list
323 */
324 updateConversationTimestamp(): void {
325 if (!this.activeConversation) return;
326
327 const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
328
329 if (chatIndex !== -1) {
330 this.conversations[chatIndex].lastModified = Date.now();
331 const updatedConv = this.conversations.splice(chatIndex, 1)[0];
332 this.conversations.unshift(updatedConv);
333 }
334 }
335
336 /**
337 * Navigates to a specific sibling branch by updating currNode and refreshing messages
338 * @param siblingId - The sibling message ID to navigate to
339 */
340 async navigateToSibling(siblingId: string): Promise<void> {
341 if (!this.activeConversation) return;
342
343 const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
344 const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
345 const currentFirstUserMessage = this.activeMessages.find(
346 (m) => m.role === 'user' && m.parent === rootMessage?.id
347 );
348
349 const currentLeafNodeId = findLeafNode(allMessages, siblingId);
350
351 await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
352 this.activeConversation.currNode = currentLeafNodeId;
353 await this.refreshActiveMessages();
354
355 // Only show title dialog if we're navigating between different first user message siblings
356 if (rootMessage && this.activeMessages.length > 0) {
357 const newFirstUserMessage = this.activeMessages.find(
358 (m) => m.role === 'user' && m.parent === rootMessage.id
359 );
360
361 if (
362 newFirstUserMessage &&
363 newFirstUserMessage.content.trim() &&
364 (!currentFirstUserMessage ||
365 newFirstUserMessage.id !== currentFirstUserMessage.id ||
366 newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
367 ) {
368 await this.updateConversationTitleWithConfirmation(
369 this.activeConversation.id,
370 newFirstUserMessage.content.trim(),
371 this.titleUpdateConfirmationCallback
372 );
373 }
374 }
375 }
376
377 /**
378 * Deletes a conversation and all its messages
379 * @param convId - The conversation ID to delete
380 */
381 async deleteConversation(convId: string): Promise<void> {
382 try {
383 await DatabaseService.deleteConversation(convId);
384
385 this.conversations = this.conversations.filter((c) => c.id !== convId);
386
387 if (this.activeConversation?.id === convId) {
388 this.clearActiveConversation();
389 await goto(`?new_chat=true#/`);
390 }
391 } catch (error) {
392 console.error('Failed to delete conversation:', error);
393 }
394 }
395
396 /**
397 * Deletes all conversations and their messages
398 */
399 async deleteAll(): Promise<void> {
400 try {
401 const allConversations = await DatabaseService.getAllConversations();
402
403 for (const conv of allConversations) {
404 await DatabaseService.deleteConversation(conv.id);
405 }
406
407 this.clearActiveConversation();
408 this.conversations = [];
409
410 toast.success('All conversations deleted');
411
412 await goto(`?new_chat=true#/`);
413 } catch (error) {
414 console.error('Failed to delete all conversations:', error);
415 toast.error('Failed to delete conversations');
416 }
417 }
418
419 // ─────────────────────────────────────────────────────────────────────────────
420 // Import/Export
421 // ─────────────────────────────────────────────────────────────────────────────
422
423 /**
424 * Downloads a conversation as JSON file
425 * @param convId - The conversation ID to download
426 */
427 async downloadConversation(convId: string): Promise<void> {
428 let conversation: DatabaseConversation | null;
429 let messages: DatabaseMessage[];
430
431 if (this.activeConversation?.id === convId) {
432 conversation = this.activeConversation;
433 messages = this.activeMessages;
434 } else {
435 conversation = await DatabaseService.getConversation(convId);
436 if (!conversation) return;
437 messages = await DatabaseService.getConversationMessages(convId);
438 }
439
440 this.triggerDownload({ conv: conversation, messages });
441 }
442
443 /**
444 * Exports all conversations with their messages as a JSON file
445 * @returns The list of exported conversations
446 */
447 async exportAllConversations(): Promise<DatabaseConversation[]> {
448 const allConversations = await DatabaseService.getAllConversations();
449
450 if (allConversations.length === 0) {
451 throw new Error('No conversations to export');
452 }
453
454 const allData = await Promise.all(
455 allConversations.map(async (conv) => {
456 const messages = await DatabaseService.getConversationMessages(conv.id);
457 return { conv, messages };
458 })
459 );
460
461 const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' });
462 const url = URL.createObjectURL(blob);
463 const a = document.createElement('a');
464 a.href = url;
465 a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
466 document.body.appendChild(a);
467 a.click();
468 document.body.removeChild(a);
469 URL.revokeObjectURL(url);
470
471 toast.success(`All conversations (${allConversations.length}) prepared for download`);
472
473 return allConversations;
474 }
475
476 /**
477 * Imports conversations from a JSON file
478 * Opens file picker and processes the selected file
479 * @returns The list of imported conversations
480 */
481 async importConversations(): Promise<DatabaseConversation[]> {
482 return new Promise((resolve, reject) => {
483 const input = document.createElement('input');
484 input.type = 'file';
485 input.accept = '.json';
486
487 input.onchange = async (e) => {
488 const file = (e.target as HTMLInputElement)?.files?.[0];
489
490 if (!file) {
491 reject(new Error('No file selected'));
492 return;
493 }
494
495 try {
496 const text = await file.text();
497 const parsedData = JSON.parse(text);
498 let importedData: ExportedConversations;
499
500 if (Array.isArray(parsedData)) {
501 importedData = parsedData;
502 } else if (
503 parsedData &&
504 typeof parsedData === 'object' &&
505 'conv' in parsedData &&
506 'messages' in parsedData
507 ) {
508 importedData = [parsedData];
509 } else {
510 throw new Error('Invalid file format');
511 }
512
513 const result = await DatabaseService.importConversations(importedData);
514 toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
515
516 await this.loadConversations();
517
518 const importedConversations = (
519 Array.isArray(importedData) ? importedData : [importedData]
520 ).map((item) => item.conv);
521
522 resolve(importedConversations);
523 } catch (err: unknown) {
524 const message = err instanceof Error ? err.message : 'Unknown error';
525 console.error('Failed to import conversations:', err);
526 toast.error('Import failed', { description: message });
527 reject(new Error(`Import failed: ${message}`));
528 }
529 };
530
531 input.click();
532 });
533 }
534
535 /**
536 * Gets all messages for a specific conversation
537 * @param convId - The conversation ID
538 * @returns Array of messages
539 */
540 async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
541 return await DatabaseService.getConversationMessages(convId);
542 }
543
544 /**
545 * Imports conversations from provided data (without file picker)
546 * @param data - Array of conversation data with messages
547 * @returns Import result with counts
548 */
549 async importConversationsData(
550 data: ExportedConversations
551 ): Promise<{ imported: number; skipped: number }> {
552 const result = await DatabaseService.importConversations(data);
553 await this.loadConversations();
554 return result;
555 }
556
557 /**
558 * Adds a message to the active messages array
559 * Used by chatStore when creating new messages
560 * @param message - The message to add
561 */
562 addMessageToActive(message: DatabaseMessage): void {
563 this.activeMessages.push(message);
564 }
565
566 /**
567 * Updates a message at a specific index in active messages
568 * Creates a new object to trigger Svelte 5 reactivity
569 * @param index - The index of the message to update
570 * @param updates - Partial message data to update
571 */
572 updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
573 if (index !== -1 && this.activeMessages[index]) {
574 // Create new object to trigger Svelte 5 reactivity
575 this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
576 }
577 }
578
579 /**
580 * Finds the index of a message in active messages
581 * @param messageId - The message ID to find
582 * @returns The index of the message, or -1 if not found
583 */
584 findMessageIndex(messageId: string): number {
585 return this.activeMessages.findIndex((m) => m.id === messageId);
586 }
587
588 /**
589 * Removes messages from active messages starting at an index
590 * @param startIndex - The index to start removing from
591 */
592 sliceActiveMessages(startIndex: number): void {
593 this.activeMessages = this.activeMessages.slice(0, startIndex);
594 }
595
596 /**
597 * Removes a message from active messages by index
598 * @param index - The index to remove
599 * @returns The removed message or undefined
600 */
601 removeMessageAtIndex(index: number): DatabaseMessage | undefined {
602 if (index !== -1) {
603 return this.activeMessages.splice(index, 1)[0];
604 }
605 return undefined;
606 }
607
608 /**
609 * Triggers file download in browser
610 * @param data - The data to download
611 * @param filename - Optional filename for the download
612 */
613 private triggerDownload(data: ExportedConversations, filename?: string): void {
614 const conversation =
615 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
616
617 if (!conversation) {
618 console.error('Invalid data: missing conversation');
619 return;
620 }
621
622 const conversationName = conversation.name?.trim() || '';
623 const truncatedSuffix = conversationName
624 .toLowerCase()
625 .replace(/[^a-z0-9]/gi, '_')
626 .replace(/_+/g, '_')
627 .substring(0, 20);
628 const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`;
629
630 const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
631 const url = URL.createObjectURL(blob);
632 const a = document.createElement('a');
633 a.href = url;
634 a.download = downloadFilename;
635 document.body.appendChild(a);
636 a.click();
637 document.body.removeChild(a);
638 URL.revokeObjectURL(url);
639 }
640
641 // ─────────────────────────────────────────────────────────────────────────────
642 // Utilities
643 // ─────────────────────────────────────────────────────────────────────────────
644
645 /**
646 * Sets the callback function for title update confirmations
647 * @param callback - Function to call when confirmation is needed
648 */
649 setTitleUpdateConfirmationCallback(
650 callback: (currentTitle: string, newTitle: string) => Promise<boolean>
651 ): void {
652 this.titleUpdateConfirmationCallback = callback;
653 }
654}
655
656export const conversationsStore = new ConversationsStore();
657
658export const conversations = () => conversationsStore.conversations;
659export const activeConversation = () => conversationsStore.activeConversation;
660export const activeMessages = () => conversationsStore.activeMessages;
661export const isConversationsInitialized = () => conversationsStore.isInitialized;
662export const usedModalities = () => conversationsStore.usedModalities;
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts
new file mode 100644
index 0000000..34b2640
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts
@@ -0,0 +1,605 @@
1import { SvelteSet } from 'svelte/reactivity';
2import { ModelsService } from '$lib/services/models';
3import { PropsService } from '$lib/services/props';
4import { ServerModelStatus, ModelModality } from '$lib/enums';
5import { serverStore } from '$lib/stores/server.svelte';
6
7/**
8 * modelsStore - Reactive store for model management in both MODEL and ROUTER modes
9 *
10 * This store manages:
11 * - Available models list
12 * - Selected model for new conversations
13 * - Loaded models tracking (ROUTER mode)
14 * - Model usage tracking per conversation
15 * - Automatic unloading of unused models
16 *
17 * **Architecture & Relationships:**
18 * - **ModelsService**: Stateless service for model API communication
19 * - **PropsService**: Stateless service for props/modalities fetching
20 * - **modelsStore** (this class): Reactive store for model state
21 * - **conversationsStore**: Tracks which conversations use which models
22 *
23 * **API Inconsistency Workaround:**
24 * In MODEL mode, `/props` returns modalities for the single model.
25 * In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
26 * This store normalizes this behavior so consumers don't need to know the server mode.
27 *
28 * **Key Features:**
29 * - **MODEL mode**: Single model, always loaded
30 * - **ROUTER mode**: Multi-model with load/unload capability
31 * - **Auto-unload**: Automatically unloads models not used by any conversation
32 * - **Lazy loading**: ensureModelLoaded() loads models on demand
33 */
34class ModelsStore {
35 // ─────────────────────────────────────────────────────────────────────────────
36 // State
37 // ─────────────────────────────────────────────────────────────────────────────
38
39 models = $state<ModelOption[]>([]);
40 routerModels = $state<ApiModelDataEntry[]>([]);
41 loading = $state(false);
42 updating = $state(false);
43 error = $state<string | null>(null);
44 selectedModelId = $state<string | null>(null);
45 selectedModelName = $state<string | null>(null);
46
47 private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
48 private modelLoadingStates = $state<Map<string, boolean>>(new Map());
49
50 /**
51 * Model-specific props cache
52 * Key: modelId, Value: props data including modalities
53 */
54 private modelPropsCache = $state<Map<string, ApiLlamaCppServerProps>>(new Map());
55 private modelPropsFetching = $state<Set<string>>(new Set());
56
57 /**
58 * Version counter for props cache - used to trigger reactivity when props are updated
59 */
60 propsCacheVersion = $state(0);
61
62 // ─────────────────────────────────────────────────────────────────────────────
63 // Computed Getters
64 // ─────────────────────────────────────────────────────────────────────────────
65
66 get selectedModel(): ModelOption | null {
67 if (!this.selectedModelId) return null;
68 return this.models.find((model) => model.id === this.selectedModelId) ?? null;
69 }
70
71 get loadedModelIds(): string[] {
72 return this.routerModels
73 .filter((m) => m.status.value === ServerModelStatus.LOADED)
74 .map((m) => m.id);
75 }
76
77 get loadingModelIds(): string[] {
78 return Array.from(this.modelLoadingStates.entries())
79 .filter(([, loading]) => loading)
80 .map(([id]) => id);
81 }
82
83 /**
84 * Get model name in MODEL mode (single model).
85 * Extracts from model_path or model_alias from server props.
86 * In ROUTER mode, returns null (model is per-conversation).
87 */
88 get singleModelName(): string | null {
89 if (serverStore.isRouterMode) return null;
90
91 const props = serverStore.props;
92 if (props?.model_alias) return props.model_alias;
93 if (!props?.model_path) return null;
94
95 return props.model_path.split(/(\\|\/)/).pop() || null;
96 }
97
98 // ─────────────────────────────────────────────────────────────────────────────
99 // Modalities
100 // ─────────────────────────────────────────────────────────────────────────────
101
102 /**
103 * Get modalities for a specific model
104 * Returns cached modalities from model props
105 */
106 getModelModalities(modelId: string): ModelModalities | null {
107 // First check if modalities are stored in the model option
108 const model = this.models.find((m) => m.model === modelId || m.id === modelId);
109 if (model?.modalities) {
110 return model.modalities;
111 }
112
113 // Fall back to props cache
114 const props = this.modelPropsCache.get(modelId);
115 if (props?.modalities) {
116 return {
117 vision: props.modalities.vision ?? false,
118 audio: props.modalities.audio ?? false
119 };
120 }
121
122 return null;
123 }
124
125 /**
126 * Check if a model supports vision modality
127 */
128 modelSupportsVision(modelId: string): boolean {
129 return this.getModelModalities(modelId)?.vision ?? false;
130 }
131
132 /**
133 * Check if a model supports audio modality
134 */
135 modelSupportsAudio(modelId: string): boolean {
136 return this.getModelModalities(modelId)?.audio ?? false;
137 }
138
139 /**
140 * Get model modalities as an array of ModelModality enum values
141 */
142 getModelModalitiesArray(modelId: string): ModelModality[] {
143 const modalities = this.getModelModalities(modelId);
144 if (!modalities) return [];
145
146 const result: ModelModality[] = [];
147
148 if (modalities.vision) result.push(ModelModality.VISION);
149 if (modalities.audio) result.push(ModelModality.AUDIO);
150
151 return result;
152 }
153
154 /**
155 * Get props for a specific model (from cache)
156 */
157 getModelProps(modelId: string): ApiLlamaCppServerProps | null {
158 return this.modelPropsCache.get(modelId) ?? null;
159 }
160
161 /**
162 * Get context size (n_ctx) for a specific model from cached props
163 */
164 getModelContextSize(modelId: string): number | null {
165 const props = this.modelPropsCache.get(modelId);
166 return props?.default_generation_settings?.n_ctx ?? null;
167 }
168
169 /**
170 * Get context size for the currently selected model or null if no model is selected
171 */
172 get selectedModelContextSize(): number | null {
173 if (!this.selectedModelName) return null;
174 return this.getModelContextSize(this.selectedModelName);
175 }
176
177 /**
178 * Check if props are being fetched for a model
179 */
180 isModelPropsFetching(modelId: string): boolean {
181 return this.modelPropsFetching.has(modelId);
182 }
183
184 // ─────────────────────────────────────────────────────────────────────────────
185 // Status Queries
186 // ─────────────────────────────────────────────────────────────────────────────
187
188 isModelLoaded(modelId: string): boolean {
189 const model = this.routerModels.find((m) => m.id === modelId);
190 return model?.status.value === ServerModelStatus.LOADED || false;
191 }
192
193 isModelOperationInProgress(modelId: string): boolean {
194 return this.modelLoadingStates.get(modelId) ?? false;
195 }
196
197 getModelStatus(modelId: string): ServerModelStatus | null {
198 const model = this.routerModels.find((m) => m.id === modelId);
199 return model?.status.value ?? null;
200 }
201
202 getModelUsage(modelId: string): SvelteSet<string> {
203 return this.modelUsage.get(modelId) ?? new SvelteSet<string>();
204 }
205
206 isModelInUse(modelId: string): boolean {
207 const usage = this.modelUsage.get(modelId);
208 return usage !== undefined && usage.size > 0;
209 }
210
211 // ─────────────────────────────────────────────────────────────────────────────
212 // Data Fetching
213 // ─────────────────────────────────────────────────────────────────────────────
214
215 /**
216 * Fetch list of models from server and detect server role
217 * Also fetches modalities for MODEL mode (single model)
218 */
219 async fetch(force = false): Promise<void> {
220 if (this.loading) return;
221 if (this.models.length > 0 && !force) return;
222
223 this.loading = true;
224 this.error = null;
225
226 try {
227 // Ensure server props are loaded (for role detection and MODEL mode modalities)
228 if (!serverStore.props) {
229 await serverStore.fetch();
230 }
231
232 const response = await ModelsService.list();
233
234 const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => {
235 const details = response.models?.[index];
236 const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
237 const displayNameSource =
238 details?.name && details.name.trim().length > 0 ? details.name : item.id;
239 const displayName = this.toDisplayName(displayNameSource);
240
241 return {
242 id: item.id,
243 name: displayName,
244 model: details?.model || item.id,
245 description: details?.description,
246 capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
247 details: details?.details,
248 meta: item.meta ?? null
249 } satisfies ModelOption;
250 });
251
252 this.models = models;
253
254 // In MODEL mode, populate modalities from serverStore.props (single model)
255 // WORKAROUND: In MODEL mode, /props returns modalities for the single model,
256 // but /v1/models doesn't include modalities. We bridge this gap here.
257 const serverProps = serverStore.props;
258 if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) {
259 const modalities: ModelModalities = {
260 vision: serverProps.modalities.vision ?? false,
261 audio: serverProps.modalities.audio ?? false
262 };
263 // Cache props for the single model
264 this.modelPropsCache.set(this.models[0].model, serverProps);
265 // Update model with modalities
266 this.models = this.models.map((model, index) =>
267 index === 0 ? { ...model, modalities } : model
268 );
269 }
270 } catch (error) {
271 this.models = [];
272 this.error = error instanceof Error ? error.message : 'Failed to load models';
273 throw error;
274 } finally {
275 this.loading = false;
276 }
277 }
278
279 /**
280 * Fetch router models with full metadata (ROUTER mode only)
281 * This fetches the /models endpoint which returns status info for each model
282 */
283 async fetchRouterModels(): Promise<void> {
284 try {
285 const response = await ModelsService.listRouter();
286 this.routerModels = response.data;
287 await this.fetchModalitiesForLoadedModels();
288 } catch (error) {
289 console.warn('Failed to fetch router models:', error);
290 this.routerModels = [];
291 }
292 }
293
294 /**
295 * Fetch props for a specific model from /props endpoint
296 * Uses caching to avoid redundant requests
297 *
298 * In ROUTER mode, this will only fetch props if the model is loaded,
299 * since unloaded models return 400 from /props endpoint.
300 *
301 * @param modelId - Model identifier to fetch props for
302 * @returns Props data or null if fetch failed or model not loaded
303 */
304 async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
305 // Return cached props if available
306 const cached = this.modelPropsCache.get(modelId);
307 if (cached) return cached;
308
309 if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) {
310 return null;
311 }
312
313 // Avoid duplicate fetches
314 if (this.modelPropsFetching.has(modelId)) return null;
315
316 this.modelPropsFetching.add(modelId);
317
318 try {
319 const props = await PropsService.fetchForModel(modelId);
320 this.modelPropsCache.set(modelId, props);
321 return props;
322 } catch (error) {
323 console.warn(`Failed to fetch props for model ${modelId}:`, error);
324 return null;
325 } finally {
326 this.modelPropsFetching.delete(modelId);
327 }
328 }
329
330 /**
331 * Fetch modalities for all loaded models from /props endpoint
332 * This updates the modalities field in models array
333 */
334 async fetchModalitiesForLoadedModels(): Promise<void> {
335 const loadedModelIds = this.loadedModelIds;
336 if (loadedModelIds.length === 0) return;
337
338 // Fetch props for each loaded model in parallel
339 const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
340
341 try {
342 const results = await Promise.all(propsPromises);
343
344 // Update models with modalities
345 this.models = this.models.map((model) => {
346 const modelIndex = loadedModelIds.indexOf(model.model);
347 if (modelIndex === -1) return model;
348
349 const props = results[modelIndex];
350 if (!props?.modalities) return model;
351
352 const modalities: ModelModalities = {
353 vision: props.modalities.vision ?? false,
354 audio: props.modalities.audio ?? false
355 };
356
357 return { ...model, modalities };
358 });
359
360 // Increment version to trigger reactivity
361 this.propsCacheVersion++;
362 } catch (error) {
363 console.warn('Failed to fetch modalities for loaded models:', error);
364 }
365 }
366
367 /**
368 * Update modalities for a specific model
369 * Called when a model is loaded or when we need fresh modality data
370 */
371 async updateModelModalities(modelId: string): Promise<void> {
372 try {
373 const props = await this.fetchModelProps(modelId);
374 if (!props?.modalities) return;
375
376 const modalities: ModelModalities = {
377 vision: props.modalities.vision ?? false,
378 audio: props.modalities.audio ?? false
379 };
380
381 this.models = this.models.map((model) =>
382 model.model === modelId ? { ...model, modalities } : model
383 );
384
385 // Increment version to trigger reactivity
386 this.propsCacheVersion++;
387 } catch (error) {
388 console.warn(`Failed to update modalities for model ${modelId}:`, error);
389 }
390 }
391
392 // ─────────────────────────────────────────────────────────────────────────────
393 // Model Selection
394 // ─────────────────────────────────────────────────────────────────────────────
395
396 /**
397 * Select a model for new conversations
398 */
399 async selectModelById(modelId: string): Promise<void> {
400 if (!modelId || this.updating) return;
401 if (this.selectedModelId === modelId) return;
402
403 const option = this.models.find((model) => model.id === modelId);
404 if (!option) throw new Error('Selected model is not available');
405
406 this.updating = true;
407 this.error = null;
408
409 try {
410 this.selectedModelId = option.id;
411 this.selectedModelName = option.model;
412 } finally {
413 this.updating = false;
414 }
415 }
416
417 /**
418 * Select a model by its model name (used for syncing with conversation model)
419 * @param modelName - Model name to select (e.g., "unsloth/gemma-3-12b-it-GGUF:latest")
420 */
421 selectModelByName(modelName: string): void {
422 const option = this.models.find((model) => model.model === modelName);
423 if (option) {
424 this.selectedModelId = option.id;
425 this.selectedModelName = option.model;
426 }
427 }
428
429 clearSelection(): void {
430 this.selectedModelId = null;
431 this.selectedModelName = null;
432 }
433
434 findModelByName(modelName: string): ModelOption | null {
435 return this.models.find((model) => model.model === modelName) ?? null;
436 }
437
438 findModelById(modelId: string): ModelOption | null {
439 return this.models.find((model) => model.id === modelId) ?? null;
440 }
441
442 hasModel(modelName: string): boolean {
443 return this.models.some((model) => model.model === modelName);
444 }
445
446 // ─────────────────────────────────────────────────────────────────────────────
447 // Loading/Unloading Models
448 // ─────────────────────────────────────────────────────────────────────────────
449
450 /**
451 * WORKAROUND: Polling for model status after load/unload operations.
452 *
453 * Currently, the `/models/load` and `/models/unload` endpoints return success
454 * before the operation actually completes on the server. This means an immediate
455 * request to `/models` returns stale status (e.g., "loading" after load request,
456 * "loaded" after unload request).
457 *
458 * TODO: Remove this polling once llama-server properly waits for the operation
459 * to complete before returning success from `/load` and `/unload` endpoints.
460 * At that point, a single `fetchRouterModels()` call after the operation will
461 * be sufficient to get the correct status.
462 */
463
464 /** Polling interval in ms for checking model status */
465 private static readonly STATUS_POLL_INTERVAL = 500;
466 /** Maximum polling attempts before giving up */
467 private static readonly STATUS_POLL_MAX_ATTEMPTS = 60; // 30 seconds max
468
469 /**
470 * Poll for expected model status after load/unload operation.
471 * Keeps polling until the model reaches the expected status or max attempts reached.
472 *
473 * @param modelId - Model identifier to check
474 * @param expectedStatus - Expected status to wait for
475 * @returns Promise that resolves when expected status is reached
476 */
477 private async pollForModelStatus(
478 modelId: string,
479 expectedStatus: ServerModelStatus
480 ): Promise<void> {
481 for (let attempt = 0; attempt < ModelsStore.STATUS_POLL_MAX_ATTEMPTS; attempt++) {
482 await this.fetchRouterModels();
483
484 const currentStatus = this.getModelStatus(modelId);
485 if (currentStatus === expectedStatus) {
486 return;
487 }
488
489 // Wait before next poll
490 await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
491 }
492
493 console.warn(
494 `Model ${modelId} did not reach expected status ${expectedStatus} after ${ModelsStore.STATUS_POLL_MAX_ATTEMPTS} attempts`
495 );
496 }
497
498 /**
499 * Load a model (ROUTER mode)
500 * @param modelId - Model identifier to load
501 */
502 async loadModel(modelId: string): Promise<void> {
503 if (this.isModelLoaded(modelId)) {
504 return;
505 }
506
507 if (this.modelLoadingStates.get(modelId)) return;
508
509 this.modelLoadingStates.set(modelId, true);
510 this.error = null;
511
512 try {
513 await ModelsService.load(modelId);
514
515 // Poll until model is loaded
516 await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
517
518 await this.updateModelModalities(modelId);
519 } catch (error) {
520 this.error = error instanceof Error ? error.message : 'Failed to load model';
521 throw error;
522 } finally {
523 this.modelLoadingStates.set(modelId, false);
524 }
525 }
526
527 /**
528 * Unload a model (ROUTER mode)
529 * @param modelId - Model identifier to unload
530 */
531 async unloadModel(modelId: string): Promise<void> {
532 if (!this.isModelLoaded(modelId)) {
533 return;
534 }
535
536 if (this.modelLoadingStates.get(modelId)) return;
537
538 this.modelLoadingStates.set(modelId, true);
539 this.error = null;
540
541 try {
542 await ModelsService.unload(modelId);
543
544 await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
545 } catch (error) {
546 this.error = error instanceof Error ? error.message : 'Failed to unload model';
547 throw error;
548 } finally {
549 this.modelLoadingStates.set(modelId, false);
550 }
551 }
552
553 /**
554 * Ensure a model is loaded before use
555 * @param modelId - Model identifier to ensure is loaded
556 */
557 async ensureModelLoaded(modelId: string): Promise<void> {
558 if (this.isModelLoaded(modelId)) {
559 return;
560 }
561
562 await this.loadModel(modelId);
563 }
564
565 // ─────────────────────────────────────────────────────────────────────────────
566 // Utilities
567 // ─────────────────────────────────────────────────────────────────────────────
568
569 private toDisplayName(id: string): string {
570 const segments = id.split(/\\|\//);
571 const candidate = segments.pop();
572
573 return candidate && candidate.trim().length > 0 ? candidate : id;
574 }
575
576 clear(): void {
577 this.models = [];
578 this.routerModels = [];
579 this.loading = false;
580 this.updating = false;
581 this.error = null;
582 this.selectedModelId = null;
583 this.selectedModelName = null;
584 this.modelUsage.clear();
585 this.modelLoadingStates.clear();
586 this.modelPropsCache.clear();
587 this.modelPropsFetching.clear();
588 }
589}
590
591export const modelsStore = new ModelsStore();
592
593export const modelOptions = () => modelsStore.models;
594export const routerModels = () => modelsStore.routerModels;
595export const modelsLoading = () => modelsStore.loading;
596export const modelsUpdating = () => modelsStore.updating;
597export const modelsError = () => modelsStore.error;
598export const selectedModelId = () => modelsStore.selectedModelId;
599export const selectedModelName = () => modelsStore.selectedModelName;
600export const selectedModelOption = () => modelsStore.selectedModel;
601export const loadedModelIds = () => modelsStore.loadedModelIds;
602export const loadingModelIds = () => modelsStore.loadingModelIds;
603export const propsCacheVersion = () => modelsStore.propsCacheVersion;
604export const singleModelName = () => modelsStore.singleModelName;
605export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts
new file mode 100644
index 0000000..1e07f80
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts
@@ -0,0 +1,50 @@
1import { browser } from '$app/environment';
2
3type PersistedValue<T> = {
4 get value(): T;
5 set value(newValue: T);
6};
7
8export function persisted<T>(key: string, initialValue: T): PersistedValue<T> {
9 let value = initialValue;
10
11 if (browser) {
12 try {
13 const stored = localStorage.getItem(key);
14
15 if (stored !== null) {
16 value = JSON.parse(stored) as T;
17 }
18 } catch (error) {
19 console.warn(`Failed to load ${key}:`, error);
20 }
21 }
22
23 const persist = (next: T) => {
24 if (!browser) {
25 return;
26 }
27
28 try {
29 if (next === null || next === undefined) {
30 localStorage.removeItem(key);
31 return;
32 }
33
34 localStorage.setItem(key, JSON.stringify(next));
35 } catch (error) {
36 console.warn(`Failed to persist ${key}:`, error);
37 }
38 };
39
40 return {
41 get value() {
42 return value;
43 },
44
45 set value(newValue: T) {
46 value = newValue;
47 persist(newValue);
48 }
49 };
50}
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts
new file mode 100644
index 0000000..facfd33
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts
@@ -0,0 +1,140 @@
1import { PropsService } from '$lib/services/props';
2import { ServerRole } from '$lib/enums';
3
4/**
5 * serverStore - Server connection state, configuration, and role detection
6 *
7 * This store manages the server connection state and properties fetched from `/props`.
8 * It provides reactive state for server configuration and role detection.
9 *
10 * **Architecture & Relationships:**
11 * - **PropsService**: Stateless service for fetching `/props` data
12 * - **serverStore** (this class): Reactive store for server state
13 * - **modelsStore**: Independent store for model management (uses PropsService directly)
14 *
15 * **Key Features:**
16 * - **Server State**: Connection status, loading, error handling
17 * - **Role Detection**: MODEL (single model) vs ROUTER (multi-model)
18 * - **Default Params**: Server-wide generation defaults
19 */
20class ServerStore {
21 // ─────────────────────────────────────────────────────────────────────────────
22 // State
23 // ─────────────────────────────────────────────────────────────────────────────
24
25 props = $state<ApiLlamaCppServerProps | null>(null);
26 loading = $state(false);
27 error = $state<string | null>(null);
28 role = $state<ServerRole | null>(null);
29 private fetchPromise: Promise<void> | null = null;
30
31 // ─────────────────────────────────────────────────────────────────────────────
32 // Getters
33 // ─────────────────────────────────────────────────────────────────────────────
34
35 get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
36 return this.props?.default_generation_settings?.params || null;
37 }
38
39 get contextSize(): number | null {
40 return this.props?.default_generation_settings?.n_ctx ?? null;
41 }
42
43 get webuiSettings(): Record<string, string | number | boolean> | undefined {
44 return this.props?.webui_settings;
45 }
46
47 get isRouterMode(): boolean {
48 return this.role === ServerRole.ROUTER;
49 }
50
51 get isModelMode(): boolean {
52 return this.role === ServerRole.MODEL;
53 }
54
55 // ─────────────────────────────────────────────────────────────────────────────
56 // Data Handling
57 // ─────────────────────────────────────────────────────────────────────────────
58
59 async fetch(): Promise<void> {
60 if (this.fetchPromise) return this.fetchPromise;
61
62 this.loading = true;
63 this.error = null;
64
65 const fetchPromise = (async () => {
66 try {
67 const props = await PropsService.fetch();
68 this.props = props;
69 this.error = null;
70 this.detectRole(props);
71 } catch (error) {
72 this.error = this.getErrorMessage(error);
73 console.error('Error fetching server properties:', error);
74 } finally {
75 this.loading = false;
76 this.fetchPromise = null;
77 }
78 })();
79
80 this.fetchPromise = fetchPromise;
81 await fetchPromise;
82 }
83
84 private getErrorMessage(error: unknown): string {
85 if (error instanceof Error) {
86 const message = error.message || '';
87
88 if (error.name === 'TypeError' && message.includes('fetch')) {
89 return 'Server is not running or unreachable';
90 } else if (message.includes('ECONNREFUSED')) {
91 return 'Connection refused - server may be offline';
92 } else if (message.includes('ENOTFOUND')) {
93 return 'Server not found - check server address';
94 } else if (message.includes('ETIMEDOUT')) {
95 return 'Request timed out';
96 } else if (message.includes('503')) {
97 return 'Server temporarily unavailable';
98 } else if (message.includes('500')) {
99 return 'Server error - check server logs';
100 } else if (message.includes('404')) {
101 return 'Server endpoint not found';
102 } else if (message.includes('403') || message.includes('401')) {
103 return 'Access denied';
104 }
105 }
106
107 return 'Failed to connect to server';
108 }
109
110 clear(): void {
111 this.props = null;
112 this.error = null;
113 this.loading = false;
114 this.role = null;
115 this.fetchPromise = null;
116 }
117
118 // ─────────────────────────────────────────────────────────────────────────────
119 // Utilities
120 // ─────────────────────────────────────────────────────────────────────────────
121
122 private detectRole(props: ApiLlamaCppServerProps): void {
123 const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
124 if (this.role !== newRole) {
125 this.role = newRole;
126 console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`);
127 }
128 }
129}
130
131export const serverStore = new ServerStore();
132
133export const serverProps = () => serverStore.props;
134export const serverLoading = () => serverStore.loading;
135export const serverError = () => serverStore.error;
136export const serverRole = () => serverStore.role;
137export const defaultParams = () => serverStore.defaultParams;
138export const contextSize = () => serverStore.contextSize;
139export const isRouterMode = () => serverStore.isRouterMode;
140export const isModelMode = () => serverStore.isModelMode;
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts
new file mode 100644
index 0000000..cda940b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts
@@ -0,0 +1,421 @@
1/**
2 * settingsStore - Application configuration and theme management
3 *
4 * This store manages all application settings including AI model parameters, UI preferences,
5 * and theme configuration. It provides persistent storage through localStorage with reactive
6 * state management using Svelte 5 runes.
7 *
8 * **Architecture & Relationships:**
9 * - **settingsStore** (this class): Configuration state management
10 * - Manages AI model parameters (temperature, max tokens, etc.)
11 * - Handles theme switching and persistence
12 * - Provides localStorage synchronization
13 * - Offers reactive configuration access
14 *
15 * - **ChatService**: Reads model parameters for API requests
16 * - **UI Components**: Subscribe to theme and configuration changes
17 *
18 * **Key Features:**
19 * - **Model Parameters**: Temperature, max tokens, top-p, top-k, repeat penalty
20 * - **Theme Management**: Auto, light, dark theme switching
21 * - **Persistence**: Automatic localStorage synchronization
22 * - **Reactive State**: Svelte 5 runes for automatic UI updates
23 * - **Default Handling**: Graceful fallback to defaults for missing settings
24 * - **Batch Updates**: Efficient multi-setting updates
25 * - **Reset Functionality**: Restore defaults for individual or all settings
26 *
27 * **Configuration Categories:**
28 * - Generation parameters (temperature, tokens, sampling)
29 * - UI preferences (theme, display options)
30 * - System settings (model selection, prompts)
31 * - Advanced options (seed, penalties, context handling)
32 */
33
34import { browser } from '$app/environment';
35import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
36import { ParameterSyncService } from '$lib/services/parameter-sync';
37import { serverStore } from '$lib/stores/server.svelte';
38import {
39 configToParameterRecord,
40 normalizeFloatingPoint,
41 getConfigValue,
42 setConfigValue
43} from '$lib/utils';
44import {
45 CONFIG_LOCALSTORAGE_KEY,
46 USER_OVERRIDES_LOCALSTORAGE_KEY
47} from '$lib/constants/localstorage-keys';
48
49class SettingsStore {
50 // ─────────────────────────────────────────────────────────────────────────────
51 // State
52 // ─────────────────────────────────────────────────────────────────────────────
53
54 config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
55 theme = $state<string>('auto');
56 isInitialized = $state(false);
57 userOverrides = $state<Set<string>>(new Set());
58
59 // ─────────────────────────────────────────────────────────────────────────────
60 // Utilities (private helpers)
61 // ─────────────────────────────────────────────────────────────────────────────
62
63 /**
64 * Helper method to get server defaults with null safety
65 * Centralizes the pattern of getting and extracting server defaults
66 */
67 private getServerDefaults(): Record<string, string | number | boolean> {
68 const serverParams = serverStore.defaultParams;
69 const webuiSettings = serverStore.webuiSettings;
70 return ParameterSyncService.extractServerDefaults(serverParams, webuiSettings);
71 }
72
73 constructor() {
74 if (browser) {
75 this.initialize();
76 }
77 }
78
79 // ─────────────────────────────────────────────────────────────────────────────
80 // Lifecycle
81 // ─────────────────────────────────────────────────────────────────────────────
82
83 /**
84 * Initialize the settings store by loading from localStorage
85 */
86 initialize() {
87 try {
88 this.loadConfig();
89 this.loadTheme();
90 this.isInitialized = true;
91 } catch (error) {
92 console.error('Failed to initialize settings store:', error);
93 }
94 }
95
96 /**
97 * Load configuration from localStorage
98 * Returns default values for missing keys to prevent breaking changes
99 */
100 private loadConfig() {
101 if (!browser) return;
102
103 try {
104 const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
105 const savedVal = JSON.parse(storedConfigRaw || '{}');
106
107 // Merge with defaults to prevent breaking changes
108 this.config = {
109 ...SETTING_CONFIG_DEFAULT,
110 ...savedVal
111 };
112
113 // Load user overrides
114 const savedOverrides = JSON.parse(
115 localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
116 );
117 this.userOverrides = new Set(savedOverrides);
118 } catch (error) {
119 console.warn('Failed to parse config from localStorage, using defaults:', error);
120 this.config = { ...SETTING_CONFIG_DEFAULT };
121 this.userOverrides = new Set();
122 }
123 }
124
125 /**
126 * Load theme from localStorage
127 */
128 private loadTheme() {
129 if (!browser) return;
130
131 this.theme = localStorage.getItem('theme') || 'auto';
132 }
133 // ─────────────────────────────────────────────────────────────────────────────
134 // Config Updates
135 // ─────────────────────────────────────────────────────────────────────────────
136
137 /**
138 * Update a specific configuration setting
139 * @param key - The configuration key to update
140 * @param value - The new value for the configuration key
141 */
142 updateConfig<K extends keyof SettingsConfigType>(key: K, value: SettingsConfigType[K]): void {
143 this.config[key] = value;
144
145 if (ParameterSyncService.canSyncParameter(key as string)) {
146 const propsDefaults = this.getServerDefaults();
147 const propsDefault = propsDefaults[key as string];
148
149 if (propsDefault !== undefined) {
150 const normalizedValue = normalizeFloatingPoint(value);
151 const normalizedDefault = normalizeFloatingPoint(propsDefault);
152
153 if (normalizedValue === normalizedDefault) {
154 this.userOverrides.delete(key as string);
155 } else {
156 this.userOverrides.add(key as string);
157 }
158 }
159 }
160
161 this.saveConfig();
162 }
163
164 /**
165 * Update multiple configuration settings at once
166 * @param updates - Object containing the configuration updates
167 */
168 updateMultipleConfig(updates: Partial<SettingsConfigType>) {
169 Object.assign(this.config, updates);
170
171 const propsDefaults = this.getServerDefaults();
172
173 for (const [key, value] of Object.entries(updates)) {
174 if (ParameterSyncService.canSyncParameter(key)) {
175 const propsDefault = propsDefaults[key];
176
177 if (propsDefault !== undefined) {
178 const normalizedValue = normalizeFloatingPoint(value);
179 const normalizedDefault = normalizeFloatingPoint(propsDefault);
180
181 if (normalizedValue === normalizedDefault) {
182 this.userOverrides.delete(key);
183 } else {
184 this.userOverrides.add(key);
185 }
186 }
187 }
188 }
189
190 this.saveConfig();
191 }
192
193 /**
194 * Save the current configuration to localStorage
195 */
196 private saveConfig() {
197 if (!browser) return;
198
199 try {
200 localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config));
201
202 localStorage.setItem(
203 USER_OVERRIDES_LOCALSTORAGE_KEY,
204 JSON.stringify(Array.from(this.userOverrides))
205 );
206 } catch (error) {
207 console.error('Failed to save config to localStorage:', error);
208 }
209 }
210
211 /**
212 * Update the theme setting
213 * @param newTheme - The new theme value
214 */
215 updateTheme(newTheme: string) {
216 this.theme = newTheme;
217 this.saveTheme();
218 }
219
220 /**
221 * Save the current theme to localStorage
222 */
223 private saveTheme() {
224 if (!browser) return;
225
226 try {
227 if (this.theme === 'auto') {
228 localStorage.removeItem('theme');
229 } else {
230 localStorage.setItem('theme', this.theme);
231 }
232 } catch (error) {
233 console.error('Failed to save theme to localStorage:', error);
234 }
235 }
236
237 // ─────────────────────────────────────────────────────────────────────────────
238 // Reset
239 // ─────────────────────────────────────────────────────────────────────────────
240
241 /**
242 * Reset configuration to defaults
243 */
244 resetConfig() {
245 this.config = { ...SETTING_CONFIG_DEFAULT };
246 this.saveConfig();
247 }
248
249 /**
250 * Reset theme to auto
251 */
252 resetTheme() {
253 this.theme = 'auto';
254 this.saveTheme();
255 }
256
257 /**
258 * Reset all settings to defaults
259 */
260 resetAll() {
261 this.resetConfig();
262 this.resetTheme();
263 }
264
265 /**
266 * Reset a parameter to server default (or webui default if no server default)
267 */
268 resetParameterToServerDefault(key: string): void {
269 const serverDefaults = this.getServerDefaults();
270
271 if (serverDefaults[key] !== undefined) {
272 const value = normalizeFloatingPoint(serverDefaults[key]);
273
274 this.config[key as keyof SettingsConfigType] =
275 value as SettingsConfigType[keyof SettingsConfigType];
276 } else {
277 if (key in SETTING_CONFIG_DEFAULT) {
278 const defaultValue = getConfigValue(SETTING_CONFIG_DEFAULT, key);
279
280 setConfigValue(this.config, key, defaultValue);
281 }
282 }
283
284 this.userOverrides.delete(key);
285 this.saveConfig();
286 }
287
288 // ─────────────────────────────────────────────────────────────────────────────
289 // Server Sync
290 // ─────────────────────────────────────────────────────────────────────────────
291
292 /**
293 * Initialize settings with props defaults when server properties are first loaded
294 * This sets up the default values from /props endpoint
295 */
296 syncWithServerDefaults(): void {
297 const propsDefaults = this.getServerDefaults();
298
299 if (Object.keys(propsDefaults).length === 0) {
300 console.warn('No server defaults available for initialization');
301
302 return;
303 }
304
305 for (const [key, propsValue] of Object.entries(propsDefaults)) {
306 const currentValue = getConfigValue(this.config, key);
307
308 const normalizedCurrent = normalizeFloatingPoint(currentValue);
309 const normalizedDefault = normalizeFloatingPoint(propsValue);
310
311 if (normalizedCurrent === normalizedDefault) {
312 this.userOverrides.delete(key);
313 setConfigValue(this.config, key, propsValue);
314 } else if (!this.userOverrides.has(key)) {
315 setConfigValue(this.config, key, propsValue);
316 }
317 }
318
319 this.saveConfig();
320 console.log('Settings initialized with props defaults:', propsDefaults);
321 console.log('Current user overrides after sync:', Array.from(this.userOverrides));
322 }
323
324 /**
325 * Reset all parameters to their default values (from props)
326 * This is used by the "Reset to Default" functionality
327 * Prioritizes server defaults from /props, falls back to webui defaults
328 */
329 forceSyncWithServerDefaults(): void {
330 const propsDefaults = this.getServerDefaults();
331 const syncableKeys = ParameterSyncService.getSyncableParameterKeys();
332
333 for (const key of syncableKeys) {
334 if (propsDefaults[key] !== undefined) {
335 const normalizedValue = normalizeFloatingPoint(propsDefaults[key]);
336
337 setConfigValue(this.config, key, normalizedValue);
338 } else {
339 if (key in SETTING_CONFIG_DEFAULT) {
340 const defaultValue = getConfigValue(SETTING_CONFIG_DEFAULT, key);
341
342 setConfigValue(this.config, key, defaultValue);
343 }
344 }
345
346 this.userOverrides.delete(key);
347 }
348
349 this.saveConfig();
350 }
351
352 // ─────────────────────────────────────────────────────────────────────────────
353 // Utilities
354 // ─────────────────────────────────────────────────────────────────────────────
355
356 /**
357 * Get a specific configuration value
358 * @param key - The configuration key to get
359 * @returns The configuration value
360 */
361 getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
362 return this.config[key];
363 }
364
365 /**
366 * Get the entire configuration object
367 * @returns The complete configuration object
368 */
369 getAllConfig(): SettingsConfigType {
370 return { ...this.config };
371 }
372
373 canSyncParameter(key: string): boolean {
374 return ParameterSyncService.canSyncParameter(key);
375 }
376
377 /**
378 * Get parameter information including source for a specific parameter
379 */
380 getParameterInfo(key: string) {
381 const propsDefaults = this.getServerDefaults();
382 const currentValue = getConfigValue(this.config, key);
383
384 return ParameterSyncService.getParameterInfo(
385 key,
386 currentValue ?? '',
387 propsDefaults,
388 this.userOverrides
389 );
390 }
391
392 /**
393 * Get diff between current settings and server defaults
394 */
395 getParameterDiff() {
396 const serverDefaults = this.getServerDefaults();
397 if (Object.keys(serverDefaults).length === 0) return {};
398
399 const configAsRecord = configToParameterRecord(
400 this.config,
401 ParameterSyncService.getSyncableParameterKeys()
402 );
403
404 return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults);
405 }
406
407 /**
408 * Clear all user overrides (for debugging)
409 */
410 clearAllUserOverrides(): void {
411 this.userOverrides.clear();
412 this.saveConfig();
413 console.log('Cleared all user overrides');
414 }
415}
416
417export const settingsStore = new SettingsStore();
418
419export const config = () => settingsStore.config;
420export const theme = () => settingsStore.theme;
421export const isInitialized = () => settingsStore.isInitialized;
diff --git a/llama.cpp/tools/server/webui/src/lib/types/api.d.ts b/llama.cpp/tools/server/webui/src/lib/types/api.d.ts
new file mode 100644
index 0000000..714509f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/types/api.d.ts
@@ -0,0 +1,430 @@
1import type { ServerModelStatus, ServerRole } from '$lib/enums';
2import type { ChatMessagePromptProgress } from './chat';
3
4export interface ApiChatMessageContentPart {
5 type: 'text' | 'image_url' | 'input_audio';
6 text?: string;
7 image_url?: {
8 url: string;
9 };
10 input_audio?: {
11 data: string;
12 format: 'wav' | 'mp3';
13 };
14}
15
16export interface ApiContextSizeError {
17 code: number;
18 message: string;
19 type: 'exceed_context_size_error';
20 n_prompt_tokens: number;
21 n_ctx: number;
22}
23
24export interface ApiErrorResponse {
25 error:
26 | ApiContextSizeError
27 | {
28 code: number;
29 message: string;
30 type?: string;
31 };
32}
33
34export interface ApiChatMessageData {
35 role: ChatRole;
36 content: string | ApiChatMessageContentPart[];
37 timestamp?: number;
38}
39
40/**
41 * Model status object from /models endpoint
42 */
43export interface ApiModelStatus {
44 /** Status value: loaded, unloaded, loading, failed */
45 value: ServerModelStatus;
46 /** Command line arguments used when loading (only for loaded models) */
47 args?: string[];
48}
49
50/**
51 * Model entry from /models endpoint (ROUTER mode)
52 * Based on actual API response structure
53 */
54export interface ApiModelDataEntry {
55 /** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */
56 id: string;
57 /** Model name (optional, usually same as id - not always returned by API) */
58 name?: string;
59 /** Object type, always "model" */
60 object: string;
61 /** Owner, usually "llamacpp" */
62 owned_by: string;
63 /** Creation timestamp */
64 created: number;
65 /** Whether model files are in HuggingFace cache */
66 in_cache: boolean;
67 /** Path to model manifest file */
68 path: string;
69 /** Current status of the model */
70 status: ApiModelStatus;
71 /** Legacy meta field (may be present in older responses) */
72 meta?: Record<string, unknown> | null;
73}
74
75export interface ApiModelDetails {
76 name: string;
77 model: string;
78 modified_at?: string;
79 size?: string | number;
80 digest?: string;
81 type?: string;
82 description?: string;
83 tags?: string[];
84 capabilities?: string[];
85 parameters?: string;
86 details?: {
87 parent_model?: string;
88 format?: string;
89 family?: string;
90 families?: string[];
91 parameter_size?: string;
92 quantization_level?: string;
93 };
94}
95
96export interface ApiModelListResponse {
97 object: string;
98 data: ApiModelDataEntry[];
99 models?: ApiModelDetails[];
100}
101
102export interface ApiLlamaCppServerProps {
103 default_generation_settings: {
104 id: number;
105 id_task: number;
106 n_ctx: number;
107 speculative: boolean;
108 is_processing: boolean;
109 params: {
110 n_predict: number;
111 seed: number;
112 temperature: number;
113 dynatemp_range: number;
114 dynatemp_exponent: number;
115 top_k: number;
116 top_p: number;
117 min_p: number;
118 top_n_sigma: number;
119 xtc_probability: number;
120 xtc_threshold: number;
121 typ_p: number;
122 repeat_last_n: number;
123 repeat_penalty: number;
124 presence_penalty: number;
125 frequency_penalty: number;
126 dry_multiplier: number;
127 dry_base: number;
128 dry_allowed_length: number;
129 dry_penalty_last_n: number;
130 dry_sequence_breakers: string[];
131 mirostat: number;
132 mirostat_tau: number;
133 mirostat_eta: number;
134 stop: string[];
135 max_tokens: number;
136 n_keep: number;
137 n_discard: number;
138 ignore_eos: boolean;
139 stream: boolean;
140 logit_bias: Array<[number, number]>;
141 n_probs: number;
142 min_keep: number;
143 grammar: string;
144 grammar_lazy: boolean;
145 grammar_triggers: string[];
146 preserved_tokens: number[];
147 chat_format: string;
148 reasoning_format: string;
149 reasoning_in_content: boolean;
150 thinking_forced_open: boolean;
151 samplers: string[];
152 backend_sampling: boolean;
153 'speculative.n_max': number;
154 'speculative.n_min': number;
155 'speculative.p_min': number;
156 timings_per_token: boolean;
157 post_sampling_probs: boolean;
158 lora: Array<{ name: string; scale: number }>;
159 };
160 prompt: string;
161 next_token: {
162 has_next_token: boolean;
163 has_new_line: boolean;
164 n_remain: number;
165 n_decoded: number;
166 stopping_word: string;
167 };
168 };
169 total_slots: number;
170 model_path: string;
171 role: ServerRole;
172 modalities: {
173 vision: boolean;
174 audio: boolean;
175 };
176 chat_template: string;
177 bos_token: string;
178 eos_token: string;
179 build_info: string;
180 webui_settings?: Record<string, string | number | boolean>;
181}
182
183export interface ApiChatCompletionRequest {
184 messages: Array<{
185 role: ChatRole;
186 content: string | ApiChatMessageContentPart[];
187 }>;
188 stream?: boolean;
189 model?: string;
190 return_progress?: boolean;
191 // Reasoning parameters
192 reasoning_format?: string;
193 // Generation parameters
194 temperature?: number;
195 max_tokens?: number;
196 // Sampling parameters
197 dynatemp_range?: number;
198 dynatemp_exponent?: number;
199 top_k?: number;
200 top_p?: number;
201 min_p?: number;
202 xtc_probability?: number;
203 xtc_threshold?: number;
204 typ_p?: number;
205 // Penalty parameters
206 repeat_last_n?: number;
207 repeat_penalty?: number;
208 presence_penalty?: number;
209 frequency_penalty?: number;
210 dry_multiplier?: number;
211 dry_base?: number;
212 dry_allowed_length?: number;
213 dry_penalty_last_n?: number;
214 // Sampler configuration
215 samplers?: string[];
216 backend_sampling?: boolean;
217 // Custom parameters (JSON string)
218 custom?: Record<string, unknown>;
219 timings_per_token?: boolean;
220}
221
222export interface ApiChatCompletionToolCallFunctionDelta {
223 name?: string;
224 arguments?: string;
225}
226
227export interface ApiChatCompletionToolCallDelta {
228 index?: number;
229 id?: string;
230 type?: string;
231 function?: ApiChatCompletionToolCallFunctionDelta;
232}
233
234export interface ApiChatCompletionToolCall extends ApiChatCompletionToolCallDelta {
235 function?: ApiChatCompletionToolCallFunctionDelta & { arguments?: string };
236}
237
238export interface ApiChatCompletionStreamChunk {
239 object?: string;
240 model?: string;
241 choices: Array<{
242 model?: string;
243 metadata?: { model?: string };
244 delta: {
245 content?: string;
246 reasoning_content?: string;
247 model?: string;
248 tool_calls?: ApiChatCompletionToolCallDelta[];
249 };
250 }>;
251 timings?: {
252 prompt_n?: number;
253 prompt_ms?: number;
254 predicted_n?: number;
255 predicted_ms?: number;
256 cache_n?: number;
257 };
258 prompt_progress?: ChatMessagePromptProgress;
259}
260
261export interface ApiChatCompletionResponse {
262 model?: string;
263 choices: Array<{
264 model?: string;
265 metadata?: { model?: string };
266 message: {
267 content: string;
268 reasoning_content?: string;
269 model?: string;
270 tool_calls?: ApiChatCompletionToolCallDelta[];
271 };
272 }>;
273}
274
275export interface ApiSlotData {
276 id: number;
277 id_task: number;
278 n_ctx: number;
279 speculative: boolean;
280 is_processing: boolean;
281 params: {
282 n_predict: number;
283 seed: number;
284 temperature: number;
285 dynatemp_range: number;
286 dynatemp_exponent: number;
287 top_k: number;
288 top_p: number;
289 min_p: number;
290 top_n_sigma: number;
291 xtc_probability: number;
292 xtc_threshold: number;
293 typical_p: number;
294 repeat_last_n: number;
295 repeat_penalty: number;
296 presence_penalty: number;
297 frequency_penalty: number;
298 dry_multiplier: number;
299 dry_base: number;
300 dry_allowed_length: number;
301 dry_penalty_last_n: number;
302 mirostat: number;
303 mirostat_tau: number;
304 mirostat_eta: number;
305 max_tokens: number;
306 n_keep: number;
307 n_discard: number;
308 ignore_eos: boolean;
309 stream: boolean;
310 n_probs: number;
311 min_keep: number;
312 chat_format: string;
313 reasoning_format: string;
314 reasoning_in_content: boolean;
315 thinking_forced_open: boolean;
316 samplers: string[];
317 backend_sampling: boolean;
318 'speculative.n_max': number;
319 'speculative.n_min': number;
320 'speculative.p_min': number;
321 timings_per_token: boolean;
322 post_sampling_probs: boolean;
323 lora: Array<{ name: string; scale: number }>;
324 };
325 next_token: {
326 has_next_token: boolean;
327 has_new_line: boolean;
328 n_remain: number;
329 n_decoded: number;
330 };
331}
332
333export interface ApiProcessingState {
334 status: 'initializing' | 'generating' | 'preparing' | 'idle';
335 tokensDecoded: number;
336 tokensRemaining: number;
337 contextUsed: number;
338 contextTotal: number;
339 outputTokensUsed: number; // Total output tokens (thinking + regular content)
340 outputTokensMax: number; // Max output tokens allowed
341 temperature: number;
342 topP: number;
343 speculative: boolean;
344 hasNextToken: boolean;
345 tokensPerSecond?: number;
346 // Progress information from prompt_progress
347 progressPercent?: number;
348 promptProgress?: ChatMessagePromptProgress;
349 promptTokens?: number;
350 promptMs?: number;
351 cacheTokens?: number;
352}
353
354/**
355 * Router model metadata - extended from ApiModelDataEntry with additional router-specific fields
356 * @deprecated Use ApiModelDataEntry instead - the /models endpoint returns this structure directly
357 */
358export interface ApiRouterModelMeta {
359 /** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */
360 name: string;
361 /** Path to model file or manifest */
362 path: string;
363 /** Optional path to multimodal projector */
364 path_mmproj?: string;
365 /** Whether model is in HuggingFace cache */
366 in_cache: boolean;
367 /** Port where model instance is running (0 if not loaded) */
368 port?: number;
369 /** Current status of the model */
370 status: ApiModelStatus;
371 /** Error message if status is FAILED */
372 error?: string;
373}
374
375/**
376 * Request to load a model
377 */
378export interface ApiRouterModelsLoadRequest {
379 model: string;
380}
381
382/**
383 * Response from loading a model
384 */
385export interface ApiRouterModelsLoadResponse {
386 success: boolean;
387 error?: string;
388}
389
390/**
391 * Request to check model status
392 */
393export interface ApiRouterModelsStatusRequest {
394 model: string;
395}
396
397/**
398 * Response with model status
399 */
400export interface ApiRouterModelsStatusResponse {
401 model: string;
402 status: ModelStatus;
403 port?: number;
404 error?: string;
405}
406
407/**
408 * Response with list of all models from /models endpoint
409 * Note: This is the same as ApiModelListResponse - the endpoint returns the same structure
410 * regardless of server mode (MODEL or ROUTER)
411 */
412export interface ApiRouterModelsListResponse {
413 object: string;
414 data: ApiModelDataEntry[];
415}
416
417/**
418 * Request to unload a model
419 */
420export interface ApiRouterModelsUnloadRequest {
421 model: string;
422}
423
424/**
425 * Response from unloading a model
426 */
427export interface ApiRouterModelsUnloadResponse {
428 success: boolean;
429 error?: string;
430}
diff --git a/llama.cpp/tools/server/webui/src/lib/types/chat.d.ts b/llama.cpp/tools/server/webui/src/lib/types/chat.d.ts
new file mode 100644
index 0000000..0e706b7
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/types/chat.d.ts
@@ -0,0 +1,55 @@
1export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
2export type ChatRole = 'user' | 'assistant' | 'system';
3
4export interface ChatUploadedFile {
5 id: string;
6 name: string;
7 size: number;
8 type: string;
9 file: File;
10 preview?: string;
11 textContent?: string;
12}
13
14export interface ChatAttachmentDisplayItem {
15 id: string;
16 name: string;
17 size?: number;
18 preview?: string;
19 isImage: boolean;
20 uploadedFile?: ChatUploadedFile;
21 attachment?: DatabaseMessageExtra;
22 attachmentIndex?: number;
23 textContent?: string;
24}
25
26export interface ChatAttachmentPreviewItem {
27 uploadedFile?: ChatUploadedFile;
28 attachment?: DatabaseMessageExtra;
29 preview?: string;
30 name?: string;
31 size?: number;
32 textContent?: string;
33}
34
35export interface ChatMessageSiblingInfo {
36 message: DatabaseMessage;
37 siblingIds: string[];
38 currentIndex: number;
39 totalSiblings: number;
40}
41
42export interface ChatMessagePromptProgress {
43 cache: number;
44 processed: number;
45 time_ms: number;
46 total: number;
47}
48
49export interface ChatMessageTimings {
50 cache_n?: number;
51 predicted_ms?: number;
52 predicted_n?: number;
53 prompt_ms?: number;
54 prompt_n?: number;
55}
diff --git a/llama.cpp/tools/server/webui/src/lib/types/database.d.ts b/llama.cpp/tools/server/webui/src/lib/types/database.d.ts
new file mode 100644
index 0000000..1a336e0
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/types/database.d.ts
@@ -0,0 +1,85 @@
1import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat';
2import { AttachmentType } from '$lib/enums';
3
4export interface DatabaseConversation {
5 currNode: string | null;
6 id: string;
7 lastModified: number;
8 name: string;
9}
10
11export interface DatabaseMessageExtraAudioFile {
12 type: AttachmentType.AUDIO;
13 name: string;
14 base64Data: string;
15 mimeType: string;
16}
17
18export interface DatabaseMessageExtraImageFile {
19 type: AttachmentType.IMAGE;
20 name: string;
21 base64Url: string;
22}
23
24/**
25 * Legacy format from old webui - pasted content was stored as "context" type
26 * @deprecated Use DatabaseMessageExtraTextFile instead
27 */
28export interface DatabaseMessageExtraLegacyContext {
29 type: AttachmentType.LEGACY_CONTEXT;
30 name: string;
31 content: string;
32}
33
34export interface DatabaseMessageExtraPdfFile {
35 type: AttachmentType.PDF;
36 base64Data: string;
37 name: string;
38 content: string; // Text content extracted from PDF
39 images?: string[]; // Optional: PDF pages as base64 images
40 processedAsImages: boolean; // Whether PDF was processed as images
41}
42
43export interface DatabaseMessageExtraTextFile {
44 type: AttachmentType.TEXT;
45 name: string;
46 content: string;
47}
48
49export type DatabaseMessageExtra =
50 | DatabaseMessageExtraImageFile
51 | DatabaseMessageExtraTextFile
52 | DatabaseMessageExtraAudioFile
53 | DatabaseMessageExtraPdfFile
54 | DatabaseMessageExtraLegacyContext;
55
56export interface DatabaseMessage {
57 id: string;
58 convId: string;
59 type: ChatMessageType;
60 timestamp: number;
61 role: ChatRole;
62 content: string;
63 parent: string;
64 thinking: string;
65 toolCalls?: string;
66 children: string[];
67 extra?: DatabaseMessageExtra[];
68 timings?: ChatMessageTimings;
69 model?: string;
70}
71
72/**
73 * Represents a single conversation with its associated messages,
74 * typically used for import/export operations.
75 */
76export type ExportedConversation = {
77 conv: DatabaseConversation;
78 messages: DatabaseMessage[];
79};
80
81/**
82 * Type representing one or more exported conversations.
83 * Can be a single conversation object or an array of them.
84 */
85export type ExportedConversations = ExportedConversation | ExportedConversation[];
diff --git a/llama.cpp/tools/server/webui/src/lib/types/index.ts b/llama.cpp/tools/server/webui/src/lib/types/index.ts
new file mode 100644
index 0000000..2a21c6d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/types/index.ts
@@ -0,0 +1,70 @@
1/**
2 * Unified exports for all type definitions
3 * Import types from '$lib/types' for cleaner imports
4 */
5
6// API types
7export type {
8 ApiChatMessageContentPart,
9 ApiContextSizeError,
10 ApiErrorResponse,
11 ApiChatMessageData,
12 ApiModelStatus,
13 ApiModelDataEntry,
14 ApiModelDetails,
15 ApiModelListResponse,
16 ApiLlamaCppServerProps,
17 ApiChatCompletionRequest,
18 ApiChatCompletionToolCallFunctionDelta,
19 ApiChatCompletionToolCallDelta,
20 ApiChatCompletionToolCall,
21 ApiChatCompletionStreamChunk,
22 ApiChatCompletionResponse,
23 ApiSlotData,
24 ApiProcessingState,
25 ApiRouterModelMeta,
26 ApiRouterModelsLoadRequest,
27 ApiRouterModelsLoadResponse,
28 ApiRouterModelsStatusRequest,
29 ApiRouterModelsStatusResponse,
30 ApiRouterModelsListResponse,
31 ApiRouterModelsUnloadRequest,
32 ApiRouterModelsUnloadResponse
33} from './api';
34
35// Chat types
36export type {
37 ChatMessageType,
38 ChatRole,
39 ChatUploadedFile,
40 ChatAttachmentDisplayItem,
41 ChatAttachmentPreviewItem,
42 ChatMessageSiblingInfo,
43 ChatMessagePromptProgress,
44 ChatMessageTimings
45} from './chat';
46
47// Database types
48export type {
49 DatabaseConversation,
50 DatabaseMessageExtraAudioFile,
51 DatabaseMessageExtraImageFile,
52 DatabaseMessageExtraLegacyContext,
53 DatabaseMessageExtraPdfFile,
54 DatabaseMessageExtraTextFile,
55 DatabaseMessageExtra,
56 DatabaseMessage,
57 ExportedConversation,
58 ExportedConversations
59} from './database';
60
61// Model types
62export type { ModelModalities, ModelOption } from './models';
63
64// Settings types
65export type {
66 SettingsConfigValue,
67 SettingsFieldConfig,
68 SettingsChatServiceOptions,
69 SettingsConfigType
70} from './settings';
diff --git a/llama.cpp/tools/server/webui/src/lib/types/models.d.ts b/llama.cpp/tools/server/webui/src/lib/types/models.d.ts
new file mode 100644
index 0000000..ef44a2c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/types/models.d.ts
@@ -0,0 +1,21 @@
1import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
2
3/**
4 * Model modalities - vision and audio capabilities
5 */
6export interface ModelModalities {
7 vision: boolean;
8 audio: boolean;
9}
10
11export interface ModelOption {
12 id: string;
13 name: string;
14 model: string;
15 description?: string;
16 capabilities: string[];
17 /** Model modalities from /props endpoint */
18 modalities?: ModelModalities;
19 details?: ApiModelDetails['details'];
20 meta?: ApiModelDataEntry['meta'];
21}
diff --git a/llama.cpp/tools/server/webui/src/lib/types/settings.d.ts b/llama.cpp/tools/server/webui/src/lib/types/settings.d.ts
new file mode 100644
index 0000000..38b3047
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/types/settings.d.ts
@@ -0,0 +1,67 @@
1import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
2import type { ChatMessageTimings } from './chat';
3
4export type SettingsConfigValue = string | number | boolean;
5
6export interface SettingsFieldConfig {
7 key: string;
8 label: string;
9 type: 'input' | 'textarea' | 'checkbox' | 'select';
10 isExperimental?: boolean;
11 help?: string;
12 options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
13}
14
15export interface SettingsChatServiceOptions {
16 stream?: boolean;
17 // Model (required in ROUTER mode, optional in MODEL mode)
18 model?: string;
19 // System message to inject
20 systemMessage?: string;
21 // Disable reasoning format (use 'none' instead of 'auto')
22 disableReasoningFormat?: boolean;
23 // Generation parameters
24 temperature?: number;
25 max_tokens?: number;
26 // Sampling parameters
27 dynatemp_range?: number;
28 dynatemp_exponent?: number;
29 top_k?: number;
30 top_p?: number;
31 min_p?: number;
32 xtc_probability?: number;
33 xtc_threshold?: number;
34 typ_p?: number;
35 // Penalty parameters
36 repeat_last_n?: number;
37 repeat_penalty?: number;
38 presence_penalty?: number;
39 frequency_penalty?: number;
40 dry_multiplier?: number;
41 dry_base?: number;
42 dry_allowed_length?: number;
43 dry_penalty_last_n?: number;
44 // Sampler configuration
45 samplers?: string | string[];
46 backend_sampling?: boolean;
47 // Custom parameters
48 custom?: string;
49 timings_per_token?: boolean;
50 // Callbacks
51 onChunk?: (chunk: string) => void;
52 onReasoningChunk?: (chunk: string) => void;
53 onToolCallChunk?: (chunk: string) => void;
54 onModel?: (model: string) => void;
55 onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
56 onComplete?: (
57 response: string,
58 reasoningContent?: string,
59 timings?: ChatMessageTimings,
60 toolCalls?: string
61 ) => void;
62 onError?: (error: Error) => void;
63}
64
65export type SettingsConfigType = typeof SETTING_CONFIG_DEFAULT & {
66 [key: string]: SettingsConfigValue;
67};
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts b/llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts
new file mode 100644
index 0000000..77ce3e8
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/api-headers.ts
@@ -0,0 +1,22 @@
1import { config } from '$lib/stores/settings.svelte';
2
3/**
4 * Get authorization headers for API requests
5 * Includes Bearer token if API key is configured
6 */
7export function getAuthHeaders(): Record<string, string> {
8 const currentConfig = config();
9 const apiKey = currentConfig.apiKey?.toString().trim();
10
11 return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
12}
13
14/**
15 * Get standard JSON headers with optional authorization
16 */
17export function getJsonHeaders(): Record<string, string> {
18 return {
19 'Content-Type': 'application/json',
20 ...getAuthHeaders()
21 };
22}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts b/llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts
new file mode 100644
index 0000000..948b7d7
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/api-key-validation.ts
@@ -0,0 +1,45 @@
1import { base } from '$app/paths';
2import { error } from '@sveltejs/kit';
3import { browser } from '$app/environment';
4import { config } from '$lib/stores/settings.svelte';
5
6/**
7 * Validates API key by making a request to the server props endpoint
8 * Throws SvelteKit errors for authentication failures or server issues
9 */
10export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<void> {
11 if (!browser) {
12 return;
13 }
14
15 try {
16 const apiKey = config().apiKey;
17
18 const headers: Record<string, string> = {
19 'Content-Type': 'application/json'
20 };
21
22 if (apiKey) {
23 headers.Authorization = `Bearer ${apiKey}`;
24 }
25
26 const response = await fetch(`${base}/props`, { headers });
27
28 if (!response.ok) {
29 if (response.status === 401 || response.status === 403) {
30 throw error(401, 'Access denied');
31 }
32
33 console.warn(`Server responded with status ${response.status} during API key validation`);
34 return;
35 }
36 } catch (err) {
37 // If it's already a SvelteKit error, re-throw it
38 if (err && typeof err === 'object' && 'status' in err) {
39 throw err;
40 }
41
42 // Network or other errors
43 console.warn('Cannot connect to server for API key validation:', err);
44 }
45}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts b/llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts
new file mode 100644
index 0000000..750aaa3
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/attachment-display.ts
@@ -0,0 +1,61 @@
1import { FileTypeCategory } from '$lib/enums';
2import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
3
4export interface AttachmentDisplayItemsOptions {
5 uploadedFiles?: ChatUploadedFile[];
6 attachments?: DatabaseMessageExtra[];
7}
8
9/**
10 * Gets the file type category from an uploaded file, checking both MIME type and extension
11 */
12function getUploadedFileCategory(file: ChatUploadedFile): FileTypeCategory | null {
13 const categoryByMime = getFileTypeCategory(file.type);
14
15 if (categoryByMime) {
16 return categoryByMime;
17 }
18
19 return getFileTypeCategoryByExtension(file.name);
20}
21
22/**
23 * Creates a unified list of display items from uploaded files and stored attachments.
24 * Items are returned in reverse order (newest first).
25 */
26export function getAttachmentDisplayItems(
27 options: AttachmentDisplayItemsOptions
28): ChatAttachmentDisplayItem[] {
29 const { uploadedFiles = [], attachments = [] } = options;
30 const items: ChatAttachmentDisplayItem[] = [];
31
32 // Add uploaded files (ChatForm)
33 for (const file of uploadedFiles) {
34 items.push({
35 id: file.id,
36 name: file.name,
37 size: file.size,
38 preview: file.preview,
39 isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
40 uploadedFile: file,
41 textContent: file.textContent
42 });
43 }
44
45 // Add stored attachments (ChatMessage)
46 for (const [index, attachment] of attachments.entries()) {
47 const isImage = isImageFile(attachment);
48
49 items.push({
50 id: `attachment-${index}`,
51 name: attachment.name,
52 preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
53 isImage,
54 attachment,
55 attachmentIndex: index,
56 textContent: 'content' in attachment ? attachment.content : undefined
57 });
58 }
59
60 return items.reverse();
61}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts b/llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts
new file mode 100644
index 0000000..9e9f096
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/attachment-type.ts
@@ -0,0 +1,105 @@
1import { AttachmentType, FileTypeCategory } from '$lib/enums';
2import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils';
3
4/**
5 * Gets the file type category from an uploaded file, checking both MIME type and extension
6 * @param uploadedFile - The uploaded file to check
7 * @returns The file type category or null if not recognized
8 */
9function getUploadedFileCategory(uploadedFile: ChatUploadedFile): FileTypeCategory | null {
10 // First try MIME type
11 const categoryByMime = getFileTypeCategory(uploadedFile.type);
12
13 if (categoryByMime) {
14 return categoryByMime;
15 }
16
17 // Fallback to extension (browsers don't always provide correct MIME types)
18 return getFileTypeCategoryByExtension(uploadedFile.name);
19}
20
21/**
22 * Determines if an attachment or uploaded file is a text file
23 * @param uploadedFile - Optional uploaded file
24 * @param attachment - Optional database attachment
25 * @returns true if the file is a text file
26 */
27export function isTextFile(
28 attachment?: DatabaseMessageExtra,
29 uploadedFile?: ChatUploadedFile
30): boolean {
31 if (uploadedFile) {
32 return getUploadedFileCategory(uploadedFile) === FileTypeCategory.TEXT;
33 }
34
35 if (attachment) {
36 return (
37 attachment.type === AttachmentType.TEXT || attachment.type === AttachmentType.LEGACY_CONTEXT
38 );
39 }
40
41 return false;
42}
43
44/**
45 * Determines if an attachment or uploaded file is an image
46 * @param uploadedFile - Optional uploaded file
47 * @param attachment - Optional database attachment
48 * @returns true if the file is an image
49 */
50export function isImageFile(
51 attachment?: DatabaseMessageExtra,
52 uploadedFile?: ChatUploadedFile
53): boolean {
54 if (uploadedFile) {
55 return getUploadedFileCategory(uploadedFile) === FileTypeCategory.IMAGE;
56 }
57
58 if (attachment) {
59 return attachment.type === AttachmentType.IMAGE;
60 }
61
62 return false;
63}
64
65/**
66 * Determines if an attachment or uploaded file is a PDF
67 * @param uploadedFile - Optional uploaded file
68 * @param attachment - Optional database attachment
69 * @returns true if the file is a PDF
70 */
71export function isPdfFile(
72 attachment?: DatabaseMessageExtra,
73 uploadedFile?: ChatUploadedFile
74): boolean {
75 if (uploadedFile) {
76 return getUploadedFileCategory(uploadedFile) === FileTypeCategory.PDF;
77 }
78
79 if (attachment) {
80 return attachment.type === AttachmentType.PDF;
81 }
82
83 return false;
84}
85
86/**
87 * Determines if an attachment or uploaded file is an audio file
88 * @param uploadedFile - Optional uploaded file
89 * @param attachment - Optional database attachment
90 * @returns true if the file is an audio file
91 */
92export function isAudioFile(
93 attachment?: DatabaseMessageExtra,
94 uploadedFile?: ChatUploadedFile
95): boolean {
96 if (uploadedFile) {
97 return getUploadedFileCategory(uploadedFile) === FileTypeCategory.AUDIO;
98 }
99
100 if (attachment) {
101 return attachment.type === AttachmentType.AUDIO;
102 }
103
104 return false;
105}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts b/llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts
new file mode 100644
index 0000000..2a21985
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/audio-recording.ts
@@ -0,0 +1,226 @@
1import { MimeTypeAudio } from '$lib/enums';
2
3/**
4 * AudioRecorder - Browser-based audio recording with MediaRecorder API
5 *
6 * This class provides a complete audio recording solution using the browser's MediaRecorder API.
7 * It handles microphone access, recording state management, and audio format optimization.
8 *
9 * **Features:**
10 * - Automatic microphone permission handling
11 * - Audio enhancement (echo cancellation, noise suppression, auto gain)
12 * - Multiple format support with fallback (WAV, WebM, MP4, AAC)
13 * - Real-time recording state tracking
14 * - Proper cleanup and resource management
15 */
16export class AudioRecorder {
17 private mediaRecorder: MediaRecorder | null = null;
18 private audioChunks: Blob[] = [];
19 private stream: MediaStream | null = null;
20 private recordingState: boolean = false;
21
22 async startRecording(): Promise<void> {
23 try {
24 this.stream = await navigator.mediaDevices.getUserMedia({
25 audio: {
26 echoCancellation: true,
27 noiseSuppression: true,
28 autoGainControl: true
29 }
30 });
31
32 this.initializeRecorder(this.stream);
33
34 this.audioChunks = [];
35 // Start recording with a small timeslice to ensure we get data
36 this.mediaRecorder!.start(100);
37 this.recordingState = true;
38 } catch (error) {
39 console.error('Failed to start recording:', error);
40 throw new Error('Failed to access microphone. Please check permissions.');
41 }
42 }
43
44 async stopRecording(): Promise<Blob> {
45 return new Promise((resolve, reject) => {
46 if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') {
47 reject(new Error('No active recording to stop'));
48 return;
49 }
50
51 this.mediaRecorder.onstop = () => {
52 const mimeType = this.mediaRecorder?.mimeType || MimeTypeAudio.WAV;
53 const audioBlob = new Blob(this.audioChunks, { type: mimeType });
54
55 this.cleanup();
56
57 resolve(audioBlob);
58 };
59
60 this.mediaRecorder.onerror = (event) => {
61 console.error('Recording error:', event);
62 this.cleanup();
63 reject(new Error('Recording failed'));
64 };
65
66 this.mediaRecorder.stop();
67 });
68 }
69
70 isRecording(): boolean {
71 return this.recordingState;
72 }
73
74 cancelRecording(): void {
75 if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
76 this.mediaRecorder.stop();
77 }
78 this.cleanup();
79 }
80
81 private initializeRecorder(stream: MediaStream): void {
82 const options: MediaRecorderOptions = {};
83
84 if (MediaRecorder.isTypeSupported(MimeTypeAudio.WAV)) {
85 options.mimeType = MimeTypeAudio.WAV;
86 } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM_OPUS)) {
87 options.mimeType = MimeTypeAudio.WEBM_OPUS;
88 } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM)) {
89 options.mimeType = MimeTypeAudio.WEBM;
90 } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.MP4)) {
91 options.mimeType = MimeTypeAudio.MP4;
92 } else {
93 console.warn('No preferred audio format supported, using default');
94 }
95
96 this.mediaRecorder = new MediaRecorder(stream, options);
97
98 this.mediaRecorder.ondataavailable = (event) => {
99 if (event.data.size > 0) {
100 this.audioChunks.push(event.data);
101 }
102 };
103
104 this.mediaRecorder.onstop = () => {
105 this.recordingState = false;
106 };
107
108 this.mediaRecorder.onerror = (event) => {
109 console.error('MediaRecorder error:', event);
110 this.recordingState = false;
111 };
112 }
113
114 private cleanup(): void {
115 if (this.stream) {
116 for (const track of this.stream.getTracks()) {
117 track.stop();
118 }
119
120 this.stream = null;
121 }
122 this.mediaRecorder = null;
123 this.audioChunks = [];
124 this.recordingState = false;
125 }
126}
127
128export async function convertToWav(audioBlob: Blob): Promise<Blob> {
129 try {
130 if (audioBlob.type.includes('wav')) {
131 return audioBlob;
132 }
133
134 const arrayBuffer = await audioBlob.arrayBuffer();
135
136 // eslint-disable-next-line @typescript-eslint/no-explicit-any
137 const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
138
139 const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
140
141 const wavBlob = audioBufferToWav(audioBuffer);
142
143 audioContext.close();
144
145 return wavBlob;
146 } catch (error) {
147 console.error('Failed to convert audio to WAV:', error);
148 return audioBlob;
149 }
150}
151
152function audioBufferToWav(buffer: AudioBuffer): Blob {
153 const length = buffer.length;
154 const numberOfChannels = buffer.numberOfChannels;
155 const sampleRate = buffer.sampleRate;
156 const bytesPerSample = 2; // 16-bit
157 const blockAlign = numberOfChannels * bytesPerSample;
158 const byteRate = sampleRate * blockAlign;
159 const dataSize = length * blockAlign;
160 const bufferSize = 44 + dataSize;
161
162 const arrayBuffer = new ArrayBuffer(bufferSize);
163 const view = new DataView(arrayBuffer);
164
165 const writeString = (offset: number, string: string) => {
166 for (let i = 0; i < string.length; i++) {
167 view.setUint8(offset + i, string.charCodeAt(i));
168 }
169 };
170
171 writeString(0, 'RIFF'); // ChunkID
172 view.setUint32(4, bufferSize - 8, true); // ChunkSize
173 writeString(8, 'WAVE'); // Format
174 writeString(12, 'fmt '); // Subchunk1ID
175 view.setUint32(16, 16, true); // Subchunk1Size
176 view.setUint16(20, 1, true); // AudioFormat (PCM)
177 view.setUint16(22, numberOfChannels, true); // NumChannels
178 view.setUint32(24, sampleRate, true); // SampleRate
179 view.setUint32(28, byteRate, true); // ByteRate
180 view.setUint16(32, blockAlign, true); // BlockAlign
181 view.setUint16(34, 16, true); // BitsPerSample
182 writeString(36, 'data'); // Subchunk2ID
183 view.setUint32(40, dataSize, true); // Subchunk2Size
184
185 let offset = 44;
186 for (let i = 0; i < length; i++) {
187 for (let channel = 0; channel < numberOfChannels; channel++) {
188 const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channel)[i]));
189 view.setInt16(offset, sample * 0x7fff, true);
190 offset += 2;
191 }
192 }
193
194 return new Blob([arrayBuffer], { type: MimeTypeAudio.WAV });
195}
196
197/**
198 * Create a File object from audio blob with timestamp-based naming
199 * @param audioBlob - The audio blob to wrap
200 * @param filename - Optional custom filename
201 * @returns File object with appropriate name and metadata
202 */
203export function createAudioFile(audioBlob: Blob, filename?: string): File {
204 const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
205 const extension = audioBlob.type.includes('wav') ? 'wav' : 'mp3';
206 const defaultFilename = `recording-${timestamp}.${extension}`;
207
208 return new File([audioBlob], filename || defaultFilename, {
209 type: audioBlob.type,
210 lastModified: Date.now()
211 });
212}
213
214/**
215 * Check if audio recording is supported in the current browser
216 * @returns True if MediaRecorder and getUserMedia are available
217 */
218export function isAudioRecordingSupported(): boolean {
219 return !!(
220 typeof navigator !== 'undefined' &&
221 navigator.mediaDevices &&
222 typeof navigator.mediaDevices.getUserMedia === 'function' &&
223 typeof window !== 'undefined' &&
224 window.MediaRecorder
225 );
226}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts b/llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts
new file mode 100644
index 0000000..cfee5ec
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/autoresize-textarea.ts
@@ -0,0 +1,10 @@
1/**
2 * Automatically resizes a textarea element to fit its content
3 * @param textareaElement - The textarea element to resize
4 */
5export default function autoResizeTextarea(textareaElement: HTMLTextAreaElement | null): void {
6 if (textareaElement) {
7 textareaElement.style.height = '1rem';
8 textareaElement.style.height = textareaElement.scrollHeight + 'px';
9 }
10}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/branching.ts b/llama.cpp/tools/server/webui/src/lib/utils/branching.ts
new file mode 100644
index 0000000..3be5604
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/branching.ts
@@ -0,0 +1,283 @@
1/**
2 * Message branching utilities for conversation tree navigation.
3 *
4 * Conversation branching allows users to edit messages and create alternate paths
5 * while preserving the original conversation flow. Each message has parent/children
6 * relationships forming a tree structure.
7 *
8 * Example tree:
9 * root
10 * ├── message 1 (user)
11 * │ └── message 2 (assistant)
12 * │ ├── message 3 (user)
13 * │ └── message 6 (user) ← new branch
14 * └── message 4 (user)
15 * └── message 5 (assistant)
16 */
17
18/**
19 * Filters messages to get the conversation path from root to a specific leaf node.
20 * If the leafNodeId doesn't exist, returns the path with the latest timestamp.
21 *
22 * @param messages - All messages in the conversation
23 * @param leafNodeId - The target leaf node ID to trace back from
24 * @param includeRoot - Whether to include root messages in the result
25 * @returns Array of messages from root to leaf, sorted by timestamp
26 */
27export function filterByLeafNodeId(
28 messages: readonly DatabaseMessage[],
29 leafNodeId: string,
30 includeRoot: boolean = false
31): readonly DatabaseMessage[] {
32 const result: DatabaseMessage[] = [];
33 const nodeMap = new Map<string, DatabaseMessage>();
34
35 // Build node map for quick lookups
36 for (const msg of messages) {
37 nodeMap.set(msg.id, msg);
38 }
39
40 // Find the starting node (leaf node or latest if not found)
41 let startNode: DatabaseMessage | undefined = nodeMap.get(leafNodeId);
42 if (!startNode) {
43 // If leaf node not found, use the message with latest timestamp
44 let latestTime = -1;
45 for (const msg of messages) {
46 if (msg.timestamp > latestTime) {
47 startNode = msg;
48 latestTime = msg.timestamp;
49 }
50 }
51 }
52
53 // Traverse from leaf to root, collecting messages
54 let currentNode: DatabaseMessage | undefined = startNode;
55 while (currentNode) {
56 // Include message if it's not root, or if we want to include root
57 if (currentNode.type !== 'root' || includeRoot) {
58 result.push(currentNode);
59 }
60
61 // Stop traversal if parent is null (reached root)
62 if (currentNode.parent === null) {
63 break;
64 }
65 currentNode = nodeMap.get(currentNode.parent);
66 }
67
68 // Sort by timestamp to get chronological order (root to leaf)
69 result.sort((a, b) => a.timestamp - b.timestamp);
70 return result;
71}
72
73/**
74 * Finds the leaf node (message with no children) for a given message branch.
75 * Traverses down the tree following the last child until reaching a leaf.
76 *
77 * @param messages - All messages in the conversation
78 * @param messageId - Starting message ID to find leaf for
79 * @returns The leaf node ID, or the original messageId if no children
80 */
81export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string {
82 const nodeMap = new Map<string, DatabaseMessage>();
83
84 // Build node map for quick lookups
85 for (const msg of messages) {
86 nodeMap.set(msg.id, msg);
87 }
88
89 let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId);
90 while (currentNode && currentNode.children.length > 0) {
91 // Follow the last child (most recent branch)
92 const lastChildId = currentNode.children[currentNode.children.length - 1];
93 currentNode = nodeMap.get(lastChildId);
94 }
95
96 return currentNode?.id ?? messageId;
97}
98
99/**
100 * Finds all descendant messages (children, grandchildren, etc.) of a given message.
101 * This is used for cascading deletion to remove all messages in a branch.
102 *
103 * @param messages - All messages in the conversation
104 * @param messageId - The root message ID to find descendants for
105 * @returns Array of all descendant message IDs
106 */
107export function findDescendantMessages(
108 messages: readonly DatabaseMessage[],
109 messageId: string
110): string[] {
111 const nodeMap = new Map<string, DatabaseMessage>();
112
113 // Build node map for quick lookups
114 for (const msg of messages) {
115 nodeMap.set(msg.id, msg);
116 }
117
118 const descendants: string[] = [];
119 const queue: string[] = [messageId];
120
121 while (queue.length > 0) {
122 const currentId = queue.shift()!;
123 const currentNode = nodeMap.get(currentId);
124
125 if (currentNode) {
126 // Add all children to the queue and descendants list
127 for (const childId of currentNode.children) {
128 descendants.push(childId);
129 queue.push(childId);
130 }
131 }
132 }
133
134 return descendants;
135}
136
137/**
138 * Gets sibling information for a message, including all sibling IDs and current position.
139 * Siblings are messages that share the same parent.
140 *
141 * @param messages - All messages in the conversation
142 * @param messageId - The message to get sibling info for
143 * @returns Sibling information including leaf node IDs for navigation
144 */
145export function getMessageSiblings(
146 messages: readonly DatabaseMessage[],
147 messageId: string
148): ChatMessageSiblingInfo | null {
149 const nodeMap = new Map<string, DatabaseMessage>();
150
151 // Build node map for quick lookups
152 for (const msg of messages) {
153 nodeMap.set(msg.id, msg);
154 }
155
156 const message = nodeMap.get(messageId);
157 if (!message) {
158 return null;
159 }
160
161 // Handle null parent (root message) case
162 if (message.parent === null) {
163 // No parent means this is likely a root node with no siblings
164 return {
165 message,
166 siblingIds: [messageId],
167 currentIndex: 0,
168 totalSiblings: 1
169 };
170 }
171
172 const parentNode = nodeMap.get(message.parent);
173 if (!parentNode) {
174 // Parent not found - treat as single message
175 return {
176 message,
177 siblingIds: [messageId],
178 currentIndex: 0,
179 totalSiblings: 1
180 };
181 }
182
183 // Get all sibling IDs (including self)
184 const siblingIds = parentNode.children;
185
186 // Convert sibling message IDs to their corresponding leaf node IDs
187 // This allows navigation between different conversation branches
188 const siblingLeafIds = siblingIds.map((siblingId: string) => findLeafNode(messages, siblingId));
189
190 // Find current message's position among siblings
191 const currentIndex = siblingIds.indexOf(messageId);
192
193 return {
194 message,
195 siblingIds: siblingLeafIds,
196 currentIndex,
197 totalSiblings: siblingIds.length
198 };
199}
200
201/**
202 * Creates a display-ready list of messages with sibling information for UI rendering.
203 * This is the main function used by chat components to render conversation branches.
204 *
205 * @param messages - All messages in the conversation
206 * @param leafNodeId - Current leaf node being viewed
207 * @returns Array of messages with sibling navigation info
208 */
209export function getMessageDisplayList(
210 messages: readonly DatabaseMessage[],
211 leafNodeId: string
212): ChatMessageSiblingInfo[] {
213 // Get the current conversation path
214 const currentPath = filterByLeafNodeId(messages, leafNodeId, true);
215 const result: ChatMessageSiblingInfo[] = [];
216
217 // Add sibling info for each message in the current path
218 for (const message of currentPath) {
219 if (message.type === 'root') {
220 continue; // Skip root messages in display
221 }
222
223 const siblingInfo = getMessageSiblings(messages, message.id);
224 if (siblingInfo) {
225 result.push(siblingInfo);
226 }
227 }
228
229 return result;
230}
231
232/**
233 * Checks if a message has multiple siblings (indicating branching at that point).
234 *
235 * @param messages - All messages in the conversation
236 * @param messageId - The message to check
237 * @returns True if the message has siblings
238 */
239export function hasMessageSiblings(
240 messages: readonly DatabaseMessage[],
241 messageId: string
242): boolean {
243 const siblingInfo = getMessageSiblings(messages, messageId);
244 return siblingInfo ? siblingInfo.totalSiblings > 1 : false;
245}
246
247/**
248 * Gets the next sibling message ID for navigation.
249 *
250 * @param messages - All messages in the conversation
251 * @param messageId - Current message ID
252 * @returns Next sibling's leaf node ID, or null if at the end
253 */
254export function getNextSibling(
255 messages: readonly DatabaseMessage[],
256 messageId: string
257): string | null {
258 const siblingInfo = getMessageSiblings(messages, messageId);
259 if (!siblingInfo || siblingInfo.currentIndex >= siblingInfo.totalSiblings - 1) {
260 return null;
261 }
262
263 return siblingInfo.siblingIds[siblingInfo.currentIndex + 1];
264}
265
266/**
267 * Gets the previous sibling message ID for navigation.
268 *
269 * @param messages - All messages in the conversation
270 * @param messageId - Current message ID
271 * @returns Previous sibling's leaf node ID, or null if at the beginning
272 */
273export function getPreviousSibling(
274 messages: readonly DatabaseMessage[],
275 messageId: string
276): string | null {
277 const siblingInfo = getMessageSiblings(messages, messageId);
278 if (!siblingInfo || siblingInfo.currentIndex <= 0) {
279 return null;
280 }
281
282 return siblingInfo.siblingIds[siblingInfo.currentIndex - 1];
283}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts b/llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts
new file mode 100644
index 0000000..0af8006
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/browser-only.ts
@@ -0,0 +1,35 @@
1/**
2 * Browser-only utility exports
3 *
4 * These utilities require browser APIs (DOM, Canvas, MediaRecorder, etc.)
5 * and cannot be imported during SSR. Import from '$lib/utils/browser-only'
6 * only in client-side code or components that are not server-rendered.
7 */
8
9// Audio utilities (MediaRecorder API)
10export {
11 AudioRecorder,
12 convertToWav,
13 createAudioFile,
14 isAudioRecordingSupported
15} from './audio-recording';
16
17// PDF processing utilities (pdfjs-dist with DOMMatrix)
18export {
19 convertPDFToText,
20 convertPDFToImage,
21 isPdfFile as isPdfFileFromFile,
22 isApplicationMimeType
23} from './pdf-processing';
24
25// File conversion utilities (depends on pdf-processing)
26export { parseFilesToMessageExtras, type FileProcessingResult } from './convert-files-to-extra';
27
28// File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png)
29export { processFilesToChatUploaded } from './process-uploaded-files';
30
31// SVG utilities (Canvas/Image API)
32export { svgBase64UrlToPngDataURL, isSvgFile, isSvgMimeType } from './svg-to-png';
33
34// WebP utilities (Canvas/Image API)
35export { webpBase64UrlToPngDataURL, isWebpFile, isWebpMimeType } from './webp-to-png';
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts b/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts
new file mode 100644
index 0000000..940e64c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/clipboard.ts
@@ -0,0 +1,259 @@
1import { toast } from 'svelte-sonner';
2import { AttachmentType } from '$lib/enums';
3import type {
4 DatabaseMessageExtra,
5 DatabaseMessageExtraTextFile,
6 DatabaseMessageExtraLegacyContext
7} from '$lib/types/database';
8
9/**
10 * Copy text to clipboard with toast notification
11 * Uses modern clipboard API when available, falls back to legacy method for non-secure contexts
12 * @param text - Text to copy to clipboard
13 * @param successMessage - Custom success message (optional)
14 * @param errorMessage - Custom error message (optional)
15 * @returns Promise<boolean> - True if successful, false otherwise
16 */
17export async function copyToClipboard(
18 text: string,
19 successMessage = 'Copied to clipboard',
20 errorMessage = 'Failed to copy to clipboard'
21): Promise<boolean> {
22 try {
23 // Try modern clipboard API first (secure contexts only)
24 if (navigator.clipboard && navigator.clipboard.writeText) {
25 await navigator.clipboard.writeText(text);
26 toast.success(successMessage);
27 return true;
28 }
29
30 // Fallback for non-secure contexts
31 const textArea = document.createElement('textarea');
32 textArea.value = text;
33 textArea.style.position = 'fixed';
34 textArea.style.left = '-999999px';
35 textArea.style.top = '-999999px';
36 document.body.appendChild(textArea);
37 textArea.focus();
38 textArea.select();
39
40 const successful = document.execCommand('copy');
41 document.body.removeChild(textArea);
42
43 if (successful) {
44 toast.success(successMessage);
45 return true;
46 } else {
47 throw new Error('execCommand failed');
48 }
49 } catch (error) {
50 console.error('Failed to copy to clipboard:', error);
51 toast.error(errorMessage);
52 return false;
53 }
54}
55
56/**
57 * Copy code with HTML entity decoding and toast notification
58 * @param rawCode - Raw code string that may contain HTML entities
59 * @param successMessage - Custom success message (optional)
60 * @param errorMessage - Custom error message (optional)
61 * @returns Promise<boolean> - True if successful, false otherwise
62 */
63export async function copyCodeToClipboard(
64 rawCode: string,
65 successMessage = 'Code copied to clipboard',
66 errorMessage = 'Failed to copy code'
67): Promise<boolean> {
68 return copyToClipboard(rawCode, successMessage, errorMessage);
69}
70
71/**
72 * Format for text attachments when copied to clipboard
73 */
74export interface ClipboardTextAttachment {
75 type: typeof AttachmentType.TEXT;
76 name: string;
77 content: string;
78}
79
80/**
81 * Parsed result from clipboard content
82 */
83export interface ParsedClipboardContent {
84 message: string;
85 textAttachments: ClipboardTextAttachment[];
86}
87
88/**
89 * Formats a message with text attachments for clipboard copying.
90 *
91 * Default format (asPlainText = false):
92 * ```
93 * "Text message content"
94 * [
95 * {"type":"TEXT","name":"filename.txt","content":"..."},
96 * {"type":"TEXT","name":"another.txt","content":"..."}
97 * ]
98 * ```
99 *
100 * Plain text format (asPlainText = true):
101 * ```
102 * Text message content
103 *
104 * file content here
105 *
106 * another file content
107 * ```
108 *
109 * @param content - The message text content
110 * @param extras - Optional array of message attachments
111 * @param asPlainText - If true, format as plain text without JSON structure
112 * @returns Formatted string for clipboard
113 */
114export function formatMessageForClipboard(
115 content: string,
116 extras?: DatabaseMessageExtra[],
117 asPlainText: boolean = false
118): string {
119 // Filter only text attachments (TEXT type and legacy CONTEXT type)
120 const textAttachments =
121 extras?.filter(
122 (extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext =>
123 extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT
124 ) ?? [];
125
126 if (textAttachments.length === 0) {
127 return content;
128 }
129
130 if (asPlainText) {
131 const parts = [content];
132 for (const att of textAttachments) {
133 parts.push(att.content);
134 }
135 return parts.join('\n\n');
136 }
137
138 const clipboardAttachments: ClipboardTextAttachment[] = textAttachments.map((att) => ({
139 type: AttachmentType.TEXT,
140 name: att.name,
141 content: att.content
142 }));
143
144 return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`;
145}
146
147/**
148 * Parses clipboard content to extract message and text attachments.
149 * Supports both plain text and the special format with attachments.
150 *
151 * @param clipboardText - Raw text from clipboard
152 * @returns Parsed content with message and attachments
153 */
154export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
155 const defaultResult: ParsedClipboardContent = {
156 message: clipboardText,
157 textAttachments: []
158 };
159
160 if (!clipboardText.startsWith('"')) {
161 return defaultResult;
162 }
163
164 try {
165 let stringEndIndex = -1;
166 let escaped = false;
167
168 for (let i = 1; i < clipboardText.length; i++) {
169 const char = clipboardText[i];
170
171 if (escaped) {
172 escaped = false;
173 continue;
174 }
175
176 if (char === '\\') {
177 escaped = true;
178 continue;
179 }
180
181 if (char === '"') {
182 stringEndIndex = i;
183 break;
184 }
185 }
186
187 if (stringEndIndex === -1) {
188 return defaultResult;
189 }
190
191 const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1);
192 const remainingPart = clipboardText.substring(stringEndIndex + 1).trim();
193
194 const message = JSON.parse(jsonStringPart) as string;
195
196 if (!remainingPart || !remainingPart.startsWith('[')) {
197 return {
198 message,
199 textAttachments: []
200 };
201 }
202
203 const attachments = JSON.parse(remainingPart) as unknown[];
204
205 const validAttachments: ClipboardTextAttachment[] = [];
206
207 for (const att of attachments) {
208 if (isValidTextAttachment(att)) {
209 validAttachments.push({
210 type: AttachmentType.TEXT,
211 name: att.name,
212 content: att.content
213 });
214 }
215 }
216
217 return {
218 message,
219 textAttachments: validAttachments
220 };
221 } catch {
222 return defaultResult;
223 }
224}
225
226/**
227 * Type guard to validate a text attachment object
228 * @param obj The object to validate
229 * @returns true if the object is a valid text attachment
230 */
231function isValidTextAttachment(
232 obj: unknown
233): obj is { type: string; name: string; content: string } {
234 if (typeof obj !== 'object' || obj === null) {
235 return false;
236 }
237
238 const record = obj as Record<string, unknown>;
239
240 return (
241 (record.type === AttachmentType.TEXT || record.type === 'TEXT') &&
242 typeof record.name === 'string' &&
243 typeof record.content === 'string'
244 );
245}
246
247/**
248 * Checks if clipboard content contains our special format with attachments
249 * @param clipboardText - Raw text from clipboard
250 * @returns true if the clipboard content contains our special format with attachments
251 */
252export function hasClipboardAttachments(clipboardText: string): boolean {
253 if (!clipboardText.startsWith('"')) {
254 return false;
255 }
256
257 const parsed = parseClipboardContent(clipboardText);
258 return parsed.textAttachments.length > 0;
259}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts b/llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts
new file mode 100644
index 0000000..b85242d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/config-helpers.ts
@@ -0,0 +1,51 @@
1/**
2 * Type-safe configuration helpers
3 *
4 * Provides utilities for safely accessing and modifying configuration objects
5 * with dynamic keys while maintaining TypeScript type safety.
6 */
7
8/**
9 * Type-safe helper to access config properties dynamically
10 * Provides better type safety than direct casting to Record
11 */
12export function setConfigValue<T extends SettingsConfigType>(
13 config: T,
14 key: string,
15 value: unknown
16): void {
17 if (key in config) {
18 (config as Record<string, unknown>)[key] = value;
19 }
20}
21
22/**
23 * Type-safe helper to get config values dynamically
24 */
25export function getConfigValue<T extends SettingsConfigType>(
26 config: T,
27 key: string
28): string | number | boolean | undefined {
29 const value = (config as Record<string, unknown>)[key];
30 return value as string | number | boolean | undefined;
31}
32
33/**
34 * Convert a SettingsConfigType to a ParameterRecord for specific keys
35 * Useful for parameter synchronization operations
36 */
37export function configToParameterRecord<T extends SettingsConfigType>(
38 config: T,
39 keys: string[]
40): Record<string, string | number | boolean> {
41 const record: Record<string, string | number | boolean> = {};
42
43 for (const key of keys) {
44 const value = getConfigValue(config, key);
45 if (value !== undefined) {
46 record[key] = value;
47 }
48 }
49
50 return record;
51}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts b/llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts
new file mode 100644
index 0000000..aee244a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/conversation-utils.ts
@@ -0,0 +1,30 @@
1/**
2 * Utility functions for conversation data manipulation
3 */
4
5/**
6 * Creates a map of conversation IDs to their message counts from exported conversation data
7 * @param exportedData - Array of exported conversations with their messages
8 * @returns Map of conversation ID to message count
9 */
10export function createMessageCountMap(
11 exportedData: Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>
12): Map<string, number> {
13 const countMap = new Map<string, number>();
14
15 for (const item of exportedData) {
16 countMap.set(item.conv.id, item.messages.length);
17 }
18
19 return countMap;
20}
21
22/**
23 * Gets the message count for a specific conversation from the count map
24 * @param conversationId - The ID of the conversation
25 * @param countMap - Map of conversation IDs to message counts
26 * @returns The message count, or 0 if not found
27 */
28export function getMessageCount(conversationId: string, countMap: Map<string, number>): number {
29 return countMap.get(conversationId) ?? 0;
30}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts b/llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts
new file mode 100644
index 0000000..6eb50f6
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/convert-files-to-extra.ts
@@ -0,0 +1,192 @@
1import { convertPDFToImage, convertPDFToText } from './pdf-processing';
2import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
3import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
4import { FileTypeCategory, AttachmentType } from '$lib/enums';
5import { config, settingsStore } from '$lib/stores/settings.svelte';
6import { modelsStore } from '$lib/stores/models.svelte';
7import { getFileTypeCategory } from '$lib/utils';
8import { readFileAsText, isLikelyTextFile } from './text-files';
9import { toast } from 'svelte-sonner';
10
11function readFileAsBase64(file: File): Promise<string> {
12 return new Promise((resolve, reject) => {
13 const reader = new FileReader();
14
15 reader.onload = () => {
16 // Extract base64 data without the data URL prefix
17 const dataUrl = reader.result as string;
18 const base64 = dataUrl.split(',')[1];
19 resolve(base64);
20 };
21
22 reader.onerror = () => reject(reader.error);
23
24 reader.readAsDataURL(file);
25 });
26}
27
28export interface FileProcessingResult {
29 extras: DatabaseMessageExtra[];
30 emptyFiles: string[];
31}
32
33export async function parseFilesToMessageExtras(
34 files: ChatUploadedFile[],
35 activeModelId?: string
36): Promise<FileProcessingResult> {
37 const extras: DatabaseMessageExtra[] = [];
38 const emptyFiles: string[] = [];
39
40 for (const file of files) {
41 if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
42 if (file.preview) {
43 let base64Url = file.preview;
44
45 if (isSvgMimeType(file.type)) {
46 try {
47 base64Url = await svgBase64UrlToPngDataURL(base64Url);
48 } catch (error) {
49 console.error('Failed to convert SVG to PNG for database storage:', error);
50 }
51 } else if (isWebpMimeType(file.type)) {
52 try {
53 base64Url = await webpBase64UrlToPngDataURL(base64Url);
54 } catch (error) {
55 console.error('Failed to convert WebP to PNG for database storage:', error);
56 }
57 }
58
59 extras.push({
60 type: AttachmentType.IMAGE,
61 name: file.name,
62 base64Url
63 });
64 }
65 } else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
66 // Process audio files (MP3 and WAV)
67 try {
68 const base64Data = await readFileAsBase64(file.file);
69
70 extras.push({
71 type: AttachmentType.AUDIO,
72 name: file.name,
73 base64Data: base64Data,
74 mimeType: file.type
75 });
76 } catch (error) {
77 console.error(`Failed to process audio file ${file.name}:`, error);
78 }
79 } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
80 try {
81 // Always get base64 data for preview functionality
82 const base64Data = await readFileAsBase64(file.file);
83 const currentConfig = config();
84 // Use per-model vision check for router mode
85 const hasVisionSupport = activeModelId
86 ? modelsStore.modelSupportsVision(activeModelId)
87 : false;
88
89 // Force PDF-to-text for non-vision models
90 let shouldProcessAsImages = Boolean(currentConfig.pdfAsImage) && hasVisionSupport;
91
92 // If user had pdfAsImage enabled but model doesn't support vision, update setting and notify
93 if (currentConfig.pdfAsImage && !hasVisionSupport) {
94 console.log('Non-vision model detected: forcing PDF-to-text mode and updating settings');
95
96 // Update the setting in localStorage
97 settingsStore.updateConfig('pdfAsImage', false);
98
99 // Show toast notification to user
100 toast.warning(
101 'PDF setting changed: Non-vision model detected, PDFs will be processed as text instead of images.',
102 {
103 duration: 5000
104 }
105 );
106
107 shouldProcessAsImages = false;
108 }
109
110 if (shouldProcessAsImages) {
111 // Process PDF as images (only for vision models)
112 try {
113 const images = await convertPDFToImage(file.file);
114
115 // Show success toast for PDF image processing
116 toast.success(
117 `PDF "${file.name}" processed as ${images.length} images for vision model.`,
118 {
119 duration: 3000
120 }
121 );
122
123 extras.push({
124 type: AttachmentType.PDF,
125 name: file.name,
126 content: `PDF file with ${images.length} pages`,
127 images: images,
128 processedAsImages: true,
129 base64Data: base64Data
130 });
131 } catch (imageError) {
132 console.warn(
133 `Failed to process PDF ${file.name} as images, falling back to text:`,
134 imageError
135 );
136
137 // Fallback to text processing
138 const content = await convertPDFToText(file.file);
139
140 extras.push({
141 type: AttachmentType.PDF,
142 name: file.name,
143 content: content,
144 processedAsImages: false,
145 base64Data: base64Data
146 });
147 }
148 } else {
149 // Process PDF as text (default or forced for non-vision models)
150 const content = await convertPDFToText(file.file);
151
152 // Show success toast for PDF text processing
153 toast.success(`PDF "${file.name}" processed as text content.`, {
154 duration: 3000
155 });
156
157 extras.push({
158 type: AttachmentType.PDF,
159 name: file.name,
160 content: content,
161 processedAsImages: false,
162 base64Data: base64Data
163 });
164 }
165 } catch (error) {
166 console.error(`Failed to process PDF file ${file.name}:`, error);
167 }
168 } else {
169 try {
170 const content = await readFileAsText(file.file);
171
172 // Check if file is empty
173 if (content.trim() === '') {
174 console.warn(`File ${file.name} is empty and will be skipped`);
175 emptyFiles.push(file.name);
176 } else if (isLikelyTextFile(content)) {
177 extras.push({
178 type: AttachmentType.TEXT,
179 name: file.name,
180 content: content
181 });
182 } else {
183 console.warn(`File ${file.name} appears to be binary and will be skipped`);
184 }
185 } catch (error) {
186 console.error(`Failed to read file ${file.name}:`, error);
187 }
188 }
189 }
190
191 return { extras, emptyFiles };
192}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts b/llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts
new file mode 100644
index 0000000..26a6053
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/file-preview.ts
@@ -0,0 +1,36 @@
1/**
2 * Gets a display label for a file type from various input formats
3 *
4 * Handles:
5 * - MIME types: 'application/pdf' → 'PDF'
6 * - AttachmentType values: 'PDF', 'AUDIO' → 'PDF', 'AUDIO'
7 * - File names: 'document.pdf' → 'PDF'
8 * - Unknown: returns 'FILE'
9 *
10 * @param input - MIME type, AttachmentType value, or file name
11 * @returns Formatted file type label (uppercase)
12 */
13export function getFileTypeLabel(input: string | undefined): string {
14 if (!input) return 'FILE';
15
16 // Handle MIME types (contains '/')
17 if (input.includes('/')) {
18 const subtype = input.split('/').pop();
19 if (subtype) {
20 // Handle special cases like 'vnd.ms-excel' → 'EXCEL'
21 if (subtype.includes('.')) {
22 return subtype.split('.').pop()?.toUpperCase() || 'FILE';
23 }
24 return subtype.toUpperCase();
25 }
26 }
27
28 // Handle file names (contains '.')
29 if (input.includes('.')) {
30 const ext = input.split('.').pop();
31 if (ext) return ext.toUpperCase();
32 }
33
34 // Handle AttachmentType or other plain strings
35 return input.toUpperCase();
36}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/file-type.ts b/llama.cpp/tools/server/webui/src/lib/utils/file-type.ts
new file mode 100644
index 0000000..9a9996d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/file-type.ts
@@ -0,0 +1,222 @@
1import {
2 AUDIO_FILE_TYPES,
3 IMAGE_FILE_TYPES,
4 PDF_FILE_TYPES,
5 TEXT_FILE_TYPES
6} from '$lib/constants/supported-file-types';
7import {
8 FileExtensionAudio,
9 FileExtensionImage,
10 FileExtensionPdf,
11 FileExtensionText,
12 FileTypeCategory,
13 MimeTypeApplication,
14 MimeTypeAudio,
15 MimeTypeImage,
16 MimeTypeText
17} from '$lib/enums';
18
19export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
20 switch (mimeType) {
21 // Images
22 case MimeTypeImage.JPEG:
23 case MimeTypeImage.PNG:
24 case MimeTypeImage.GIF:
25 case MimeTypeImage.WEBP:
26 case MimeTypeImage.SVG:
27 return FileTypeCategory.IMAGE;
28
29 // Audio
30 case MimeTypeAudio.MP3_MPEG:
31 case MimeTypeAudio.MP3:
32 case MimeTypeAudio.MP4:
33 case MimeTypeAudio.WAV:
34 case MimeTypeAudio.WEBM:
35 case MimeTypeAudio.WEBM_OPUS:
36 return FileTypeCategory.AUDIO;
37
38 // PDF
39 case MimeTypeApplication.PDF:
40 return FileTypeCategory.PDF;
41
42 // Text
43 case MimeTypeText.PLAIN:
44 case MimeTypeText.MARKDOWN:
45 case MimeTypeText.ASCIIDOC:
46 case MimeTypeText.JAVASCRIPT:
47 case MimeTypeText.JAVASCRIPT_APP:
48 case MimeTypeText.TYPESCRIPT:
49 case MimeTypeText.JSX:
50 case MimeTypeText.TSX:
51 case MimeTypeText.CSS:
52 case MimeTypeText.HTML:
53 case MimeTypeText.JSON:
54 case MimeTypeText.XML_TEXT:
55 case MimeTypeText.XML_APP:
56 case MimeTypeText.YAML_TEXT:
57 case MimeTypeText.YAML_APP:
58 case MimeTypeText.CSV:
59 case MimeTypeText.PYTHON:
60 case MimeTypeText.JAVA:
61 case MimeTypeText.CPP_SRC:
62 case MimeTypeText.C_SRC:
63 case MimeTypeText.C_HDR:
64 case MimeTypeText.PHP:
65 case MimeTypeText.RUBY:
66 case MimeTypeText.GO:
67 case MimeTypeText.RUST:
68 case MimeTypeText.SHELL:
69 case MimeTypeText.BAT:
70 case MimeTypeText.SQL:
71 case MimeTypeText.R:
72 case MimeTypeText.SCALA:
73 case MimeTypeText.KOTLIN:
74 case MimeTypeText.SWIFT:
75 case MimeTypeText.DART:
76 case MimeTypeText.VUE:
77 case MimeTypeText.SVELTE:
78 case MimeTypeText.LATEX:
79 case MimeTypeText.BIBTEX:
80 case MimeTypeText.CUDA:
81 case MimeTypeText.CPP_HDR:
82 case MimeTypeText.CSHARP:
83 case MimeTypeText.HASKELL:
84 case MimeTypeText.PROPERTIES:
85 case MimeTypeText.TEX:
86 case MimeTypeText.TEX_APP:
87 return FileTypeCategory.TEXT;
88
89 default:
90 return null;
91 }
92}
93
94export function getFileTypeCategoryByExtension(filename: string): FileTypeCategory | null {
95 const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
96
97 switch (extension) {
98 // Images
99 case FileExtensionImage.JPG:
100 case FileExtensionImage.JPEG:
101 case FileExtensionImage.PNG:
102 case FileExtensionImage.GIF:
103 case FileExtensionImage.WEBP:
104 case FileExtensionImage.SVG:
105 return FileTypeCategory.IMAGE;
106
107 // Audio
108 case FileExtensionAudio.MP3:
109 case FileExtensionAudio.WAV:
110 return FileTypeCategory.AUDIO;
111
112 // PDF
113 case FileExtensionPdf.PDF:
114 return FileTypeCategory.PDF;
115
116 // Text
117 case FileExtensionText.TXT:
118 case FileExtensionText.MD:
119 case FileExtensionText.ADOC:
120 case FileExtensionText.JS:
121 case FileExtensionText.TS:
122 case FileExtensionText.JSX:
123 case FileExtensionText.TSX:
124 case FileExtensionText.CSS:
125 case FileExtensionText.HTML:
126 case FileExtensionText.HTM:
127 case FileExtensionText.JSON:
128 case FileExtensionText.XML:
129 case FileExtensionText.YAML:
130 case FileExtensionText.YML:
131 case FileExtensionText.CSV:
132 case FileExtensionText.LOG:
133 case FileExtensionText.PY:
134 case FileExtensionText.JAVA:
135 case FileExtensionText.CPP:
136 case FileExtensionText.C:
137 case FileExtensionText.H:
138 case FileExtensionText.PHP:
139 case FileExtensionText.RB:
140 case FileExtensionText.GO:
141 case FileExtensionText.RS:
142 case FileExtensionText.SH:
143 case FileExtensionText.BAT:
144 case FileExtensionText.SQL:
145 case FileExtensionText.R:
146 case FileExtensionText.SCALA:
147 case FileExtensionText.KT:
148 case FileExtensionText.SWIFT:
149 case FileExtensionText.DART:
150 case FileExtensionText.VUE:
151 case FileExtensionText.SVELTE:
152 case FileExtensionText.TEX:
153 case FileExtensionText.BIB:
154 case FileExtensionText.COMP:
155 case FileExtensionText.CU:
156 case FileExtensionText.CUH:
157 case FileExtensionText.HPP:
158 case FileExtensionText.HS:
159 case FileExtensionText.PROPERTIES:
160 return FileTypeCategory.TEXT;
161
162 default:
163 return null;
164 }
165}
166
167export function getFileTypeByExtension(filename: string): string | null {
168 const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
169
170 for (const [key, type] of Object.entries(IMAGE_FILE_TYPES)) {
171 if ((type.extensions as readonly string[]).includes(extension)) {
172 return `${FileTypeCategory.IMAGE}:${key}`;
173 }
174 }
175
176 for (const [key, type] of Object.entries(AUDIO_FILE_TYPES)) {
177 if ((type.extensions as readonly string[]).includes(extension)) {
178 return `${FileTypeCategory.AUDIO}:${key}`;
179 }
180 }
181
182 for (const [key, type] of Object.entries(PDF_FILE_TYPES)) {
183 if ((type.extensions as readonly string[]).includes(extension)) {
184 return `${FileTypeCategory.PDF}:${key}`;
185 }
186 }
187
188 for (const [key, type] of Object.entries(TEXT_FILE_TYPES)) {
189 if ((type.extensions as readonly string[]).includes(extension)) {
190 return `${FileTypeCategory.TEXT}:${key}`;
191 }
192 }
193
194 return null;
195}
196
197export function isFileTypeSupported(filename: string, mimeType?: string): boolean {
198 // Images are detected and handled separately for vision models
199 if (mimeType) {
200 const category = getFileTypeCategory(mimeType);
201 if (
202 category === FileTypeCategory.IMAGE ||
203 category === FileTypeCategory.AUDIO ||
204 category === FileTypeCategory.PDF
205 ) {
206 return true;
207 }
208 }
209
210 // Check extension for known types (especially images without MIME)
211 const extCategory = getFileTypeCategoryByExtension(filename);
212 if (
213 extCategory === FileTypeCategory.IMAGE ||
214 extCategory === FileTypeCategory.AUDIO ||
215 extCategory === FileTypeCategory.PDF
216 ) {
217 return true;
218 }
219
220 // Fallback: treat everything else as text (inclusive by default)
221 return true;
222}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/formatters.ts b/llama.cpp/tools/server/webui/src/lib/utils/formatters.ts
new file mode 100644
index 0000000..ae9f59a
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/formatters.ts
@@ -0,0 +1,53 @@
1/**
2 * Formats file size in bytes to human readable format
3 * Supports Bytes, KB, MB, and GB
4 *
5 * @param bytes - File size in bytes (or unknown for safety)
6 * @returns Formatted file size string
7 */
8export function formatFileSize(bytes: number | unknown): string {
9 if (typeof bytes !== 'number') return 'Unknown';
10 if (bytes === 0) return '0 Bytes';
11
12 const k = 1024;
13 const sizes = ['Bytes', 'KB', 'MB', 'GB'];
14 const i = Math.floor(Math.log(bytes) / Math.log(k));
15
16 return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
17}
18
19/**
20 * Format parameter count to human-readable format (B, M, K)
21 *
22 * @param params - Parameter count
23 * @returns Human-readable parameter count
24 */
25export function formatParameters(params: number | unknown): string {
26 if (typeof params !== 'number') return 'Unknown';
27
28 if (params >= 1e9) {
29 return `${(params / 1e9).toFixed(2)}B`;
30 }
31
32 if (params >= 1e6) {
33 return `${(params / 1e6).toFixed(2)}M`;
34 }
35
36 if (params >= 1e3) {
37 return `${(params / 1e3).toFixed(2)}K`;
38 }
39
40 return params.toString();
41}
42
43/**
44 * Format number with locale-specific thousands separators
45 *
46 * @param num - Number to format
47 * @returns Human-readable number
48 */
49export function formatNumber(num: number | unknown): string {
50 if (typeof num !== 'number') return 'Unknown';
51
52 return num.toLocaleString();
53}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/index.ts b/llama.cpp/tools/server/webui/src/lib/utils/index.ts
new file mode 100644
index 0000000..588167b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/index.ts
@@ -0,0 +1,95 @@
1/**
2 * Unified exports for all utility functions
3 * Import utilities from '$lib/utils' for cleaner imports
4 *
5 * For browser-only utilities (pdf-processing, audio-recording, svg-to-png,
6 * webp-to-png, process-uploaded-files, convert-files-to-extra), use:
7 * import { ... } from '$lib/utils/browser-only'
8 */
9
10// API utilities
11export { getAuthHeaders, getJsonHeaders } from './api-headers';
12export { validateApiKey } from './api-key-validation';
13
14// Attachment utilities
15export {
16 getAttachmentDisplayItems,
17 type AttachmentDisplayItemsOptions
18} from './attachment-display';
19export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type';
20
21// Textarea utilities
22export { default as autoResizeTextarea } from './autoresize-textarea';
23
24// Branching utilities
25export {
26 filterByLeafNodeId,
27 findLeafNode,
28 findDescendantMessages,
29 getMessageSiblings,
30 getMessageDisplayList,
31 hasMessageSiblings,
32 getNextSibling,
33 getPreviousSibling
34} from './branching';
35
36// Config helpers
37export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
38
39// Conversation utilities
40export { createMessageCountMap, getMessageCount } from './conversation-utils';
41
42// Clipboard utilities
43export {
44 copyToClipboard,
45 copyCodeToClipboard,
46 formatMessageForClipboard,
47 parseClipboardContent,
48 hasClipboardAttachments,
49 type ClipboardTextAttachment,
50 type ParsedClipboardContent
51} from './clipboard';
52
53// File preview utilities
54export { getFileTypeLabel } from './file-preview';
55export { getPreviewText } from './text';
56
57// File type utilities
58export {
59 getFileTypeCategory,
60 getFileTypeCategoryByExtension,
61 getFileTypeByExtension,
62 isFileTypeSupported
63} from './file-type';
64
65// Formatting utilities
66export { formatFileSize, formatParameters, formatNumber } from './formatters';
67
68// IME utilities
69export { isIMEComposing } from './is-ime-composing';
70
71// LaTeX utilities
72export { maskInlineLaTeX, preprocessLaTeX } from './latex-protection';
73
74// Modality file validation utilities
75export {
76 isFileTypeSupportedByModel,
77 filterFilesByModalities,
78 generateModalityErrorMessage,
79 type ModalityCapabilities
80} from './modality-file-validation';
81
82// Model name utilities
83export { normalizeModelName, isValidModelName } from './model-names';
84
85// Portal utilities
86export { portalToBody } from './portal-to-body';
87
88// Precision utilities
89export { normalizeFloatingPoint, normalizeNumber } from './precision';
90
91// Syntax highlighting utilities
92export { getLanguageFromFilename } from './syntax-highlight-language';
93
94// Text file utilities
95export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts b/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts
new file mode 100644
index 0000000..9182ea4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/is-ime-composing.ts
@@ -0,0 +1,5 @@
1export function isIMEComposing(event: KeyboardEvent) {
2 // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari, which is notorious for not supporting KeyboardEvent.isComposing)
3 // This prevents form submission when confirming IME word selection (e.g., Japanese/Chinese input)
4 return event.isComposing || event.keyCode === 229;
5}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts b/llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts
new file mode 100644
index 0000000..cafa2d4
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/latex-protection.ts
@@ -0,0 +1,270 @@
1import {
2 CODE_BLOCK_REGEXP,
3 LATEX_MATH_AND_CODE_PATTERN,
4 LATEX_LINEBREAK_REGEXP,
5 MHCHEM_PATTERN_MAP
6} from '$lib/constants/latex-protection';
7
8/**
9 * Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs
10 * that appear to be part of monetary values or identifiers.
11 *
12 * This function processes the input line by line and skips `$` sequences that are likely
13 * part of money amounts (e.g., `$5`, `$100.99`) or code-like tokens (e.g., `var$`, `$var`).
14 * Valid LaTeX inline math is replaced with a placeholder like `<<LATEX_0>>`, and the
15 * actual LaTeX content is stored in the provided `latexExpressions` array.
16 *
17 * @param content - The input text potentially containing LaTeX expressions.
18 * @param latexExpressions - An array used to collect extracted LaTeX expressions.
19 * @returns The processed string with LaTeX replaced by placeholders.
20 */
21export function maskInlineLaTeX(content: string, latexExpressions: string[]): string {
22 if (!content.includes('$')) {
23 return content;
24 }
25 return content
26 .split('\n')
27 .map((line) => {
28 if (line.indexOf('$') == -1) {
29 return line;
30 }
31
32 let processedLine = '';
33 let currentPosition = 0;
34
35 while (currentPosition < line.length) {
36 const openDollarIndex = line.indexOf('$', currentPosition);
37
38 if (openDollarIndex == -1) {
39 processedLine += line.slice(currentPosition);
40 break;
41 }
42
43 // Is there a next $-sign?
44 const closeDollarIndex = line.indexOf('$', openDollarIndex + 1);
45
46 if (closeDollarIndex == -1) {
47 processedLine += line.slice(currentPosition);
48 break;
49 }
50
51 const charBeforeOpen = openDollarIndex > 0 ? line[openDollarIndex - 1] : '';
52 const charAfterOpen = line[openDollarIndex + 1];
53 const charBeforeClose =
54 openDollarIndex + 1 < closeDollarIndex ? line[closeDollarIndex - 1] : '';
55 const charAfterClose = closeDollarIndex + 1 < line.length ? line[closeDollarIndex + 1] : '';
56
57 let shouldSkipAsNonLatex = false;
58
59 if (closeDollarIndex == currentPosition + 1) {
60 // No content
61 shouldSkipAsNonLatex = true;
62 }
63
64 if (/[A-Za-z0-9_$-]/.test(charBeforeOpen)) {
65 // Character, digit, $, _ or - before first '$', no TeX.
66 shouldSkipAsNonLatex = true;
67 }
68
69 if (
70 /[0-9]/.test(charAfterOpen) &&
71 (/[A-Za-z0-9_$-]/.test(charAfterClose) || ' ' == charBeforeClose)
72 ) {
73 // First $ seems to belong to an amount.
74 shouldSkipAsNonLatex = true;
75 }
76
77 if (shouldSkipAsNonLatex) {
78 processedLine += line.slice(currentPosition, openDollarIndex + 1);
79 currentPosition = openDollarIndex + 1;
80
81 continue;
82 }
83
84 // Treat as LaTeX
85 processedLine += line.slice(currentPosition, openDollarIndex);
86 const latexContent = line.slice(openDollarIndex, closeDollarIndex + 1);
87 latexExpressions.push(latexContent);
88 processedLine += `<<LATEX_${latexExpressions.length - 1}>>`;
89 currentPosition = closeDollarIndex + 1;
90 }
91
92 return processedLine;
93 })
94 .join('\n');
95}
96
97function escapeBrackets(text: string): string {
98 return text.replace(
99 LATEX_MATH_AND_CODE_PATTERN,
100 (
101 match: string,
102 codeBlock: string | undefined,
103 squareBracket: string | undefined,
104 roundBracket: string | undefined
105 ): string => {
106 if (codeBlock != null) {
107 return codeBlock;
108 } else if (squareBracket != null) {
109 return `$$${squareBracket}$$`;
110 } else if (roundBracket != null) {
111 return `$${roundBracket}$`;
112 }
113
114 return match;
115 }
116 );
117}
118
119// Escape $\\ce{...} → $\\ce{...} but with proper handling
120function escapeMhchem(text: string): string {
121 return MHCHEM_PATTERN_MAP.reduce((result, [pattern, replacement]) => {
122 return result.replace(pattern, replacement);
123 }, text);
124}
125
126const doEscapeMhchem = false;
127
128/**
129 * Preprocesses markdown content to safely handle LaTeX math expressions while protecting
130 * against false positives (e.g., dollar amounts like $5.99) and ensuring proper rendering.
131 *
132 * This function:
133 * - Protects code blocks (```) and inline code (`...`)
134 * - Safeguards block and inline LaTeX: \(...\), \[...\], $$...$$, and selective $...$
135 * - Escapes standalone dollar signs before numbers (e.g., $5 → \$5) to prevent misinterpretation
136 * - Restores protected LaTeX and code blocks after processing
137 * - Converts \(...\) → $...$ and \[...\] → $$...$$ for compatibility with math renderers
138 * - Applies additional escaping for brackets and mhchem syntax if needed
139 *
140 * @param content - The raw text (e.g., markdown) that may contain LaTeX or code blocks.
141 * @returns The preprocessed string with properly escaped and normalized LaTeX.
142 *
143 * @example
144 * preprocessLaTeX("Price: $10. The equation is \\(x^2\\).")
145 * // → "Price: $10. The equation is $x^2$."
146 */
147export function preprocessLaTeX(content: string): string {
148 // See also:
149 // https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
150
151 // Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly
152 // Store the structure so we can restore it later
153 const blockquoteMarkers: Map<number, string> = new Map();
154 const lines = content.split('\n');
155 const processedLines = lines.map((line, index) => {
156 const match = line.match(/^(>\s*)/);
157 if (match) {
158 blockquoteMarkers.set(index, match[1]);
159 return line.slice(match[1].length);
160 }
161 return line;
162 });
163 content = processedLines.join('\n');
164
165 // Step 1: Protect code blocks
166 const codeBlocks: string[] = [];
167
168 content = content.replace(CODE_BLOCK_REGEXP, (match) => {
169 codeBlocks.push(match);
170
171 return `<<CODE_BLOCK_${codeBlocks.length - 1}>>`;
172 });
173
174 // Step 2: Protect existing LaTeX expressions
175 const latexExpressions: string[] = [];
176
177 // Match \S...\[...\] and protect them and insert a line-break.
178 content = content.replace(/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g, (match, group1, group2, group3) => {
179 // Check if there are characters following the formula (display-formula in a table-cell?)
180 if (group1.endsWith('\\')) {
181 return match; // Backslash before \[, do nothing.
182 }
183 const hasSuffix = /\S/.test(group3);
184 let optBreak;
185
186 if (hasSuffix) {
187 latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline.
188 optBreak = '';
189 } else {
190 latexExpressions.push(`\\[${group2}\\]`);
191 optBreak = '\n';
192 }
193
194 return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
195 });
196
197 // Match \(...\), \[...\], $$...$$ and protect them
198 content = content.replace(
199 /(\$\$[\s\S]*?\$\$|(?<!\\)\\\[[\s\S]*?\\\]|(?<!\\)\\\(.*?\\\))/g,
200 (match) => {
201 latexExpressions.push(match);
202
203 return `<<LATEX_${latexExpressions.length - 1}>>`;
204 }
205 );
206
207 // Protect inline $...$ but NOT if it looks like money (e.g., $10, $3.99)
208 content = maskInlineLaTeX(content, latexExpressions);
209
210 // Step 3: Escape standalone $ before digits (currency like $5 → \$5)
211 // (Now that inline math is protected, this will only escape dollars not already protected)
212 content = content.replace(/\$(?=\d)/g, '\\$');
213
214 // Step 4: Restore protected LaTeX expressions (they are valid)
215 content = content.replace(/<<LATEX_(\d+)>>/g, (_, index) => {
216 let expr = latexExpressions[parseInt(index)];
217 const match = expr.match(LATEX_LINEBREAK_REGEXP);
218 if (match) {
219 // Katex: The $$-delimiters should be in their own line
220 // if there are \\-line-breaks.
221 const formula = match[1];
222 const prefix = formula.startsWith('\n') ? '' : '\n';
223 const suffix = formula.endsWith('\n') ? '' : '\n';
224 expr = '$$' + prefix + formula + suffix + '$$';
225 }
226 return expr;
227 });
228
229 // Step 5: Apply additional escaping functions (brackets and mhchem)
230 // This must happen BEFORE restoring code blocks to avoid affecting code content
231 content = escapeBrackets(content);
232
233 if (doEscapeMhchem && (content.includes('\\ce{') || content.includes('\\pu{'))) {
234 content = escapeMhchem(content);
235 }
236
237 // Step 6: Convert remaining \(...\) → $...$, \[...\] → $$...$$
238 // This must happen BEFORE restoring code blocks to avoid affecting code content
239 content = content
240 // Using the look‑behind pattern `(?<!\\)` we skip matches
241 // that are preceded by a backslash, e.g.
242 // `Definitions\\(also called macros)` (title of chapter 20 in The TeXbook).
243 .replace(/(?<!\\)\\\((.+?)\\\)/g, '$$$1$') // inline
244 .replace(
245 // Using the look‑behind pattern `(?<!\\)` we skip matches
246 // that are preceded by a backslash, e.g. `\\[4pt]`.
247 /(?<!\\)\\\[([\s\S]*?)\\\]/g, // display, see also PR #16599
248 (_, content: string) => {
249 return `$$${content}$$`;
250 }
251 );
252
253 // Step 7: Restore code blocks
254 // This happens AFTER all LaTeX conversions to preserve code content
255 content = content.replace(/<<CODE_BLOCK_(\d+)>>/g, (_, index) => {
256 return codeBlocks[parseInt(index)];
257 });
258
259 // Step 8: Restore blockquote markers
260 if (blockquoteMarkers.size > 0) {
261 const finalLines = content.split('\n');
262 const restoredLines = finalLines.map((line, index) => {
263 const marker = blockquoteMarkers.get(index);
264 return marker ? marker + line : line;
265 });
266 content = restoredLines.join('\n');
267 }
268
269 return content;
270}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts b/llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts
new file mode 100644
index 0000000..136c084
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/modality-file-validation.ts
@@ -0,0 +1,162 @@
1/**
2 * File validation utilities based on model modalities
3 * Ensures only compatible file types are processed based on model capabilities
4 */
5
6import { getFileTypeCategory } from '$lib/utils';
7import { FileTypeCategory } from '$lib/enums';
8
9/** Modality capabilities for file validation */
10export interface ModalityCapabilities {
11 hasVision: boolean;
12 hasAudio: boolean;
13}
14
15/**
16 * Check if a file type is supported by the given modalities
17 * @param filename - The filename to check
18 * @param mimeType - The MIME type of the file
19 * @param capabilities - The modality capabilities to check against
20 * @returns true if the file type is supported
21 */
22export function isFileTypeSupportedByModel(
23 filename: string,
24 mimeType: string | undefined,
25 capabilities: ModalityCapabilities
26): boolean {
27 const category = mimeType ? getFileTypeCategory(mimeType) : null;
28
29 // If we can't determine the category from MIME type, fall back to general support check
30 if (!category) {
31 // For unknown types, only allow if they might be text files
32 // This is a conservative approach for edge cases
33 return true; // Let the existing isFileTypeSupported handle this
34 }
35
36 switch (category) {
37 case FileTypeCategory.TEXT:
38 // Text files are always supported
39 return true;
40
41 case FileTypeCategory.PDF:
42 // PDFs are always supported (will be processed as text for non-vision models)
43 return true;
44
45 case FileTypeCategory.IMAGE:
46 // Images require vision support
47 return capabilities.hasVision;
48
49 case FileTypeCategory.AUDIO:
50 // Audio files require audio support
51 return capabilities.hasAudio;
52
53 default:
54 // Unknown categories - be conservative and allow
55 return true;
56 }
57}
58
59/**
60 * Filter files based on model modalities and return supported/unsupported lists
61 * @param files - Array of files to filter
62 * @param capabilities - The modality capabilities to check against
63 * @returns Object with supportedFiles and unsupportedFiles arrays
64 */
65export function filterFilesByModalities(
66 files: File[],
67 capabilities: ModalityCapabilities
68): {
69 supportedFiles: File[];
70 unsupportedFiles: File[];
71 modalityReasons: Record<string, string>;
72} {
73 const supportedFiles: File[] = [];
74 const unsupportedFiles: File[] = [];
75 const modalityReasons: Record<string, string> = {};
76
77 const { hasVision, hasAudio } = capabilities;
78
79 for (const file of files) {
80 const category = getFileTypeCategory(file.type);
81 let isSupported = true;
82 let reason = '';
83
84 switch (category) {
85 case FileTypeCategory.IMAGE:
86 if (!hasVision) {
87 isSupported = false;
88 reason = 'Images require a vision-capable model';
89 }
90 break;
91
92 case FileTypeCategory.AUDIO:
93 if (!hasAudio) {
94 isSupported = false;
95 reason = 'Audio files require an audio-capable model';
96 }
97 break;
98
99 case FileTypeCategory.TEXT:
100 case FileTypeCategory.PDF:
101 // Always supported
102 break;
103
104 default:
105 // For unknown types, check if it's a generally supported file type
106 // This handles edge cases and maintains backward compatibility
107 break;
108 }
109
110 if (isSupported) {
111 supportedFiles.push(file);
112 } else {
113 unsupportedFiles.push(file);
114 modalityReasons[file.name] = reason;
115 }
116 }
117
118 return { supportedFiles, unsupportedFiles, modalityReasons };
119}
120
121/**
122 * Generate a user-friendly error message for unsupported files
123 * @param unsupportedFiles - Array of unsupported files
124 * @param modalityReasons - Reasons why files are unsupported
125 * @param capabilities - The modality capabilities to check against
126 * @returns Formatted error message
127 */
128export function generateModalityErrorMessage(
129 unsupportedFiles: File[],
130 modalityReasons: Record<string, string>,
131 capabilities: ModalityCapabilities
132): string {
133 if (unsupportedFiles.length === 0) return '';
134
135 const { hasVision, hasAudio } = capabilities;
136
137 let message = '';
138
139 if (unsupportedFiles.length === 1) {
140 const file = unsupportedFiles[0];
141 const reason = modalityReasons[file.name];
142 message = `The file "${file.name}" cannot be uploaded: ${reason}.`;
143 } else {
144 const fileNames = unsupportedFiles.map((f) => f.name).join(', ');
145 message = `The following files cannot be uploaded: ${fileNames}.`;
146 }
147
148 // Add helpful information about what is supported
149 const supportedTypes: string[] = ['text files', 'PDFs'];
150 if (hasVision) supportedTypes.push('images');
151 if (hasAudio) supportedTypes.push('audio files');
152
153 message += ` This model supports: ${supportedTypes.join(', ')}.`;
154
155 return message;
156}
157
158/**
159 * Generate file input accept string based on model modalities
160 * @param capabilities - The modality capabilities to check against
161 * @returns Accept string for HTML file input element
162 */
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/model-names.ts b/llama.cpp/tools/server/webui/src/lib/utils/model-names.ts
new file mode 100644
index 0000000..c0a1e1c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/model-names.ts
@@ -0,0 +1,56 @@
1/**
2 * Normalizes a model name by extracting the filename from a path, but preserves Hugging Face repository format.
3 *
4 * Handles both forward slashes (/) and backslashes (\) as path separators.
5 * - If the model name has exactly one slash (org/model format), preserves the full "org/model" name
6 * - If the model name has no slash or multiple slashes, extracts just the filename
7 * - If the model name is just a filename (no path), returns it as-is.
8 *
9 * @param modelName - The model name or path to normalize
10 * @returns The normalized model name
11 *
12 * @example
13 * normalizeModelName('models/llama-3.1-8b') // Returns: 'llama-3.1-8b' (multiple slashes -> filename)
14 * normalizeModelName('C:\\Models\\gpt-4') // Returns: 'gpt-4' (multiple slashes -> filename)
15 * normalizeModelName('meta-llama/Llama-3.1-8B') // Returns: 'meta-llama/Llama-3.1-8B' (Hugging Face format)
16 * normalizeModelName('simple-model') // Returns: 'simple-model' (no slash)
17 * normalizeModelName(' spaced ') // Returns: 'spaced'
18 * normalizeModelName('') // Returns: ''
19 */
20export function normalizeModelName(modelName: string): string {
21 const trimmed = modelName.trim();
22
23 if (!trimmed) {
24 return '';
25 }
26
27 const segments = trimmed.split(/[\\/]/);
28
29 // If we have exactly 2 segments (one slash), treat it as Hugging Face repo format
30 // and preserve the full "org/model" format
31 if (segments.length === 2) {
32 const [org, model] = segments;
33 const trimmedOrg = org?.trim();
34 const trimmedModel = model?.trim();
35
36 if (trimmedOrg && trimmedModel) {
37 return `${trimmedOrg}/${trimmedModel}`;
38 }
39 }
40
41 // For other cases (no slash, or multiple slashes), extract just the filename
42 const candidate = segments.pop();
43 const normalized = candidate?.trim();
44
45 return normalized && normalized.length > 0 ? normalized : trimmed;
46}
47
48/**
49 * Validates if a model name is valid (non-empty after normalization).
50 *
51 * @param modelName - The model name to validate
52 * @returns true if valid, false otherwise
53 */
54export function isValidModelName(modelName: string): boolean {
55 return normalizeModelName(modelName).length > 0;
56}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts b/llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts
new file mode 100644
index 0000000..84c456d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/pdf-processing.ts
@@ -0,0 +1,150 @@
1/**
2 * PDF processing utilities using PDF.js
3 * Handles PDF text extraction and image conversion in the browser
4 */
5
6import { browser } from '$app/environment';
7import { MimeTypeApplication, MimeTypeImage } from '$lib/enums';
8import * as pdfjs from 'pdfjs-dist';
9
10type TextContent = {
11 items: Array<{ str: string }>;
12};
13
14if (browser) {
15 // Import worker as text and create blob URL for inline bundling
16 import('pdfjs-dist/build/pdf.worker.min.mjs?raw')
17 .then((workerModule) => {
18 const workerBlob = new Blob([workerModule.default], { type: 'application/javascript' });
19 pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(workerBlob);
20 })
21 .catch(() => {
22 console.warn('Failed to load PDF.js worker, PDF processing may not work');
23 });
24}
25
26/**
27 * Convert a File object to ArrayBuffer for PDF.js processing
28 * @param file - The PDF file to convert
29 * @returns Promise resolving to the file's ArrayBuffer
30 */
31async function getFileAsBuffer(file: File): Promise<ArrayBuffer> {
32 return new Promise((resolve, reject) => {
33 const reader = new FileReader();
34 reader.onload = (event) => {
35 if (event.target?.result) {
36 resolve(event.target.result as ArrayBuffer);
37 } else {
38 reject(new Error('Failed to read file.'));
39 }
40 };
41 reader.onerror = () => {
42 reject(new Error('Failed to read file.'));
43 };
44 reader.readAsArrayBuffer(file);
45 });
46}
47
48/**
49 * Extract text content from a PDF file
50 * @param file - The PDF file to process
51 * @returns Promise resolving to the extracted text content
52 */
53export async function convertPDFToText(file: File): Promise<string> {
54 if (!browser) {
55 throw new Error('PDF processing is only available in the browser');
56 }
57
58 try {
59 const buffer = await getFileAsBuffer(file);
60 const pdf = await pdfjs.getDocument(buffer).promise;
61 const numPages = pdf.numPages;
62
63 const textContentPromises: Promise<TextContent>[] = [];
64
65 for (let i = 1; i <= numPages; i++) {
66 // eslint-disable-next-line @typescript-eslint/no-explicit-any
67 textContentPromises.push(pdf.getPage(i).then((page: any) => page.getTextContent()));
68 }
69
70 const textContents = await Promise.all(textContentPromises);
71 const textItems = textContents.flatMap((textContent: TextContent) =>
72 textContent.items.map((item) => item.str ?? '')
73 );
74
75 return textItems.join('\n');
76 } catch (error) {
77 console.error('Error converting PDF to text:', error);
78 throw new Error(
79 `Failed to convert PDF to text: ${error instanceof Error ? error.message : 'Unknown error'}`
80 );
81 }
82}
83
84/**
85 * Convert PDF pages to PNG images as data URLs
86 * @param file - The PDF file to convert
87 * @param scale - Rendering scale factor (default: 1.5)
88 * @returns Promise resolving to array of PNG data URLs
89 */
90export async function convertPDFToImage(file: File, scale: number = 1.5): Promise<string[]> {
91 if (!browser) {
92 throw new Error('PDF processing is only available in the browser');
93 }
94
95 try {
96 const buffer = await getFileAsBuffer(file);
97 const doc = await pdfjs.getDocument(buffer).promise;
98 const pages: Promise<string>[] = [];
99
100 for (let i = 1; i <= doc.numPages; i++) {
101 const page = await doc.getPage(i);
102 const viewport = page.getViewport({ scale });
103 const canvas = document.createElement('canvas');
104 const ctx = canvas.getContext('2d');
105
106 canvas.width = viewport.width;
107 canvas.height = viewport.height;
108
109 if (!ctx) {
110 throw new Error('Failed to get 2D context from canvas');
111 }
112
113 const task = page.render({
114 canvasContext: ctx,
115 viewport: viewport,
116 canvas: canvas
117 });
118 pages.push(
119 task.promise.then(() => {
120 return canvas.toDataURL(MimeTypeImage.PNG);
121 })
122 );
123 }
124
125 return await Promise.all(pages);
126 } catch (error) {
127 console.error('Error converting PDF to images:', error);
128 throw new Error(
129 `Failed to convert PDF to images: ${error instanceof Error ? error.message : 'Unknown error'}`
130 );
131 }
132}
133
134/**
135 * Check if a file is a PDF based on its MIME type
136 * @param file - The file to check
137 * @returns True if the file is a PDF
138 */
139export function isPdfFile(file: File): boolean {
140 return file.type === MimeTypeApplication.PDF;
141}
142
143/**
144 * Check if a MIME type represents a PDF
145 * @param mimeType - The MIME type to check
146 * @returns True if the MIME type is application/pdf
147 */
148export function isApplicationMimeType(mimeType: string): boolean {
149 return mimeType === MimeTypeApplication.PDF;
150}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts b/llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts
new file mode 100644
index 0000000..bffbe89
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/portal-to-body.ts
@@ -0,0 +1,20 @@
1export function portalToBody(node: HTMLElement) {
2 if (typeof document === 'undefined') {
3 return;
4 }
5
6 const target = document.body;
7 if (!target) {
8 return;
9 }
10
11 target.appendChild(node);
12
13 return {
14 destroy() {
15 if (node.parentNode === target) {
16 target.removeChild(node);
17 }
18 }
19 };
20}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/precision.ts b/llama.cpp/tools/server/webui/src/lib/utils/precision.ts
new file mode 100644
index 0000000..6da200c
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/precision.ts
@@ -0,0 +1,25 @@
1/**
2 * Floating-point precision utilities
3 *
4 * Provides functions to normalize floating-point numbers for consistent comparison
5 * and display, addressing JavaScript's floating-point precision issues.
6 */
7
8import { PRECISION_MULTIPLIER } from '$lib/constants/precision';
9
10/**
11 * Normalize floating-point numbers for consistent comparison
12 * Addresses JavaScript floating-point precision issues (e.g., 0.949999988079071 → 0.95)
13 */
14export function normalizeFloatingPoint(value: unknown): unknown {
15 return typeof value === 'number'
16 ? Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER
17 : value;
18}
19
20/**
21 * Type-safe version that only accepts numbers
22 */
23export function normalizeNumber(value: number): number {
24 return Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER;
25}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts b/llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts
new file mode 100644
index 0000000..0342dce
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/process-uploaded-files.ts
@@ -0,0 +1,136 @@
1import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
2import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
3import { FileTypeCategory } from '$lib/enums';
4import { modelsStore } from '$lib/stores/models.svelte';
5import { settingsStore } from '$lib/stores/settings.svelte';
6import { toast } from 'svelte-sonner';
7import { getFileTypeCategory } from '$lib/utils';
8import { convertPDFToText } from './pdf-processing';
9
10/**
11 * Read a file as a data URL (base64 encoded)
12 * @param file - The file to read
13 * @returns Promise resolving to the data URL string
14 */
15function readFileAsDataURL(file: File): Promise<string> {
16 return new Promise((resolve, reject) => {
17 const reader = new FileReader();
18 reader.onload = () => resolve(reader.result as string);
19 reader.onerror = () => reject(reader.error);
20 reader.readAsDataURL(file);
21 });
22}
23
24/**
25 * Read a file as UTF-8 text
26 * @param file - The file to read
27 * @returns Promise resolving to the text content
28 */
29function readFileAsUTF8(file: File): Promise<string> {
30 return new Promise((resolve, reject) => {
31 const reader = new FileReader();
32 reader.onload = () => resolve(reader.result as string);
33 reader.onerror = () => reject(reader.error);
34 reader.readAsText(file);
35 });
36}
37
38/**
39 * Process uploaded files into ChatUploadedFile format with previews and content
40 *
41 * This function processes various file types and generates appropriate previews:
42 * - Images: Base64 data URLs with format normalization (SVG/WebP → PNG)
43 * - Text files: UTF-8 content extraction
44 * - PDFs: Metadata only (processed later in conversion pipeline)
45 * - Audio: Base64 data URLs for preview
46 *
47 * @param files - Array of File objects to process
48 * @returns Promise resolving to array of ChatUploadedFile objects
49 */
50export async function processFilesToChatUploaded(
51 files: File[],
52 activeModelId?: string
53): Promise<ChatUploadedFile[]> {
54 const results: ChatUploadedFile[] = [];
55
56 for (const file of files) {
57 const id = Date.now().toString() + Math.random().toString(36).substr(2, 9);
58 const base: ChatUploadedFile = {
59 id,
60 name: file.name,
61 size: file.size,
62 type: file.type,
63 file
64 };
65
66 try {
67 if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
68 let preview = await readFileAsDataURL(file);
69
70 // Normalize SVG and WebP to PNG in previews
71 if (isSvgMimeType(file.type)) {
72 try {
73 preview = await svgBase64UrlToPngDataURL(preview);
74 } catch (err) {
75 console.error('Failed to convert SVG to PNG:', err);
76 }
77 } else if (isWebpMimeType(file.type)) {
78 try {
79 preview = await webpBase64UrlToPngDataURL(preview);
80 } catch (err) {
81 console.error('Failed to convert WebP to PNG:', err);
82 }
83 }
84
85 results.push({ ...base, preview });
86 } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
87 // Extract text content from PDF for preview
88 try {
89 const textContent = await convertPDFToText(file);
90 results.push({ ...base, textContent });
91 } catch (err) {
92 console.warn('Failed to extract text from PDF, adding without content:', err);
93 results.push(base);
94 }
95
96 // Show suggestion toast if vision model is available but PDF as image is disabled
97 const hasVisionSupport = activeModelId
98 ? modelsStore.modelSupportsVision(activeModelId)
99 : false;
100 const currentConfig = settingsStore.config;
101 if (hasVisionSupport && !currentConfig.pdfAsImage) {
102 toast.info(`You can enable parsing PDF as images with vision models.`, {
103 duration: 8000,
104 action: {
105 label: 'Enable PDF as Images',
106 onClick: () => {
107 settingsStore.updateConfig('pdfAsImage', true);
108 toast.success('PDF parsing as images enabled!', {
109 duration: 3000
110 });
111 }
112 }
113 });
114 }
115 } else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
116 // Generate preview URL for audio files
117 const preview = await readFileAsDataURL(file);
118 results.push({ ...base, preview });
119 } else {
120 // Fallback: treat unknown files as text
121 try {
122 const textContent = await readFileAsUTF8(file);
123 results.push({ ...base, textContent });
124 } catch (err) {
125 console.warn('Failed to read file as text, adding without content:', err);
126 results.push(base);
127 }
128 }
129 } catch (error) {
130 console.error('Error processing file', file.name, error);
131 results.push(base);
132 }
133 }
134
135 return results;
136}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts b/llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts
new file mode 100644
index 0000000..d5a7f7d
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/svg-to-png.ts
@@ -0,0 +1,71 @@
1import { MimeTypeImage } from '$lib/enums';
2
3/**
4 * Convert an SVG base64 data URL to a PNG data URL
5 * @param base64UrlSvg - The SVG base64 data URL to convert
6 * @param backgroundColor - Background color for the PNG (default: 'white')
7 * @returns Promise resolving to PNG data URL
8 */
9export function svgBase64UrlToPngDataURL(
10 base64UrlSvg: string,
11 backgroundColor: string = 'white'
12): Promise<string> {
13 return new Promise((resolve, reject) => {
14 try {
15 const img = new Image();
16
17 img.onload = () => {
18 const canvas = document.createElement('canvas');
19 const ctx = canvas.getContext('2d');
20
21 if (!ctx) {
22 reject(new Error('Failed to get 2D canvas context.'));
23 return;
24 }
25
26 const targetWidth = img.naturalWidth || 300;
27 const targetHeight = img.naturalHeight || 300;
28
29 canvas.width = targetWidth;
30 canvas.height = targetHeight;
31
32 if (backgroundColor) {
33 ctx.fillStyle = backgroundColor;
34 ctx.fillRect(0, 0, canvas.width, canvas.height);
35 }
36 ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
37
38 resolve(canvas.toDataURL(MimeTypeImage.PNG));
39 };
40
41 img.onerror = () => {
42 reject(new Error('Failed to load SVG image. Ensure the SVG data is valid.'));
43 };
44
45 img.src = base64UrlSvg;
46 } catch (error) {
47 const message = error instanceof Error ? error.message : String(error);
48 const errorMessage = `Error converting SVG to PNG: ${message}`;
49 console.error(errorMessage, error);
50 reject(new Error(errorMessage));
51 }
52 });
53}
54
55/**
56 * Check if a file is an SVG based on its MIME type
57 * @param file - The file to check
58 * @returns True if the file is an SVG
59 */
60export function isSvgFile(file: File): boolean {
61 return file.type === MimeTypeImage.SVG;
62}
63
64/**
65 * Check if a MIME type represents an SVG
66 * @param mimeType - The MIME type to check
67 * @returns True if the MIME type is image/svg+xml
68 */
69export function isSvgMimeType(mimeType: string): boolean {
70 return mimeType === MimeTypeImage.SVG;
71}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts b/llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts
new file mode 100644
index 0000000..5384291
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/syntax-highlight-language.ts
@@ -0,0 +1,145 @@
1/**
2 * Maps file extensions to highlight.js language identifiers
3 */
4export function getLanguageFromFilename(filename: string): string {
5 const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
6
7 switch (extension) {
8 // JavaScript / TypeScript
9 case '.js':
10 case '.mjs':
11 case '.cjs':
12 return 'javascript';
13 case '.ts':
14 case '.mts':
15 case '.cts':
16 return 'typescript';
17 case '.jsx':
18 return 'javascript';
19 case '.tsx':
20 return 'typescript';
21
22 // Web
23 case '.html':
24 case '.htm':
25 return 'html';
26 case '.css':
27 return 'css';
28 case '.scss':
29 return 'scss';
30 case '.less':
31 return 'less';
32 case '.vue':
33 return 'html';
34 case '.svelte':
35 return 'html';
36
37 // Data formats
38 case '.json':
39 return 'json';
40 case '.xml':
41 return 'xml';
42 case '.yaml':
43 case '.yml':
44 return 'yaml';
45 case '.toml':
46 return 'ini';
47 case '.csv':
48 return 'plaintext';
49
50 // Programming languages
51 case '.py':
52 return 'python';
53 case '.java':
54 return 'java';
55 case '.kt':
56 case '.kts':
57 return 'kotlin';
58 case '.scala':
59 return 'scala';
60 case '.cpp':
61 case '.cc':
62 case '.cxx':
63 case '.c++':
64 return 'cpp';
65 case '.c':
66 return 'c';
67 case '.h':
68 case '.hpp':
69 return 'cpp';
70 case '.cs':
71 return 'csharp';
72 case '.go':
73 return 'go';
74 case '.rs':
75 return 'rust';
76 case '.rb':
77 return 'ruby';
78 case '.php':
79 return 'php';
80 case '.swift':
81 return 'swift';
82 case '.dart':
83 return 'dart';
84 case '.r':
85 return 'r';
86 case '.lua':
87 return 'lua';
88 case '.pl':
89 case '.pm':
90 return 'perl';
91
92 // Shell
93 case '.sh':
94 case '.bash':
95 case '.zsh':
96 return 'bash';
97 case '.bat':
98 case '.cmd':
99 return 'dos';
100 case '.ps1':
101 return 'powershell';
102
103 // Database
104 case '.sql':
105 return 'sql';
106
107 // Markup / Documentation
108 case '.md':
109 case '.markdown':
110 return 'markdown';
111 case '.tex':
112 case '.latex':
113 return 'latex';
114 case '.adoc':
115 case '.asciidoc':
116 return 'asciidoc';
117
118 // Config
119 case '.ini':
120 case '.cfg':
121 case '.conf':
122 return 'ini';
123 case '.dockerfile':
124 return 'dockerfile';
125 case '.nginx':
126 return 'nginx';
127
128 // Other
129 case '.graphql':
130 case '.gql':
131 return 'graphql';
132 case '.proto':
133 return 'protobuf';
134 case '.diff':
135 case '.patch':
136 return 'diff';
137 case '.log':
138 return 'plaintext';
139 case '.txt':
140 return 'plaintext';
141
142 default:
143 return 'plaintext';
144 }
145}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/text-files.ts b/llama.cpp/tools/server/webui/src/lib/utils/text-files.ts
new file mode 100644
index 0000000..e8006de
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/text-files.ts
@@ -0,0 +1,97 @@
1/**
2 * Text file processing utilities
3 * Handles text file detection, reading, and validation
4 */
5
6import {
7 DEFAULT_BINARY_DETECTION_OPTIONS,
8 type BinaryDetectionOptions
9} from '$lib/constants/binary-detection';
10import { FileExtensionText } from '$lib/enums';
11
12/**
13 * Check if a filename indicates a text file based on its extension
14 * @param filename - The filename to check
15 * @returns True if the filename has a recognized text file extension
16 */
17export function isTextFileByName(filename: string): boolean {
18 const textExtensions = Object.values(FileExtensionText);
19
20 return textExtensions.some((ext: FileExtensionText) => filename.toLowerCase().endsWith(ext));
21}
22
23/**
24 * Read a file's content as text
25 * @param file - The file to read
26 * @returns Promise resolving to the file's text content
27 */
28export async function readFileAsText(file: File): Promise<string> {
29 return new Promise((resolve, reject) => {
30 const reader = new FileReader();
31
32 reader.onload = (event) => {
33 if (event.target?.result !== null && event.target?.result !== undefined) {
34 resolve(event.target.result as string);
35 } else {
36 reject(new Error('Failed to read file'));
37 }
38 };
39
40 reader.onerror = () => reject(new Error('File reading error'));
41
42 reader.readAsText(file);
43 });
44}
45
46/**
47 * Heuristic check to determine if content is likely from a text file
48 * Detects binary files by counting suspicious characters and null bytes
49 * @param content - The file content to analyze
50 * @param options - Optional configuration for detection parameters
51 * @returns True if the content appears to be text-based
52 */
53export function isLikelyTextFile(
54 content: string,
55 options: Partial<BinaryDetectionOptions> = {}
56): boolean {
57 if (!content) return true;
58
59 const config = { ...DEFAULT_BINARY_DETECTION_OPTIONS, ...options };
60 const sample = content.substring(0, config.prefixLength);
61
62 let nullCount = 0;
63 let suspiciousControlCount = 0;
64
65 for (let i = 0; i < sample.length; i++) {
66 const charCode = sample.charCodeAt(i);
67
68 // Count null bytes - these are strong indicators of binary files
69 if (charCode === 0) {
70 nullCount++;
71
72 continue;
73 }
74
75 // Count suspicious control characters
76 // Allow common whitespace characters: tab (9), newline (10), carriage return (13)
77 if (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13) {
78 // Count most suspicious control characters
79 if (charCode < 8 || (charCode > 13 && charCode < 27)) {
80 suspiciousControlCount++;
81 }
82 }
83
84 // Count replacement characters (indicates encoding issues)
85 if (charCode === 0xfffd) {
86 suspiciousControlCount++;
87 }
88 }
89
90 // Reject if too many null bytes
91 if (nullCount > config.maxAbsoluteNullBytes) return false;
92
93 // Reject if too many suspicious characters
94 if (suspiciousControlCount / sample.length > config.suspiciousCharThresholdRatio) return false;
95
96 return true;
97}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/text.ts b/llama.cpp/tools/server/webui/src/lib/utils/text.ts
new file mode 100644
index 0000000..5c5dd0f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/text.ts
@@ -0,0 +1,7 @@
1/**
2 * Returns a shortened preview of the provided content capped at the given length.
3 * Appends an ellipsis when the content exceeds the maximum.
4 */
5export function getPreviewText(content: string, max = 150): string {
6 return content.length > max ? content.slice(0, max) + '...' : content;
7}
diff --git a/llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts b/llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts
new file mode 100644
index 0000000..ea51838
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/utils/webp-to-png.ts
@@ -0,0 +1,73 @@
1import { FileExtensionImage, MimeTypeImage } from '$lib/enums';
2
3/**
4 * Convert a WebP base64 data URL to a PNG data URL
5 * @param base64UrlWebp - The WebP base64 data URL to convert
6 * @param backgroundColor - Background color for the PNG (default: 'white')
7 * @returns Promise resolving to PNG data URL
8 */
9export function webpBase64UrlToPngDataURL(
10 base64UrlWebp: string,
11 backgroundColor: string = 'white'
12): Promise<string> {
13 return new Promise((resolve, reject) => {
14 try {
15 const img = new Image();
16
17 img.onload = () => {
18 const canvas = document.createElement('canvas');
19 const ctx = canvas.getContext('2d');
20
21 if (!ctx) {
22 reject(new Error('Failed to get 2D canvas context.'));
23 return;
24 }
25
26 const targetWidth = img.naturalWidth || 300;
27 const targetHeight = img.naturalHeight || 300;
28
29 canvas.width = targetWidth;
30 canvas.height = targetHeight;
31
32 if (backgroundColor) {
33 ctx.fillStyle = backgroundColor;
34 ctx.fillRect(0, 0, canvas.width, canvas.height);
35 }
36 ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
37
38 resolve(canvas.toDataURL(MimeTypeImage.PNG));
39 };
40
41 img.onerror = () => {
42 reject(new Error('Failed to load WebP image. Ensure the WebP data is valid.'));
43 };
44
45 img.src = base64UrlWebp;
46 } catch (error) {
47 const message = error instanceof Error ? error.message : String(error);
48 const errorMessage = `Error converting WebP to PNG: ${message}`;
49 console.error(errorMessage, error);
50 reject(new Error(errorMessage));
51 }
52 });
53}
54
55/**
56 * Check if a file is a WebP based on its MIME type
57 * @param file - The file to check
58 * @returns True if the file is a WebP
59 */
60export function isWebpFile(file: File): boolean {
61 return (
62 file.type === MimeTypeImage.WEBP || file.name.toLowerCase().endsWith(FileExtensionImage.WEBP)
63 );
64}
65
66/**
67 * Check if a MIME type represents a WebP
68 * @param mimeType - The MIME type to check
69 * @returns True if the MIME type is image/webp
70 */
71export function isWebpMimeType(mimeType: string): boolean {
72 return mimeType === MimeTypeImage.WEBP;
73}
diff --git a/llama.cpp/tools/server/webui/src/routes/+error.svelte b/llama.cpp/tools/server/webui/src/routes/+error.svelte
new file mode 100644
index 0000000..faddf0b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/+error.svelte
@@ -0,0 +1,70 @@
1<script lang="ts">
2 import { page } from '$app/stores';
3 import { goto } from '$app/navigation';
4 import { ServerErrorSplash } from '$lib/components/app';
5
6 let error = $derived($page.error);
7 let status = $derived($page.status);
8
9 // Check if this is an API key related error
10 let isApiKeyError = $derived(
11 status === 401 ||
12 status === 403 ||
13 error?.message?.toLowerCase().includes('access denied') ||
14 error?.message?.toLowerCase().includes('unauthorized') ||
15 error?.message?.toLowerCase().includes('invalid api key')
16 );
17
18 function handleRetry() {
19 // Navigate back to home page after successful API key validation
20 goto('#/');
21 }
22</script>
23
24<svelte:head>
25 <title>Error {status} - WebUI</title>
26</svelte:head>
27
28{#if isApiKeyError}
29 <ServerErrorSplash
30 error={error?.message || 'Access denied - check server permissions'}
31 onRetry={handleRetry}
32 showRetry={false}
33 showTroubleshooting={false}
34 />
35{:else}
36 <!-- Generic error page for non-API key errors -->
37 <div class="flex h-full items-center justify-center">
38 <div class="w-full max-w-md px-4 text-center">
39 <div class="mb-6">
40 <div
41 class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10"
42 >
43 <svg
44 class="h-8 w-8 text-destructive"
45 fill="none"
46 stroke="currentColor"
47 viewBox="0 0 24 24"
48 >
49 <path
50 stroke-linecap="round"
51 stroke-linejoin="round"
52 stroke-width="2"
53 d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
54 />
55 </svg>
56 </div>
57 <h1 class="mb-2 text-2xl font-bold">Error {status}</h1>
58 <p class="text-muted-foreground">
59 {error?.message || 'Something went wrong'}
60 </p>
61 </div>
62 <button
63 onclick={() => goto('#/')}
64 class="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
65 >
66 Go Home
67 </button>
68 </div>
69 </div>
70{/if}
diff --git a/llama.cpp/tools/server/webui/src/routes/+layout.svelte b/llama.cpp/tools/server/webui/src/routes/+layout.svelte
new file mode 100644
index 0000000..095827b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/+layout.svelte
@@ -0,0 +1,223 @@
1<script lang="ts">
2 import '../app.css';
3 import { base } from '$app/paths';
4 import { page } from '$app/state';
5 import { untrack } from 'svelte';
6 import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app';
7 import { isLoading } from '$lib/stores/chat.svelte';
8 import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
9 import * as Sidebar from '$lib/components/ui/sidebar/index.js';
10 import * as Tooltip from '$lib/components/ui/tooltip';
11 import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
12 import { config, settingsStore } from '$lib/stores/settings.svelte';
13 import { ModeWatcher } from 'mode-watcher';
14 import { Toaster } from 'svelte-sonner';
15 import { goto } from '$app/navigation';
16 import { modelsStore } from '$lib/stores/models.svelte';
17 import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
18 import { IsMobile } from '$lib/hooks/is-mobile.svelte';
19
20 let { children } = $props();
21
22 let isChatRoute = $derived(page.route.id === '/chat/[id]');
23 let isHomeRoute = $derived(page.route.id === '/');
24 let isNewChatMode = $derived(page.url.searchParams.get('new_chat') === 'true');
25 let showSidebarByDefault = $derived(activeMessages().length > 0 || isLoading());
26 let alwaysShowSidebarOnDesktop = $derived(config().alwaysShowSidebarOnDesktop);
27 let autoShowSidebarOnNewChat = $derived(config().autoShowSidebarOnNewChat);
28 let isMobile = new IsMobile();
29 let isDesktop = $derived(!isMobile.current);
30 let sidebarOpen = $state(false);
31 let innerHeight = $state<number | undefined>();
32 let chatSidebar:
33 | { activateSearchMode?: () => void; editActiveConversation?: () => void }
34 | undefined = $state();
35
36 // Conversation title update dialog state
37 let titleUpdateDialogOpen = $state(false);
38 let titleUpdateCurrentTitle = $state('');
39 let titleUpdateNewTitle = $state('');
40 let titleUpdateResolve: ((value: boolean) => void) | null = null;
41
42 // Global keyboard shortcuts
43 function handleKeydown(event: KeyboardEvent) {
44 const isCtrlOrCmd = event.ctrlKey || event.metaKey;
45
46 if (isCtrlOrCmd && event.key === 'k') {
47 event.preventDefault();
48 if (chatSidebar?.activateSearchMode) {
49 chatSidebar.activateSearchMode();
50 sidebarOpen = true;
51 }
52 }
53
54 if (isCtrlOrCmd && event.shiftKey && event.key === 'O') {
55 event.preventDefault();
56 goto('?new_chat=true#/');
57 }
58
59 if (event.shiftKey && isCtrlOrCmd && event.key === 'E') {
60 event.preventDefault();
61
62 if (chatSidebar?.editActiveConversation) {
63 chatSidebar.editActiveConversation();
64 }
65 }
66 }
67
68 function handleTitleUpdateCancel() {
69 titleUpdateDialogOpen = false;
70 if (titleUpdateResolve) {
71 titleUpdateResolve(false);
72 titleUpdateResolve = null;
73 }
74 }
75
76 function handleTitleUpdateConfirm() {
77 titleUpdateDialogOpen = false;
78 if (titleUpdateResolve) {
79 titleUpdateResolve(true);
80 titleUpdateResolve = null;
81 }
82 }
83
84 $effect(() => {
85 if (alwaysShowSidebarOnDesktop && isDesktop) {
86 sidebarOpen = true;
87 return;
88 }
89
90 if (isHomeRoute && !isNewChatMode) {
91 // Auto-collapse sidebar when navigating to home route (but not in new chat mode)
92 sidebarOpen = false;
93 } else if (isHomeRoute && isNewChatMode) {
94 // Keep sidebar open in new chat mode
95 sidebarOpen = true;
96 } else if (isChatRoute) {
97 // On chat routes, only auto-show sidebar if setting is enabled
98 if (autoShowSidebarOnNewChat) {
99 sidebarOpen = true;
100 }
101 // If setting is disabled, don't change sidebar state - let user control it manually
102 } else {
103 // Other routes follow default behavior
104 sidebarOpen = showSidebarByDefault;
105 }
106 });
107
108 // Initialize server properties on app load (run once)
109 $effect(() => {
110 // Only fetch if we don't already have props
111 if (!serverStore.props) {
112 untrack(() => {
113 serverStore.fetch();
114 });
115 }
116 });
117
118 // Sync settings when server props are loaded
119 $effect(() => {
120 const serverProps = serverStore.props;
121
122 if (serverProps) {
123 settingsStore.syncWithServerDefaults();
124 }
125 });
126
127 // Fetch router models when in router mode (for status and modalities)
128 // Wait for models to be loaded first, run only once
129 let routerModelsFetched = false;
130
131 $effect(() => {
132 const isRouter = isRouterMode();
133 const modelsCount = modelsStore.models.length;
134
135 // Only fetch router models once when we have models loaded and in router mode
136 if (isRouter && modelsCount > 0 && !routerModelsFetched) {
137 routerModelsFetched = true;
138 untrack(() => {
139 modelsStore.fetchRouterModels();
140 });
141 }
142 });
143
144 // Monitor API key changes and redirect to error page if removed or changed when required
145 $effect(() => {
146 const apiKey = config().apiKey;
147
148 if (
149 (page.route.id === '/' || page.route.id === '/chat/[id]') &&
150 page.status !== 401 &&
151 page.status !== 403
152 ) {
153 const headers: Record<string, string> = {
154 'Content-Type': 'application/json'
155 };
156
157 if (apiKey && apiKey.trim() !== '') {
158 headers.Authorization = `Bearer ${apiKey.trim()}`;
159 }
160
161 fetch(`${base}/props`, { headers })
162 .then((response) => {
163 if (response.status === 401 || response.status === 403) {
164 window.location.reload();
165 }
166 })
167 .catch((e) => {
168 console.error('Error checking API key:', e);
169 });
170 }
171 });
172
173 // Set up title update confirmation callback
174 $effect(() => {
175 conversationsStore.setTitleUpdateConfirmationCallback(
176 async (currentTitle: string, newTitle: string) => {
177 return new Promise<boolean>((resolve) => {
178 titleUpdateCurrentTitle = currentTitle;
179 titleUpdateNewTitle = newTitle;
180 titleUpdateResolve = resolve;
181 titleUpdateDialogOpen = true;
182 });
183 }
184 );
185 });
186</script>
187
188<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
189 <ModeWatcher />
190
191 <Toaster richColors />
192
193 <DialogConversationTitleUpdate
194 bind:open={titleUpdateDialogOpen}
195 currentTitle={titleUpdateCurrentTitle}
196 newTitle={titleUpdateNewTitle}
197 onConfirm={handleTitleUpdateConfirm}
198 onCancel={handleTitleUpdateCancel}
199 />
200
201 <Sidebar.Provider bind:open={sidebarOpen}>
202 <div class="flex h-screen w-full" style:height="{innerHeight}px">
203 <Sidebar.Root class="h-full">
204 <ChatSidebar bind:this={chatSidebar} />
205 </Sidebar.Root>
206
207 {#if !(alwaysShowSidebarOnDesktop && isDesktop)}
208 <Sidebar.Trigger
209 class="transition-left absolute left-0 z-[900] h-8 w-8 duration-200 ease-linear {sidebarOpen
210 ? 'md:left-[var(--sidebar-width)]'
211 : ''}"
212 style="translate: 1rem 1rem;"
213 />
214 {/if}
215
216 <Sidebar.Inset class="flex flex-1 flex-col overflow-hidden">
217 {@render children?.()}
218 </Sidebar.Inset>
219 </div>
220 </Sidebar.Provider>
221</Tooltip.Provider>
222
223<svelte:window onkeydown={handleKeydown} bind:innerHeight />
diff --git a/llama.cpp/tools/server/webui/src/routes/+page.svelte b/llama.cpp/tools/server/webui/src/routes/+page.svelte
new file mode 100644
index 0000000..32a7c2e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/+page.svelte
@@ -0,0 +1,91 @@
1<script lang="ts">
2 import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app';
3 import { chatStore } from '$lib/stores/chat.svelte';
4 import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
5 import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
6 import { onMount } from 'svelte';
7 import { page } from '$app/state';
8 import { replaceState } from '$app/navigation';
9
10 let qParam = $derived(page.url.searchParams.get('q'));
11 let modelParam = $derived(page.url.searchParams.get('model'));
12 let newChatParam = $derived(page.url.searchParams.get('new_chat'));
13
14 // Dialog state for model not available error
15 let showModelNotAvailable = $state(false);
16 let requestedModelName = $state('');
17 let availableModelNames = $derived(modelOptions().map((m) => m.model));
18
19 /**
20 * Clear URL params after message is sent to prevent re-sending on refresh
21 */
22 function clearUrlParams() {
23 const url = new URL(page.url);
24
25 url.searchParams.delete('q');
26 url.searchParams.delete('model');
27 url.searchParams.delete('new_chat');
28
29 replaceState(url.toString(), {});
30 }
31
32 async function handleUrlParams() {
33 await modelsStore.fetch();
34
35 if (modelParam) {
36 const model = modelsStore.findModelByName(modelParam);
37
38 if (model) {
39 try {
40 await modelsStore.selectModelById(model.id);
41 } catch (error) {
42 console.error('Failed to select model:', error);
43 requestedModelName = modelParam;
44 showModelNotAvailable = true;
45
46 return;
47 }
48 } else {
49 requestedModelName = modelParam;
50 showModelNotAvailable = true;
51
52 return;
53 }
54 }
55
56 // Handle ?q= parameter - create new conversation and send message
57 if (qParam !== null) {
58 await conversationsStore.createConversation();
59 await chatStore.sendMessage(qParam);
60 clearUrlParams();
61 } else if (modelParam || newChatParam === 'true') {
62 clearUrlParams();
63 }
64 }
65
66 onMount(async () => {
67 if (!isConversationsInitialized()) {
68 await conversationsStore.initialize();
69 }
70
71 conversationsStore.clearActiveConversation();
72 chatStore.clearUIState();
73
74 // Handle URL params only if we have ?q= or ?model= or ?new_chat=true
75 if (qParam !== null || modelParam !== null || newChatParam === 'true') {
76 await handleUrlParams();
77 }
78 });
79</script>
80
81<svelte:head>
82 <title>llama.cpp - AI Chat Interface</title>
83</svelte:head>
84
85<ChatScreen showCenteredEmpty={true} />
86
87<DialogModelNotAvailable
88 bind:open={showModelNotAvailable}
89 modelName={requestedModelName}
90 availableModels={availableModelNames}
91/>
diff --git a/llama.cpp/tools/server/webui/src/routes/+page.ts b/llama.cpp/tools/server/webui/src/routes/+page.ts
new file mode 100644
index 0000000..7905af6
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/+page.ts
@@ -0,0 +1,6 @@
1import type { PageLoad } from './$types';
2import { validateApiKey } from '$lib/utils';
3
4export const load: PageLoad = async ({ fetch }) => {
5 await validateApiKey(fetch);
6};
diff --git a/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte
new file mode 100644
index 0000000..b897ef5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte
@@ -0,0 +1,176 @@
1<script lang="ts">
2 import { goto, replaceState } from '$app/navigation';
3 import { page } from '$app/state';
4 import { afterNavigate } from '$app/navigation';
5 import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app';
6 import { chatStore, isLoading } from '$lib/stores/chat.svelte';
7 import {
8 conversationsStore,
9 activeConversation,
10 activeMessages
11 } from '$lib/stores/conversations.svelte';
12 import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
13
14 let chatId = $derived(page.params.id);
15 let currentChatId: string | undefined = undefined;
16
17 // URL parameters for prompt and model selection
18 let qParam = $derived(page.url.searchParams.get('q'));
19 let modelParam = $derived(page.url.searchParams.get('model'));
20
21 // Dialog state for model not available error
22 let showModelNotAvailable = $state(false);
23 let requestedModelName = $state('');
24 let availableModelNames = $derived(modelOptions().map((m) => m.model));
25
26 // Track if URL params have been processed for this chat
27 let urlParamsProcessed = $state(false);
28
29 /**
30 * Clear URL params after message is sent to prevent re-sending on refresh
31 */
32 function clearUrlParams() {
33 const url = new URL(page.url);
34 url.searchParams.delete('q');
35 url.searchParams.delete('model');
36 replaceState(url.toString(), {});
37 }
38
39 async function handleUrlParams() {
40 // Ensure models are loaded first
41 await modelsStore.fetch();
42
43 // Handle model parameter - select model if provided
44 if (modelParam) {
45 const model = modelsStore.findModelByName(modelParam);
46 if (model) {
47 try {
48 await modelsStore.selectModelById(model.id);
49 } catch (error) {
50 console.error('Failed to select model:', error);
51 requestedModelName = modelParam;
52 showModelNotAvailable = true;
53 return;
54 }
55 } else {
56 // Model not found - show error dialog
57 requestedModelName = modelParam;
58 showModelNotAvailable = true;
59 return;
60 }
61 }
62
63 // Handle ?q= parameter - send message in current conversation
64 if (qParam !== null) {
65 await chatStore.sendMessage(qParam);
66 // Clear URL params after message is sent
67 clearUrlParams();
68 } else if (modelParam) {
69 // Clear params even if no message was sent (just model selection)
70 clearUrlParams();
71 }
72
73 urlParamsProcessed = true;
74 }
75
76 async function selectModelFromLastAssistantResponse() {
77 const messages = activeMessages();
78 if (messages.length === 0) return;
79
80 let lastMessageWithModel: DatabaseMessage | undefined;
81
82 for (let i = messages.length - 1; i >= 0; i--) {
83 if (messages[i].model) {
84 lastMessageWithModel = messages[i];
85 break;
86 }
87 }
88
89 if (!lastMessageWithModel) return;
90
91 const currentModelId = selectedModelId();
92 const currentModelName = modelOptions().find((m) => m.id === currentModelId)?.model;
93
94 if (currentModelName === lastMessageWithModel.model) {
95 return;
96 }
97
98 const matchingModel = modelOptions().find(
99 (option) => option.model === lastMessageWithModel.model
100 );
101
102 if (matchingModel) {
103 try {
104 await modelsStore.selectModelById(matchingModel.id);
105 console.log(`Automatically loaded model: ${lastMessageWithModel.model} from last message`);
106 } catch (error) {
107 console.warn('Failed to automatically select model from last message:', error);
108 }
109 }
110 }
111
112 afterNavigate(() => {
113 setTimeout(() => {
114 selectModelFromLastAssistantResponse();
115 }, 100);
116 });
117
118 $effect(() => {
119 if (chatId && chatId !== currentChatId) {
120 currentChatId = chatId;
121 urlParamsProcessed = false; // Reset for new chat
122
123 // Skip loading if this conversation is already active (e.g., just created)
124 if (activeConversation()?.id === chatId) {
125 // Still handle URL params even if conversation is active
126 if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) {
127 handleUrlParams();
128 }
129 return;
130 }
131
132 (async () => {
133 const success = await conversationsStore.loadConversation(chatId);
134 if (success) {
135 chatStore.syncLoadingStateForChat(chatId);
136
137 // Handle URL params after conversation is loaded
138 if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) {
139 await handleUrlParams();
140 }
141 } else {
142 await goto('#/');
143 }
144 })();
145 }
146 });
147
148 $effect(() => {
149 if (typeof window !== 'undefined') {
150 const handleBeforeUnload = () => {
151 if (isLoading()) {
152 console.log('Page unload detected while streaming - aborting stream');
153 chatStore.stopGeneration();
154 }
155 };
156
157 window.addEventListener('beforeunload', handleBeforeUnload);
158
159 return () => {
160 window.removeEventListener('beforeunload', handleBeforeUnload);
161 };
162 }
163 });
164</script>
165
166<svelte:head>
167 <title>{activeConversation()?.name || 'Chat'} - llama.cpp</title>
168</svelte:head>
169
170<ChatScreen />
171
172<DialogModelNotAvailable
173 bind:open={showModelNotAvailable}
174 modelName={requestedModelName}
175 availableModels={availableModelNames}
176/>
diff --git a/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts
new file mode 100644
index 0000000..7905af6
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts
@@ -0,0 +1,6 @@
1import type { PageLoad } from './$types';
2import { validateApiKey } from '$lib/utils';
3
4export const load: PageLoad = async ({ fetch }) => {
5 await validateApiKey(fetch);
6};
diff --git a/llama.cpp/tools/server/webui/src/styles/katex-custom.scss b/llama.cpp/tools/server/webui/src/styles/katex-custom.scss
new file mode 100644
index 0000000..9c8b96e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/styles/katex-custom.scss
@@ -0,0 +1,13 @@
1// Override KaTeX SCSS variables to disable ttf and woff fonts
2// Only use woff2 format which is embedded in the bundle
3$use-woff2: true;
4$use-woff: false;
5$use-ttf: false;
6
7// Use Vite alias for font folder
8$font-folder: 'katex-fonts';
9
10// Import KaTeX SCSS with overridden variables
11// Note: @import is deprecated but required because KaTeX uses @import internally
12// The deprecation warnings are from KaTeX's code and cannot be avoided
13@import 'katex/src/styles/katex.scss';