summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components
diff options
context:
space:
mode:
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components')
-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
200 files changed, 13123 insertions, 0 deletions
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 @@
+<script lang="ts">
+ import { Button } from '$lib/components/ui/button';
+ import * as Alert from '$lib/components/ui/alert';
+ import { SyntaxHighlightedCode } from '$lib/components/app';
+ import { FileText, Image, Music, FileIcon, Eye, Info } from '@lucide/svelte';
+ import {
+ isTextFile,
+ isImageFile,
+ isPdfFile,
+ isAudioFile,
+ getLanguageFromFilename
+ } from '$lib/utils';
+ import { convertPDFToImage } from '$lib/utils/browser-only';
+ import { modelsStore } from '$lib/stores/models.svelte';
+
+ interface Props {
+ // Either an uploaded file or a stored attachment
+ uploadedFile?: ChatUploadedFile;
+ attachment?: DatabaseMessageExtra;
+ // For uploaded files
+ preview?: string;
+ name?: string;
+ textContent?: string;
+ // For checking vision modality
+ activeModelId?: string;
+ }
+
+ let { uploadedFile, attachment, preview, name, textContent, activeModelId }: Props = $props();
+
+ let hasVisionModality = $derived(
+ activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
+ );
+
+ let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
+
+ // Determine file type from uploaded file or attachment
+ let isAudio = $derived(isAudioFile(attachment, uploadedFile));
+ let isImage = $derived(isImageFile(attachment, uploadedFile));
+ let isPdf = $derived(isPdfFile(attachment, uploadedFile));
+ let isText = $derived(isTextFile(attachment, uploadedFile));
+
+ let displayPreview = $derived(
+ uploadedFile?.preview ||
+ (isImage && attachment && 'base64Url' in attachment ? attachment.base64Url : preview)
+ );
+
+ let displayTextContent = $derived(
+ uploadedFile?.textContent ||
+ (attachment && 'content' in attachment ? attachment.content : textContent)
+ );
+
+ let language = $derived(getLanguageFromFilename(displayName));
+
+ let IconComponent = $derived(() => {
+ if (isImage) return Image;
+ if (isText || isPdf) return FileText;
+ if (isAudio) return Music;
+
+ return FileIcon;
+ });
+
+ let pdfViewMode = $state<'text' | 'pages'>('pages');
+
+ let pdfImages = $state<string[]>([]);
+
+ let pdfImagesLoading = $state(false);
+
+ let pdfImagesError = $state<string | null>(null);
+
+ async function loadPdfImages() {
+ if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
+
+ pdfImagesLoading = true;
+ pdfImagesError = null;
+
+ try {
+ let file: File | null = null;
+
+ if (uploadedFile?.file) {
+ file = uploadedFile.file;
+ } else if (isPdf && attachment) {
+ // Check if we have pre-processed images
+ if (
+ 'images' in attachment &&
+ attachment.images &&
+ Array.isArray(attachment.images) &&
+ attachment.images.length > 0
+ ) {
+ pdfImages = attachment.images;
+ return;
+ }
+
+ // Convert base64 back to File for processing
+ if ('base64Data' in attachment && attachment.base64Data) {
+ const base64Data = attachment.base64Data;
+ const byteCharacters = atob(base64Data);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ file = new File([byteArray], displayName, { type: 'application/pdf' });
+ }
+ }
+
+ if (file) {
+ pdfImages = await convertPDFToImage(file);
+ } else {
+ throw new Error('No PDF file available for conversion');
+ }
+ } catch (error) {
+ pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
+ } finally {
+ pdfImagesLoading = false;
+ }
+ }
+
+ export function reset() {
+ pdfImages = [];
+ pdfImagesLoading = false;
+ pdfImagesError = null;
+ pdfViewMode = 'pages';
+ }
+
+ $effect(() => {
+ if (isPdf && pdfViewMode === 'pages') {
+ loadPdfImages();
+ }
+ });
+</script>
+
+<div class="space-y-4">
+ <div class="flex items-center justify-end gap-6">
+ {#if isPdf}
+ <div class="flex items-center gap-2">
+ <Button
+ variant={pdfViewMode === 'text' ? 'default' : 'outline'}
+ size="sm"
+ onclick={() => (pdfViewMode = 'text')}
+ disabled={pdfImagesLoading}
+ >
+ <FileText class="mr-1 h-4 w-4" />
+
+ Text
+ </Button>
+
+ <Button
+ variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
+ size="sm"
+ onclick={() => {
+ pdfViewMode = 'pages';
+ loadPdfImages();
+ }}
+ disabled={pdfImagesLoading}
+ >
+ {#if pdfImagesLoading}
+ <div
+ class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
+ ></div>
+ {:else}
+ <Eye class="mr-1 h-4 w-4" />
+ {/if}
+
+ Pages
+ </Button>
+ </div>
+ {/if}
+ </div>
+
+ <div class="flex-1 overflow-auto">
+ {#if isImage && displayPreview}
+ <div class="flex items-center justify-center">
+ <img
+ src={displayPreview}
+ alt={displayName}
+ class="max-h-full rounded-lg object-contain shadow-lg"
+ />
+ </div>
+ {:else if isPdf && pdfViewMode === 'pages'}
+ {#if !hasVisionModality && activeModelId}
+ <Alert.Root class="mb-4">
+ <Info class="h-4 w-4" />
+ <Alert.Title>Preview only</Alert.Title>
+ <Alert.Description>
+ <span class="inline-flex">
+ The selected model does not support vision. Only the extracted
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <span class="mx-1 cursor-pointer underline" onclick={() => (pdfViewMode = 'text')}>
+ text
+ </span>
+ will be sent to the model.
+ </span>
+ </Alert.Description>
+ </Alert.Root>
+ {/if}
+
+ {#if pdfImagesLoading}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ <div
+ class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
+ ></div>
+
+ <p class="text-muted-foreground">Converting PDF to images...</p>
+ </div>
+ </div>
+ {:else if pdfImagesError}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+ <p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
+
+ <p class="text-sm text-muted-foreground">{pdfImagesError}</p>
+
+ <Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
+ </div>
+ </div>
+ {:else if pdfImages.length > 0}
+ <div class="max-h-[70vh] space-y-4 overflow-auto">
+ {#each pdfImages as image, index (image)}
+ <div class="text-center">
+ <p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
+
+ <img
+ src={image}
+ alt="PDF Page {index + 1}"
+ class="mx-auto max-w-full rounded-lg shadow-lg"
+ />
+ </div>
+ {/each}
+ </div>
+ {:else}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+ <p class="mb-4 text-muted-foreground">No PDF pages available</p>
+ </div>
+ </div>
+ {/if}
+ {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
+ <SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="calc(69rem - 2rem)" />
+ {:else if isAudio}
+ <div class="flex items-center justify-center p-8">
+ <div class="w-full max-w-md text-center">
+ <Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+ {#if uploadedFile?.preview}
+ <audio controls class="mb-4 w-full" src={uploadedFile.preview}>
+ Your browser does not support the audio element.
+ </audio>
+ {:else if isAudio && attachment && 'mimeType' in attachment && 'base64Data' in attachment}
+ <audio
+ controls
+ class="mb-4 w-full"
+ src={`data:${attachment.mimeType};base64,${attachment.base64Data}`}
+ >
+ Your browser does not support the audio element.
+ </audio>
+ {:else}
+ <p class="mb-4 text-muted-foreground">Audio preview not available</p>
+ {/if}
+
+ <p class="text-sm text-muted-foreground">
+ {displayName}
+ </p>
+ </div>
+ </div>
+ {:else}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ {#if IconComponent}
+ <IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+ {/if}
+
+ <p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
+ </div>
+ </div>
+ {/if}
+ </div>
+</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 @@
+<script lang="ts">
+ import { RemoveButton } from '$lib/components/app';
+ import { formatFileSize, getFileTypeLabel, getPreviewText, isTextFile } from '$lib/utils';
+ import { AttachmentType } from '$lib/enums';
+
+ interface Props {
+ class?: string;
+ id: string;
+ onClick?: (event?: MouseEvent) => void;
+ onRemove?: (id: string) => void;
+ name: string;
+ readonly?: boolean;
+ size?: number;
+ textContent?: string;
+ // Either uploaded file or stored attachment
+ uploadedFile?: ChatUploadedFile;
+ attachment?: DatabaseMessageExtra;
+ }
+
+ let {
+ class: className = '',
+ id,
+ onClick,
+ onRemove,
+ name,
+ readonly = false,
+ size,
+ textContent,
+ uploadedFile,
+ attachment
+ }: Props = $props();
+
+ let isText = $derived(isTextFile(attachment, uploadedFile));
+
+ let fileTypeLabel = $derived.by(() => {
+ if (uploadedFile?.type) {
+ return getFileTypeLabel(uploadedFile.type);
+ }
+
+ if (attachment) {
+ if ('mimeType' in attachment && attachment.mimeType) {
+ return getFileTypeLabel(attachment.mimeType);
+ }
+
+ if (attachment.type) {
+ return getFileTypeLabel(attachment.type);
+ }
+ }
+
+ return getFileTypeLabel(name);
+ });
+
+ let pdfProcessingMode = $derived.by(() => {
+ if (attachment?.type === AttachmentType.PDF) {
+ const pdfAttachment = attachment as DatabaseMessageExtraPdfFile;
+
+ return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text';
+ }
+ return null;
+ });
+</script>
+
+{#if isText}
+ {#if readonly}
+ <!-- Readonly mode (ChatMessage) -->
+ <button
+ class="cursor-pointer rounded-lg border border-border bg-muted p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl"
+ onclick={onClick}
+ aria-label={`Preview ${name}`}
+ type="button"
+ >
+ <div class="flex items-start gap-3">
+ <div class="flex min-w-0 flex-1 flex-col items-start text-left">
+ <span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
+
+ {#if size}
+ <span class="text-xs text-muted-foreground">{formatFileSize(size)}</span>
+ {/if}
+
+ {#if textContent}
+ <div class="relative mt-2 w-full">
+ <div
+ class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
+ >
+ {getPreviewText(textContent)}
+ </div>
+
+ {#if textContent.length > 150}
+ <div
+ class="pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-muted to-transparent"
+ ></div>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ </div>
+ </button>
+ {:else}
+ <!-- Non-readonly mode (ChatForm) -->
+ <button
+ class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
+ ? 'max-h-24 max-w-72'
+ : 'max-w-36'} cursor-pointer text-left"
+ onclick={onClick}
+ >
+ <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+ <RemoveButton {id} {onRemove} />
+ </div>
+
+ <div class="pr-8">
+ <span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
+
+ {#if textContent}
+ <div class="relative">
+ <div
+ class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
+ style="max-height: 3rem; line-height: 1.2em;"
+ >
+ {getPreviewText(textContent)}
+ </div>
+
+ {#if textContent.length > 150}
+ <div
+ class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent"
+ ></div>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ </button>
+ {/if}
+{:else}
+ <button
+ class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
+ onclick={onClick}
+ >
+ <div
+ class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
+ >
+ {fileTypeLabel}
+ </div>
+
+ <div class="flex flex-col gap-0.5">
+ <span
+ class="max-w-24 truncate text-sm font-medium text-foreground {readonly
+ ? ''
+ : 'group-hover:pr-6'} md:max-w-32"
+ >
+ {name}
+ </span>
+
+ {#if pdfProcessingMode}
+ <span class="text-left text-xs text-muted-foreground">{pdfProcessingMode}</span>
+ {:else if size}
+ <span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
+ {/if}
+ </div>
+
+ {#if !readonly}
+ <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+ <RemoveButton {id} {onRemove} />
+ </div>
+ {/if}
+ </button>
+{/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 @@
+<script lang="ts">
+ import { RemoveButton } from '$lib/components/app';
+
+ interface Props {
+ id: string;
+ name: string;
+ preview: string;
+ readonly?: boolean;
+ onRemove?: (id: string) => void;
+ onClick?: (event?: MouseEvent) => void;
+ class?: string;
+ // Customizable size props
+ width?: string;
+ height?: string;
+ imageClass?: string;
+ }
+
+ let {
+ id,
+ name,
+ preview,
+ readonly = false,
+ onRemove,
+ onClick,
+ class: className = '',
+ // Default to small size for form previews
+ width = 'w-auto',
+ height = 'h-16',
+ imageClass = ''
+ }: Props = $props();
+</script>
+
+<div
+ class="group relative overflow-hidden rounded-lg bg-muted shadow-lg dark:border dark:border-muted {className}"
+>
+ {#if onClick}
+ <button
+ type="button"
+ class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
+ onclick={onClick}
+ aria-label="Preview {name}"
+ >
+ <img
+ src={preview}
+ alt={name}
+ class="{height} {width} cursor-pointer object-cover {imageClass}"
+ />
+ </button>
+ {:else}
+ <img
+ src={preview}
+ alt={name}
+ class="{height} {width} cursor-pointer object-cover {imageClass}"
+ />
+ {/if}
+
+ {#if !readonly}
+ <div
+ class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
+ >
+ <RemoveButton {id} {onRemove} class="text-white" />
+ </div>
+ {/if}
+</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 @@
+<script lang="ts">
+ import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
+ import { Button } from '$lib/components/ui/button';
+ import { ChevronLeft, ChevronRight } from '@lucide/svelte';
+ import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
+ import { getAttachmentDisplayItems } from '$lib/utils';
+
+ interface Props {
+ class?: string;
+ style?: string;
+ // For ChatMessage - stored attachments
+ attachments?: DatabaseMessageExtra[];
+ readonly?: boolean;
+ // For ChatForm - pending uploads
+ onFileRemove?: (fileId: string) => void;
+ uploadedFiles?: ChatUploadedFile[];
+ // Image size customization
+ imageClass?: string;
+ imageHeight?: string;
+ imageWidth?: string;
+ // Limit display to single row with "+ X more" button
+ limitToSingleRow?: boolean;
+ // For vision modality check
+ activeModelId?: string;
+ }
+
+ let {
+ class: className = '',
+ style = '',
+ attachments = [],
+ readonly = false,
+ onFileRemove,
+ uploadedFiles = $bindable([]),
+ // Default to small size for form previews
+ imageClass = '',
+ imageHeight = 'h-24',
+ imageWidth = 'w-auto',
+ limitToSingleRow = false,
+ activeModelId
+ }: Props = $props();
+
+ let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
+
+ let canScrollLeft = $state(false);
+ let canScrollRight = $state(false);
+ let isScrollable = $state(false);
+ let previewDialogOpen = $state(false);
+ let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
+ let scrollContainer: HTMLDivElement | undefined = $state();
+ let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
+ let viewAllDialogOpen = $state(false);
+
+ function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
+ event?.stopPropagation();
+ event?.preventDefault();
+
+ previewItem = {
+ uploadedFile: item.uploadedFile,
+ attachment: item.attachment,
+ preview: item.preview,
+ name: item.name,
+ size: item.size,
+ textContent: item.textContent
+ };
+ previewDialogOpen = true;
+ }
+
+ function scrollLeft(event?: MouseEvent) {
+ event?.stopPropagation();
+ event?.preventDefault();
+
+ if (!scrollContainer) return;
+
+ scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
+ }
+
+ function scrollRight(event?: MouseEvent) {
+ event?.stopPropagation();
+ event?.preventDefault();
+
+ if (!scrollContainer) return;
+
+ scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
+ }
+
+ function updateScrollButtons() {
+ if (!scrollContainer) return;
+
+ const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
+
+ canScrollLeft = scrollLeft > 0;
+ canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
+ isScrollable = scrollWidth > clientWidth;
+ }
+
+ $effect(() => {
+ if (scrollContainer && displayItems.length) {
+ scrollContainer.scrollLeft = 0;
+
+ setTimeout(() => {
+ updateScrollButtons();
+ }, 0);
+ }
+ });
+</script>
+
+{#if displayItems.length > 0}
+ <div class={className} {style}>
+ {#if limitToSingleRow}
+ <div class="relative">
+ <button
+ 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
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollLeft}
+ aria-label="Scroll left"
+ >
+ <ChevronLeft class="h-4 w-4" />
+ </button>
+
+ <div
+ class="scrollbar-hide flex items-start gap-3 overflow-x-auto"
+ bind:this={scrollContainer}
+ onscroll={updateScrollButtons}
+ >
+ {#each displayItems as item (item.id)}
+ {#if item.isImage && item.preview}
+ <ChatAttachmentThumbnailImage
+ class="flex-shrink-0 cursor-pointer {limitToSingleRow
+ ? 'first:ml-4 last:mr-4'
+ : ''}"
+ id={item.id}
+ name={item.name}
+ preview={item.preview}
+ {readonly}
+ onRemove={onFileRemove}
+ height={imageHeight}
+ width={imageWidth}
+ {imageClass}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {:else}
+ <ChatAttachmentThumbnailFile
+ class="flex-shrink-0 cursor-pointer {limitToSingleRow
+ ? 'first:ml-4 last:mr-4'
+ : ''}"
+ id={item.id}
+ name={item.name}
+ size={item.size}
+ {readonly}
+ onRemove={onFileRemove}
+ textContent={item.textContent}
+ attachment={item.attachment}
+ uploadedFile={item.uploadedFile}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {/if}
+ {/each}
+ </div>
+
+ <button
+ 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
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollRight}
+ aria-label="Scroll right"
+ >
+ <ChevronRight class="h-4 w-4" />
+ </button>
+ </div>
+
+ {#if showViewAll}
+ <div class="mt-2 -mr-2 flex justify-end px-4">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ class="h-6 text-xs text-muted-foreground hover:text-foreground"
+ onclick={() => (viewAllDialogOpen = true)}
+ >
+ View all ({displayItems.length})
+ </Button>
+ </div>
+ {/if}
+ {:else}
+ <div class="flex flex-wrap items-start justify-end gap-3">
+ {#each displayItems as item (item.id)}
+ {#if item.isImage && item.preview}
+ <ChatAttachmentThumbnailImage
+ class="cursor-pointer"
+ id={item.id}
+ name={item.name}
+ preview={item.preview}
+ {readonly}
+ onRemove={onFileRemove}
+ height={imageHeight}
+ width={imageWidth}
+ {imageClass}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {:else}
+ <ChatAttachmentThumbnailFile
+ class="cursor-pointer"
+ id={item.id}
+ name={item.name}
+ size={item.size}
+ {readonly}
+ onRemove={onFileRemove}
+ textContent={item.textContent}
+ attachment={item.attachment}
+ uploadedFile={item.uploadedFile}
+ onClick={(event?: MouseEvent) => openPreview(item, event)}
+ />
+ {/if}
+ {/each}
+ </div>
+ {/if}
+ </div>
+{/if}
+
+{#if previewItem}
+ <DialogChatAttachmentPreview
+ bind:open={previewDialogOpen}
+ uploadedFile={previewItem.uploadedFile}
+ attachment={previewItem.attachment}
+ preview={previewItem.preview}
+ name={previewItem.name}
+ size={previewItem.size}
+ textContent={previewItem.textContent}
+ {activeModelId}
+ />
+{/if}
+
+<DialogChatAttachmentsViewAll
+ bind:open={viewAllDialogOpen}
+ {uploadedFiles}
+ {attachments}
+ {readonly}
+ {onFileRemove}
+ imageHeight="h-64"
+ {imageClass}
+ {activeModelId}
+/>
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 @@
+<script lang="ts">
+ import {
+ ChatAttachmentThumbnailImage,
+ ChatAttachmentThumbnailFile,
+ DialogChatAttachmentPreview
+ } from '$lib/components/app';
+ import { getAttachmentDisplayItems } from '$lib/utils';
+
+ interface Props {
+ uploadedFiles?: ChatUploadedFile[];
+ attachments?: DatabaseMessageExtra[];
+ readonly?: boolean;
+ onFileRemove?: (fileId: string) => void;
+ imageHeight?: string;
+ imageWidth?: string;
+ imageClass?: string;
+ activeModelId?: string;
+ }
+
+ let {
+ uploadedFiles = [],
+ attachments = [],
+ readonly = false,
+ onFileRemove,
+ imageHeight = 'h-24',
+ imageWidth = 'w-auto',
+ imageClass = '',
+ activeModelId
+ }: Props = $props();
+
+ let previewDialogOpen = $state(false);
+ let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
+
+ let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
+ let imageItems = $derived(displayItems.filter((item) => item.isImage));
+ let fileItems = $derived(displayItems.filter((item) => !item.isImage));
+
+ function openPreview(item: (typeof displayItems)[0], event?: Event) {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ previewItem = {
+ uploadedFile: item.uploadedFile,
+ attachment: item.attachment,
+ preview: item.preview,
+ name: item.name,
+ size: item.size,
+ textContent: item.textContent
+ };
+ previewDialogOpen = true;
+ }
+</script>
+
+<div class="space-y-4">
+ <div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
+ {#if fileItems.length > 0}
+ <div>
+ <h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
+ <div class="flex flex-wrap items-start gap-3">
+ {#each fileItems as item (item.id)}
+ <ChatAttachmentThumbnailFile
+ class="cursor-pointer"
+ id={item.id}
+ name={item.name}
+ size={item.size}
+ {readonly}
+ onRemove={onFileRemove}
+ textContent={item.textContent}
+ attachment={item.attachment}
+ uploadedFile={item.uploadedFile}
+ onClick={(event?: MouseEvent) => openPreview(item, event)}
+ />
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ {#if imageItems.length > 0}
+ <div>
+ <h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
+ <div class="flex flex-wrap items-start gap-3">
+ {#each imageItems as item (item.id)}
+ {#if item.preview}
+ <ChatAttachmentThumbnailImage
+ class="cursor-pointer"
+ id={item.id}
+ name={item.name}
+ preview={item.preview}
+ {readonly}
+ onRemove={onFileRemove}
+ height={imageHeight}
+ width={imageWidth}
+ {imageClass}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+</div>
+
+{#if previewItem}
+ <DialogChatAttachmentPreview
+ bind:open={previewDialogOpen}
+ uploadedFile={previewItem.uploadedFile}
+ attachment={previewItem.attachment}
+ preview={previewItem.preview}
+ name={previewItem.name}
+ size={previewItem.size}
+ textContent={previewItem.textContent}
+ {activeModelId}
+ />
+{/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 @@
+<script lang="ts">
+ import { afterNavigate } from '$app/navigation';
+ import {
+ ChatAttachmentsList,
+ ChatFormActions,
+ ChatFormFileInputInvisible,
+ ChatFormHelperText,
+ ChatFormTextarea
+ } from '$lib/components/app';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
+ import { config } from '$lib/stores/settings.svelte';
+ import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { activeMessages } from '$lib/stores/conversations.svelte';
+ import { MimeTypeText } from '$lib/enums';
+ import { isIMEComposing, parseClipboardContent } from '$lib/utils';
+ import {
+ AudioRecorder,
+ convertToWav,
+ createAudioFile,
+ isAudioRecordingSupported
+ } from '$lib/utils/browser-only';
+ import { onMount } from 'svelte';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ isLoading?: boolean;
+ onFileRemove?: (fileId: string) => void;
+ onFileUpload?: (files: File[]) => void;
+ onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
+ onStop?: () => void;
+ showHelperText?: boolean;
+ uploadedFiles?: ChatUploadedFile[];
+ }
+
+ let {
+ class: className,
+ disabled = false,
+ isLoading = false,
+ onFileRemove,
+ onFileUpload,
+ onSend,
+ onStop,
+ showHelperText = true,
+ uploadedFiles = $bindable([])
+ }: Props = $props();
+
+ let audioRecorder: AudioRecorder | undefined;
+ let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
+ let currentConfig = $derived(config());
+ let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
+ let isRecording = $state(false);
+ let message = $state('');
+ let pasteLongTextToFileLength = $derived.by(() => {
+ const n = Number(currentConfig.pasteLongTextToFileLen);
+ return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
+ });
+ let previousIsLoading = $state(isLoading);
+ let recordingSupported = $state(false);
+ let textareaRef: ChatFormTextarea | undefined = $state(undefined);
+
+ // Check if model is selected (in ROUTER mode)
+ let conversationModel = $derived(
+ chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ );
+ let isRouter = $derived(isRouterMode());
+ let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
+
+ // Get active model ID for capability detection
+ let activeModelId = $derived.by(() => {
+ const options = modelOptions();
+
+ if (!isRouter) {
+ return options.length > 0 ? options[0].model : null;
+ }
+
+ // First try user-selected model
+ const selectedId = selectedModelId();
+ if (selectedId) {
+ const model = options.find((m) => m.id === selectedId);
+ if (model) return model.model;
+ }
+
+ // Fallback to conversation model
+ if (conversationModel) {
+ const model = options.find((m) => m.model === conversationModel);
+ if (model) return model.model;
+ }
+
+ return null;
+ });
+
+ function checkModelSelected(): boolean {
+ if (!hasModelSelected) {
+ // Open the model selector
+ chatFormActionsRef?.openModelSelector();
+ return false;
+ }
+
+ return true;
+ }
+
+ function handleFileSelect(files: File[]) {
+ onFileUpload?.(files);
+ }
+
+ function handleFileUpload() {
+ fileInputRef?.click();
+ }
+
+ async function handleKeydown(event: KeyboardEvent) {
+ if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
+ event.preventDefault();
+
+ if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
+
+ if (!checkModelSelected()) return;
+
+ const messageToSend = message.trim();
+ const filesToSend = [...uploadedFiles];
+
+ message = '';
+ uploadedFiles = [];
+
+ textareaRef?.resetHeight();
+
+ const success = await onSend?.(messageToSend, filesToSend);
+
+ if (!success) {
+ message = messageToSend;
+ uploadedFiles = filesToSend;
+ }
+ }
+ }
+
+ function handlePaste(event: ClipboardEvent) {
+ if (!event.clipboardData) return;
+
+ const files = Array.from(event.clipboardData.items)
+ .filter((item) => item.kind === 'file')
+ .map((item) => item.getAsFile())
+ .filter((file): file is File => file !== null);
+
+ if (files.length > 0) {
+ event.preventDefault();
+ onFileUpload?.(files);
+
+ return;
+ }
+
+ const text = event.clipboardData.getData(MimeTypeText.PLAIN);
+
+ if (text.startsWith('"')) {
+ const parsed = parseClipboardContent(text);
+
+ if (parsed.textAttachments.length > 0) {
+ event.preventDefault();
+
+ message = parsed.message;
+
+ const attachmentFiles = parsed.textAttachments.map(
+ (att) =>
+ new File([att.content], att.name, {
+ type: MimeTypeText.PLAIN
+ })
+ );
+
+ onFileUpload?.(attachmentFiles);
+
+ setTimeout(() => {
+ textareaRef?.focus();
+ }, 10);
+
+ return;
+ }
+ }
+
+ if (
+ text.length > 0 &&
+ pasteLongTextToFileLength > 0 &&
+ text.length > pasteLongTextToFileLength
+ ) {
+ event.preventDefault();
+
+ const textFile = new File([text], 'Pasted', {
+ type: MimeTypeText.PLAIN
+ });
+
+ onFileUpload?.([textFile]);
+ }
+ }
+
+ async function handleMicClick() {
+ if (!audioRecorder || !recordingSupported) {
+ console.warn('Audio recording not supported');
+
+ return;
+ }
+
+ if (isRecording) {
+ try {
+ const audioBlob = await audioRecorder.stopRecording();
+ const wavBlob = await convertToWav(audioBlob);
+ const audioFile = createAudioFile(wavBlob);
+
+ onFileUpload?.([audioFile]);
+ isRecording = false;
+ } catch (error) {
+ console.error('Failed to stop recording:', error);
+ isRecording = false;
+ }
+ } else {
+ try {
+ await audioRecorder.startRecording();
+ isRecording = true;
+ } catch (error) {
+ console.error('Failed to start recording:', error);
+ }
+ }
+ }
+
+ function handleStop() {
+ onStop?.();
+ }
+
+ async function handleSubmit(event: SubmitEvent) {
+ event.preventDefault();
+ if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
+
+ // Check if model is selected first
+ if (!checkModelSelected()) return;
+
+ const messageToSend = message.trim();
+ const filesToSend = [...uploadedFiles];
+
+ message = '';
+ uploadedFiles = [];
+
+ textareaRef?.resetHeight();
+
+ const success = await onSend?.(messageToSend, filesToSend);
+
+ if (!success) {
+ message = messageToSend;
+ uploadedFiles = filesToSend;
+ }
+ }
+
+ onMount(() => {
+ setTimeout(() => textareaRef?.focus(), 10);
+ recordingSupported = isAudioRecordingSupported();
+ audioRecorder = new AudioRecorder();
+ });
+
+ afterNavigate(() => {
+ setTimeout(() => textareaRef?.focus(), 10);
+ });
+
+ $effect(() => {
+ if (previousIsLoading && !isLoading) {
+ setTimeout(() => textareaRef?.focus(), 10);
+ }
+
+ previousIsLoading = isLoading;
+ });
+</script>
+
+<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
+
+<form
+ onsubmit={handleSubmit}
+ class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
+ ? 'cursor-not-allowed opacity-60'
+ : ''} {className}"
+ data-slot="chat-form"
+>
+ <ChatAttachmentsList
+ bind:uploadedFiles
+ {onFileRemove}
+ limitToSingleRow
+ class="py-5"
+ style="scroll-padding: 1rem;"
+ activeModelId={activeModelId ?? undefined}
+ />
+
+ <div
+ class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
+ onpaste={handlePaste}
+ >
+ <ChatFormTextarea
+ bind:this={textareaRef}
+ bind:value={message}
+ onKeydown={handleKeydown}
+ {disabled}
+ />
+
+ <ChatFormActions
+ bind:this={chatFormActionsRef}
+ canSend={message.trim().length > 0 || uploadedFiles.length > 0}
+ hasText={message.trim().length > 0}
+ {disabled}
+ {isLoading}
+ {isRecording}
+ {uploadedFiles}
+ onFileUpload={handleFileUpload}
+ onMicClick={handleMicClick}
+ onStop={handleStop}
+ />
+ </div>
+</form>
+
+<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 @@
+<script lang="ts">
+ import { Paperclip } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { FILE_TYPE_ICONS } from '$lib/constants/icons';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ hasAudioModality?: boolean;
+ hasVisionModality?: boolean;
+ onFileUpload?: () => void;
+ }
+
+ let {
+ class: className = '',
+ disabled = false,
+ hasAudioModality = false,
+ hasVisionModality = false,
+ onFileUpload
+ }: Props = $props();
+
+ const fileUploadTooltipText = $derived.by(() => {
+ return !hasVisionModality
+ ? 'Text files and PDFs supported. Images, audio, and video require vision models.'
+ : 'Attach files';
+ });
+</script>
+
+<div class="flex items-center gap-1 {className}">
+ <DropdownMenu.Root>
+ <DropdownMenu.Trigger name="Attach files" {disabled}>
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
+ {disabled}
+ type="button"
+ >
+ <span class="sr-only">Attach files</span>
+
+ <Paperclip class="h-4 w-4" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>{fileUploadTooltipText}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </DropdownMenu.Trigger>
+
+ <DropdownMenu.Content align="start" class="w-48">
+ <Tooltip.Root>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="images-button flex cursor-pointer items-center gap-2"
+ disabled={!hasVisionModality}
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.image class="h-4 w-4" />
+
+ <span>Images</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ {#if !hasVisionModality}
+ <Tooltip.Content>
+ <p>Images require vision models to be processed</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+
+ <Tooltip.Root>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="audio-button flex cursor-pointer items-center gap-2"
+ disabled={!hasAudioModality}
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.audio class="h-4 w-4" />
+
+ <span>Audio Files</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ {#if !hasAudioModality}
+ <Tooltip.Content>
+ <p>Audio files require audio models to be processed</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.text class="h-4 w-4" />
+
+ <span>Text Files</span>
+ </DropdownMenu.Item>
+
+ <Tooltip.Root>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => onFileUpload?.()}
+ >
+ <FILE_TYPE_ICONS.pdf class="h-4 w-4" />
+
+ <span>PDF Files</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ {#if !hasVisionModality}
+ <Tooltip.Content>
+ <p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+ </DropdownMenu.Content>
+ </DropdownMenu.Root>
+</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 @@
+<script lang="ts">
+ import { Mic, Square } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ hasAudioModality?: boolean;
+ isLoading?: boolean;
+ isRecording?: boolean;
+ onMicClick?: () => void;
+ }
+
+ let {
+ class: className = '',
+ disabled = false,
+ hasAudioModality = false,
+ isLoading = false,
+ isRecording = false,
+ onMicClick
+ }: Props = $props();
+</script>
+
+<div class="flex items-center gap-1 {className}">
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ class="h-8 w-8 rounded-full p-0 {isRecording
+ ? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
+ : ''}"
+ disabled={disabled || isLoading || !hasAudioModality}
+ onclick={onMicClick}
+ type="button"
+ >
+ <span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
+
+ {#if isRecording}
+ <Square class="h-4 w-4 animate-pulse fill-white" />
+ {:else}
+ <Mic class="h-4 w-4" />
+ {/if}
+ </Button>
+ </Tooltip.Trigger>
+
+ {#if !hasAudioModality}
+ <Tooltip.Content>
+ <p>Current model does not support audio</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+</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 @@
+<script lang="ts">
+ import { ArrowUp } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { cn } from '$lib/components/ui/utils';
+
+ interface Props {
+ canSend?: boolean;
+ disabled?: boolean;
+ isLoading?: boolean;
+ showErrorState?: boolean;
+ tooltipLabel?: string;
+ }
+
+ let {
+ canSend = false,
+ disabled = false,
+ isLoading = false,
+ showErrorState = false,
+ tooltipLabel
+ }: Props = $props();
+
+ let isDisabled = $derived(!canSend || disabled || isLoading);
+</script>
+
+{#snippet submitButton(props = {})}
+ <Button
+ type="submit"
+ disabled={isDisabled}
+ class={cn(
+ 'h-8 w-8 rounded-full p-0',
+ showErrorState
+ ? 'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
+ : ''
+ )}
+ {...props}
+ >
+ <span class="sr-only">Send</span>
+ <ArrowUp class="h-12 w-12" />
+ </Button>
+{/snippet}
+
+{#if tooltipLabel}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ {@render submitButton()}
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>{tooltipLabel}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+{:else}
+ {@render submitButton()}
+{/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 @@
+<script lang="ts">
+ import { Square } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import {
+ ChatFormActionFileAttachments,
+ ChatFormActionRecord,
+ ChatFormActionSubmit,
+ ModelsSelector
+ } from '$lib/components/app';
+ import { FileTypeCategory } from '$lib/enums';
+ import { getFileTypeCategory } from '$lib/utils';
+ import { config } from '$lib/stores/settings.svelte';
+ import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { activeMessages, usedModalities } from '$lib/stores/conversations.svelte';
+ import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
+
+ interface Props {
+ canSend?: boolean;
+ class?: string;
+ disabled?: boolean;
+ isLoading?: boolean;
+ isRecording?: boolean;
+ hasText?: boolean;
+ uploadedFiles?: ChatUploadedFile[];
+ onFileUpload?: () => void;
+ onMicClick?: () => void;
+ onStop?: () => void;
+ }
+
+ let {
+ canSend = false,
+ class: className = '',
+ disabled = false,
+ isLoading = false,
+ isRecording = false,
+ hasText = false,
+ uploadedFiles = [],
+ onFileUpload,
+ onMicClick,
+ onStop
+ }: Props = $props();
+
+ let currentConfig = $derived(config());
+ let isRouter = $derived(isRouterMode());
+
+ let conversationModel = $derived(
+ chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ );
+
+ let previousConversationModel: string | null = null;
+
+ $effect(() => {
+ if (conversationModel && conversationModel !== previousConversationModel) {
+ previousConversationModel = conversationModel;
+ modelsStore.selectModelByName(conversationModel);
+ }
+ });
+
+ let activeModelId = $derived.by(() => {
+ const options = modelOptions();
+
+ if (!isRouter) {
+ return options.length > 0 ? options[0].model : null;
+ }
+
+ const selectedId = selectedModelId();
+ if (selectedId) {
+ const model = options.find((m) => m.id === selectedId);
+ if (model) return model.model;
+ }
+
+ if (conversationModel) {
+ const model = options.find((m) => m.model === conversationModel);
+ if (model) return model.model;
+ }
+
+ return null;
+ });
+
+ let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
+
+ $effect(() => {
+ if (activeModelId) {
+ const cached = modelsStore.getModelProps(activeModelId);
+
+ if (!cached) {
+ modelsStore.fetchModelProps(activeModelId).then(() => {
+ modelPropsVersion++;
+ });
+ }
+ }
+ });
+
+ let hasAudioModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+
+ return modelsStore.modelSupportsAudio(activeModelId);
+ }
+
+ return false;
+ });
+
+ let hasVisionModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+
+ return modelsStore.modelSupportsVision(activeModelId);
+ }
+
+ return false;
+ });
+
+ let hasAudioAttachments = $derived(
+ uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
+ );
+ let shouldShowRecordButton = $derived(
+ hasAudioModality && !hasText && !hasAudioAttachments && currentConfig.autoMicOnEmpty
+ );
+
+ let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
+
+ let isSelectedModelInCache = $derived.by(() => {
+ if (!isRouter) return true;
+
+ if (conversationModel) {
+ return modelOptions().some((option) => option.model === conversationModel);
+ }
+
+ const currentModelId = selectedModelId();
+ if (!currentModelId) return false;
+
+ return modelOptions().some((option) => option.id === currentModelId);
+ });
+
+ let submitTooltip = $derived.by(() => {
+ if (!hasModelSelected) {
+ return 'Please select a model first';
+ }
+
+ if (!isSelectedModelInCache) {
+ return 'Selected model is not available, please select another';
+ }
+
+ return '';
+ });
+
+ let selectorModelRef: ModelsSelector | undefined = $state(undefined);
+
+ export function openModelSelector() {
+ selectorModelRef?.open();
+ }
+
+ const { handleModelChange } = useModelChangeValidation({
+ getRequiredModalities: () => usedModalities(),
+ onValidationFailure: async (previousModelId) => {
+ if (previousModelId) {
+ await modelsStore.selectModelById(previousModelId);
+ }
+ }
+ });
+</script>
+
+<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
+ <ChatFormActionFileAttachments
+ class="mr-auto"
+ {disabled}
+ {hasAudioModality}
+ {hasVisionModality}
+ {onFileUpload}
+ />
+
+ <ModelsSelector
+ {disabled}
+ bind:this={selectorModelRef}
+ currentModel={conversationModel}
+ forceForegroundText={true}
+ useGlobalSelection={true}
+ onModelChange={handleModelChange}
+ />
+
+ {#if isLoading}
+ <Button
+ type="button"
+ onclick={onStop}
+ class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
+ >
+ <span class="sr-only">Stop</span>
+ <Square class="h-8 w-8 fill-destructive stroke-destructive" />
+ </Button>
+ {:else if shouldShowRecordButton}
+ <ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
+ {:else}
+ <ChatFormActionSubmit
+ canSend={canSend && hasModelSelected && isSelectedModelInCache}
+ {disabled}
+ {isLoading}
+ tooltipLabel={submitTooltip}
+ showErrorState={hasModelSelected && !isSelectedModelInCache}
+ />
+ {/if}
+</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 @@
+<script lang="ts">
+ interface Props {
+ class?: string;
+ multiple?: boolean;
+ onFileSelect?: (files: File[]) => void;
+ }
+
+ let { class: className = '', multiple = true, onFileSelect }: Props = $props();
+
+ let fileInputElement: HTMLInputElement | undefined;
+
+ export function click() {
+ fileInputElement?.click();
+ }
+
+ function handleFileSelect(event: Event) {
+ const input = event.target as HTMLInputElement;
+ if (input.files) {
+ onFileSelect?.(Array.from(input.files));
+ }
+ }
+</script>
+
+<input
+ bind:this={fileInputElement}
+ type="file"
+ {multiple}
+ onchange={handleFileSelect}
+ class="hidden {className}"
+/>
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 @@
+<script lang="ts">
+ interface Props {
+ class?: string;
+ show?: boolean;
+ }
+
+ let { class: className = '', show = true }: Props = $props();
+</script>
+
+{#if show}
+ <div class="mt-4 flex items-center justify-center {className}">
+ <p class="text-xs text-muted-foreground">
+ Press <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Enter</kbd> to send,
+ <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Shift + Enter</kbd> for new line
+ </p>
+ </div>
+{/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 @@
+<script lang="ts">
+ import { autoResizeTextarea } from '$lib/utils';
+ import { onMount } from 'svelte';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ onKeydown?: (event: KeyboardEvent) => void;
+ onPaste?: (event: ClipboardEvent) => void;
+ placeholder?: string;
+ value?: string;
+ }
+
+ let {
+ class: className = '',
+ disabled = false,
+ onKeydown,
+ onPaste,
+ placeholder = 'Ask anything...',
+ value = $bindable('')
+ }: Props = $props();
+
+ let textareaElement: HTMLTextAreaElement | undefined;
+
+ onMount(() => {
+ if (textareaElement) {
+ textareaElement.focus();
+ }
+ });
+
+ // Expose the textarea element for external access
+ export function getElement() {
+ return textareaElement;
+ }
+
+ export function focus() {
+ textareaElement?.focus();
+ }
+
+ export function resetHeight() {
+ if (textareaElement) {
+ textareaElement.style.height = '1rem';
+ }
+ }
+</script>
+
+<div class="flex-1 {className}">
+ <textarea
+ bind:this={textareaElement}
+ bind:value
+ 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"
+ class:cursor-not-allowed={disabled}
+ {disabled}
+ onkeydown={onKeydown}
+ oninput={(event) => autoResizeTextarea(event.currentTarget)}
+ onpaste={onPaste}
+ {placeholder}
+ ></textarea>
+</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 @@
+<script lang="ts">
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
+ import ChatMessageAssistant from './ChatMessageAssistant.svelte';
+ import ChatMessageUser from './ChatMessageUser.svelte';
+ import ChatMessageSystem from './ChatMessageSystem.svelte';
+
+ interface Props {
+ class?: string;
+ message: DatabaseMessage;
+ onCopy?: (message: DatabaseMessage) => void;
+ onContinueAssistantMessage?: (message: DatabaseMessage) => void;
+ onDelete?: (message: DatabaseMessage) => void;
+ onEditWithBranching?: (
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) => void;
+ onEditWithReplacement?: (
+ message: DatabaseMessage,
+ newContent: string,
+ shouldBranch: boolean
+ ) => void;
+ onEditUserMessagePreserveResponses?: (
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ }
+
+ let {
+ class: className = '',
+ message,
+ onCopy,
+ onContinueAssistantMessage,
+ onDelete,
+ onEditWithBranching,
+ onEditWithReplacement,
+ onEditUserMessagePreserveResponses,
+ onNavigateToSibling,
+ onRegenerateWithBranching,
+ siblingInfo = null
+ }: Props = $props();
+
+ let deletionInfo = $state<{
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null>(null);
+ let editedContent = $state(message.content);
+ let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
+ let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
+ let isEditing = $state(false);
+ let showDeleteDialog = $state(false);
+ let shouldBranchAfterEdit = $state(false);
+ let textareaElement: HTMLTextAreaElement | undefined = $state();
+
+ let thinkingContent = $derived.by(() => {
+ if (message.role === 'assistant') {
+ const trimmedThinking = message.thinking?.trim();
+
+ return trimmedThinking ? trimmedThinking : null;
+ }
+ return null;
+ });
+
+ let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
+ if (message.role === 'assistant') {
+ const trimmedToolCalls = message.toolCalls?.trim();
+
+ if (!trimmedToolCalls) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(trimmedToolCalls);
+
+ if (Array.isArray(parsed)) {
+ return parsed as ApiChatCompletionToolCall[];
+ }
+ } catch {
+ // Harmony-only path: fall back to the raw string so issues surface visibly.
+ }
+
+ return trimmedToolCalls;
+ }
+ return null;
+ });
+
+ function handleCancelEdit() {
+ isEditing = false;
+ editedContent = message.content;
+ editedExtras = message.extra ? [...message.extra] : [];
+ editedUploadedFiles = [];
+ }
+
+ function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
+ editedExtras = extras;
+ }
+
+ function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
+ editedUploadedFiles = files;
+ }
+
+ async function handleCopy() {
+ const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
+ const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
+ await copyToClipboard(clipboardContent, 'Message copied to clipboard');
+ onCopy?.(message);
+ }
+
+ function handleConfirmDelete() {
+ onDelete?.(message);
+ showDeleteDialog = false;
+ }
+
+ async function handleDelete() {
+ deletionInfo = await chatStore.getDeletionInfo(message.id);
+ showDeleteDialog = true;
+ }
+
+ function handleEdit() {
+ isEditing = true;
+ editedContent = message.content;
+ editedExtras = message.extra ? [...message.extra] : [];
+ editedUploadedFiles = [];
+
+ setTimeout(() => {
+ if (textareaElement) {
+ textareaElement.focus();
+ textareaElement.setSelectionRange(
+ textareaElement.value.length,
+ textareaElement.value.length
+ );
+ }
+ }, 0);
+ }
+
+ function handleEditedContentChange(content: string) {
+ editedContent = content;
+ }
+
+ function handleEditKeydown(event: KeyboardEvent) {
+ // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
+ // This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
+ if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
+ event.preventDefault();
+ handleSaveEdit();
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ handleCancelEdit();
+ }
+ }
+
+ function handleRegenerate(modelOverride?: string) {
+ onRegenerateWithBranching?.(message, modelOverride);
+ }
+
+ function handleContinue() {
+ onContinueAssistantMessage?.(message);
+ }
+
+ async function handleSaveEdit() {
+ if (message.role === 'user' || message.role === 'system') {
+ const finalExtras = await getMergedExtras();
+ onEditWithBranching?.(message, editedContent.trim(), finalExtras);
+ } else {
+ // For assistant messages, preserve exact content including trailing whitespace
+ // This is important for the Continue feature to work properly
+ onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
+ }
+
+ isEditing = false;
+ shouldBranchAfterEdit = false;
+ editedUploadedFiles = [];
+ }
+
+ async function handleSaveEditOnly() {
+ if (message.role === 'user') {
+ // For user messages, trim to avoid accidental whitespace
+ const finalExtras = await getMergedExtras();
+ onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
+ }
+
+ isEditing = false;
+ editedUploadedFiles = [];
+ }
+
+ async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
+ if (editedUploadedFiles.length === 0) {
+ return editedExtras;
+ }
+
+ const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
+ const result = await parseFilesToMessageExtras(editedUploadedFiles);
+ const newExtras = result?.extras || [];
+
+ return [...editedExtras, ...newExtras];
+ }
+
+ function handleShowDeleteDialogChange(show: boolean) {
+ showDeleteDialog = show;
+ }
+</script>
+
+{#if message.role === 'system'}
+ <ChatMessageSystem
+ bind:textareaElement
+ class={className}
+ {deletionInfo}
+ {editedContent}
+ {isEditing}
+ {message}
+ onCancelEdit={handleCancelEdit}
+ onConfirmDelete={handleConfirmDelete}
+ onCopy={handleCopy}
+ onDelete={handleDelete}
+ onEdit={handleEdit}
+ onEditKeydown={handleEditKeydown}
+ onEditedContentChange={handleEditedContentChange}
+ {onNavigateToSibling}
+ onSaveEdit={handleSaveEdit}
+ onShowDeleteDialogChange={handleShowDeleteDialogChange}
+ {showDeleteDialog}
+ {siblingInfo}
+ />
+{:else if message.role === 'user'}
+ <ChatMessageUser
+ bind:textareaElement
+ class={className}
+ {deletionInfo}
+ {editedContent}
+ {editedExtras}
+ {editedUploadedFiles}
+ {isEditing}
+ {message}
+ onCancelEdit={handleCancelEdit}
+ onConfirmDelete={handleConfirmDelete}
+ onCopy={handleCopy}
+ onDelete={handleDelete}
+ onEdit={handleEdit}
+ onEditKeydown={handleEditKeydown}
+ onEditedContentChange={handleEditedContentChange}
+ onEditedExtrasChange={handleEditedExtrasChange}
+ onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
+ {onNavigateToSibling}
+ onSaveEdit={handleSaveEdit}
+ onSaveEditOnly={handleSaveEditOnly}
+ onShowDeleteDialogChange={handleShowDeleteDialogChange}
+ {showDeleteDialog}
+ {siblingInfo}
+ />
+{:else}
+ <ChatMessageAssistant
+ bind:textareaElement
+ class={className}
+ {deletionInfo}
+ {editedContent}
+ {isEditing}
+ {message}
+ messageContent={message.content}
+ onCancelEdit={handleCancelEdit}
+ onConfirmDelete={handleConfirmDelete}
+ onContinue={handleContinue}
+ onCopy={handleCopy}
+ onDelete={handleDelete}
+ onEdit={handleEdit}
+ onEditKeydown={handleEditKeydown}
+ onEditedContentChange={handleEditedContentChange}
+ {onNavigateToSibling}
+ onRegenerate={handleRegenerate}
+ onSaveEdit={handleSaveEdit}
+ onShowDeleteDialogChange={handleShowDeleteDialogChange}
+ {shouldBranchAfterEdit}
+ onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
+ {showDeleteDialog}
+ {siblingInfo}
+ {thinkingContent}
+ {toolCallContent}
+ />
+{/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 @@
+<script lang="ts">
+ import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
+ import {
+ ActionButton,
+ ChatMessageBranchingControls,
+ DialogConfirmation
+ } from '$lib/components/app';
+
+ interface Props {
+ role: 'user' | 'assistant';
+ justify: 'start' | 'end';
+ actionsPosition: 'left' | 'right';
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ showDeleteDialog: boolean;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ onCopy: () => void;
+ onEdit?: () => void;
+ onRegenerate?: () => void;
+ onContinue?: () => void;
+ onDelete: () => void;
+ onConfirmDelete: () => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ }
+
+ let {
+ actionsPosition,
+ deletionInfo,
+ justify,
+ onCopy,
+ onEdit,
+ onConfirmDelete,
+ onContinue,
+ onDelete,
+ onNavigateToSibling,
+ onShowDeleteDialogChange,
+ onRegenerate,
+ role,
+ siblingInfo = null,
+ showDeleteDialog
+ }: Props = $props();
+
+ function handleConfirmDelete() {
+ onConfirmDelete();
+ onShowDeleteDialogChange(false);
+ }
+</script>
+
+<div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-{justify}">
+ <div
+ class="absolute top-0 {actionsPosition === 'left'
+ ? 'left-0'
+ : 'right-0'} flex items-center gap-2 opacity-100 transition-opacity"
+ >
+ {#if siblingInfo && siblingInfo.totalSiblings > 1}
+ <ChatMessageBranchingControls {siblingInfo} {onNavigateToSibling} />
+ {/if}
+
+ <div
+ class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
+ >
+ <ActionButton icon={Copy} tooltip="Copy" onclick={onCopy} />
+
+ {#if onEdit}
+ <ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} />
+ {/if}
+
+ {#if role === 'assistant' && onRegenerate}
+ <ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
+ {/if}
+
+ {#if role === 'assistant' && onContinue}
+ <ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
+ {/if}
+
+ <ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
+ </div>
+ </div>
+</div>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete Message"
+ description={deletionInfo && deletionInfo.totalCount > 1
+ ? `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.`
+ : 'Are you sure you want to delete this message? This action cannot be undone.'}
+ confirmText={deletionInfo && deletionInfo.totalCount > 1
+ ? `Delete ${deletionInfo.totalCount} Messages`
+ : 'Delete'}
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleConfirmDelete}
+ onCancel={() => onShowDeleteDialogChange(false)}
+/>
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 @@
+<script lang="ts">
+ import {
+ ModelBadge,
+ ChatMessageActions,
+ ChatMessageStatistics,
+ ChatMessageThinkingBlock,
+ CopyToClipboardIcon,
+ MarkdownContent,
+ ModelsSelector
+ } from '$lib/components/app';
+ import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+ import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
+ import { isLoading } from '$lib/stores/chat.svelte';
+ import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
+ import { fade } from 'svelte/transition';
+ import { Check, X, Wrench } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Checkbox } from '$lib/components/ui/checkbox';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import Label from '$lib/components/ui/label/label.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+
+ interface Props {
+ class?: string;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ editedContent?: string;
+ isEditing?: boolean;
+ message: DatabaseMessage;
+ messageContent: string | undefined;
+ onCancelEdit?: () => void;
+ onCopy: () => void;
+ onConfirmDelete: () => void;
+ onContinue?: () => void;
+ onDelete: () => void;
+ onEdit?: () => void;
+ onEditKeydown?: (event: KeyboardEvent) => void;
+ onEditedContentChange?: (content: string) => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onRegenerate: (modelOverride?: string) => void;
+ onSaveEdit?: () => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ onShouldBranchAfterEditChange?: (value: boolean) => void;
+ showDeleteDialog: boolean;
+ shouldBranchAfterEdit?: boolean;
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ textareaElement?: HTMLTextAreaElement;
+ thinkingContent: string | null;
+ toolCallContent: ApiChatCompletionToolCall[] | string | null;
+ }
+
+ let {
+ class: className = '',
+ deletionInfo,
+ editedContent = '',
+ isEditing = false,
+ message,
+ messageContent,
+ onCancelEdit,
+ onConfirmDelete,
+ onContinue,
+ onCopy,
+ onDelete,
+ onEdit,
+ onEditKeydown,
+ onEditedContentChange,
+ onNavigateToSibling,
+ onRegenerate,
+ onSaveEdit,
+ onShowDeleteDialogChange,
+ onShouldBranchAfterEditChange,
+ showDeleteDialog,
+ shouldBranchAfterEdit = false,
+ siblingInfo = null,
+ textareaElement = $bindable(),
+ thinkingContent,
+ toolCallContent = null
+ }: Props = $props();
+
+ const toolCalls = $derived(
+ Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
+ );
+ const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
+
+ const processingState = useProcessingState();
+
+ let currentConfig = $derived(config());
+ let isRouter = $derived(isRouterMode());
+ let displayedModel = $derived((): string | null => {
+ if (message.model) {
+ return message.model;
+ }
+
+ return null;
+ });
+
+ const { handleModelChange } = useModelChangeValidation({
+ getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
+ onSuccess: (modelName) => onRegenerate(modelName)
+ });
+
+ function handleCopyModel() {
+ const model = displayedModel();
+
+ void copyToClipboard(model ?? '');
+ }
+
+ $effect(() => {
+ if (isEditing && textareaElement) {
+ autoResizeTextarea(textareaElement);
+ }
+ });
+
+ $effect(() => {
+ if (isLoading() && !message?.content?.trim()) {
+ processingState.startMonitoring();
+ }
+ });
+
+ function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
+ const callNumber = index + 1;
+ const functionName = toolCall.function?.name?.trim();
+ const label = functionName || `Call #${callNumber}`;
+
+ const payload: Record<string, unknown> = {};
+
+ const id = toolCall.id?.trim();
+ if (id) {
+ payload.id = id;
+ }
+
+ const type = toolCall.type?.trim();
+ if (type) {
+ payload.type = type;
+ }
+
+ if (toolCall.function) {
+ const fnPayload: Record<string, unknown> = {};
+
+ const name = toolCall.function.name?.trim();
+ if (name) {
+ fnPayload.name = name;
+ }
+
+ const rawArguments = toolCall.function.arguments?.trim();
+ if (rawArguments) {
+ try {
+ fnPayload.arguments = JSON.parse(rawArguments);
+ } catch {
+ fnPayload.arguments = rawArguments;
+ }
+ }
+
+ if (Object.keys(fnPayload).length > 0) {
+ payload.function = fnPayload;
+ }
+ }
+
+ const formattedPayload = JSON.stringify(payload, null, 2);
+
+ return {
+ label,
+ tooltip: formattedPayload,
+ copyValue: formattedPayload
+ };
+ }
+
+ function handleCopyToolCall(payload: string) {
+ void copyToClipboard(payload, 'Tool call copied to clipboard');
+ }
+</script>
+
+<div
+ class="text-md group w-full leading-7.5 {className}"
+ role="group"
+ aria-label="Assistant message with actions"
+>
+ {#if thinkingContent}
+ <ChatMessageThinkingBlock
+ reasoningContent={thinkingContent}
+ isStreaming={!message.timestamp}
+ hasRegularContent={!!messageContent?.trim()}
+ />
+ {/if}
+
+ {#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
+ <div class="mt-6 w-full max-w-[48rem]" in:fade>
+ <div class="processing-container">
+ <span class="processing-text">
+ {processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
+ </span>
+ </div>
+ </div>
+ {/if}
+
+ {#if isEditing}
+ <div class="w-full">
+ <textarea
+ bind:this={textareaElement}
+ bind:value={editedContent}
+ class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
+ onkeydown={onEditKeydown}
+ oninput={(e) => {
+ autoResizeTextarea(e.currentTarget);
+ onEditedContentChange?.(e.currentTarget.value);
+ }}
+ placeholder="Edit assistant message..."
+ ></textarea>
+
+ <div class="mt-2 flex items-center justify-between">
+ <div class="flex items-center space-x-2">
+ <Checkbox
+ id="branch-after-edit"
+ bind:checked={shouldBranchAfterEdit}
+ onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)}
+ />
+ <Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
+ Branch conversation after edit
+ </Label>
+ </div>
+ <div class="flex gap-2">
+ <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
+ <X class="mr-1 h-3 w-3" />
+ Cancel
+ </Button>
+
+ <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm">
+ <Check class="mr-1 h-3 w-3" />
+ Save
+ </Button>
+ </div>
+ </div>
+ </div>
+ {:else if message.role === 'assistant'}
+ {#if config().disableReasoningFormat}
+ <pre class="raw-output">{messageContent || ''}</pre>
+ {:else}
+ <MarkdownContent content={messageContent || ''} />
+ {/if}
+ {:else}
+ <div class="text-sm whitespace-pre-wrap">
+ {messageContent}
+ </div>
+ {/if}
+
+ <div class="info my-6 grid gap-4 tabular-nums">
+ {#if displayedModel()}
+ <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
+ {#if isRouter}
+ <ModelsSelector
+ currentModel={displayedModel()}
+ onModelChange={handleModelChange}
+ disabled={isLoading()}
+ upToMessageId={message.id}
+ />
+ {:else}
+ <ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
+ {/if}
+
+ {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
+ <ChatMessageStatistics
+ promptTokens={message.timings.prompt_n}
+ promptMs={message.timings.prompt_ms}
+ predictedTokens={message.timings.predicted_n}
+ predictedMs={message.timings.predicted_ms}
+ />
+ {:else if isLoading() && currentConfig.showMessageStats}
+ {@const liveStats = processingState.getLiveProcessingStats()}
+ {@const genStats = processingState.getLiveGenerationStats()}
+ {@const promptProgress = processingState.processingState?.promptProgress}
+ {@const isStillProcessingPrompt =
+ promptProgress && promptProgress.processed < promptProgress.total}
+
+ {#if liveStats || genStats}
+ <ChatMessageStatistics
+ isLive={true}
+ isProcessingPrompt={!!isStillProcessingPrompt}
+ promptTokens={liveStats?.tokensProcessed}
+ promptMs={liveStats?.timeMs}
+ predictedTokens={genStats?.tokensGenerated}
+ predictedMs={genStats?.timeMs}
+ />
+ {/if}
+ {/if}
+ </div>
+ {/if}
+
+ {#if config().showToolCalls}
+ {#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
+ <span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
+ <span class="inline-flex items-center gap-1">
+ <Wrench class="h-3.5 w-3.5" />
+
+ <span>Tool calls:</span>
+ </span>
+
+ {#if toolCalls && toolCalls.length > 0}
+ {#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
+ {@const badge = formatToolCallBadge(toolCall, index)}
+ <button
+ type="button"
+ class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
+ title={badge.tooltip}
+ aria-label={`Copy tool call ${badge.label}`}
+ onclick={() => handleCopyToolCall(badge.copyValue)}
+ >
+ {badge.label}
+ <CopyToClipboardIcon
+ text={badge.copyValue}
+ ariaLabel={`Copy tool call ${badge.label}`}
+ />
+ </button>
+ {/each}
+ {:else if fallbackToolCalls}
+ <button
+ type="button"
+ 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"
+ title={fallbackToolCalls}
+ aria-label="Copy tool call payload"
+ onclick={() => handleCopyToolCall(fallbackToolCalls)}
+ >
+ {fallbackToolCalls}
+ <CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" />
+ </button>
+ {/if}
+ </span>
+ {/if}
+ {/if}
+ </div>
+
+ {#if message.timestamp && !isEditing}
+ <ChatMessageActions
+ role="assistant"
+ justify="start"
+ actionsPosition="left"
+ {siblingInfo}
+ {showDeleteDialog}
+ {deletionInfo}
+ {onCopy}
+ {onEdit}
+ {onRegenerate}
+ onContinue={currentConfig.enableContinueGeneration && !thinkingContent
+ ? onContinue
+ : undefined}
+ {onDelete}
+ {onConfirmDelete}
+ {onNavigateToSibling}
+ {onShowDeleteDialogChange}
+ />
+ {/if}
+</div>
+
+<style>
+ .processing-container {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .processing-text {
+ background: linear-gradient(
+ 90deg,
+ var(--muted-foreground),
+ var(--foreground),
+ var(--muted-foreground)
+ );
+ background-size: 200% 100%;
+ background-clip: text;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ animation: shine 1s linear infinite;
+ font-weight: 500;
+ font-size: 0.875rem;
+ }
+
+ @keyframes shine {
+ to {
+ background-position: -200% 0;
+ }
+ }
+
+ .raw-output {
+ width: 100%;
+ max-width: 48rem;
+ margin-top: 1.5rem;
+ padding: 1rem 1.25rem;
+ border-radius: 1rem;
+ background: hsl(var(--muted) / 0.3);
+ color: var(--foreground);
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
+ 'Liberation Mono', Menlo, monospace;
+ font-size: 0.875rem;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+
+ .tool-call-badge {
+ max-width: 12rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .tool-call-badge--fallback {
+ max-width: 20rem;
+ white-space: normal;
+ word-break: break-word;
+ }
+</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 @@
+<script lang="ts">
+ import { ChevronLeft, ChevronRight } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+
+ interface Props {
+ class?: string;
+ siblingInfo: ChatMessageSiblingInfo | null;
+ onNavigateToSibling?: (siblingId: string) => void;
+ }
+
+ let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props();
+
+ let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0);
+ let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1);
+ let nextSiblingId = $derived(
+ hasNext ? siblingInfo!.siblingIds[siblingInfo!.currentIndex + 1] : null
+ );
+ let previousSiblingId = $derived(
+ hasPrevious ? siblingInfo!.siblingIds[siblingInfo!.currentIndex - 1] : null
+ );
+
+ function handleNext() {
+ if (nextSiblingId) {
+ onNavigateToSibling?.(nextSiblingId);
+ }
+ }
+
+ function handlePrevious() {
+ if (previousSiblingId) {
+ onNavigateToSibling?.(previousSiblingId);
+ }
+ }
+</script>
+
+{#if siblingInfo && siblingInfo.totalSiblings > 1}
+ <div
+ aria-label="Message version {siblingInfo.currentIndex + 1} of {siblingInfo.totalSiblings}"
+ class="flex items-center gap-1 text-xs text-muted-foreground {className}"
+ role="navigation"
+ >
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ aria-label="Previous message version"
+ class="h-5 w-5 p-0 {!hasPrevious ? 'cursor-not-allowed opacity-30' : ''}"
+ disabled={!hasPrevious}
+ onclick={handlePrevious}
+ size="sm"
+ variant="ghost"
+ >
+ <ChevronLeft class="h-3 w-3" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>Previous version</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+
+ <span class="px-1 font-mono text-xs">
+ {siblingInfo.currentIndex + 1}/{siblingInfo.totalSiblings}
+ </span>
+
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ aria-label="Next message version"
+ class="h-5 w-5 p-0 {!hasNext ? 'cursor-not-allowed opacity-30' : ''}"
+ disabled={!hasNext}
+ onclick={handleNext}
+ size="sm"
+ variant="ghost"
+ >
+ <ChevronRight class="h-3 w-3" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>Next version</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </div>
+{/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 @@
+<script lang="ts">
+ import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Switch } from '$lib/components/ui/switch';
+ import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
+ import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums';
+ import { config } from '$lib/stores/settings.svelte';
+ import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
+ import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { modelsStore } from '$lib/stores/models.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import {
+ autoResizeTextarea,
+ getFileTypeCategory,
+ getFileTypeCategoryByExtension,
+ parseClipboardContent
+ } from '$lib/utils';
+
+ interface Props {
+ messageId: string;
+ editedContent: string;
+ editedExtras?: DatabaseMessageExtra[];
+ editedUploadedFiles?: ChatUploadedFile[];
+ originalContent: string;
+ originalExtras?: DatabaseMessageExtra[];
+ showSaveOnlyOption?: boolean;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+ onSaveEditOnly?: () => void;
+ onEditKeydown: (event: KeyboardEvent) => void;
+ onEditedContentChange: (content: string) => void;
+ onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
+ onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
+ textareaElement?: HTMLTextAreaElement;
+ }
+
+ let {
+ messageId,
+ editedContent,
+ editedExtras = [],
+ editedUploadedFiles = [],
+ originalContent,
+ originalExtras = [],
+ showSaveOnlyOption = false,
+ onCancelEdit,
+ onSaveEdit,
+ onSaveEditOnly,
+ onEditKeydown,
+ onEditedContentChange,
+ onEditedExtrasChange,
+ onEditedUploadedFilesChange,
+ textareaElement = $bindable()
+ }: Props = $props();
+
+ let fileInputElement: HTMLInputElement | undefined = $state();
+ let saveWithoutRegenerate = $state(false);
+ let showDiscardDialog = $state(false);
+ let isRouter = $derived(isRouterMode());
+ let currentConfig = $derived(config());
+
+ let pasteLongTextToFileLength = $derived.by(() => {
+ const n = Number(currentConfig.pasteLongTextToFileLen);
+
+ return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
+ });
+
+ let hasUnsavedChanges = $derived.by(() => {
+ if (editedContent !== originalContent) return true;
+ if (editedUploadedFiles.length > 0) return true;
+
+ const extrasChanged =
+ editedExtras.length !== originalExtras.length ||
+ editedExtras.some((extra, i) => extra !== originalExtras[i]);
+
+ if (extrasChanged) return true;
+
+ return false;
+ });
+
+ let hasAttachments = $derived(
+ (editedExtras && editedExtras.length > 0) ||
+ (editedUploadedFiles && editedUploadedFiles.length > 0)
+ );
+
+ let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
+
+ function getEditedAttachmentsModalities(): ModelModalities {
+ const modalities: ModelModalities = { vision: false, audio: false };
+
+ for (const extra of editedExtras) {
+ if (extra.type === AttachmentType.IMAGE) {
+ modalities.vision = true;
+ }
+
+ if (
+ extra.type === AttachmentType.PDF &&
+ 'processedAsImages' in extra &&
+ extra.processedAsImages
+ ) {
+ modalities.vision = true;
+ }
+
+ if (extra.type === AttachmentType.AUDIO) {
+ modalities.audio = true;
+ }
+ }
+
+ for (const file of editedUploadedFiles) {
+ const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
+ if (category === FileTypeCategory.IMAGE) {
+ modalities.vision = true;
+ }
+ if (category === FileTypeCategory.AUDIO) {
+ modalities.audio = true;
+ }
+ }
+
+ return modalities;
+ }
+
+ function getRequiredModalities(): ModelModalities {
+ const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
+ const editedModalities = getEditedAttachmentsModalities();
+
+ return {
+ vision: beforeModalities.vision || editedModalities.vision,
+ audio: beforeModalities.audio || editedModalities.audio
+ };
+ }
+
+ const { handleModelChange } = useModelChangeValidation({
+ getRequiredModalities,
+ onValidationFailure: async (previousModelId) => {
+ if (previousModelId) {
+ await modelsStore.selectModelById(previousModelId);
+ }
+ }
+ });
+
+ function handleFileInputChange(event: Event) {
+ const input = event.target as HTMLInputElement;
+ if (!input.files || input.files.length === 0) return;
+
+ const files = Array.from(input.files);
+
+ processNewFiles(files);
+ input.value = '';
+ }
+
+ function handleGlobalKeydown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ attemptCancel();
+ }
+ }
+
+ function attemptCancel() {
+ if (hasUnsavedChanges) {
+ showDiscardDialog = true;
+ } else {
+ onCancelEdit();
+ }
+ }
+
+ function handleRemoveExistingAttachment(index: number) {
+ if (!onEditedExtrasChange) return;
+
+ const newExtras = [...editedExtras];
+
+ newExtras.splice(index, 1);
+ onEditedExtrasChange(newExtras);
+ }
+
+ function handleRemoveUploadedFile(fileId: string) {
+ if (!onEditedUploadedFilesChange) return;
+
+ const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
+
+ onEditedUploadedFilesChange(newFiles);
+ }
+
+ function handleSubmit() {
+ if (!canSubmit) return;
+
+ if (saveWithoutRegenerate && onSaveEditOnly) {
+ onSaveEditOnly();
+ } else {
+ onSaveEdit();
+ }
+
+ saveWithoutRegenerate = false;
+ }
+
+ async function processNewFiles(files: File[]) {
+ if (!onEditedUploadedFilesChange) return;
+
+ const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
+ const processed = await processFilesToChatUploaded(files);
+
+ onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
+ }
+
+ function handlePaste(event: ClipboardEvent) {
+ if (!event.clipboardData) return;
+
+ const files = Array.from(event.clipboardData.items)
+ .filter((item) => item.kind === 'file')
+ .map((item) => item.getAsFile())
+ .filter((file): file is File => file !== null);
+
+ if (files.length > 0) {
+ event.preventDefault();
+ processNewFiles(files);
+
+ return;
+ }
+
+ const text = event.clipboardData.getData(MimeTypeText.PLAIN);
+
+ if (text.startsWith('"')) {
+ const parsed = parseClipboardContent(text);
+
+ if (parsed.textAttachments.length > 0) {
+ event.preventDefault();
+ onEditedContentChange(parsed.message);
+
+ const attachmentFiles = parsed.textAttachments.map(
+ (att) =>
+ new File([att.content], att.name, {
+ type: MimeTypeText.PLAIN
+ })
+ );
+
+ processNewFiles(attachmentFiles);
+
+ setTimeout(() => {
+ textareaElement?.focus();
+ }, 10);
+
+ return;
+ }
+ }
+
+ if (
+ text.length > 0 &&
+ pasteLongTextToFileLength > 0 &&
+ text.length > pasteLongTextToFileLength
+ ) {
+ event.preventDefault();
+
+ const textFile = new File([text], 'Pasted', {
+ type: MimeTypeText.PLAIN
+ });
+
+ processNewFiles([textFile]);
+ }
+ }
+
+ $effect(() => {
+ if (textareaElement) {
+ autoResizeTextarea(textareaElement);
+ }
+ });
+
+ $effect(() => {
+ setEditModeActive(processNewFiles);
+
+ return () => {
+ clearEditMode();
+ };
+ });
+</script>
+
+<svelte:window onkeydown={handleGlobalKeydown} />
+
+<input
+ bind:this={fileInputElement}
+ type="file"
+ multiple
+ class="hidden"
+ onchange={handleFileInputChange}
+/>
+
+<div
+ class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
+ data-slot="edit-form"
+>
+ <ChatAttachmentsList
+ attachments={editedExtras}
+ uploadedFiles={editedUploadedFiles}
+ readonly={false}
+ onFileRemove={(fileId) => {
+ if (fileId.startsWith('attachment-')) {
+ const index = parseInt(fileId.replace('attachment-', ''), 10);
+ if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
+ handleRemoveExistingAttachment(index);
+ }
+ } else {
+ handleRemoveUploadedFile(fileId);
+ }
+ }}
+ limitToSingleRow
+ class="py-5"
+ style="scroll-padding: 1rem;"
+ />
+
+ <div class="relative min-h-[48px] px-5 py-3">
+ <textarea
+ bind:this={textareaElement}
+ bind:value={editedContent}
+ class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
+ onkeydown={onEditKeydown}
+ oninput={(e) => {
+ autoResizeTextarea(e.currentTarget);
+ onEditedContentChange(e.currentTarget.value);
+ }}
+ onpaste={handlePaste}
+ placeholder="Edit your message..."
+ ></textarea>
+
+ <div class="flex w-full items-center gap-3" style="container-type: inline-size">
+ <Button
+ class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
+ onclick={() => fileInputElement?.click()}
+ type="button"
+ title="Add attachment"
+ >
+ <span class="sr-only">Attach files</span>
+
+ <Paperclip class="h-4 w-4" />
+ </Button>
+
+ <div class="flex-1"></div>
+
+ {#if isRouter}
+ <ModelsSelector
+ forceForegroundText={true}
+ useGlobalSelection={true}
+ onModelChange={handleModelChange}
+ />
+ {/if}
+
+ <Button
+ class="h-8 w-8 shrink-0 rounded-full p-0"
+ onclick={handleSubmit}
+ disabled={!canSubmit}
+ type="button"
+ title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
+ >
+ <span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
+
+ <ArrowUp class="h-5 w-5" />
+ </Button>
+ </div>
+ </div>
+</div>
+
+<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
+ {#if showSaveOnlyOption && onSaveEditOnly}
+ <div class="flex items-center gap-2">
+ <Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
+
+ <label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground">
+ Update without re-sending
+ </label>
+ </div>
+ {:else}
+ <div></div>
+ {/if}
+
+ <Button class="h-7 px-3 text-xs" onclick={attemptCancel} size="sm" variant="ghost">
+ <X class="mr-1 h-3 w-3" />
+
+ Cancel
+ </Button>
+</div>
+
+<DialogConfirmation
+ bind:open={showDiscardDialog}
+ title="Discard changes?"
+ description="You have unsaved changes. Are you sure you want to discard them?"
+ confirmText="Discard"
+ cancelText="Keep editing"
+ variant="destructive"
+ icon={AlertTriangle}
+ onConfirm={onCancelEdit}
+ onCancel={() => (showDiscardDialog = false)}
+/>
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 @@
+<script lang="ts">
+ import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
+ import { BadgeChatStatistic } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { ChatMessageStatsView } from '$lib/enums';
+
+ interface Props {
+ predictedTokens?: number;
+ predictedMs?: number;
+ promptTokens?: number;
+ promptMs?: number;
+ // Live mode: when true, shows stats during streaming
+ isLive?: boolean;
+ // Whether prompt processing is still in progress
+ isProcessingPrompt?: boolean;
+ // Initial view to show (defaults to READING in live mode)
+ initialView?: ChatMessageStatsView;
+ }
+
+ let {
+ predictedTokens,
+ predictedMs,
+ promptTokens,
+ promptMs,
+ isLive = false,
+ isProcessingPrompt = false,
+ initialView = ChatMessageStatsView.GENERATION
+ }: Props = $props();
+
+ let activeView: ChatMessageStatsView = $state(initialView);
+ let hasAutoSwitchedToGeneration = $state(false);
+
+ // In live mode: auto-switch to GENERATION tab when prompt processing completes
+ $effect(() => {
+ if (isLive) {
+ // Auto-switch to generation tab only when prompt processing is done (once)
+ if (
+ !hasAutoSwitchedToGeneration &&
+ !isProcessingPrompt &&
+ predictedTokens &&
+ predictedTokens > 0
+ ) {
+ activeView = ChatMessageStatsView.GENERATION;
+ hasAutoSwitchedToGeneration = true;
+ } else if (!hasAutoSwitchedToGeneration) {
+ // Stay on READING while prompt is still being processed
+ activeView = ChatMessageStatsView.READING;
+ }
+ }
+ });
+
+ let hasGenerationStats = $derived(
+ predictedTokens !== undefined &&
+ predictedTokens > 0 &&
+ predictedMs !== undefined &&
+ predictedMs > 0
+ );
+
+ let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
+ let timeInSeconds = $derived(
+ predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00'
+ );
+
+ let promptTokensPerSecond = $derived(
+ promptTokens !== undefined && promptMs !== undefined && promptMs > 0
+ ? (promptTokens / promptMs) * 1000
+ : undefined
+ );
+
+ let promptTimeInSeconds = $derived(
+ promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined
+ );
+
+ let hasPromptStats = $derived(
+ promptTokens !== undefined &&
+ promptMs !== undefined &&
+ promptTokensPerSecond !== undefined &&
+ promptTimeInSeconds !== undefined
+ );
+
+ // In live mode, generation tab is disabled until we have generation stats
+ let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
+</script>
+
+<div class="inline-flex items-center text-xs text-muted-foreground">
+ <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
+ {#if hasPromptStats || isLive}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <button
+ type="button"
+ class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
+ ChatMessageStatsView.READING
+ ? 'bg-background text-foreground shadow-sm'
+ : 'hover:text-foreground'}"
+ onclick={() => (activeView = ChatMessageStatsView.READING)}
+ >
+ <BookOpenText class="h-3 w-3" />
+ <span class="sr-only">Reading</span>
+ </button>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>Reading (prompt processing)</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <button
+ type="button"
+ class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
+ ChatMessageStatsView.GENERATION
+ ? 'bg-background text-foreground shadow-sm'
+ : isGenerationDisabled
+ ? 'cursor-not-allowed opacity-40'
+ : 'hover:text-foreground'}"
+ onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)}
+ disabled={isGenerationDisabled}
+ >
+ <Sparkles class="h-3 w-3" />
+ <span class="sr-only">Generation</span>
+ </button>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>
+ {isGenerationDisabled
+ ? 'Generation (waiting for tokens...)'
+ : 'Generation (token output)'}
+ </p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </div>
+
+ <div class="flex items-center gap-1 px-2">
+ {#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={WholeWord}
+ value="{predictedTokens?.toLocaleString()} tokens"
+ tooltipLabel="Generated tokens"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Clock}
+ value="{timeInSeconds}s"
+ tooltipLabel="Generation time"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Gauge}
+ value="{tokensPerSecond.toFixed(2)} tokens/s"
+ tooltipLabel="Generation speed"
+ />
+ {:else if hasPromptStats}
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={WholeWord}
+ value="{promptTokens} tokens"
+ tooltipLabel="Prompt tokens"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Clock}
+ value="{promptTimeInSeconds}s"
+ tooltipLabel="Prompt processing time"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Gauge}
+ value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
+ tooltipLabel="Prompt processing speed"
+ />
+ {/if}
+ </div>
+</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 @@
+<script lang="ts">
+ import { Check, X } from '@lucide/svelte';
+ import { Card } from '$lib/components/ui/card';
+ import { Button } from '$lib/components/ui/button';
+ import { MarkdownContent } from '$lib/components/app';
+ import { INPUT_CLASSES } from '$lib/constants/input-classes';
+ import { config } from '$lib/stores/settings.svelte';
+ import ChatMessageActions from './ChatMessageActions.svelte';
+
+ interface Props {
+ class?: string;
+ message: DatabaseMessage;
+ isEditing: boolean;
+ editedContent: string;
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ showDeleteDialog: boolean;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+ onEditKeydown: (event: KeyboardEvent) => void;
+ onEditedContentChange: (content: string) => void;
+ onCopy: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onConfirmDelete: () => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ textareaElement?: HTMLTextAreaElement;
+ }
+
+ let {
+ class: className = '',
+ message,
+ isEditing,
+ editedContent,
+ siblingInfo = null,
+ showDeleteDialog,
+ deletionInfo,
+ onCancelEdit,
+ onSaveEdit,
+ onEditKeydown,
+ onEditedContentChange,
+ onCopy,
+ onEdit,
+ onDelete,
+ onConfirmDelete,
+ onNavigateToSibling,
+ onShowDeleteDialogChange,
+ textareaElement = $bindable()
+ }: Props = $props();
+
+ let isMultiline = $state(false);
+ let messageElement: HTMLElement | undefined = $state();
+ let isExpanded = $state(false);
+ let contentHeight = $state(0);
+ const MAX_HEIGHT = 200; // pixels
+ const currentConfig = config();
+
+ let showExpandButton = $derived(contentHeight > MAX_HEIGHT);
+
+ $effect(() => {
+ if (!messageElement || !message.content.trim()) return;
+
+ if (message.content.includes('\n')) {
+ isMultiline = true;
+ }
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const element = entry.target as HTMLElement;
+ const estimatedSingleLineHeight = 24;
+
+ isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
+ contentHeight = element.scrollHeight;
+ }
+ });
+
+ resizeObserver.observe(messageElement);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ });
+
+ function toggleExpand() {
+ isExpanded = !isExpanded;
+ }
+</script>
+
+<div
+ aria-label="System message with actions"
+ class="group flex flex-col items-end gap-3 md:gap-2 {className}"
+ role="group"
+>
+ {#if isEditing}
+ <div class="w-full max-w-[80%]">
+ <textarea
+ bind:this={textareaElement}
+ bind:value={editedContent}
+ class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
+ onkeydown={onEditKeydown}
+ oninput={(e) => onEditedContentChange(e.currentTarget.value)}
+ placeholder="Edit system message..."
+ ></textarea>
+
+ <div class="mt-2 flex justify-end gap-2">
+ <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
+ <X class="mr-1 h-3 w-3" />
+ Cancel
+ </Button>
+
+ <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
+ <Check class="mr-1 h-3 w-3" />
+ Send
+ </Button>
+ </div>
+ </div>
+ {:else}
+ {#if message.content.trim()}
+ <div class="relative max-w-[80%]">
+ <button
+ class="group/expand w-full text-left {!isExpanded && showExpandButton
+ ? 'cursor-pointer'
+ : 'cursor-auto'}"
+ onclick={showExpandButton && !isExpanded ? toggleExpand : undefined}
+ type="button"
+ >
+ <Card
+ class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
+ data-multiline={isMultiline ? '' : undefined}
+ style="border: 2px dashed hsl(var(--border));"
+ >
+ <div
+ class="relative overflow-hidden transition-all duration-300 {isExpanded
+ ? 'cursor-text select-text'
+ : 'select-none'}"
+ style={!isExpanded && showExpandButton
+ ? `max-height: ${MAX_HEIGHT}px;`
+ : 'max-height: none;'}
+ >
+ {#if currentConfig.renderUserContentAsMarkdown}
+ <div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
+ <MarkdownContent class="markdown-system-content" content={message.content} />
+ </div>
+ {:else}
+ <span
+ bind:this={messageElement}
+ class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}"
+ >
+ {message.content}
+ </span>
+ {/if}
+
+ {#if !isExpanded && showExpandButton}
+ <div
+ class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
+ ></div>
+ <div
+ class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
+ >
+ <Button
+ class="rounded-full px-4 py-1.5 text-xs shadow-md"
+ size="sm"
+ variant="outline"
+ >
+ Show full system message
+ </Button>
+ </div>
+ {/if}
+ </div>
+
+ {#if isExpanded && showExpandButton}
+ <div class="mb-2 flex justify-center">
+ <Button
+ class="rounded-full px-4 py-1.5 text-xs"
+ onclick={(e) => {
+ e.stopPropagation();
+ toggleExpand();
+ }}
+ size="sm"
+ variant="outline"
+ >
+ Collapse System Message
+ </Button>
+ </div>
+ {/if}
+ </Card>
+ </button>
+ </div>
+ {/if}
+
+ {#if message.timestamp}
+ <div class="max-w-[80%]">
+ <ChatMessageActions
+ actionsPosition="right"
+ {deletionInfo}
+ justify="end"
+ {onConfirmDelete}
+ {onCopy}
+ {onDelete}
+ {onEdit}
+ {onNavigateToSibling}
+ {onShowDeleteDialogChange}
+ {siblingInfo}
+ {showDeleteDialog}
+ role="user"
+ />
+ </div>
+ {/if}
+ {/if}
+</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 @@
+<script lang="ts">
+ import { Brain } from '@lucide/svelte';
+ import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
+ import * as Collapsible from '$lib/components/ui/collapsible/index.js';
+ import { buttonVariants } from '$lib/components/ui/button/index.js';
+ import { Card } from '$lib/components/ui/card';
+ import { config } from '$lib/stores/settings.svelte';
+
+ interface Props {
+ class?: string;
+ hasRegularContent?: boolean;
+ isStreaming?: boolean;
+ reasoningContent: string | null;
+ }
+
+ let {
+ class: className = '',
+ hasRegularContent = false,
+ isStreaming = false,
+ reasoningContent
+ }: Props = $props();
+
+ const currentConfig = config();
+
+ let isExpanded = $state(currentConfig.showThoughtInProgress);
+
+ $effect(() => {
+ if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) {
+ isExpanded = false;
+ }
+ });
+</script>
+
+<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
+ <Card class="gap-0 border-muted bg-muted/30 py-0">
+ <Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
+ <div class="flex items-center gap-2 text-muted-foreground">
+ <Brain class="h-4 w-4" />
+
+ <span class="text-sm font-medium">
+ {isStreaming ? 'Reasoning...' : 'Reasoning'}
+ </span>
+ </div>
+
+ <div
+ class={buttonVariants({
+ variant: 'ghost',
+ size: 'sm',
+ class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
+ })}
+ >
+ <ChevronsUpDownIcon class="h-4 w-4" />
+
+ <span class="sr-only">Toggle reasoning content</span>
+ </div>
+ </Collapsible.Trigger>
+
+ <Collapsible.Content>
+ <div class="border-t border-muted px-3 pb-3">
+ <div class="pt-3">
+ <div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
+ {reasoningContent ?? ''}
+ </div>
+ </div>
+ </div>
+ </Collapsible.Content>
+ </Card>
+</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 @@
+<script lang="ts">
+ import { Card } from '$lib/components/ui/card';
+ import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
+ import { config } from '$lib/stores/settings.svelte';
+ import ChatMessageActions from './ChatMessageActions.svelte';
+ import ChatMessageEditForm from './ChatMessageEditForm.svelte';
+
+ interface Props {
+ class?: string;
+ message: DatabaseMessage;
+ isEditing: boolean;
+ editedContent: string;
+ editedExtras?: DatabaseMessageExtra[];
+ editedUploadedFiles?: ChatUploadedFile[];
+ siblingInfo?: ChatMessageSiblingInfo | null;
+ showDeleteDialog: boolean;
+ deletionInfo: {
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ } | null;
+ onCancelEdit: () => void;
+ onSaveEdit: () => void;
+ onSaveEditOnly?: () => void;
+ onEditKeydown: (event: KeyboardEvent) => void;
+ onEditedContentChange: (content: string) => void;
+ onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
+ onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
+ onCopy: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onConfirmDelete: () => void;
+ onNavigateToSibling?: (siblingId: string) => void;
+ onShowDeleteDialogChange: (show: boolean) => void;
+ textareaElement?: HTMLTextAreaElement;
+ }
+
+ let {
+ class: className = '',
+ message,
+ isEditing,
+ editedContent,
+ editedExtras = [],
+ editedUploadedFiles = [],
+ siblingInfo = null,
+ showDeleteDialog,
+ deletionInfo,
+ onCancelEdit,
+ onSaveEdit,
+ onSaveEditOnly,
+ onEditKeydown,
+ onEditedContentChange,
+ onEditedExtrasChange,
+ onEditedUploadedFilesChange,
+ onCopy,
+ onEdit,
+ onDelete,
+ onConfirmDelete,
+ onNavigateToSibling,
+ onShowDeleteDialogChange,
+ textareaElement = $bindable()
+ }: Props = $props();
+
+ let isMultiline = $state(false);
+ let messageElement: HTMLElement | undefined = $state();
+ const currentConfig = config();
+
+ $effect(() => {
+ if (!messageElement || !message.content.trim()) return;
+
+ if (message.content.includes('\n')) {
+ isMultiline = true;
+ return;
+ }
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const element = entry.target as HTMLElement;
+ const estimatedSingleLineHeight = 24; // Typical line height for text-md
+
+ isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
+ }
+ });
+
+ resizeObserver.observe(messageElement);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ });
+</script>
+
+<div
+ aria-label="User message with actions"
+ class="group flex flex-col items-end gap-3 md:gap-2 {className}"
+ role="group"
+>
+ {#if isEditing}
+ <ChatMessageEditForm
+ bind:textareaElement
+ messageId={message.id}
+ {editedContent}
+ {editedExtras}
+ {editedUploadedFiles}
+ originalContent={message.content}
+ originalExtras={message.extra}
+ showSaveOnlyOption={!!onSaveEditOnly}
+ {onCancelEdit}
+ {onSaveEdit}
+ {onSaveEditOnly}
+ {onEditKeydown}
+ {onEditedContentChange}
+ {onEditedExtrasChange}
+ {onEditedUploadedFilesChange}
+ />
+ {:else}
+ {#if message.extra && message.extra.length > 0}
+ <div class="mb-2 max-w-[80%]">
+ <ChatAttachmentsList attachments={message.extra} readonly={true} imageHeight="h-80" />
+ </div>
+ {/if}
+
+ {#if message.content.trim()}
+ <Card
+ 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"
+ data-multiline={isMultiline ? '' : undefined}
+ >
+ {#if currentConfig.renderUserContentAsMarkdown}
+ <div bind:this={messageElement} class="text-md">
+ <MarkdownContent
+ class="markdown-user-content text-primary-foreground"
+ content={message.content}
+ />
+ </div>
+ {:else}
+ <span bind:this={messageElement} class="text-md whitespace-pre-wrap">
+ {message.content}
+ </span>
+ {/if}
+ </Card>
+ {/if}
+
+ {#if message.timestamp}
+ <div class="max-w-[80%]">
+ <ChatMessageActions
+ actionsPosition="right"
+ {deletionInfo}
+ justify="end"
+ {onConfirmDelete}
+ {onCopy}
+ {onDelete}
+ {onEdit}
+ {onNavigateToSibling}
+ {onShowDeleteDialogChange}
+ {siblingInfo}
+ {showDeleteDialog}
+ role="user"
+ />
+ </div>
+ {/if}
+ {/if}
+</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 @@
+<script lang="ts">
+ import { ChatMessage } from '$lib/components/app';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { getMessageSiblings } from '$lib/utils';
+
+ interface Props {
+ class?: string;
+ messages?: DatabaseMessage[];
+ onUserAction?: () => void;
+ }
+
+ let { class: className, messages = [], onUserAction }: Props = $props();
+
+ let allConversationMessages = $state<DatabaseMessage[]>([]);
+ const currentConfig = config();
+
+ function refreshAllMessages() {
+ const conversation = activeConversation();
+
+ if (conversation) {
+ conversationsStore.getConversationMessages(conversation.id).then((messages) => {
+ allConversationMessages = messages;
+ });
+ } else {
+ allConversationMessages = [];
+ }
+ }
+
+ // Single effect that tracks both conversation and message changes
+ $effect(() => {
+ const conversation = activeConversation();
+
+ if (conversation) {
+ refreshAllMessages();
+ }
+ });
+
+ let displayMessages = $derived.by(() => {
+ if (!messages.length) {
+ return [];
+ }
+
+ // Filter out system messages if showSystemMessage is false
+ const filteredMessages = currentConfig.showSystemMessage
+ ? messages
+ : messages.filter((msg) => msg.type !== 'system');
+
+ return filteredMessages.map((message) => {
+ const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
+
+ return {
+ message,
+ siblingInfo: siblingInfo || {
+ message,
+ siblingIds: [message.id],
+ currentIndex: 0,
+ totalSiblings: 1
+ }
+ };
+ });
+ });
+
+ async function handleNavigateToSibling(siblingId: string) {
+ await conversationsStore.navigateToSibling(siblingId);
+ }
+
+ async function handleEditWithBranching(
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) {
+ onUserAction?.();
+
+ await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
+
+ refreshAllMessages();
+ }
+
+ async function handleEditWithReplacement(
+ message: DatabaseMessage,
+ newContent: string,
+ shouldBranch: boolean
+ ) {
+ onUserAction?.();
+
+ await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
+
+ refreshAllMessages();
+ }
+
+ async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
+ onUserAction?.();
+
+ await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
+
+ refreshAllMessages();
+ }
+
+ async function handleContinueAssistantMessage(message: DatabaseMessage) {
+ onUserAction?.();
+
+ await chatStore.continueAssistantMessage(message.id);
+
+ refreshAllMessages();
+ }
+
+ async function handleEditUserMessagePreserveResponses(
+ message: DatabaseMessage,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ) {
+ onUserAction?.();
+
+ await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
+
+ refreshAllMessages();
+ }
+
+ async function handleDeleteMessage(message: DatabaseMessage) {
+ await chatStore.deleteMessage(message.id);
+
+ refreshAllMessages();
+ }
+</script>
+
+<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
+ {#each displayMessages as { message, siblingInfo } (message.id)}
+ <ChatMessage
+ class="mx-auto w-full max-w-[48rem]"
+ {message}
+ {siblingInfo}
+ onDelete={handleDeleteMessage}
+ onNavigateToSibling={handleNavigateToSibling}
+ onEditWithBranching={handleEditWithBranching}
+ onEditWithReplacement={handleEditWithReplacement}
+ onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
+ onRegenerateWithBranching={handleRegenerateWithBranching}
+ onContinueAssistantMessage={handleContinueAssistantMessage}
+ />
+ {/each}
+</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 @@
+<script lang="ts">
+ import { afterNavigate } from '$app/navigation';
+ import {
+ ChatForm,
+ ChatScreenHeader,
+ ChatMessages,
+ ChatScreenProcessingInfo,
+ DialogEmptyFileAlert,
+ DialogChatError,
+ ServerLoadingSplash,
+ DialogConfirmation
+ } from '$lib/components/app';
+ import * as Alert from '$lib/components/ui/alert';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import {
+ AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
+ AUTO_SCROLL_INTERVAL,
+ INITIAL_SCROLL_DELAY
+ } from '$lib/constants/auto-scroll';
+ import {
+ chatStore,
+ errorDialog,
+ isLoading,
+ isEditing,
+ getAddFilesHandler
+ } from '$lib/stores/chat.svelte';
+ import {
+ conversationsStore,
+ activeMessages,
+ activeConversation
+ } from '$lib/stores/conversations.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { serverLoading, serverError, serverStore, isRouterMode } from '$lib/stores/server.svelte';
+ import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+ import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
+ import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
+ import { onMount } from 'svelte';
+ import { fade, fly, slide } from 'svelte/transition';
+ import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
+ import ChatScreenDragOverlay from './ChatScreenDragOverlay.svelte';
+
+ let { showCenteredEmpty = false } = $props();
+
+ let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
+ let autoScrollEnabled = $state(true);
+ let chatScrollContainer: HTMLDivElement | undefined = $state();
+ let dragCounter = $state(0);
+ let isDragOver = $state(false);
+ let lastScrollTop = $state(0);
+ let scrollInterval: ReturnType<typeof setInterval> | undefined;
+ let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
+ let showFileErrorDialog = $state(false);
+ let uploadedFiles = $state<ChatUploadedFile[]>([]);
+ let userScrolledUp = $state(false);
+
+ let fileErrorData = $state<{
+ generallyUnsupported: File[];
+ modalityUnsupported: File[];
+ modalityReasons: Record<string, string>;
+ supportedTypes: string[];
+ }>({
+ generallyUnsupported: [],
+ modalityUnsupported: [],
+ modalityReasons: {},
+ supportedTypes: []
+ });
+
+ let showDeleteDialog = $state(false);
+
+ let showEmptyFileDialog = $state(false);
+
+ let emptyFileNames = $state<string[]>([]);
+
+ let isEmpty = $derived(
+ showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
+ );
+
+ let activeErrorDialog = $derived(errorDialog());
+ let isServerLoading = $derived(serverLoading());
+ let hasPropsError = $derived(!!serverError());
+
+ let isCurrentConversationLoading = $derived(isLoading());
+
+ let isRouter = $derived(isRouterMode());
+
+ let conversationModel = $derived(
+ chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ );
+
+ let activeModelId = $derived.by(() => {
+ const options = modelOptions();
+
+ if (!isRouter) {
+ return options.length > 0 ? options[0].model : null;
+ }
+
+ const selectedId = selectedModelId();
+ if (selectedId) {
+ const model = options.find((m) => m.id === selectedId);
+ if (model) return model.model;
+ }
+
+ if (conversationModel) {
+ const model = options.find((m) => m.model === conversationModel);
+ if (model) return model.model;
+ }
+
+ return null;
+ });
+
+ let modelPropsVersion = $state(0);
+
+ $effect(() => {
+ if (activeModelId) {
+ const cached = modelsStore.getModelProps(activeModelId);
+ if (!cached) {
+ modelsStore.fetchModelProps(activeModelId).then(() => {
+ modelPropsVersion++;
+ });
+ }
+ }
+ });
+
+ let hasAudioModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+ return modelsStore.modelSupportsAudio(activeModelId);
+ }
+
+ return false;
+ });
+
+ let hasVisionModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+
+ return modelsStore.modelSupportsVision(activeModelId);
+ }
+
+ return false;
+ });
+
+ async function handleDeleteConfirm() {
+ const conversation = activeConversation();
+
+ if (conversation) {
+ await conversationsStore.deleteConversation(conversation.id);
+ }
+
+ showDeleteDialog = false;
+ }
+
+ function handleDragEnter(event: DragEvent) {
+ event.preventDefault();
+
+ dragCounter++;
+
+ if (event.dataTransfer?.types.includes('Files')) {
+ isDragOver = true;
+ }
+ }
+
+ function handleDragLeave(event: DragEvent) {
+ event.preventDefault();
+
+ dragCounter--;
+
+ if (dragCounter === 0) {
+ isDragOver = false;
+ }
+ }
+
+ function handleErrorDialogOpenChange(open: boolean) {
+ if (!open) {
+ chatStore.dismissErrorDialog();
+ }
+ }
+
+ function handleDragOver(event: DragEvent) {
+ event.preventDefault();
+ }
+
+ function handleDrop(event: DragEvent) {
+ event.preventDefault();
+
+ isDragOver = false;
+ dragCounter = 0;
+
+ if (event.dataTransfer?.files) {
+ const files = Array.from(event.dataTransfer.files);
+
+ if (isEditing()) {
+ const handler = getAddFilesHandler();
+
+ if (handler) {
+ handler(files);
+ return;
+ }
+ }
+
+ processFiles(files);
+ }
+ }
+
+ function handleFileRemove(fileId: string) {
+ uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
+ }
+
+ function handleFileUpload(files: File[]) {
+ processFiles(files);
+ }
+
+ function handleKeydown(event: KeyboardEvent) {
+ const isCtrlOrCmd = event.ctrlKey || event.metaKey;
+
+ if (isCtrlOrCmd && event.shiftKey && (event.key === 'd' || event.key === 'D')) {
+ event.preventDefault();
+ if (activeConversation()) {
+ showDeleteDialog = true;
+ }
+ }
+ }
+
+ function handleScroll() {
+ if (disableAutoScroll || !chatScrollContainer) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer;
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
+ const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
+
+ if (scrollTop < lastScrollTop && !isAtBottom) {
+ userScrolledUp = true;
+ autoScrollEnabled = false;
+ } else if (isAtBottom && userScrolledUp) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ }
+
+ if (scrollTimeout) {
+ clearTimeout(scrollTimeout);
+ }
+
+ scrollTimeout = setTimeout(() => {
+ if (isAtBottom) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ }
+ }, AUTO_SCROLL_INTERVAL);
+
+ lastScrollTop = scrollTop;
+ }
+
+ async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
+ const result = files
+ ? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
+ : undefined;
+
+ if (result?.emptyFiles && result.emptyFiles.length > 0) {
+ emptyFileNames = result.emptyFiles;
+ showEmptyFileDialog = true;
+
+ if (files) {
+ const emptyFileNamesSet = new Set(result.emptyFiles);
+ uploadedFiles = uploadedFiles.filter((file) => !emptyFileNamesSet.has(file.name));
+ }
+ return false;
+ }
+
+ const extras = result?.extras;
+
+ // Enable autoscroll for user-initiated message sending
+ if (!disableAutoScroll) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ }
+ await chatStore.sendMessage(message, extras);
+ scrollChatToBottom();
+
+ return true;
+ }
+
+ async function processFiles(files: File[]) {
+ const generallySupported: File[] = [];
+ const generallyUnsupported: File[] = [];
+
+ for (const file of files) {
+ if (isFileTypeSupported(file.name, file.type)) {
+ generallySupported.push(file);
+ } else {
+ generallyUnsupported.push(file);
+ }
+ }
+
+ // Use model-specific capabilities for file validation
+ const capabilities = { hasVision: hasVisionModality, hasAudio: hasAudioModality };
+ const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
+ generallySupported,
+ capabilities
+ );
+
+ const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
+
+ if (allUnsupportedFiles.length > 0) {
+ const supportedTypes: string[] = ['text files', 'PDFs'];
+
+ if (hasVisionModality) supportedTypes.push('images');
+ if (hasAudioModality) supportedTypes.push('audio files');
+
+ fileErrorData = {
+ generallyUnsupported,
+ modalityUnsupported: unsupportedFiles,
+ modalityReasons,
+ supportedTypes
+ };
+ showFileErrorDialog = true;
+ }
+
+ if (supportedFiles.length > 0) {
+ const processed = await processFilesToChatUploaded(
+ supportedFiles,
+ activeModelId ?? undefined
+ );
+ uploadedFiles = [...uploadedFiles, ...processed];
+ }
+ }
+
+ function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') {
+ if (disableAutoScroll) return;
+
+ chatScrollContainer?.scrollTo({
+ top: chatScrollContainer?.scrollHeight,
+ behavior
+ });
+ }
+
+ afterNavigate(() => {
+ if (!disableAutoScroll) {
+ setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
+ }
+ });
+
+ onMount(() => {
+ if (!disableAutoScroll) {
+ setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
+ }
+ });
+
+ $effect(() => {
+ if (disableAutoScroll) {
+ autoScrollEnabled = false;
+ if (scrollInterval) {
+ clearInterval(scrollInterval);
+ scrollInterval = undefined;
+ }
+ return;
+ }
+
+ if (isCurrentConversationLoading && autoScrollEnabled) {
+ scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL);
+ } else if (scrollInterval) {
+ clearInterval(scrollInterval);
+ scrollInterval = undefined;
+ }
+ });
+</script>
+
+{#if isDragOver}
+ <ChatScreenDragOverlay />
+{/if}
+
+<svelte:window onkeydown={handleKeydown} />
+
+<ChatScreenHeader />
+
+{#if !isEmpty}
+ <div
+ bind:this={chatScrollContainer}
+ aria-label="Chat interface with file drop zone"
+ class="flex h-full flex-col overflow-y-auto px-4 md:px-6"
+ ondragenter={handleDragEnter}
+ ondragleave={handleDragLeave}
+ ondragover={handleDragOver}
+ ondrop={handleDrop}
+ onscroll={handleScroll}
+ role="main"
+ >
+ <ChatMessages
+ class="mb-16 md:mb-24"
+ messages={activeMessages()}
+ onUserAction={() => {
+ if (!disableAutoScroll) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ scrollChatToBottom();
+ }
+ }}
+ />
+
+ <div
+ class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
+ in:slide={{ duration: 150, axis: 'y' }}
+ >
+ <ChatScreenProcessingInfo />
+
+ {#if hasPropsError}
+ <div
+ class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
+ in:fly={{ y: 10, duration: 250 }}
+ >
+ <Alert.Root variant="destructive">
+ <AlertTriangle class="h-4 w-4" />
+ <Alert.Title class="flex items-center justify-between">
+ <span>Server unavailable</span>
+ <button
+ onclick={() => serverStore.fetch()}
+ disabled={isServerLoading}
+ 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"
+ >
+ <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
+ {isServerLoading ? 'Retrying...' : 'Retry'}
+ </button>
+ </Alert.Title>
+ <Alert.Description>{serverError()}</Alert.Description>
+ </Alert.Root>
+ </div>
+ {/if}
+
+ <div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
+ <ChatForm
+ disabled={hasPropsError || isEditing()}
+ isLoading={isCurrentConversationLoading}
+ onFileRemove={handleFileRemove}
+ onFileUpload={handleFileUpload}
+ onSend={handleSendMessage}
+ onStop={() => chatStore.stopGeneration()}
+ showHelperText={false}
+ bind:uploadedFiles
+ />
+ </div>
+ </div>
+ </div>
+{:else if isServerLoading}
+ <!-- Server Loading State -->
+ <ServerLoadingSplash />
+{:else}
+ <div
+ aria-label="Welcome screen with file drop zone"
+ class="flex h-full items-center justify-center"
+ ondragenter={handleDragEnter}
+ ondragleave={handleDragLeave}
+ ondragover={handleDragOver}
+ ondrop={handleDrop}
+ role="main"
+ >
+ <div class="w-full max-w-[48rem] px-4">
+ <div class="mb-10 text-center" in:fade={{ duration: 300 }}>
+ <h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
+
+ <p class="text-lg text-muted-foreground">
+ {serverStore.props?.modalities?.audio
+ ? 'Record audio, type a message '
+ : 'Type a message'} or upload files to get started
+ </p>
+ </div>
+
+ {#if hasPropsError}
+ <div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
+ <Alert.Root variant="destructive">
+ <AlertTriangle class="h-4 w-4" />
+ <Alert.Title class="flex items-center justify-between">
+ <span>Server unavailable</span>
+ <button
+ onclick={() => serverStore.fetch()}
+ disabled={isServerLoading}
+ 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"
+ >
+ <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
+ {isServerLoading ? 'Retrying...' : 'Retry'}
+ </button>
+ </Alert.Title>
+ <Alert.Description>{serverError()}</Alert.Description>
+ </Alert.Root>
+ </div>
+ {/if}
+
+ <div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
+ <ChatForm
+ disabled={hasPropsError}
+ isLoading={isCurrentConversationLoading}
+ onFileRemove={handleFileRemove}
+ onFileUpload={handleFileUpload}
+ onSend={handleSendMessage}
+ onStop={() => chatStore.stopGeneration()}
+ showHelperText={true}
+ bind:uploadedFiles
+ />
+ </div>
+ </div>
+ </div>
+{/if}
+
+<!-- File Upload Error Alert Dialog -->
+<AlertDialog.Root bind:open={showFileErrorDialog}>
+ <AlertDialog.Portal>
+ <AlertDialog.Overlay />
+
+ <AlertDialog.Content class="flex max-w-md flex-col">
+ <AlertDialog.Header>
+ <AlertDialog.Title>File Upload Error</AlertDialog.Title>
+
+ <AlertDialog.Description class="text-sm text-muted-foreground">
+ Some files cannot be uploaded with the current model.
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <div class="!max-h-[50vh] min-h-0 flex-1 space-y-4 overflow-y-auto">
+ {#if fileErrorData.generallyUnsupported.length > 0}
+ <div class="space-y-2">
+ <h4 class="text-sm font-medium text-destructive">Unsupported File Types</h4>
+
+ <div class="space-y-1">
+ {#each fileErrorData.generallyUnsupported as file (file.name)}
+ <div class="rounded-md bg-destructive/10 px-3 py-2">
+ <p class="font-mono text-sm break-all text-destructive">
+ {file.name}
+ </p>
+
+ <p class="mt-1 text-xs text-muted-foreground">File type not supported</p>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ {#if fileErrorData.modalityUnsupported.length > 0}
+ <div class="space-y-2">
+ <div class="space-y-1">
+ {#each fileErrorData.modalityUnsupported as file (file.name)}
+ <div class="rounded-md bg-destructive/10 px-3 py-2">
+ <p class="font-mono text-sm break-all text-destructive">
+ {file.name}
+ </p>
+
+ <p class="mt-1 text-xs text-muted-foreground">
+ {fileErrorData.modalityReasons[file.name] || 'Not supported by current model'}
+ </p>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <div class="rounded-md bg-muted/50 p-3">
+ <h4 class="mb-2 text-sm font-medium">This model supports:</h4>
+
+ <p class="text-sm text-muted-foreground">
+ {fileErrorData.supportedTypes.join(', ')}
+ </p>
+ </div>
+
+ <AlertDialog.Footer>
+ <AlertDialog.Action onclick={() => (showFileErrorDialog = false)}>
+ Got it
+ </AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+ </AlertDialog.Portal>
+</AlertDialog.Root>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete Conversation"
+ description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
+ confirmText="Delete"
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleDeleteConfirm}
+ onCancel={() => (showDeleteDialog = false)}
+/>
+
+<DialogEmptyFileAlert
+ bind:open={showEmptyFileDialog}
+ emptyFiles={emptyFileNames}
+ onOpenChange={(open) => {
+ if (!open) {
+ emptyFileNames = [];
+ }
+ }}
+/>
+
+<DialogChatError
+ message={activeErrorDialog?.message ?? ''}
+ contextInfo={activeErrorDialog?.contextInfo}
+ onOpenChange={handleErrorDialogOpenChange}
+ open={Boolean(activeErrorDialog)}
+ type={activeErrorDialog?.type ?? 'server'}
+/>
+
+<style>
+ .conversation-chat-form {
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ width: 100%;
+ height: 2.375rem;
+ background-color: var(--background);
+ }
+ }
+</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 @@
+<script>
+ import { Upload } from '@lucide/svelte';
+</script>
+
+<div
+ class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
+>
+ <div
+ class="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border bg-background p-12 shadow-lg"
+ >
+ <Upload class="mb-4 h-12 w-12 text-muted-foreground" />
+
+ <p class="text-lg font-medium text-foreground">Attach a file</p>
+
+ <p class="text-sm text-muted-foreground">Drop your files here to upload</p>
+ </div>
+</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 @@
+<script lang="ts">
+ import { Settings } from '@lucide/svelte';
+ import { DialogChatSettings } from '$lib/components/app';
+ import { Button } from '$lib/components/ui/button';
+ import { useSidebar } from '$lib/components/ui/sidebar';
+
+ const sidebar = useSidebar();
+
+ let settingsOpen = $state(false);
+
+ function toggleSettings() {
+ settingsOpen = true;
+ }
+</script>
+
+<header
+ 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
+ ? 'md:left-[var(--sidebar-width)]'
+ : ''}"
+>
+ <div class="pointer-events-auto flex items-center space-x-2">
+ <Button variant="ghost" size="sm" onclick={toggleSettings}>
+ <Settings class="h-4 w-4" />
+ </Button>
+ </div>
+</header>
+
+<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 @@
+<script lang="ts">
+ import { untrack } from 'svelte';
+ import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
+ import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+ import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
+ import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+
+ const processingState = useProcessingState();
+
+ let isCurrentConversationLoading = $derived(isLoading());
+ let isStreaming = $derived(isChatStreaming());
+ let hasProcessingData = $derived(processingState.processingState !== null);
+ let processingDetails = $derived(processingState.getProcessingDetails());
+
+ let showProcessingInfo = $derived(
+ isCurrentConversationLoading || isStreaming || config().keepStatsVisible || hasProcessingData
+ );
+
+ $effect(() => {
+ const conversation = activeConversation();
+
+ untrack(() => chatStore.setActiveProcessingConversation(conversation?.id ?? null));
+ });
+
+ $effect(() => {
+ const keepStatsVisible = config().keepStatsVisible;
+ const shouldMonitor = keepStatsVisible || isCurrentConversationLoading || isStreaming;
+
+ if (shouldMonitor) {
+ processingState.startMonitoring();
+ }
+
+ if (!isCurrentConversationLoading && !isStreaming && !keepStatsVisible) {
+ const timeout = setTimeout(() => {
+ if (!config().keepStatsVisible && !isChatStreaming()) {
+ processingState.stopMonitoring();
+ }
+ }, PROCESSING_INFO_TIMEOUT);
+
+ return () => clearTimeout(timeout);
+ }
+ });
+
+ $effect(() => {
+ const conversation = activeConversation();
+ const messages = activeMessages() as DatabaseMessage[];
+ const keepStatsVisible = config().keepStatsVisible;
+
+ if (keepStatsVisible && conversation) {
+ if (messages.length === 0) {
+ untrack(() => chatStore.clearProcessingState(conversation.id));
+ return;
+ }
+
+ if (!isCurrentConversationLoading && !isStreaming) {
+ untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conversation.id));
+ }
+ }
+ });
+</script>
+
+<div class="chat-processing-info-container pointer-events-none" class:visible={showProcessingInfo}>
+ <div class="chat-processing-info-content">
+ {#each processingDetails as detail (detail)}
+ <span class="chat-processing-info-detail pointer-events-auto">{detail}</span>
+ {/each}
+ </div>
+</div>
+
+<style>
+ .chat-processing-info-container {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ padding: 1.5rem 1rem;
+ opacity: 0;
+ transform: translateY(50%);
+ transition:
+ opacity 300ms ease-out,
+ transform 300ms ease-out;
+ }
+
+ .chat-processing-info-container.visible {
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ .chat-processing-info-content {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1rem;
+ justify-content: center;
+ max-width: 48rem;
+ margin: 0 auto;
+ }
+
+ .chat-processing-info-detail {
+ color: var(--muted-foreground);
+ font-size: 0.75rem;
+ padding: 0.25rem 0.75rem;
+ background: var(--muted);
+ border-radius: 0.375rem;
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
+ white-space: nowrap;
+ }
+
+ @media (max-width: 768px) {
+ .chat-processing-info-content {
+ gap: 0.5rem;
+ }
+
+ .chat-processing-info-detail {
+ font-size: 0.7rem;
+ padding: 0.2rem 0.5rem;
+ }
+ }
+</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 @@
+<script lang="ts">
+ import {
+ Settings,
+ Funnel,
+ AlertTriangle,
+ Code,
+ Monitor,
+ Sun,
+ Moon,
+ ChevronLeft,
+ ChevronRight,
+ Database
+ } from '@lucide/svelte';
+ import {
+ ChatSettingsFooter,
+ ChatSettingsImportExportTab,
+ ChatSettingsFields
+ } from '$lib/components/app';
+ import { ScrollArea } from '$lib/components/ui/scroll-area';
+ import { config, settingsStore } from '$lib/stores/settings.svelte';
+ import { setMode } from 'mode-watcher';
+ import type { Component } from 'svelte';
+
+ interface Props {
+ onSave?: () => void;
+ }
+
+ let { onSave }: Props = $props();
+
+ const settingSections: Array<{
+ fields: SettingsFieldConfig[];
+ icon: Component;
+ title: string;
+ }> = [
+ {
+ title: 'General',
+ icon: Settings,
+ fields: [
+ {
+ key: 'theme',
+ label: 'Theme',
+ type: 'select',
+ options: [
+ { value: 'system', label: 'System', icon: Monitor },
+ { value: 'light', label: 'Light', icon: Sun },
+ { value: 'dark', label: 'Dark', icon: Moon }
+ ]
+ },
+ { key: 'apiKey', label: 'API Key', type: 'input' },
+ {
+ key: 'systemMessage',
+ label: 'System Message',
+ type: 'textarea'
+ },
+ {
+ key: 'pasteLongTextToFileLen',
+ label: 'Paste long text to file length',
+ type: 'input'
+ },
+ {
+ key: 'copyTextAttachmentsAsPlainText',
+ label: 'Copy text attachments as plain text',
+ type: 'checkbox'
+ },
+ {
+ key: 'enableContinueGeneration',
+ label: 'Enable "Continue" button',
+ type: 'checkbox',
+ isExperimental: true
+ },
+ {
+ key: 'pdfAsImage',
+ label: 'Parse PDF as image',
+ type: 'checkbox'
+ },
+ {
+ key: 'askForTitleConfirmation',
+ label: 'Ask for confirmation before changing conversation title',
+ type: 'checkbox'
+ }
+ ]
+ },
+ {
+ title: 'Display',
+ icon: Monitor,
+ fields: [
+ {
+ key: 'showMessageStats',
+ label: 'Show message generation statistics',
+ type: 'checkbox'
+ },
+ {
+ key: 'showThoughtInProgress',
+ label: 'Show thought in progress',
+ type: 'checkbox'
+ },
+ {
+ key: 'keepStatsVisible',
+ label: 'Keep stats visible after generation',
+ type: 'checkbox'
+ },
+ {
+ key: 'autoMicOnEmpty',
+ label: 'Show microphone on empty input',
+ type: 'checkbox',
+ isExperimental: true
+ },
+ {
+ key: 'renderUserContentAsMarkdown',
+ label: 'Render user content as Markdown',
+ type: 'checkbox'
+ },
+ {
+ key: 'disableAutoScroll',
+ label: 'Disable automatic scroll',
+ type: 'checkbox'
+ },
+ {
+ key: 'alwaysShowSidebarOnDesktop',
+ label: 'Always show sidebar on desktop',
+ type: 'checkbox'
+ },
+ {
+ key: 'autoShowSidebarOnNewChat',
+ label: 'Auto-show sidebar on new chat',
+ type: 'checkbox'
+ }
+ ]
+ },
+ {
+ title: 'Sampling',
+ icon: Funnel,
+ fields: [
+ {
+ key: 'temperature',
+ label: 'Temperature',
+ type: 'input'
+ },
+ {
+ key: 'dynatemp_range',
+ label: 'Dynamic temperature range',
+ type: 'input'
+ },
+ {
+ key: 'dynatemp_exponent',
+ label: 'Dynamic temperature exponent',
+ type: 'input'
+ },
+ {
+ key: 'top_k',
+ label: 'Top K',
+ type: 'input'
+ },
+ {
+ key: 'top_p',
+ label: 'Top P',
+ type: 'input'
+ },
+ {
+ key: 'min_p',
+ label: 'Min P',
+ type: 'input'
+ },
+ {
+ key: 'xtc_probability',
+ label: 'XTC probability',
+ type: 'input'
+ },
+ {
+ key: 'xtc_threshold',
+ label: 'XTC threshold',
+ type: 'input'
+ },
+ {
+ key: 'typ_p',
+ label: 'Typical P',
+ type: 'input'
+ },
+ {
+ key: 'max_tokens',
+ label: 'Max tokens',
+ type: 'input'
+ },
+ {
+ key: 'samplers',
+ label: 'Samplers',
+ type: 'input'
+ },
+ {
+ key: 'backend_sampling',
+ label: 'Backend sampling',
+ type: 'checkbox'
+ }
+ ]
+ },
+ {
+ title: 'Penalties',
+ icon: AlertTriangle,
+ fields: [
+ {
+ key: 'repeat_last_n',
+ label: 'Repeat last N',
+ type: 'input'
+ },
+ {
+ key: 'repeat_penalty',
+ label: 'Repeat penalty',
+ type: 'input'
+ },
+ {
+ key: 'presence_penalty',
+ label: 'Presence penalty',
+ type: 'input'
+ },
+ {
+ key: 'frequency_penalty',
+ label: 'Frequency penalty',
+ type: 'input'
+ },
+ {
+ key: 'dry_multiplier',
+ label: 'DRY multiplier',
+ type: 'input'
+ },
+ {
+ key: 'dry_base',
+ label: 'DRY base',
+ type: 'input'
+ },
+ {
+ key: 'dry_allowed_length',
+ label: 'DRY allowed length',
+ type: 'input'
+ },
+ {
+ key: 'dry_penalty_last_n',
+ label: 'DRY penalty last N',
+ type: 'input'
+ }
+ ]
+ },
+ {
+ title: 'Import/Export',
+ icon: Database,
+ fields: []
+ },
+ {
+ title: 'Developer',
+ icon: Code,
+ fields: [
+ {
+ key: 'showToolCalls',
+ label: 'Show tool call labels',
+ type: 'checkbox'
+ },
+ {
+ key: 'disableReasoningFormat',
+ label: 'Show raw LLM output',
+ type: 'checkbox'
+ },
+ {
+ key: 'custom',
+ label: 'Custom JSON',
+ type: 'textarea'
+ }
+ ]
+ }
+ // TODO: Experimental features section will be implemented after initial release
+ // This includes Python interpreter (Pyodide integration) and other experimental features
+ // {
+ // title: 'Experimental',
+ // icon: Beaker,
+ // fields: [
+ // {
+ // key: 'pyInterpreterEnabled',
+ // label: 'Enable Python interpreter',
+ // type: 'checkbox'
+ // }
+ // ]
+ // }
+ ];
+
+ let activeSection = $state('General');
+ let currentSection = $derived(
+ settingSections.find((section) => section.title === activeSection) || settingSections[0]
+ );
+ let localConfig: SettingsConfigType = $state({ ...config() });
+
+ let canScrollLeft = $state(false);
+ let canScrollRight = $state(false);
+ let scrollContainer: HTMLDivElement | undefined = $state();
+
+ function handleThemeChange(newTheme: string) {
+ localConfig.theme = newTheme;
+
+ setMode(newTheme as 'light' | 'dark' | 'system');
+ }
+
+ function handleConfigChange(key: string, value: string | boolean) {
+ localConfig[key] = value;
+ }
+
+ function handleReset() {
+ localConfig = { ...config() };
+
+ setMode(localConfig.theme as 'light' | 'dark' | 'system');
+ }
+
+ function handleSave() {
+ if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
+ try {
+ JSON.parse(localConfig.custom);
+ } catch (error) {
+ alert('Invalid JSON in custom parameters. Please check the format and try again.');
+ console.error(error);
+ return;
+ }
+ }
+
+ // Convert numeric strings to numbers for numeric fields
+ const processedConfig = { ...localConfig };
+ const numericFields = [
+ 'temperature',
+ 'top_k',
+ 'top_p',
+ 'min_p',
+ 'max_tokens',
+ 'pasteLongTextToFileLen',
+ 'dynatemp_range',
+ 'dynatemp_exponent',
+ 'typ_p',
+ 'xtc_probability',
+ 'xtc_threshold',
+ 'repeat_last_n',
+ 'repeat_penalty',
+ 'presence_penalty',
+ 'frequency_penalty',
+ 'dry_multiplier',
+ 'dry_base',
+ 'dry_allowed_length',
+ 'dry_penalty_last_n'
+ ];
+
+ for (const field of numericFields) {
+ if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
+ const numValue = Number(processedConfig[field]);
+ if (!isNaN(numValue)) {
+ processedConfig[field] = numValue;
+ } else {
+ alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
+ return;
+ }
+ }
+ }
+
+ settingsStore.updateMultipleConfig(processedConfig);
+ onSave?.();
+ }
+
+ function scrollToCenter(element: HTMLElement) {
+ if (!scrollContainer) return;
+
+ const containerRect = scrollContainer.getBoundingClientRect();
+ const elementRect = element.getBoundingClientRect();
+
+ const elementCenter = elementRect.left + elementRect.width / 2;
+ const containerCenter = containerRect.left + containerRect.width / 2;
+ const scrollOffset = elementCenter - containerCenter;
+
+ scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
+ }
+
+ function scrollLeft() {
+ if (!scrollContainer) return;
+
+ scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
+ }
+
+ function scrollRight() {
+ if (!scrollContainer) return;
+
+ scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
+ }
+
+ function updateScrollButtons() {
+ if (!scrollContainer) return;
+
+ const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
+ canScrollLeft = scrollLeft > 0;
+ canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
+ }
+
+ export function reset() {
+ localConfig = { ...config() };
+
+ setTimeout(updateScrollButtons, 100);
+ }
+
+ $effect(() => {
+ if (scrollContainer) {
+ updateScrollButtons();
+ }
+ });
+</script>
+
+<div class="flex h-full flex-col overflow-hidden md:flex-row">
+ <!-- Desktop Sidebar -->
+ <div class="hidden w-64 border-r border-border/30 p-6 md:block">
+ <nav class="space-y-1 py-2">
+ {#each settingSections as section (section.title)}
+ <button
+ 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 ===
+ section.title
+ ? 'bg-accent text-accent-foreground'
+ : 'text-muted-foreground'}"
+ onclick={() => (activeSection = section.title)}
+ >
+ <section.icon class="h-4 w-4" />
+
+ <span class="ml-2">{section.title}</span>
+ </button>
+ {/each}
+ </nav>
+ </div>
+
+ <!-- Mobile Header with Horizontal Scrollable Menu -->
+ <div class="flex flex-col pt-6 md:hidden">
+ <div class="border-b border-border/30 py-4">
+ <!-- Horizontal Scrollable Category Menu with Navigation -->
+ <div class="relative flex items-center" style="scroll-padding: 1rem;">
+ <button
+ 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
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollLeft}
+ aria-label="Scroll left"
+ >
+ <ChevronLeft class="h-4 w-4" />
+ </button>
+
+ <div
+ class="scrollbar-hide overflow-x-auto py-2"
+ bind:this={scrollContainer}
+ onscroll={updateScrollButtons}
+ >
+ <div class="flex min-w-max gap-2">
+ {#each settingSections as section (section.title)}
+ <button
+ 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 ===
+ section.title
+ ? 'bg-accent text-accent-foreground'
+ : 'text-muted-foreground'}"
+ onclick={(e: MouseEvent) => {
+ activeSection = section.title;
+ scrollToCenter(e.currentTarget as HTMLElement);
+ }}
+ >
+ <section.icon class="h-4 w-4 flex-shrink-0" />
+ <span>{section.title}</span>
+ </button>
+ {/each}
+ </div>
+ </div>
+
+ <button
+ 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
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollRight}
+ aria-label="Scroll right"
+ >
+ <ChevronRight class="h-4 w-4" />
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
+ <div class="space-y-6 p-4 md:p-6">
+ <div class="grid">
+ <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
+ <currentSection.icon class="h-5 w-5" />
+
+ <h3 class="text-lg font-semibold">{currentSection.title}</h3>
+ </div>
+
+ {#if currentSection.title === 'Import/Export'}
+ <ChatSettingsImportExportTab />
+ {:else}
+ <div class="space-y-6">
+ <ChatSettingsFields
+ fields={currentSection.fields}
+ {localConfig}
+ onConfigChange={handleConfigChange}
+ onThemeChange={handleThemeChange}
+ />
+ </div>
+ {/if}
+ </div>
+
+ <div class="mt-8 border-t pt-6">
+ <p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
+ </div>
+ </div>
+ </ScrollArea>
+</div>
+
+<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 @@
+<script lang="ts">
+ import { RotateCcw, FlaskConical } from '@lucide/svelte';
+ import { Checkbox } from '$lib/components/ui/checkbox';
+ import { Input } from '$lib/components/ui/input';
+ import Label from '$lib/components/ui/label/label.svelte';
+ import * as Select from '$lib/components/ui/select';
+ import { Textarea } from '$lib/components/ui/textarea';
+ import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
+ import { settingsStore } from '$lib/stores/settings.svelte';
+ import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
+ import type { Component } from 'svelte';
+
+ interface Props {
+ fields: SettingsFieldConfig[];
+ localConfig: SettingsConfigType;
+ onConfigChange: (key: string, value: string | boolean) => void;
+ onThemeChange?: (theme: string) => void;
+ }
+
+ let { fields, localConfig, onConfigChange, onThemeChange }: Props = $props();
+
+ // Helper function to get parameter source info for syncable parameters
+ function getParameterSourceInfo(key: string) {
+ if (!settingsStore.canSyncParameter(key)) {
+ return null;
+ }
+
+ return settingsStore.getParameterInfo(key);
+ }
+</script>
+
+{#each fields as field (field.key)}
+ <div class="space-y-2">
+ {#if field.type === 'input'}
+ {@const paramInfo = getParameterSourceInfo(field.key)}
+ {@const currentValue = String(localConfig[field.key] ?? '')}
+ {@const propsDefault = paramInfo?.serverDefault}
+ {@const isCustomRealTime = (() => {
+ if (!paramInfo || propsDefault === undefined) return false;
+
+ // Apply same rounding logic for real-time comparison
+ const inputValue = currentValue;
+ const numericInput = parseFloat(inputValue);
+ const normalizedInput = !isNaN(numericInput)
+ ? Math.round(numericInput * 1000000) / 1000000
+ : inputValue;
+ const normalizedDefault =
+ typeof propsDefault === 'number'
+ ? Math.round(propsDefault * 1000000) / 1000000
+ : propsDefault;
+
+ return normalizedInput !== normalizedDefault;
+ })()}
+
+ <div class="flex items-center gap-2">
+ <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
+ {field.label}
+
+ {#if field.isExperimental}
+ <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+ {/if}
+ </Label>
+ {#if isCustomRealTime}
+ <ChatSettingsParameterSourceIndicator />
+ {/if}
+ </div>
+
+ <div class="relative w-full md:max-w-md">
+ <Input
+ id={field.key}
+ value={currentValue}
+ oninput={(e) => {
+ // Update local config immediately for real-time badge feedback
+ onConfigChange(field.key, e.currentTarget.value);
+ }}
+ placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
+ class="w-full {isCustomRealTime ? 'pr-8' : ''}"
+ />
+ {#if isCustomRealTime}
+ <button
+ type="button"
+ onclick={() => {
+ settingsStore.resetParameterToServerDefault(field.key);
+ // Trigger UI update by calling onConfigChange with the default value
+ const defaultValue = propsDefault ?? SETTING_CONFIG_DEFAULT[field.key];
+ onConfigChange(field.key, String(defaultValue));
+ }}
+ 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"
+ aria-label="Reset to default"
+ title="Reset to default"
+ >
+ <RotateCcw class="h-3 w-3" />
+ </button>
+ {/if}
+ </div>
+ {#if field.help || SETTING_CONFIG_INFO[field.key]}
+ <p class="mt-1 text-xs text-muted-foreground">
+ {@html field.help || SETTING_CONFIG_INFO[field.key]}
+ </p>
+ {/if}
+ {:else if field.type === 'textarea'}
+ <Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
+ {field.label}
+
+ {#if field.isExperimental}
+ <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+ {/if}
+ </Label>
+
+ <Textarea
+ id={field.key}
+ value={String(localConfig[field.key] ?? '')}
+ onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
+ placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
+ class="min-h-[10rem] w-full md:max-w-2xl"
+ />
+
+ {#if field.help || SETTING_CONFIG_INFO[field.key]}
+ <p class="mt-1 text-xs text-muted-foreground">
+ {field.help || SETTING_CONFIG_INFO[field.key]}
+ </p>
+ {/if}
+
+ {#if field.key === 'systemMessage'}
+ <div class="mt-3 flex items-center gap-2">
+ <Checkbox
+ id="showSystemMessage"
+ checked={Boolean(localConfig.showSystemMessage ?? true)}
+ onCheckedChange={(checked) => onConfigChange('showSystemMessage', Boolean(checked))}
+ />
+
+ <Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
+ Show system message in conversations
+ </Label>
+ </div>
+ {/if}
+ {:else if field.type === 'select'}
+ {@const selectedOption = field.options?.find(
+ (opt: { value: string; label: string; icon?: Component }) =>
+ opt.value === localConfig[field.key]
+ )}
+ {@const paramInfo = getParameterSourceInfo(field.key)}
+ {@const currentValue = localConfig[field.key]}
+ {@const propsDefault = paramInfo?.serverDefault}
+ {@const isCustomRealTime = (() => {
+ if (!paramInfo || propsDefault === undefined) return false;
+
+ // For select fields, do direct comparison (no rounding needed)
+ return currentValue !== propsDefault;
+ })()}
+
+ <div class="flex items-center gap-2">
+ <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
+ {field.label}
+
+ {#if field.isExperimental}
+ <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+ {/if}
+ </Label>
+ {#if isCustomRealTime}
+ <ChatSettingsParameterSourceIndicator />
+ {/if}
+ </div>
+
+ <Select.Root
+ type="single"
+ value={currentValue}
+ onValueChange={(value) => {
+ if (field.key === 'theme' && value && onThemeChange) {
+ onThemeChange(value);
+ } else {
+ onConfigChange(field.key, value);
+ }
+ }}
+ >
+ <div class="relative w-full md:w-auto md:max-w-md">
+ <Select.Trigger class="w-full">
+ <div class="flex items-center gap-2">
+ {#if selectedOption?.icon}
+ {@const IconComponent = selectedOption.icon}
+ <IconComponent class="h-4 w-4" />
+ {/if}
+
+ {selectedOption?.label || `Select ${field.label.toLowerCase()}`}
+ </div>
+ </Select.Trigger>
+ {#if isCustomRealTime}
+ <button
+ type="button"
+ onclick={() => {
+ settingsStore.resetParameterToServerDefault(field.key);
+ // Trigger UI update by calling onConfigChange with the default value
+ const defaultValue = propsDefault ?? SETTING_CONFIG_DEFAULT[field.key];
+ onConfigChange(field.key, String(defaultValue));
+ }}
+ 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"
+ aria-label="Reset to default"
+ title="Reset to default"
+ >
+ <RotateCcw class="h-3 w-3" />
+ </button>
+ {/if}
+ </div>
+ <Select.Content>
+ {#if field.options}
+ {#each field.options as option (option.value)}
+ <Select.Item value={option.value} label={option.label}>
+ <div class="flex items-center gap-2">
+ {#if option.icon}
+ {@const IconComponent = option.icon}
+ <IconComponent class="h-4 w-4" />
+ {/if}
+ {option.label}
+ </div>
+ </Select.Item>
+ {/each}
+ {/if}
+ </Select.Content>
+ </Select.Root>
+ {#if field.help || SETTING_CONFIG_INFO[field.key]}
+ <p class="mt-1 text-xs text-muted-foreground">
+ {field.help || SETTING_CONFIG_INFO[field.key]}
+ </p>
+ {/if}
+ {:else if field.type === 'checkbox'}
+ <div class="flex items-start space-x-3">
+ <Checkbox
+ id={field.key}
+ checked={Boolean(localConfig[field.key])}
+ onCheckedChange={(checked) => onConfigChange(field.key, checked)}
+ class="mt-1"
+ />
+
+ <div class="space-y-1">
+ <label
+ for={field.key}
+ class="flex cursor-pointer items-center gap-1.5 pt-1 pb-0.5 text-sm leading-none font-medium"
+ >
+ {field.label}
+
+ {#if field.isExperimental}
+ <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+ {/if}
+ </label>
+
+ {#if field.help || SETTING_CONFIG_INFO[field.key]}
+ <p class="text-xs text-muted-foreground">
+ {field.help || SETTING_CONFIG_INFO[field.key]}
+ </p>
+ {/if}
+ </div>
+ </div>
+ {/if}
+ </div>
+{/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 @@
+<script lang="ts">
+ import { Button } from '$lib/components/ui/button';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { settingsStore } from '$lib/stores/settings.svelte';
+ import { RotateCcw } from '@lucide/svelte';
+
+ interface Props {
+ onReset?: () => void;
+ onSave?: () => void;
+ }
+
+ let { onReset, onSave }: Props = $props();
+
+ let showResetDialog = $state(false);
+
+ function handleResetClick() {
+ showResetDialog = true;
+ }
+
+ function handleConfirmReset() {
+ settingsStore.forceSyncWithServerDefaults();
+ onReset?.();
+
+ showResetDialog = false;
+ }
+
+ function handleSave() {
+ onSave?.();
+ }
+</script>
+
+<div class="flex justify-between border-t border-border/30 p-6">
+ <div class="flex gap-2">
+ <Button variant="outline" onclick={handleResetClick}>
+ <RotateCcw class="h-3 w-3" />
+
+ Reset to default
+ </Button>
+ </div>
+
+ <Button onclick={handleSave}>Save settings</Button>
+</div>
+
+<AlertDialog.Root bind:open={showResetDialog}>
+ <AlertDialog.Content>
+ <AlertDialog.Header>
+ <AlertDialog.Title>Reset Settings to Default</AlertDialog.Title>
+ <AlertDialog.Description>
+ Are you sure you want to reset all settings to their default values? This will reset all
+ parameters to the values provided by the server's /props endpoint and remove all your custom
+ configurations.
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+ <AlertDialog.Footer>
+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
+ <AlertDialog.Action onclick={handleConfirmReset}>Reset to Default</AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</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 @@
+<script lang="ts">
+ import { Download, Upload, Trash2 } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { DialogConversationSelection } from '$lib/components/app';
+ import { createMessageCountMap } from '$lib/utils';
+ import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
+ import { toast } from 'svelte-sonner';
+ import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
+
+ let exportedConversations = $state<DatabaseConversation[]>([]);
+ let importedConversations = $state<DatabaseConversation[]>([]);
+ let showExportSummary = $state(false);
+ let showImportSummary = $state(false);
+
+ let showExportDialog = $state(false);
+ let showImportDialog = $state(false);
+ let availableConversations = $state<DatabaseConversation[]>([]);
+ let messageCountMap = $state<Map<string, number>>(new Map());
+ let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
+ []
+ );
+
+ // Delete functionality state
+ let showDeleteDialog = $state(false);
+
+ async function handleExportClick() {
+ try {
+ const allConversations = conversations();
+ if (allConversations.length === 0) {
+ toast.info('No conversations to export');
+ return;
+ }
+
+ const conversationsWithMessages = await Promise.all(
+ allConversations.map(async (conv: DatabaseConversation) => {
+ const messages = await conversationsStore.getConversationMessages(conv.id);
+ return { conv, messages };
+ })
+ );
+
+ messageCountMap = createMessageCountMap(conversationsWithMessages);
+ availableConversations = allConversations;
+ showExportDialog = true;
+ } catch (err) {
+ console.error('Failed to load conversations:', err);
+ alert('Failed to load conversations');
+ }
+ }
+
+ async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
+ try {
+ const allData: ExportedConversations = await Promise.all(
+ selectedConversations.map(async (conv) => {
+ const messages = await conversationsStore.getConversationMessages(conv.id);
+ return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
+ })
+ );
+
+ const blob = new Blob([JSON.stringify(allData, null, 2)], {
+ type: 'application/json'
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+
+ a.href = url;
+ a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ exportedConversations = selectedConversations;
+ showExportSummary = true;
+ showImportSummary = false;
+ showExportDialog = false;
+ } catch (err) {
+ console.error('Export failed:', err);
+ alert('Failed to export conversations');
+ }
+ }
+
+ async function handleImportClick() {
+ try {
+ const input = document.createElement('input');
+
+ input.type = 'file';
+ input.accept = '.json';
+
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement)?.files?.[0];
+ if (!file) return;
+
+ try {
+ const text = await file.text();
+ const parsedData = JSON.parse(text);
+ let importedData: ExportedConversations;
+
+ if (Array.isArray(parsedData)) {
+ importedData = parsedData;
+ } else if (
+ parsedData &&
+ typeof parsedData === 'object' &&
+ 'conv' in parsedData &&
+ 'messages' in parsedData
+ ) {
+ // Single conversation object
+ importedData = [parsedData];
+ } else {
+ throw new Error(
+ 'Invalid file format: expected array of conversations or single conversation object'
+ );
+ }
+
+ fullImportData = importedData;
+ availableConversations = importedData.map(
+ (item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
+ );
+ messageCountMap = createMessageCountMap(importedData);
+ showImportDialog = true;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+
+ console.error('Failed to parse file:', err);
+ alert(`Failed to parse file: ${message}`);
+ }
+ };
+
+ input.click();
+ } catch (err) {
+ console.error('Import failed:', err);
+ alert('Failed to import conversations');
+ }
+ }
+
+ async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
+ try {
+ const selectedIds = new Set(selectedConversations.map((c) => c.id));
+ const selectedData = $state
+ .snapshot(fullImportData)
+ .filter((item) => selectedIds.has(item.conv.id));
+
+ await conversationsStore.importConversationsData(selectedData);
+
+ importedConversations = selectedConversations;
+ showImportSummary = true;
+ showExportSummary = false;
+ showImportDialog = false;
+ } catch (err) {
+ console.error('Import failed:', err);
+ alert('Failed to import conversations. Please check the file format.');
+ }
+ }
+
+ async function handleDeleteAllClick() {
+ try {
+ const allConversations = conversations();
+
+ if (allConversations.length === 0) {
+ toast.info('No conversations to delete');
+ return;
+ }
+
+ showDeleteDialog = true;
+ } catch (err) {
+ console.error('Failed to load conversations for deletion:', err);
+ toast.error('Failed to load conversations');
+ }
+ }
+
+ async function handleDeleteAllConfirm() {
+ try {
+ await conversationsStore.deleteAll();
+
+ showDeleteDialog = false;
+ } catch (err) {
+ console.error('Failed to delete conversations:', err);
+ }
+ }
+
+ function handleDeleteAllCancel() {
+ showDeleteDialog = false;
+ }
+</script>
+
+<div class="space-y-6">
+ <div class="space-y-4">
+ <div class="grid">
+ <h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
+
+ <p class="mb-4 text-sm text-muted-foreground">
+ Download all your conversations as a JSON file. This includes all messages, attachments, and
+ conversation history.
+ </p>
+
+ <Button
+ class="w-full justify-start justify-self-start md:w-auto"
+ onclick={handleExportClick}
+ variant="outline"
+ >
+ <Download class="mr-2 h-4 w-4" />
+
+ Export conversations
+ </Button>
+
+ {#if showExportSummary && exportedConversations.length > 0}
+ <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
+ <h5 class="mb-2 text-sm font-medium">
+ Exported {exportedConversations.length} conversation{exportedConversations.length === 1
+ ? ''
+ : 's'}
+ </h5>
+
+ <ul class="space-y-1 text-sm text-muted-foreground">
+ {#each exportedConversations.slice(0, 10) as conv (conv.id)}
+ <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
+ {/each}
+
+ {#if exportedConversations.length > 10}
+ <li class="italic">
+ ... and {exportedConversations.length - 10} more
+ </li>
+ {/if}
+ </ul>
+ </div>
+ {/if}
+ </div>
+
+ <div class="grid border-t border-border/30 pt-4">
+ <h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
+
+ <p class="mb-4 text-sm text-muted-foreground">
+ Import one or more conversations from a previously exported JSON file. This will merge with
+ your existing conversations.
+ </p>
+
+ <Button
+ class="w-full justify-start justify-self-start md:w-auto"
+ onclick={handleImportClick}
+ variant="outline"
+ >
+ <Upload class="mr-2 h-4 w-4" />
+ Import conversations
+ </Button>
+
+ {#if showImportSummary && importedConversations.length > 0}
+ <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
+ <h5 class="mb-2 text-sm font-medium">
+ Imported {importedConversations.length} conversation{importedConversations.length === 1
+ ? ''
+ : 's'}
+ </h5>
+
+ <ul class="space-y-1 text-sm text-muted-foreground">
+ {#each importedConversations.slice(0, 10) as conv (conv.id)}
+ <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
+ {/each}
+
+ {#if importedConversations.length > 10}
+ <li class="italic">
+ ... and {importedConversations.length - 10} more
+ </li>
+ {/if}
+ </ul>
+ </div>
+ {/if}
+ </div>
+
+ <div class="grid border-t border-border/30 pt-4">
+ <h4 class="mb-2 text-sm font-medium text-destructive">Delete All Conversations</h4>
+
+ <p class="mb-4 text-sm text-muted-foreground">
+ Permanently delete all conversations and their messages. This action cannot be undone.
+ Consider exporting your conversations first if you want to keep a backup.
+ </p>
+
+ <Button
+ class="text-destructive-foreground w-full justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto"
+ onclick={handleDeleteAllClick}
+ variant="destructive"
+ >
+ <Trash2 class="mr-2 h-4 w-4" />
+
+ Delete all conversations
+ </Button>
+ </div>
+ </div>
+</div>
+
+<DialogConversationSelection
+ conversations={availableConversations}
+ {messageCountMap}
+ mode="export"
+ bind:open={showExportDialog}
+ onCancel={() => (showExportDialog = false)}
+ onConfirm={handleExportConfirm}
+/>
+
+<DialogConversationSelection
+ conversations={availableConversations}
+ {messageCountMap}
+ mode="import"
+ bind:open={showImportDialog}
+ onCancel={() => (showImportDialog = false)}
+ onConfirm={handleImportConfirm}
+/>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete all conversations"
+ description="Are you sure you want to delete all conversations? This action cannot be undone and will permanently remove all your conversations and messages."
+ confirmText="Delete All"
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleDeleteAllConfirm}
+ onCancel={handleDeleteAllCancel}
+/>
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 @@
+<script lang="ts">
+ import { Wrench } from '@lucide/svelte';
+ import { Badge } from '$lib/components/ui/badge';
+
+ interface Props {
+ class?: string;
+ }
+
+ let { class: className = '' }: Props = $props();
+</script>
+
+<Badge
+ variant="secondary"
+ 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}"
+>
+ <Wrench class="mr-1 h-3 w-3" />
+ Custom
+</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 @@
+<script lang="ts">
+ import { goto } from '$app/navigation';
+ import { page } from '$app/state';
+ import { Trash2 } from '@lucide/svelte';
+ import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
+ import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
+ import * as Sidebar from '$lib/components/ui/sidebar';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import Input from '$lib/components/ui/input/input.svelte';
+ import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { getPreviewText } from '$lib/utils/text';
+ import ChatSidebarActions from './ChatSidebarActions.svelte';
+
+ const sidebar = Sidebar.useSidebar();
+
+ let currentChatId = $derived(page.params.id);
+ let isSearchModeActive = $state(false);
+ let searchQuery = $state('');
+ let showDeleteDialog = $state(false);
+ let showEditDialog = $state(false);
+ let selectedConversation = $state<DatabaseConversation | null>(null);
+ let editedName = $state('');
+ let selectedConversationNamePreview = $derived.by(() =>
+ selectedConversation ? getPreviewText(selectedConversation.name) : ''
+ );
+
+ let filteredConversations = $derived.by(() => {
+ if (searchQuery.trim().length > 0) {
+ return conversations().filter((conversation: { name: string }) =>
+ conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ }
+
+ return conversations();
+ });
+
+ async function handleDeleteConversation(id: string) {
+ const conversation = conversations().find((conv) => conv.id === id);
+ if (conversation) {
+ selectedConversation = conversation;
+ showDeleteDialog = true;
+ }
+ }
+
+ async function handleEditConversation(id: string) {
+ const conversation = conversations().find((conv) => conv.id === id);
+ if (conversation) {
+ selectedConversation = conversation;
+ editedName = conversation.name;
+ showEditDialog = true;
+ }
+ }
+
+ function handleConfirmDelete() {
+ if (selectedConversation) {
+ showDeleteDialog = false;
+
+ setTimeout(() => {
+ conversationsStore.deleteConversation(selectedConversation.id);
+ selectedConversation = null;
+ }, 100); // Wait for animation to finish
+ }
+ }
+
+ function handleConfirmEdit() {
+ if (!editedName.trim() || !selectedConversation) return;
+
+ showEditDialog = false;
+
+ conversationsStore.updateConversationName(selectedConversation.id, editedName);
+ selectedConversation = null;
+ }
+
+ export function handleMobileSidebarItemClick() {
+ if (sidebar.isMobile) {
+ sidebar.toggle();
+ }
+ }
+
+ export function activateSearchMode() {
+ isSearchModeActive = true;
+ }
+
+ export function editActiveConversation() {
+ if (currentChatId) {
+ const activeConversation = filteredConversations.find((conv) => conv.id === currentChatId);
+
+ if (activeConversation) {
+ const event = new CustomEvent('edit-active-conversation', {
+ detail: { conversationId: currentChatId }
+ });
+ document.dispatchEvent(event);
+ }
+ }
+ }
+
+ async function selectConversation(id: string) {
+ if (isSearchModeActive) {
+ isSearchModeActive = false;
+ searchQuery = '';
+ }
+
+ await goto(`#/chat/${id}`);
+ }
+
+ function handleStopGeneration(id: string) {
+ chatStore.stopGenerationForChat(id);
+ }
+</script>
+
+<ScrollArea class="h-[100vh]">
+ <Sidebar.Header class=" top-0 z-10 gap-6 bg-sidebar/50 px-4 py-4 pb-2 backdrop-blur-lg md:sticky">
+ <a href="#/" onclick={handleMobileSidebarItemClick}>
+ <h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
+ </a>
+
+ <ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
+ </Sidebar.Header>
+
+ <Sidebar.Group class="mt-4 space-y-2 p-0 px-4">
+ {#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
+ <Sidebar.GroupLabel>
+ {isSearchModeActive ? 'Search results' : 'Conversations'}
+ </Sidebar.GroupLabel>
+ {/if}
+
+ <Sidebar.GroupContent>
+ <Sidebar.Menu>
+ {#each filteredConversations as conversation (conversation.id)}
+ <Sidebar.MenuItem class="mb-1">
+ <ChatSidebarConversationItem
+ conversation={{
+ id: conversation.id,
+ name: conversation.name,
+ lastModified: conversation.lastModified,
+ currNode: conversation.currNode
+ }}
+ {handleMobileSidebarItemClick}
+ isActive={currentChatId === conversation.id}
+ onSelect={selectConversation}
+ onEdit={handleEditConversation}
+ onDelete={handleDeleteConversation}
+ onStop={handleStopGeneration}
+ />
+ </Sidebar.MenuItem>
+ {/each}
+
+ {#if filteredConversations.length === 0}
+ <div class="px-2 py-4 text-center">
+ <p class="mb-4 p-4 text-sm text-muted-foreground">
+ {searchQuery.length > 0
+ ? 'No results found'
+ : isSearchModeActive
+ ? 'Start typing to see results'
+ : 'No conversations yet'}
+ </p>
+ </div>
+ {/if}
+ </Sidebar.Menu>
+ </Sidebar.GroupContent>
+ </Sidebar.Group>
+</ScrollArea>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete Conversation"
+ description={selectedConversation
+ ? `Are you sure you want to delete "${selectedConversationNamePreview}"? This action cannot be undone and will permanently remove all messages in this conversation.`
+ : ''}
+ confirmText="Delete"
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleConfirmDelete}
+ onCancel={() => {
+ showDeleteDialog = false;
+ selectedConversation = null;
+ }}
+/>
+
+<AlertDialog.Root bind:open={showEditDialog}>
+ <AlertDialog.Content>
+ <AlertDialog.Header>
+ <AlertDialog.Title>Edit Conversation Name</AlertDialog.Title>
+ <AlertDialog.Description>
+ <Input
+ class="mt-4 text-foreground"
+ onkeydown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleConfirmEdit();
+ }
+ }}
+ placeholder="Enter a new name"
+ type="text"
+ bind:value={editedName}
+ />
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+ <AlertDialog.Footer>
+ <AlertDialog.Cancel
+ onclick={() => {
+ showEditDialog = false;
+ selectedConversation = null;
+ }}>Cancel</AlertDialog.Cancel
+ >
+ <AlertDialog.Action onclick={handleConfirmEdit}>Save</AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</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 @@
+<script lang="ts">
+ import { Search, SquarePen, X } from '@lucide/svelte';
+ import { KeyboardShortcutInfo } from '$lib/components/app';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+
+ interface Props {
+ handleMobileSidebarItemClick: () => void;
+ isSearchModeActive: boolean;
+ searchQuery: string;
+ }
+
+ let {
+ handleMobileSidebarItemClick,
+ isSearchModeActive = $bindable(),
+ searchQuery = $bindable()
+ }: Props = $props();
+
+ let searchInput: HTMLInputElement | null = $state(null);
+
+ function handleSearchModeDeactivate() {
+ isSearchModeActive = false;
+ searchQuery = '';
+ }
+
+ $effect(() => {
+ if (isSearchModeActive) {
+ searchInput?.focus();
+ }
+ });
+</script>
+
+<div class="space-y-0.5">
+ {#if isSearchModeActive}
+ <div class="relative">
+ <Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
+
+ <Input
+ bind:ref={searchInput}
+ bind:value={searchQuery}
+ onkeydown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
+ placeholder="Search conversations..."
+ class="pl-8"
+ />
+
+ <X
+ class="cursor-pointertext-muted-foreground absolute top-2.5 right-2 h-4 w-4"
+ onclick={handleSearchModeDeactivate}
+ />
+ </div>
+ {:else}
+ <Button
+ class="w-full justify-between hover:[&>kbd]:opacity-100"
+ href="?new_chat=true#/"
+ onclick={handleMobileSidebarItemClick}
+ variant="ghost"
+ >
+ <div class="flex items-center gap-2">
+ <SquarePen class="h-4 w-4" />
+ New chat
+ </div>
+
+ <KeyboardShortcutInfo keys={['shift', 'cmd', 'o']} />
+ </Button>
+
+ <Button
+ class="w-full justify-between hover:[&>kbd]:opacity-100"
+ onclick={() => {
+ isSearchModeActive = true;
+ }}
+ variant="ghost"
+ >
+ <div class="flex items-center gap-2">
+ <Search class="h-4 w-4" />
+ Search conversations
+ </div>
+
+ <KeyboardShortcutInfo keys={['cmd', 'k']} />
+ </Button>
+ {/if}
+</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 @@
+<script lang="ts">
+ import { Trash2, Pencil, MoreHorizontal, Download, Loader2, Square } from '@lucide/svelte';
+ import { ActionDropdown } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { getAllLoadingChats } from '$lib/stores/chat.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
+ import { onMount } from 'svelte';
+
+ interface Props {
+ isActive?: boolean;
+ conversation: DatabaseConversation;
+ handleMobileSidebarItemClick?: () => void;
+ onDelete?: (id: string) => void;
+ onEdit?: (id: string) => void;
+ onSelect?: (id: string) => void;
+ onStop?: (id: string) => void;
+ }
+
+ let {
+ conversation,
+ handleMobileSidebarItemClick,
+ onDelete,
+ onEdit,
+ onSelect,
+ onStop,
+ isActive = false
+ }: Props = $props();
+
+ let renderActionsDropdown = $state(false);
+ let dropdownOpen = $state(false);
+
+ let isLoading = $derived(getAllLoadingChats().includes(conversation.id));
+
+ function handleEdit(event: Event) {
+ event.stopPropagation();
+ onEdit?.(conversation.id);
+ }
+
+ function handleDelete(event: Event) {
+ event.stopPropagation();
+ onDelete?.(conversation.id);
+ }
+
+ function handleStop(event: Event) {
+ event.stopPropagation();
+ onStop?.(conversation.id);
+ }
+
+ function handleGlobalEditEvent(event: Event) {
+ const customEvent = event as CustomEvent<{ conversationId: string }>;
+
+ if (customEvent.detail.conversationId === conversation.id && isActive) {
+ handleEdit(event);
+ }
+ }
+
+ function handleMouseLeave() {
+ if (!dropdownOpen) {
+ renderActionsDropdown = false;
+ }
+ }
+
+ function handleMouseOver() {
+ renderActionsDropdown = true;
+ }
+
+ function handleSelect() {
+ onSelect?.(conversation.id);
+ }
+
+ $effect(() => {
+ if (!dropdownOpen) {
+ renderActionsDropdown = false;
+ }
+ });
+
+ onMount(() => {
+ document.addEventListener('edit-active-conversation', handleGlobalEditEvent as EventListener);
+
+ return () => {
+ document.removeEventListener(
+ 'edit-active-conversation',
+ handleGlobalEditEvent as EventListener
+ );
+ };
+ });
+</script>
+
+<!-- svelte-ignore a11y_mouse_events_have_key_events -->
+<button
+ 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
+ ? 'bg-foreground/5 text-accent-foreground'
+ : ''}"
+ onclick={handleSelect}
+ onmouseover={handleMouseOver}
+ onmouseleave={handleMouseLeave}
+>
+ <div class="flex min-w-0 flex-1 items-center gap-2">
+ {#if isLoading}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <div
+ 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"
+ onclick={handleStop}
+ onkeydown={(e) => e.key === 'Enter' && handleStop(e)}
+ role="button"
+ tabindex="0"
+ aria-label="Stop generation"
+ >
+ <Loader2 class="loading-icon h-3.5 w-3.5 animate-spin" />
+
+ <Square class="stop-icon hidden h-3 w-3 fill-current text-destructive" />
+ </div>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>Stop generation</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <span class="truncate text-sm font-medium" onclick={handleMobileSidebarItemClick}>
+ {conversation.name}
+ </span>
+ </div>
+
+ {#if renderActionsDropdown}
+ <div class="actions flex items-center">
+ <ActionDropdown
+ triggerIcon={MoreHorizontal}
+ triggerTooltip="More actions"
+ bind:open={dropdownOpen}
+ actions={[
+ {
+ icon: Pencil,
+ label: 'Edit',
+ onclick: handleEdit,
+ shortcut: ['shift', 'cmd', 'e']
+ },
+ {
+ icon: Download,
+ label: 'Export',
+ onclick: (e) => {
+ e.stopPropagation();
+ conversationsStore.downloadConversation(conversation.id);
+ },
+ shortcut: ['shift', 'cmd', 's']
+ },
+ {
+ icon: Trash2,
+ label: 'Delete',
+ onclick: handleDelete,
+ variant: 'destructive',
+ shortcut: ['shift', 'cmd', 'd'],
+ separator: true
+ }
+ ]}
+ />
+ </div>
+ {/if}
+</button>
+
+<style>
+ button {
+ :global([data-slot='dropdown-menu-trigger']:not([data-state='open'])) {
+ opacity: 0;
+ }
+
+ &:is(:hover) :global([data-slot='dropdown-menu-trigger']) {
+ opacity: 1;
+ }
+ @media (max-width: 768px) {
+ :global([data-slot='dropdown-menu-trigger']) {
+ opacity: 1 !important;
+ }
+ }
+
+ .stop-button {
+ :global(.stop-icon) {
+ display: none;
+ }
+
+ :global(.loading-icon) {
+ display: block;
+ }
+ }
+
+ &:is(:hover) .stop-button {
+ :global(.stop-icon) {
+ display: block;
+ }
+
+ :global(.loading-icon) {
+ display: none;
+ }
+ }
+ }
+</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 @@
+<script lang="ts">
+ import { SearchInput } from '$lib/components/app';
+
+ interface Props {
+ value?: string;
+ placeholder?: string;
+ onInput?: (value: string) => void;
+ class?: string;
+ }
+
+ let {
+ value = $bindable(''),
+ placeholder = 'Search conversations...',
+ onInput,
+ class: className
+ }: Props = $props();
+</script>
+
+<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 @@
+import { useSidebar } from '$lib/components/ui/sidebar';
+
+const sidebar = useSidebar();
+
+export function handleMobileSidebarItemClick() {
+ if (sidebar.isMobile) {
+ sidebar.toggle();
+ }
+}
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 @@
+<script lang="ts">
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { ChatAttachmentPreview } from '$lib/components/app';
+ import { formatFileSize } from '$lib/utils';
+
+ interface Props {
+ open: boolean;
+ onOpenChange?: (open: boolean) => void;
+ // Either an uploaded file or a stored attachment
+ uploadedFile?: ChatUploadedFile;
+ attachment?: DatabaseMessageExtra;
+ // For uploaded files
+ preview?: string;
+ name?: string;
+ size?: number;
+ textContent?: string;
+ // For vision modality check
+ activeModelId?: string;
+ }
+
+ let {
+ open = $bindable(),
+ onOpenChange,
+ uploadedFile,
+ attachment,
+ preview,
+ name,
+ size,
+ textContent,
+ activeModelId
+ }: Props = $props();
+
+ let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state();
+
+ let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
+
+ let displaySize = $derived(uploadedFile?.size || size);
+
+ $effect(() => {
+ if (open && chatAttachmentPreviewRef) {
+ chatAttachmentPreviewRef.reset();
+ }
+ });
+</script>
+
+<Dialog.Root bind:open {onOpenChange}>
+ <Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
+ <Dialog.Header>
+ <Dialog.Title class="pr-8">{displayName}</Dialog.Title>
+ <Dialog.Description>
+ {#if displaySize}
+ {formatFileSize(displaySize)}
+ {/if}
+ </Dialog.Description>
+ </Dialog.Header>
+
+ <ChatAttachmentPreview
+ bind:this={chatAttachmentPreviewRef}
+ {uploadedFile}
+ {attachment}
+ {preview}
+ name={displayName}
+ {textContent}
+ {activeModelId}
+ />
+ </Dialog.Content>
+</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 @@
+<script lang="ts">
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { ChatAttachmentsViewAll } from '$lib/components/app';
+
+ interface Props {
+ open?: boolean;
+ uploadedFiles?: ChatUploadedFile[];
+ attachments?: DatabaseMessageExtra[];
+ readonly?: boolean;
+ onFileRemove?: (fileId: string) => void;
+ imageHeight?: string;
+ imageWidth?: string;
+ imageClass?: string;
+ activeModelId?: string;
+ }
+
+ let {
+ open = $bindable(false),
+ uploadedFiles = [],
+ attachments = [],
+ readonly = false,
+ onFileRemove,
+ imageHeight = 'h-24',
+ imageWidth = 'w-auto',
+ imageClass = '',
+ activeModelId
+ }: Props = $props();
+
+ let totalCount = $derived(uploadedFiles.length + attachments.length);
+</script>
+
+<Dialog.Root bind:open>
+ <Dialog.Portal>
+ <Dialog.Overlay />
+
+ <Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col">
+ <Dialog.Header>
+ <Dialog.Title>All Attachments ({totalCount})</Dialog.Title>
+ <Dialog.Description>View and manage all attached files</Dialog.Description>
+ </Dialog.Header>
+
+ <ChatAttachmentsViewAll
+ {uploadedFiles}
+ {attachments}
+ {readonly}
+ {onFileRemove}
+ {imageHeight}
+ {imageWidth}
+ {imageClass}
+ {activeModelId}
+ />
+ </Dialog.Content>
+ </Dialog.Portal>
+</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 @@
+<script lang="ts">
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { AlertTriangle, TimerOff } from '@lucide/svelte';
+
+ interface Props {
+ open: boolean;
+ type: 'timeout' | 'server';
+ message: string;
+ contextInfo?: { n_prompt_tokens: number; n_ctx: number };
+ onOpenChange?: (open: boolean) => void;
+ }
+
+ let { open = $bindable(), type, message, contextInfo, onOpenChange }: Props = $props();
+
+ const isTimeout = $derived(type === 'timeout');
+ const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
+ const description = $derived(
+ isTimeout
+ ? 'The request did not receive a response from the server before timing out.'
+ : 'The server responded with an error message. Review the details below.'
+ );
+ const iconClass = $derived(isTimeout ? 'text-destructive' : 'text-amber-500');
+ const badgeClass = $derived(
+ isTimeout
+ ? 'border-destructive/40 bg-destructive/10 text-destructive'
+ : 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
+ );
+
+ function handleOpenChange(newOpen: boolean) {
+ open = newOpen;
+ onOpenChange?.(newOpen);
+ }
+</script>
+
+<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
+ <AlertDialog.Content>
+ <AlertDialog.Header>
+ <AlertDialog.Title class="flex items-center gap-2">
+ {#if isTimeout}
+ <TimerOff class={`h-5 w-5 ${iconClass}`} />
+ {:else}
+ <AlertTriangle class={`h-5 w-5 ${iconClass}`} />
+ {/if}
+
+ {title}
+ </AlertDialog.Title>
+
+ <AlertDialog.Description>
+ {description}
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <div class={`rounded-lg border px-4 py-3 text-sm ${badgeClass}`}>
+ <p class="font-medium">{message}</p>
+ {#if contextInfo}
+ <div class="mt-2 space-y-1 text-xs opacity-80">
+ <p>
+ <span class="font-medium">Prompt tokens:</span>
+ {contextInfo.n_prompt_tokens.toLocaleString()}
+ </p>
+ <p><span class="font-medium">Context size:</span> {contextInfo.n_ctx.toLocaleString()}</p>
+ </div>
+ {/if}
+ </div>
+
+ <AlertDialog.Footer>
+ <AlertDialog.Action onclick={() => handleOpenChange(false)}>Close</AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</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 @@
+<script lang="ts">
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { ChatSettings } from '$lib/components/app';
+
+ interface Props {
+ onOpenChange?: (open: boolean) => void;
+ open?: boolean;
+ }
+
+ let { onOpenChange, open = false }: Props = $props();
+
+ let chatSettingsRef: ChatSettings | undefined = $state();
+
+ function handleClose() {
+ onOpenChange?.(false);
+ }
+
+ function handleSave() {
+ onOpenChange?.(false);
+ }
+
+ $effect(() => {
+ if (open && chatSettingsRef) {
+ chatSettingsRef.reset();
+ }
+ });
+</script>
+
+<Dialog.Root {open} onOpenChange={handleClose}>
+ <Dialog.Content
+ class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
+ md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
+ style="max-width: 48rem;"
+ >
+ <ChatSettings bind:this={chatSettingsRef} onSave={handleSave} />
+ </Dialog.Content>
+</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 @@
+<script lang="ts">
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import type { Component } from 'svelte';
+
+ interface Props {
+ open: boolean;
+ title: string;
+ description: string;
+ confirmText?: string;
+ cancelText?: string;
+ variant?: 'default' | 'destructive';
+ icon?: Component;
+ onConfirm: () => void;
+ onCancel: () => void;
+ onKeydown?: (event: KeyboardEvent) => void;
+ }
+
+ let {
+ open = $bindable(),
+ title,
+ description,
+ confirmText = 'Confirm',
+ cancelText = 'Cancel',
+ variant = 'default',
+ icon,
+ onConfirm,
+ onCancel,
+ onKeydown
+ }: Props = $props();
+
+ function handleKeydown(event: KeyboardEvent) {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ onConfirm();
+ }
+ onKeydown?.(event);
+ }
+
+ function handleOpenChange(newOpen: boolean) {
+ if (!newOpen) {
+ onCancel();
+ }
+ }
+</script>
+
+<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
+ <AlertDialog.Content onkeydown={handleKeydown}>
+ <AlertDialog.Header>
+ <AlertDialog.Title class="flex items-center gap-2">
+ {#if icon}
+ {@const IconComponent = icon}
+ <IconComponent class="h-5 w-5 {variant === 'destructive' ? 'text-destructive' : ''}" />
+ {/if}
+ {title}
+ </AlertDialog.Title>
+
+ <AlertDialog.Description>
+ {description}
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <AlertDialog.Footer>
+ <AlertDialog.Cancel onclick={onCancel}>{cancelText}</AlertDialog.Cancel>
+ <AlertDialog.Action
+ onclick={onConfirm}
+ class={variant === 'destructive' ? 'bg-destructive text-white hover:bg-destructive/80' : ''}
+ >
+ {confirmText}
+ </AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</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 @@
+<script lang="ts">
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { ConversationSelection } from '$lib/components/app';
+
+ interface Props {
+ conversations: DatabaseConversation[];
+ messageCountMap?: Map<string, number>;
+ mode: 'export' | 'import';
+ onCancel: () => void;
+ onConfirm: (selectedConversations: DatabaseConversation[]) => void;
+ open?: boolean;
+ }
+
+ let {
+ conversations,
+ messageCountMap = new Map(),
+ mode,
+ onCancel,
+ onConfirm,
+ open = $bindable(false)
+ }: Props = $props();
+
+ let conversationSelectionRef: ConversationSelection | undefined = $state();
+
+ let previousOpen = $state(false);
+
+ $effect(() => {
+ if (open && !previousOpen && conversationSelectionRef) {
+ conversationSelectionRef.reset();
+ } else if (!open && previousOpen) {
+ onCancel();
+ }
+
+ previousOpen = open;
+ });
+</script>
+
+<Dialog.Root bind:open>
+ <Dialog.Portal>
+ <Dialog.Overlay class="z-[1000000]" />
+
+ <Dialog.Content class="z-[1000001] max-w-2xl">
+ <Dialog.Header>
+ <Dialog.Title>
+ Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
+ </Dialog.Title>
+ <Dialog.Description>
+ {#if mode === 'export'}
+ Choose which conversations you want to export. Selected conversations will be downloaded
+ as a JSON file.
+ {:else}
+ Choose which conversations you want to import. Selected conversations will be merged
+ with your existing conversations.
+ {/if}
+ </Dialog.Description>
+ </Dialog.Header>
+
+ <ConversationSelection
+ bind:this={conversationSelectionRef}
+ {conversations}
+ {messageCountMap}
+ {mode}
+ {onCancel}
+ {onConfirm}
+ />
+ </Dialog.Content>
+ </Dialog.Portal>
+</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 @@
+<script lang="ts">
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { Button } from '$lib/components/ui/button';
+
+ interface Props {
+ open: boolean;
+ currentTitle: string;
+ newTitle: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ }
+
+ let { open = $bindable(), currentTitle, newTitle, onConfirm, onCancel }: Props = $props();
+</script>
+
+<AlertDialog.Root bind:open>
+ <AlertDialog.Content>
+ <AlertDialog.Header>
+ <AlertDialog.Title>Update Conversation Title?</AlertDialog.Title>
+
+ <AlertDialog.Description>
+ Do you want to update the conversation title to match the first message content?
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <div class="space-y-4 pt-2 pb-6">
+ <div class="space-y-2">
+ <p class="text-sm font-medium text-muted-foreground">Current title:</p>
+
+ <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{currentTitle}</p>
+ </div>
+
+ <div class="space-y-2">
+ <p class="text-sm font-medium text-muted-foreground">New title would be:</p>
+
+ <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{newTitle}</p>
+ </div>
+ </div>
+
+ <AlertDialog.Footer>
+ <Button variant="outline" onclick={onCancel}>Keep Current Title</Button>
+
+ <Button onclick={onConfirm}>Update Title</Button>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</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 @@
+<script lang="ts">
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { FileX } from '@lucide/svelte';
+
+ interface Props {
+ open: boolean;
+ emptyFiles: string[];
+ onOpenChange?: (open: boolean) => void;
+ }
+
+ let { open = $bindable(), emptyFiles, onOpenChange }: Props = $props();
+
+ function handleOpenChange(newOpen: boolean) {
+ open = newOpen;
+ onOpenChange?.(newOpen);
+ }
+</script>
+
+<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
+ <AlertDialog.Content>
+ <AlertDialog.Header>
+ <AlertDialog.Title class="flex items-center gap-2">
+ <FileX class="h-5 w-5 text-destructive" />
+
+ Empty Files Detected
+ </AlertDialog.Title>
+
+ <AlertDialog.Description>
+ The following files are empty and have been removed from your attachments:
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <div class="space-y-3 text-sm">
+ <div class="rounded-lg bg-muted p-3">
+ <div class="mb-2 font-medium">Empty Files:</div>
+
+ <ul class="list-inside list-disc space-y-1 text-muted-foreground">
+ {#each emptyFiles as fileName (fileName)}
+ <li class="font-mono text-sm">{fileName}</li>
+ {/each}
+ </ul>
+ </div>
+
+ <div>
+ <div class="mb-2 font-medium">What happened:</div>
+
+ <ul class="list-inside list-disc space-y-1 text-muted-foreground">
+ <li>Empty files cannot be processed or sent to the AI model</li>
+
+ <li>These files have been automatically removed from your attachments</li>
+
+ <li>You can try uploading files with content instead</li>
+ </ul>
+ </div>
+ </div>
+
+ <AlertDialog.Footer>
+ <AlertDialog.Action onclick={() => handleOpenChange(false)}>Got it</AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</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 @@
+<script lang="ts">
+ import * as Dialog from '$lib/components/ui/dialog';
+ import * as Table from '$lib/components/ui/table';
+ import { BadgeModality, CopyToClipboardIcon } from '$lib/components/app';
+ import { serverStore } from '$lib/stores/server.svelte';
+ import { modelsStore, modelOptions, modelsLoading } from '$lib/stores/models.svelte';
+ import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
+
+ interface Props {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ }
+
+ let { open = $bindable(), onOpenChange }: Props = $props();
+
+ let serverProps = $derived(serverStore.props);
+ let modelName = $derived(modelsStore.singleModelName);
+ let models = $derived(modelOptions());
+ let isLoadingModels = $derived(modelsLoading());
+
+ // Get the first model for single-model mode display
+ let firstModel = $derived(models[0] ?? null);
+
+ // Get modalities from modelStore using the model ID from the first model
+ let modalities = $derived.by(() => {
+ if (!firstModel?.id) return [];
+ return modelsStore.getModelModalitiesArray(firstModel.id);
+ });
+
+ // Ensure models are fetched when dialog opens
+ $effect(() => {
+ if (open && models.length === 0) {
+ modelsStore.fetch();
+ }
+ });
+</script>
+
+<Dialog.Root bind:open {onOpenChange}>
+ <Dialog.Content class="@container z-9999 !max-w-[60rem] max-w-full">
+ <style>
+ @container (max-width: 56rem) {
+ .resizable-text-container {
+ max-width: calc(100vw - var(--threshold));
+ }
+ }
+ </style>
+
+ <Dialog.Header>
+ <Dialog.Title>Model Information</Dialog.Title>
+ <Dialog.Description>Current model details and capabilities</Dialog.Description>
+ </Dialog.Header>
+
+ <div class="space-y-6 py-4">
+ {#if isLoadingModels}
+ <div class="flex items-center justify-center py-8">
+ <div class="text-sm text-muted-foreground">Loading model information...</div>
+ </div>
+ {:else if firstModel}
+ {@const modelMeta = firstModel.meta}
+
+ {#if serverProps}
+ <Table.Root>
+ <Table.Header>
+ <Table.Row>
+ <Table.Head class="w-[10rem]">Model</Table.Head>
+
+ <Table.Head>
+ <div class="inline-flex items-center gap-2">
+ <span
+ class="resizable-text-container min-w-0 flex-1 truncate"
+ style:--threshold="12rem"
+ >
+ {modelName}
+ </span>
+
+ <CopyToClipboardIcon
+ text={modelName || ''}
+ canCopy={!!modelName}
+ ariaLabel="Copy model name to clipboard"
+ />
+ </div>
+ </Table.Head>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
+ <!-- Model Path -->
+ <Table.Row>
+ <Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell>
+
+ <Table.Cell
+ class="inline-flex h-10 items-center gap-2 align-middle font-mono text-xs"
+ >
+ <span
+ class="resizable-text-container min-w-0 flex-1 truncate"
+ style:--threshold="14rem"
+ >
+ {serverProps.model_path}
+ </span>
+
+ <CopyToClipboardIcon
+ text={serverProps.model_path}
+ ariaLabel="Copy model path to clipboard"
+ />
+ </Table.Cell>
+ </Table.Row>
+
+ <!-- Context Size -->
+ <Table.Row>
+ <Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
+ <Table.Cell
+ >{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
+ >
+ </Table.Row>
+
+ <!-- Training Context -->
+ {#if modelMeta?.n_ctx_train}
+ <Table.Row>
+ <Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
+ <Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
+ </Table.Row>
+ {/if}
+
+ <!-- Model Size -->
+ {#if modelMeta?.size}
+ <Table.Row>
+ <Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
+ <Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
+ </Table.Row>
+ {/if}
+
+ <!-- Parameters -->
+ {#if modelMeta?.n_params}
+ <Table.Row>
+ <Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
+ <Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
+ </Table.Row>
+ {/if}
+
+ <!-- Embedding Size -->
+ {#if modelMeta?.n_embd}
+ <Table.Row>
+ <Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
+ <Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
+ </Table.Row>
+ {/if}
+
+ <!-- Vocabulary Size -->
+ {#if modelMeta?.n_vocab}
+ <Table.Row>
+ <Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
+ <Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
+ </Table.Row>
+ {/if}
+
+ <!-- Vocabulary Type -->
+ {#if modelMeta?.vocab_type}
+ <Table.Row>
+ <Table.Cell class="align-middle font-medium">Vocabulary Type</Table.Cell>
+ <Table.Cell class="align-middle capitalize">{modelMeta.vocab_type}</Table.Cell>
+ </Table.Row>
+ {/if}
+
+ <!-- Total Slots -->
+ <Table.Row>
+ <Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
+ <Table.Cell>{serverProps.total_slots}</Table.Cell>
+ </Table.Row>
+
+ <!-- Modalities -->
+ {#if modalities.length > 0}
+ <Table.Row>
+ <Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
+ <Table.Cell>
+ <div class="flex flex-wrap gap-1">
+ <BadgeModality {modalities} />
+ </div>
+ </Table.Cell>
+ </Table.Row>
+ {/if}
+
+ <!-- Build Info -->
+ <Table.Row>
+ <Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
+ <Table.Cell class="align-middle font-mono text-xs"
+ >{serverProps.build_info}</Table.Cell
+ >
+ </Table.Row>
+
+ <!-- Chat Template -->
+ {#if serverProps.chat_template}
+ <Table.Row>
+ <Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
+ <Table.Cell class="py-10">
+ <div class="max-h-120 overflow-y-auto rounded-md bg-muted p-4">
+ <pre
+ class="font-mono text-xs whitespace-pre-wrap">{serverProps.chat_template}</pre>
+ </div>
+ </Table.Cell>
+ </Table.Row>
+ {/if}
+ </Table.Body>
+ </Table.Root>
+ {/if}
+ {:else if !isLoadingModels}
+ <div class="flex items-center justify-center py-8">
+ <div class="text-sm text-muted-foreground">No model information available</div>
+ </div>
+ {/if}
+ </div>
+ </Dialog.Content>
+</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 @@
+<script lang="ts">
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { AlertTriangle, ArrowRight } from '@lucide/svelte';
+ import { goto } from '$app/navigation';
+ import { page } from '$app/state';
+
+ interface Props {
+ open: boolean;
+ modelName: string;
+ availableModels?: string[];
+ onOpenChange?: (open: boolean) => void;
+ }
+
+ let { open = $bindable(), modelName, availableModels = [], onOpenChange }: Props = $props();
+
+ function handleOpenChange(newOpen: boolean) {
+ open = newOpen;
+ onOpenChange?.(newOpen);
+ }
+
+ function handleSelectModel(model: string) {
+ // Build URL with selected model, preserving other params
+ const url = new URL(page.url);
+ url.searchParams.set('model', model);
+
+ handleOpenChange(false);
+ goto(url.toString());
+ }
+</script>
+
+<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
+ <AlertDialog.Content class="max-w-lg">
+ <AlertDialog.Header>
+ <AlertDialog.Title class="flex items-center gap-2">
+ <AlertTriangle class="h-5 w-5 text-amber-500" />
+ Model Not Available
+ </AlertDialog.Title>
+
+ <AlertDialog.Description>
+ The requested model could not be found. Select an available model to continue.
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <div class="space-y-3">
+ <div class="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm">
+ <p class="font-medium text-amber-600 dark:text-amber-400">
+ Requested: <code class="rounded bg-amber-500/20 px-1.5 py-0.5">{modelName}</code>
+ </p>
+ </div>
+
+ {#if availableModels.length > 0}
+ <div class="text-sm">
+ <p class="mb-2 font-medium text-muted-foreground">Select an available model:</p>
+ <div class="max-h-48 space-y-1 overflow-y-auto rounded-md border p-1">
+ {#each availableModels as model (model)}
+ <button
+ type="button"
+ 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"
+ onclick={() => handleSelectModel(model)}
+ >
+ <span class="min-w-0 truncate font-mono text-xs">{model}</span>
+ <ArrowRight
+ class="h-4 w-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
+ />
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <AlertDialog.Footer>
+ <AlertDialog.Action onclick={() => handleOpenChange(false)}>Cancel</AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</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 @@
+// Chat
+
+export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
+export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
+export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
+export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
+export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
+
+export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
+export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
+export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
+export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
+export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
+export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
+export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
+export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
+
+export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
+export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
+export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
+export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
+export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
+export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
+export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
+export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
+
+export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
+export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
+export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
+
+export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
+export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
+export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
+export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
+export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
+
+export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
+export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
+export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
+
+// Dialogs
+
+export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
+export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
+export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
+export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte';
+export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
+export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
+export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
+export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
+export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
+export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
+
+// Miscellanous
+
+export { default as ActionButton } from './misc/ActionButton.svelte';
+export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
+export { default as BadgeChatStatistic } from './misc/BadgeChatStatistic.svelte';
+export { default as BadgeInfo } from './misc/BadgeInfo.svelte';
+export { default as ModelBadge } from './models/ModelBadge.svelte';
+export { default as BadgeModality } from './misc/BadgeModality.svelte';
+export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
+export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelte';
+export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
+export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
+export { default as RemoveButton } from './misc/RemoveButton.svelte';
+export { default as SearchInput } from './misc/SearchInput.svelte';
+export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
+export { default as ModelsSelector } from './models/ModelsSelector.svelte';
+
+// Server
+
+export { default as ServerStatus } from './server/ServerStatus.svelte';
+export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
+export { 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 @@
+<script lang="ts">
+ import { Button } from '$lib/components/ui/button';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import type { Component } from 'svelte';
+
+ interface Props {
+ icon: Component;
+ tooltip: string;
+ variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
+ size?: 'default' | 'sm' | 'lg' | 'icon';
+ class?: string;
+ disabled?: boolean;
+ onclick: () => void;
+ 'aria-label'?: string;
+ }
+
+ let {
+ icon,
+ tooltip,
+ variant = 'ghost',
+ size = 'sm',
+ class: className = '',
+ disabled = false,
+ onclick,
+ 'aria-label': ariaLabel
+ }: Props = $props();
+</script>
+
+<Tooltip.Root>
+ <Tooltip.Trigger>
+ <Button
+ {variant}
+ {size}
+ {disabled}
+ {onclick}
+ class="h-6 w-6 p-0 {className} flex"
+ aria-label={ariaLabel || tooltip}
+ >
+ {@const IconComponent = icon}
+ <IconComponent class="h-3 w-3" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>{tooltip}</p>
+ </Tooltip.Content>
+</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 @@
+<script lang="ts">
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { KeyboardShortcutInfo } from '$lib/components/app';
+ import type { Component } from 'svelte';
+
+ interface ActionItem {
+ icon: Component;
+ label: string;
+ onclick: (event: Event) => void;
+ variant?: 'default' | 'destructive';
+ disabled?: boolean;
+ shortcut?: string[];
+ separator?: boolean;
+ }
+
+ interface Props {
+ triggerIcon: Component;
+ triggerTooltip?: string;
+ triggerClass?: string;
+ actions: ActionItem[];
+ align?: 'start' | 'center' | 'end';
+ open?: boolean;
+ }
+
+ let {
+ triggerIcon,
+ triggerTooltip,
+ triggerClass = '',
+ actions,
+ align = 'end',
+ open = $bindable(false)
+ }: Props = $props();
+</script>
+
+<DropdownMenu.Root bind:open>
+ <DropdownMenu.Trigger
+ 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}"
+ onclick={(e) => e.stopPropagation()}
+ >
+ {#if triggerTooltip}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ {@render iconComponent(triggerIcon, 'h-3 w-3')}
+ <span class="sr-only">{triggerTooltip}</span>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>{triggerTooltip}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {:else}
+ {@render iconComponent(triggerIcon, 'h-3 w-3')}
+ {/if}
+ </DropdownMenu.Trigger>
+
+ <DropdownMenu.Content {align} class="z-[999999] w-48">
+ {#each actions as action, index (action.label)}
+ {#if action.separator && index > 0}
+ <DropdownMenu.Separator />
+ {/if}
+
+ <DropdownMenu.Item
+ onclick={action.onclick}
+ variant={action.variant}
+ disabled={action.disabled}
+ class="flex items-center justify-between hover:[&>kbd]:opacity-100"
+ >
+ <div class="flex items-center gap-2">
+ {@render iconComponent(
+ action.icon,
+ `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
+ )}
+ {action.label}
+ </div>
+
+ {#if action.shortcut}
+ <KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
+ {/if}
+ </DropdownMenu.Item>
+ {/each}
+ </DropdownMenu.Content>
+</DropdownMenu.Root>
+
+{#snippet iconComponent(IconComponent: Component, className: string)}
+ <IconComponent class={className} />
+{/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 @@
+<script lang="ts">
+ import { BadgeInfo } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { copyToClipboard } from '$lib/utils';
+ import type { Component } from 'svelte';
+
+ interface Props {
+ class?: string;
+ icon: Component;
+ value: string | number;
+ tooltipLabel?: string;
+ }
+
+ let { class: className = '', icon: Icon, value, tooltipLabel }: Props = $props();
+
+ function handleClick() {
+ void copyToClipboard(String(value));
+ }
+</script>
+
+{#if tooltipLabel}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <BadgeInfo class={className} onclick={handleClick}>
+ {#snippet icon()}
+ <Icon class="h-3 w-3" />
+ {/snippet}
+
+ {value}
+ </BadgeInfo>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>{tooltipLabel}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+{:else}
+ <BadgeInfo class={className} onclick={handleClick}>
+ {#snippet icon()}
+ <Icon class="h-3 w-3" />
+ {/snippet}
+
+ {value}
+ </BadgeInfo>
+{/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 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils';
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ children: Snippet;
+ class?: string;
+ icon?: Snippet;
+ onclick?: () => void;
+ }
+
+ let { children, class: className = '', icon, onclick }: Props = $props();
+</script>
+
+<button
+ class={cn(
+ 'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75',
+ className
+ )}
+ {onclick}
+>
+ {#if icon}
+ {@render icon()}
+ {/if}
+
+ {@render children()}
+</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 @@
+<script lang="ts">
+ import { ModelModality } from '$lib/enums';
+ import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants/icons';
+ import { cn } from '$lib/components/ui/utils';
+
+ type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO;
+
+ interface Props {
+ modalities: ModelModality[];
+ class?: string;
+ }
+
+ let { modalities, class: className = '' }: Props = $props();
+
+ // Filter to only modalities that have icons (VISION, AUDIO)
+ const displayableModalities = $derived(
+ modalities.filter(
+ (m): m is DisplayableModality => m === ModelModality.VISION || m === ModelModality.AUDIO
+ )
+ );
+</script>
+
+{#each displayableModalities as modality, index (index)}
+ {@const IconComponent = MODALITY_ICONS[modality]}
+ {@const label = MODALITY_LABELS[modality]}
+
+ <span
+ class={cn(
+ 'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
+ className
+ )}
+ >
+ {#if IconComponent}
+ <IconComponent class="h-3 w-3" />
+ {/if}
+
+ {label}
+ </span>
+{/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 @@
+<script lang="ts">
+ import { Dialog as DialogPrimitive } from 'bits-ui';
+ import XIcon from '@lucide/svelte/icons/x';
+
+ interface Props {
+ open: boolean;
+ code: string;
+ language: string;
+ onOpenChange?: (open: boolean) => void;
+ }
+
+ let { open = $bindable(), code, language, onOpenChange }: Props = $props();
+
+ let iframeRef = $state<HTMLIFrameElement | null>(null);
+
+ $effect(() => {
+ if (!iframeRef) return;
+
+ if (open) {
+ iframeRef.srcdoc = code;
+ } else {
+ iframeRef.srcdoc = '';
+ }
+ });
+
+ function handleOpenChange(nextOpen: boolean) {
+ open = nextOpen;
+ onOpenChange?.(nextOpen);
+ }
+</script>
+
+<DialogPrimitive.Root {open} onOpenChange={handleOpenChange}>
+ <DialogPrimitive.Portal>
+ <DialogPrimitive.Overlay class="code-preview-overlay" />
+
+ <DialogPrimitive.Content class="code-preview-content">
+ <iframe
+ bind:this={iframeRef}
+ title="Preview {language}"
+ sandbox="allow-scripts"
+ class="code-preview-iframe"
+ ></iframe>
+
+ <DialogPrimitive.Close
+ 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"
+ aria-label="Close preview"
+ >
+ <XIcon />
+ <span class="sr-only">Close preview</span>
+ </DialogPrimitive.Close>
+ </DialogPrimitive.Content>
+ </DialogPrimitive.Portal>
+</DialogPrimitive.Root>
+
+<style lang="postcss">
+ :global(.code-preview-overlay) {
+ position: fixed;
+ inset: 0;
+ background-color: transparent;
+ z-index: 100000;
+ }
+
+ :global(.code-preview-content) {
+ position: fixed;
+ inset: 0;
+ top: 0 !important;
+ left: 0 !important;
+ width: 100dvw;
+ height: 100dvh;
+ margin: 0;
+ padding: 0;
+ border: none;
+ border-radius: 0;
+ background-color: transparent;
+ box-shadow: none;
+ display: block;
+ overflow: hidden;
+ transform: none !important;
+ z-index: 100001;
+ }
+
+ :global(.code-preview-iframe) {
+ display: block;
+ width: 100dvw;
+ height: 100dvh;
+ border: 0;
+ }
+
+ :global(.code-preview-close) {
+ position: absolute;
+ z-index: 100002;
+ }
+</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 @@
+<script lang="ts">
+ import { Search, X } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+ import { Checkbox } from '$lib/components/ui/checkbox';
+ import { ScrollArea } from '$lib/components/ui/scroll-area';
+ import { SvelteSet } from 'svelte/reactivity';
+
+ interface Props {
+ conversations: DatabaseConversation[];
+ messageCountMap?: Map<string, number>;
+ mode: 'export' | 'import';
+ onCancel: () => void;
+ onConfirm: (selectedConversations: DatabaseConversation[]) => void;
+ }
+
+ let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props();
+
+ let searchQuery = $state('');
+ let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
+ let lastClickedId = $state<string | null>(null);
+
+ let filteredConversations = $derived(
+ conversations.filter((conv) => {
+ const name = conv.name || 'Untitled conversation';
+ return name.toLowerCase().includes(searchQuery.toLowerCase());
+ })
+ );
+
+ let allSelected = $derived(
+ filteredConversations.length > 0 &&
+ filteredConversations.every((conv) => selectedIds.has(conv.id))
+ );
+
+ let someSelected = $derived(
+ filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
+ );
+
+ function toggleConversation(id: string, shiftKey: boolean = false) {
+ const newSet = new SvelteSet(selectedIds);
+
+ if (shiftKey && lastClickedId !== null) {
+ const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
+ const currentIndex = filteredConversations.findIndex((c) => c.id === id);
+
+ if (lastIndex !== -1 && currentIndex !== -1) {
+ const start = Math.min(lastIndex, currentIndex);
+ const end = Math.max(lastIndex, currentIndex);
+
+ const shouldSelect = !newSet.has(id);
+
+ for (let i = start; i <= end; i++) {
+ if (shouldSelect) {
+ newSet.add(filteredConversations[i].id);
+ } else {
+ newSet.delete(filteredConversations[i].id);
+ }
+ }
+
+ selectedIds = newSet;
+ return;
+ }
+ }
+
+ if (newSet.has(id)) {
+ newSet.delete(id);
+ } else {
+ newSet.add(id);
+ }
+
+ selectedIds = newSet;
+ lastClickedId = id;
+ }
+
+ function toggleAll() {
+ if (allSelected) {
+ const newSet = new SvelteSet(selectedIds);
+
+ filteredConversations.forEach((conv) => newSet.delete(conv.id));
+ selectedIds = newSet;
+ } else {
+ const newSet = new SvelteSet(selectedIds);
+
+ filteredConversations.forEach((conv) => newSet.add(conv.id));
+ selectedIds = newSet;
+ }
+ }
+
+ function handleConfirm() {
+ const selected = conversations.filter((conv) => selectedIds.has(conv.id));
+ onConfirm(selected);
+ }
+
+ function handleCancel() {
+ selectedIds = new SvelteSet(conversations.map((c) => c.id));
+ searchQuery = '';
+ lastClickedId = null;
+
+ onCancel();
+ }
+
+ export function reset() {
+ selectedIds = new SvelteSet(conversations.map((c) => c.id));
+ searchQuery = '';
+ lastClickedId = null;
+ }
+</script>
+
+<div class="space-y-4">
+ <div class="relative">
+ <Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+
+ <Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
+
+ {#if searchQuery}
+ <button
+ class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ onclick={() => (searchQuery = '')}
+ type="button"
+ >
+ <X class="h-4 w-4" />
+ </button>
+ {/if}
+ </div>
+
+ <div class="flex items-center justify-between text-sm text-muted-foreground">
+ <span>
+ {selectedIds.size} of {conversations.length} selected
+ {#if searchQuery}
+ ({filteredConversations.length} shown)
+ {/if}
+ </span>
+ </div>
+
+ <div class="overflow-hidden rounded-md border">
+ <ScrollArea class="h-[400px]">
+ <table class="w-full">
+ <thead class="sticky top-0 z-10 bg-muted">
+ <tr class="border-b">
+ <th class="w-12 p-3 text-left">
+ <Checkbox
+ checked={allSelected}
+ indeterminate={someSelected}
+ onCheckedChange={toggleAll}
+ />
+ </th>
+
+ <th class="p-3 text-left text-sm font-medium">Conversation Name</th>
+
+ <th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#if filteredConversations.length === 0}
+ <tr>
+ <td colspan="3" class="p-8 text-center text-sm text-muted-foreground">
+ {#if searchQuery}
+ No conversations found matching "{searchQuery}"
+ {:else}
+ No conversations available
+ {/if}
+ </td>
+ </tr>
+ {:else}
+ {#each filteredConversations as conv (conv.id)}
+ <tr
+ class="cursor-pointer border-b transition-colors hover:bg-muted/50"
+ onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
+ >
+ <td class="p-3">
+ <Checkbox
+ checked={selectedIds.has(conv.id)}
+ onclick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleConversation(conv.id, e.shiftKey);
+ }}
+ />
+ </td>
+
+ <td class="p-3 text-sm">
+ <div class="max-w-[17rem] truncate" title={conv.name || 'Untitled conversation'}>
+ {conv.name || 'Untitled conversation'}
+ </div>
+ </td>
+
+ <td class="p-3 text-sm text-muted-foreground">
+ {messageCountMap.get(conv.id) ?? 0}
+ </td>
+ </tr>
+ {/each}
+ {/if}
+ </tbody>
+ </table>
+ </ScrollArea>
+ </div>
+
+ <div class="flex justify-end gap-2">
+ <Button variant="outline" onclick={handleCancel}>Cancel</Button>
+
+ <Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
+ {mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
+ </Button>
+ </div>
+</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 @@
+<script lang="ts">
+ import { Copy } from '@lucide/svelte';
+ import { copyToClipboard } from '$lib/utils';
+
+ interface Props {
+ ariaLabel?: string;
+ canCopy?: boolean;
+ text: string;
+ }
+
+ let { ariaLabel = 'Copy to clipboard', canCopy = true, text }: Props = $props();
+</script>
+
+<Copy
+ class="h-3 w-3 flex-shrink-0 cursor-{canCopy ? 'pointer' : 'not-allowed'}"
+ aria-label={ariaLabel}
+ onclick={() => canCopy && copyToClipboard(text)}
+/>
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 @@
+<script lang="ts">
+ import { ArrowBigUp } from '@lucide/svelte';
+
+ interface Props {
+ keys: string[];
+ variant?: 'default' | 'destructive';
+ class?: string;
+ }
+
+ let { keys, variant = 'default', class: className = '' }: Props = $props();
+
+ let baseClasses =
+ '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';
+ let variantClasses = variant === 'destructive' ? 'text-destructive' : 'text-muted-foreground';
+</script>
+
+<kbd class="{baseClasses} {variantClasses} {className}">
+ {#each keys as key, index (index)}
+ {#if key === 'shift'}
+ <ArrowBigUp class="h-1 w-1 {variant === 'destructive' ? 'text-destructive' : ''} -mr-1" />
+ {:else if key === 'cmd'}
+ <span class={variant === 'destructive' ? 'text-destructive' : ''}>⌘</span>
+ {:else}
+ {key.toUpperCase()}
+ {/if}
+
+ {#if index < keys.length - 1}
+ <span> </span>
+ {/if}
+ {/each}
+</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 @@
+<script lang="ts">
+ import { remark } from 'remark';
+ import remarkBreaks from 'remark-breaks';
+ import remarkGfm from 'remark-gfm';
+ import remarkMath from 'remark-math';
+ import rehypeHighlight from 'rehype-highlight';
+ import remarkRehype from 'remark-rehype';
+ import rehypeKatex from 'rehype-katex';
+ import rehypeStringify from 'rehype-stringify';
+ import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
+ import type { Root as MdastRoot } from 'mdast';
+ import { browser } from '$app/environment';
+ import { onDestroy, tick } from 'svelte';
+ import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
+ import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
+ import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
+ import { remarkLiteralHtml } from '$lib/markdown/literal-html';
+ import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
+ import '$styles/katex-custom.scss';
+ import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+ import githubLightCss from 'highlight.js/styles/github.css?inline';
+ import { mode } from 'mode-watcher';
+ import CodePreviewDialog from './CodePreviewDialog.svelte';
+
+ interface Props {
+ content: string;
+ class?: string;
+ }
+
+ interface MarkdownBlock {
+ id: string;
+ html: string;
+ }
+
+ let { content, class: className = '' }: Props = $props();
+
+ let containerRef = $state<HTMLDivElement>();
+ let renderedBlocks = $state<MarkdownBlock[]>([]);
+ let unstableBlockHtml = $state('');
+ let previewDialogOpen = $state(false);
+ let previewCode = $state('');
+ let previewLanguage = $state('text');
+
+ let pendingMarkdown: string | null = null;
+ let isProcessing = false;
+
+ const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
+
+ let processor = $derived(() => {
+ return remark()
+ .use(remarkGfm) // GitHub Flavored Markdown
+ .use(remarkMath) // Parse $inline$ and $$block$$ math
+ .use(remarkBreaks) // Convert line breaks to <br>
+ .use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
+ .use(remarkRehype) // Convert Markdown AST to rehype
+ .use(rehypeKatex) // Render math using KaTeX
+ .use(rehypeHighlight) // Add syntax highlighting
+ .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
+ .use(rehypeEnhanceLinks) // Add target="_blank" to links
+ .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
+ .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
+ });
+
+ /**
+ * Removes click event listeners from copy and preview buttons.
+ * Called on component destroy.
+ */
+ function cleanupEventListeners() {
+ if (!containerRef) return;
+
+ const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
+ const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn');
+
+ for (const button of copyButtons) {
+ button.removeEventListener('click', handleCopyClick);
+ }
+
+ for (const button of previewButtons) {
+ button.removeEventListener('click', handlePreviewClick);
+ }
+ }
+
+ /**
+ * Removes this component's highlight.js theme style from the document head.
+ * Called on component destroy to clean up injected styles.
+ */
+ function cleanupHighlightTheme() {
+ if (!browser) return;
+
+ const existingTheme = document.getElementById(themeStyleId);
+ existingTheme?.remove();
+ }
+
+ /**
+ * Loads the appropriate highlight.js theme based on dark/light mode.
+ * Injects a scoped style element into the document head.
+ * @param isDark - Whether to load the dark theme (true) or light theme (false)
+ */
+ function loadHighlightTheme(isDark: boolean) {
+ if (!browser) return;
+
+ const existingTheme = document.getElementById(themeStyleId);
+ existingTheme?.remove();
+
+ const style = document.createElement('style');
+ style.id = themeStyleId;
+ style.textContent = isDark ? githubDarkCss : githubLightCss;
+
+ document.head.appendChild(style);
+ }
+
+ /**
+ * Extracts code information from a button click target within a code block.
+ * @param target - The clicked button element
+ * @returns Object with rawCode and language, or null if extraction fails
+ */
+ function getCodeInfoFromTarget(target: HTMLElement) {
+ const wrapper = target.closest('.code-block-wrapper');
+
+ if (!wrapper) {
+ console.error('No wrapper found');
+ return null;
+ }
+
+ const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
+
+ if (!codeElement) {
+ console.error('No code element found in wrapper');
+ return null;
+ }
+
+ const rawCode = codeElement.textContent ?? '';
+
+ const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
+ const language = languageLabel?.textContent?.trim() || 'text';
+
+ return { rawCode, language };
+ }
+
+ /**
+ * Generates a unique identifier for a HAST node based on its position.
+ * Used for stable block identification during incremental rendering.
+ * @param node - The HAST root content node
+ * @param indexFallback - Fallback index if position is unavailable
+ * @returns Unique string identifier for the node
+ */
+ function getHastNodeId(node: HastRootContent, indexFallback: number): string {
+ const position = node.position;
+
+ if (position?.start?.offset != null && position?.end?.offset != null) {
+ return `hast-${position.start.offset}-${position.end.offset}`;
+ }
+
+ return `${node.type}-${indexFallback}`;
+ }
+
+ /**
+ * Handles click events on copy buttons within code blocks.
+ * Copies the raw code content to the clipboard.
+ * @param event - The click event from the copy button
+ */
+ async function handleCopyClick(event: Event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const target = event.currentTarget as HTMLButtonElement | null;
+
+ if (!target) {
+ return;
+ }
+
+ const info = getCodeInfoFromTarget(target);
+
+ if (!info) {
+ return;
+ }
+
+ try {
+ await copyCodeToClipboard(info.rawCode);
+ } catch (error) {
+ console.error('Failed to copy code:', error);
+ }
+ }
+
+ /**
+ * Handles preview dialog open state changes.
+ * Clears preview content when dialog is closed.
+ * @param open - Whether the dialog is being opened or closed
+ */
+ function handlePreviewDialogOpenChange(open: boolean) {
+ previewDialogOpen = open;
+
+ if (!open) {
+ previewCode = '';
+ previewLanguage = 'text';
+ }
+ }
+
+ /**
+ * Handles click events on preview buttons within HTML code blocks.
+ * Opens a preview dialog with the rendered HTML content.
+ * @param event - The click event from the preview button
+ */
+ function handlePreviewClick(event: Event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const target = event.currentTarget as HTMLButtonElement | null;
+
+ if (!target) {
+ return;
+ }
+
+ const info = getCodeInfoFromTarget(target);
+
+ if (!info) {
+ return;
+ }
+
+ previewCode = info.rawCode;
+ previewLanguage = info.language;
+ previewDialogOpen = true;
+ }
+
+ /**
+ * Processes markdown content into stable and unstable HTML blocks.
+ * Uses incremental rendering: stable blocks are cached, unstable block is re-rendered.
+ * @param markdown - The raw markdown string to process
+ */
+ async function processMarkdown(markdown: string) {
+ if (!markdown) {
+ renderedBlocks = [];
+ unstableBlockHtml = '';
+ return;
+ }
+
+ const normalized = preprocessLaTeX(markdown);
+ const processorInstance = processor();
+ const ast = processorInstance.parse(normalized) as MdastRoot;
+ const processedRoot = (await processorInstance.run(ast)) as HastRoot;
+ const processedChildren = processedRoot.children ?? [];
+ const stableCount = Math.max(processedChildren.length - 1, 0);
+ const nextBlocks: MarkdownBlock[] = [];
+
+ for (let index = 0; index < stableCount; index++) {
+ const hastChild = processedChildren[index];
+ const id = getHastNodeId(hastChild, index);
+ const existing = renderedBlocks[index];
+
+ if (existing && existing.id === id) {
+ nextBlocks.push(existing);
+ continue;
+ }
+
+ const html = stringifyProcessedNode(
+ processorInstance,
+ processedRoot,
+ processedChildren[index]
+ );
+
+ nextBlocks.push({ id, html });
+ }
+
+ let unstableHtml = '';
+
+ if (processedChildren.length > stableCount) {
+ const unstableChild = processedChildren[stableCount];
+ unstableHtml = stringifyProcessedNode(processorInstance, processedRoot, unstableChild);
+ }
+
+ renderedBlocks = nextBlocks;
+ await tick(); // Force DOM sync before updating unstable HTML block
+ unstableBlockHtml = unstableHtml;
+ }
+
+ /**
+ * Attaches click event listeners to copy and preview buttons in code blocks.
+ * Uses data-listener-bound attribute to prevent duplicate bindings.
+ */
+ function setupCodeBlockActions() {
+ if (!containerRef) return;
+
+ const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
+
+ for (const wrapper of wrappers) {
+ const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
+ const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
+
+ if (copyButton && copyButton.dataset.listenerBound !== 'true') {
+ copyButton.dataset.listenerBound = 'true';
+ copyButton.addEventListener('click', handleCopyClick);
+ }
+
+ if (previewButton && previewButton.dataset.listenerBound !== 'true') {
+ previewButton.dataset.listenerBound = 'true';
+ previewButton.addEventListener('click', handlePreviewClick);
+ }
+ }
+ }
+
+ /**
+ * Converts a single HAST node to an enhanced HTML string.
+ * Applies link and code block enhancements to the output.
+ * @param processorInstance - The remark/rehype processor instance
+ * @param processedRoot - The full processed HAST root (for context)
+ * @param child - The specific HAST child node to stringify
+ * @returns Enhanced HTML string representation of the node
+ */
+ function stringifyProcessedNode(
+ processorInstance: ReturnType<typeof processor>,
+ processedRoot: HastRoot,
+ child: unknown
+ ) {
+ const root: HastRoot = {
+ ...(processedRoot as HastRoot),
+ children: [child as never]
+ };
+
+ return processorInstance.stringify(root);
+ }
+
+ /**
+ * Queues markdown for processing with coalescing support.
+ * Only processes the latest markdown when multiple updates arrive quickly.
+ * @param markdown - The markdown content to render
+ */
+ async function updateRenderedBlocks(markdown: string) {
+ pendingMarkdown = markdown;
+
+ if (isProcessing) {
+ return;
+ }
+
+ isProcessing = true;
+
+ try {
+ while (pendingMarkdown !== null) {
+ const nextMarkdown = pendingMarkdown;
+ pendingMarkdown = null;
+
+ await processMarkdown(nextMarkdown);
+ }
+ } catch (error) {
+ console.error('Failed to process markdown:', error);
+ renderedBlocks = [];
+ unstableBlockHtml = markdown.replace(/\n/g, '<br>');
+ } finally {
+ isProcessing = false;
+ }
+ }
+
+ $effect(() => {
+ const currentMode = mode.current;
+ const isDark = currentMode === 'dark';
+
+ loadHighlightTheme(isDark);
+ });
+
+ $effect(() => {
+ updateRenderedBlocks(content);
+ });
+
+ $effect(() => {
+ const hasRenderedBlocks = renderedBlocks.length > 0;
+ const hasUnstableBlock = Boolean(unstableBlockHtml);
+
+ if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
+ setupCodeBlockActions();
+ }
+ });
+
+ onDestroy(() => {
+ cleanupEventListeners();
+ cleanupHighlightTheme();
+ });
+</script>
+
+<div bind:this={containerRef} class={className}>
+ {#each renderedBlocks as block (block.id)}
+ <div class="markdown-block" data-block-id={block.id}>
+ <!-- eslint-disable-next-line no-at-html-tags -->
+ {@html block.html}
+ </div>
+ {/each}
+
+ {#if unstableBlockHtml}
+ <div class="markdown-block markdown-block--unstable" data-block-id="unstable">
+ <!-- eslint-disable-next-line no-at-html-tags -->
+ {@html unstableBlockHtml}
+ </div>
+ {/if}
+</div>
+
+<CodePreviewDialog
+ open={previewDialogOpen}
+ code={previewCode}
+ language={previewLanguage}
+ onOpenChange={handlePreviewDialogOpenChange}
+/>
+
+<style>
+ .markdown-block,
+ .markdown-block--unstable {
+ display: contents;
+ }
+
+ /* Base typography styles */
+ div :global(p:not(:last-child)) {
+ margin-bottom: 1rem;
+ line-height: 1.75;
+ }
+
+ div :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
+ margin-top: 0;
+ }
+
+ /* Headers with consistent spacing */
+ div :global(h1) {
+ font-size: 1.875rem;
+ font-weight: 700;
+ line-height: 1.2;
+ margin: 1.5rem 0 0.75rem 0;
+ }
+
+ div :global(h2) {
+ font-size: 1.5rem;
+ font-weight: 600;
+ line-height: 1.3;
+ margin: 1.25rem 0 0.5rem 0;
+ }
+
+ div :global(h3) {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 1.5rem 0 0.5rem 0;
+ line-height: 1.4;
+ }
+
+ div :global(h4) {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin: 0.75rem 0 0.25rem 0;
+ }
+
+ div :global(h5) {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0.5rem 0 0.25rem 0;
+ }
+
+ div :global(h6) {
+ font-size: 0.875rem;
+ font-weight: 600;
+ margin: 0.5rem 0 0.25rem 0;
+ }
+
+ /* Text formatting */
+ div :global(strong) {
+ font-weight: 600;
+ }
+
+ div :global(em) {
+ font-style: italic;
+ }
+
+ div :global(del) {
+ text-decoration: line-through;
+ opacity: 0.7;
+ }
+
+ /* Inline code */
+ div :global(code:not(pre code)) {
+ background: var(--muted);
+ color: var(--muted-foreground);
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
+ 'Liberation Mono', Menlo, monospace;
+ }
+
+ /* Links */
+ div :global(a) {
+ color: var(--primary);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ transition: color 0.2s ease;
+ }
+
+ div :global(a:hover) {
+ color: var(--primary);
+ }
+
+ /* Lists */
+ div :global(ul) {
+ list-style-type: disc;
+ margin-left: 1.5rem;
+ margin-bottom: 1rem;
+ }
+
+ div :global(ol) {
+ list-style-type: decimal;
+ margin-left: 1.5rem;
+ margin-bottom: 1rem;
+ }
+
+ div :global(li) {
+ margin-bottom: 0.25rem;
+ padding-left: 0.5rem;
+ }
+
+ div :global(li::marker) {
+ color: var(--muted-foreground);
+ }
+
+ /* Nested lists */
+ div :global(ul ul) {
+ list-style-type: circle;
+ margin-top: 0.25rem;
+ margin-bottom: 0.25rem;
+ }
+
+ div :global(ol ol) {
+ list-style-type: lower-alpha;
+ margin-top: 0.25rem;
+ margin-bottom: 0.25rem;
+ }
+
+ /* Task lists */
+ div :global(.task-list-item) {
+ list-style: none;
+ margin-left: 0;
+ padding-left: 0;
+ }
+
+ div :global(.task-list-item-checkbox) {
+ margin-right: 0.5rem;
+ margin-top: 0.125rem;
+ }
+
+ /* Blockquotes */
+ div :global(blockquote) {
+ border-left: 4px solid var(--border);
+ padding: 0.5rem 1rem;
+ margin: 1.5rem 0;
+ font-style: italic;
+ color: var(--muted-foreground);
+ background: var(--muted);
+ border-radius: 0 0.375rem 0.375rem 0;
+ }
+
+ /* Tables */
+ div :global(table) {
+ width: 100%;
+ margin: 1.5rem 0;
+ border-collapse: collapse;
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ overflow: hidden;
+ }
+
+ div :global(th) {
+ background: hsl(var(--muted) / 0.3);
+ border: 1px solid var(--border);
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ font-weight: 600;
+ }
+
+ div :global(td) {
+ border: 1px solid var(--border);
+ padding: 0.5rem 0.75rem;
+ }
+
+ div :global(tr:nth-child(even)) {
+ background: hsl(var(--muted) / 0.1);
+ }
+
+ /* User message markdown should keep table borders visible on light primary backgrounds */
+ div.markdown-user-content :global(table),
+ div.markdown-user-content :global(th),
+ div.markdown-user-content :global(td),
+ div.markdown-user-content :global(.table-wrapper) {
+ border-color: currentColor;
+ }
+
+ /* Horizontal rules */
+ div :global(hr) {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 1.5rem 0;
+ }
+
+ /* Images */
+ div :global(img) {
+ border-radius: 0.5rem;
+ box-shadow:
+ 0 1px 3px 0 rgb(0 0 0 / 0.1),
+ 0 1px 2px -1px rgb(0 0 0 / 0.1);
+ margin: 1.5rem 0;
+ max-width: 100%;
+ height: auto;
+ }
+
+ /* Code blocks */
+
+ div :global(.code-block-wrapper) {
+ margin: 1.5rem 0;
+ border-radius: 0.75rem;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ background: var(--code-background);
+ }
+
+ div :global(.code-block-header) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 1rem;
+ background: hsl(var(--muted) / 0.5);
+ border-bottom: 1px solid var(--border);
+ font-size: 0.875rem;
+ }
+
+ div :global(.code-language) {
+ color: var(--code-foreground);
+ font-weight: 500;
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
+ 'Liberation Mono', Menlo, monospace;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.05em;
+ }
+
+ div :global(.code-block-actions) {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ div :global(.copy-code-btn),
+ div :global(.preview-code-btn) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: transparent;
+ color: var(--code-foreground);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ div :global(.copy-code-btn:hover),
+ div :global(.preview-code-btn:hover) {
+ transform: scale(1.05);
+ }
+
+ div :global(.copy-code-btn:active),
+ div :global(.preview-code-btn:active) {
+ transform: scale(0.95);
+ }
+
+ div :global(.code-block-wrapper pre) {
+ background: transparent;
+ padding: 1rem;
+ margin: 0;
+ overflow-x: auto;
+ border-radius: 0;
+ border: none;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ }
+
+ div :global(pre) {
+ background: var(--muted);
+ margin: 1.5rem 0;
+ overflow-x: auto;
+ border-radius: 1rem;
+ border: none;
+ }
+
+ div :global(code) {
+ background: transparent;
+ color: var(--code-foreground);
+ }
+
+ /* Mentions and hashtags */
+ div :global(.mention) {
+ color: hsl(var(--primary));
+ font-weight: 500;
+ text-decoration: none;
+ }
+
+ div :global(.mention:hover) {
+ text-decoration: underline;
+ }
+
+ div :global(.hashtag) {
+ color: hsl(var(--primary));
+ font-weight: 500;
+ text-decoration: none;
+ }
+
+ div :global(.hashtag:hover) {
+ text-decoration: underline;
+ }
+
+ /* Advanced table enhancements */
+ div :global(table) {
+ transition: all 0.2s ease;
+ }
+
+ div :global(table:hover) {
+ box-shadow:
+ 0 4px 6px -1px rgb(0 0 0 / 0.1),
+ 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ }
+
+ div :global(th:hover),
+ div :global(td:hover) {
+ background: var(--muted);
+ }
+
+ /* Disable hover effects when rendering user messages */
+ .markdown-user-content :global(a),
+ .markdown-user-content :global(a:hover) {
+ color: var(--primary-foreground);
+ }
+
+ .markdown-user-content :global(table:hover) {
+ box-shadow: none;
+ }
+
+ .markdown-user-content :global(th:hover),
+ .markdown-user-content :global(td:hover) {
+ background: inherit;
+ }
+
+ /* Enhanced blockquotes */
+ div :global(blockquote) {
+ transition: all 0.2s ease;
+ position: relative;
+ }
+
+ div :global(blockquote:hover) {
+ border-left-width: 6px;
+ background: var(--muted);
+ transform: translateX(2px);
+ }
+
+ div :global(blockquote::before) {
+ content: '"';
+ position: absolute;
+ top: -0.5rem;
+ left: 0.5rem;
+ font-size: 3rem;
+ color: var(--muted-foreground);
+ font-family: serif;
+ line-height: 1;
+ }
+
+ /* Enhanced images */
+ div :global(img) {
+ transition: all 0.3s ease;
+ cursor: pointer;
+ }
+
+ div :global(img:hover) {
+ transform: scale(1.02);
+ box-shadow:
+ 0 10px 15px -3px rgb(0 0 0 / 0.1),
+ 0 4px 6px -4px rgb(0 0 0 / 0.1);
+ }
+
+ /* Image zoom overlay */
+ div :global(.image-zoom-overlay) {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ cursor: pointer;
+ }
+
+ div :global(.image-zoom-overlay img) {
+ max-width: 90vw;
+ max-height: 90vh;
+ border-radius: 0.5rem;
+ box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+ }
+
+ /* Enhanced horizontal rules */
+ div :global(hr) {
+ border: none;
+ height: 2px;
+ background: linear-gradient(to right, transparent, var(--border), transparent);
+ margin: 2rem 0;
+ position: relative;
+ }
+
+ div :global(hr::after) {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 1rem;
+ height: 1rem;
+ background: var(--border);
+ border-radius: 50%;
+ }
+
+ /* Scrollable tables */
+ div :global(.table-wrapper) {
+ overflow-x: auto;
+ margin: 1.5rem 0;
+ border-radius: 0.5rem;
+ border: 1px solid var(--border);
+ }
+
+ div :global(.table-wrapper table) {
+ margin: 0;
+ border: none;
+ }
+
+ /* Responsive adjustments */
+ @media (max-width: 640px) {
+ div :global(h1) {
+ font-size: 1.5rem;
+ }
+
+ div :global(h2) {
+ font-size: 1.25rem;
+ }
+
+ div :global(h3) {
+ font-size: 1.125rem;
+ }
+
+ div :global(table) {
+ font-size: 0.875rem;
+ }
+
+ div :global(th),
+ div :global(td) {
+ padding: 0.375rem 0.5rem;
+ }
+
+ div :global(.table-wrapper) {
+ margin: 0.5rem -1rem;
+ border-radius: 0;
+ border-left: none;
+ border-right: none;
+ }
+ }
+
+ /* Dark mode adjustments */
+ @media (prefers-color-scheme: dark) {
+ div :global(blockquote:hover) {
+ background: var(--muted);
+ }
+ }
+</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 @@
+<script lang="ts">
+ import { X } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+
+ interface Props {
+ id: string;
+ onRemove?: (id: string) => void;
+ class?: string;
+ }
+
+ let { id, onRemove, class: className = '' }: Props = $props();
+</script>
+
+<Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}"
+ onclick={(e) => {
+ e.stopPropagation();
+ onRemove?.(id);
+ }}
+ aria-label="Remove file"
+>
+ <X class="h-3 w-3" />
+</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 @@
+<script lang="ts">
+ import { Input } from '$lib/components/ui/input';
+ import { Search, X } from '@lucide/svelte';
+
+ interface Props {
+ value?: string;
+ placeholder?: string;
+ onInput?: (value: string) => void;
+ onClose?: () => void;
+ onKeyDown?: (event: KeyboardEvent) => void;
+ class?: string;
+ id?: string;
+ ref?: HTMLInputElement | null;
+ }
+
+ let {
+ value = $bindable(''),
+ placeholder = 'Search...',
+ onInput,
+ onClose,
+ onKeyDown,
+ class: className,
+ id,
+ ref = $bindable(null)
+ }: Props = $props();
+
+ let showClearButton = $derived(!!value || !!onClose);
+
+ function handleInput(event: Event) {
+ const target = event.target as HTMLInputElement;
+
+ value = target.value;
+ onInput?.(target.value);
+ }
+
+ function handleClear() {
+ if (value) {
+ value = '';
+ onInput?.('');
+ ref?.focus();
+ } else {
+ onClose?.();
+ }
+ }
+</script>
+
+<div class="relative {className}">
+ <Search
+ class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
+ />
+
+ <Input
+ {id}
+ bind:value
+ bind:ref
+ class="pl-9 {showClearButton ? 'pr-9' : ''}"
+ oninput={handleInput}
+ onkeydown={onKeyDown}
+ {placeholder}
+ type="search"
+ />
+
+ {#if showClearButton}
+ <button
+ type="button"
+ class="absolute top-1/2 right-3 -translate-y-1/2 transform text-muted-foreground transition-colors hover:text-foreground"
+ onclick={handleClear}
+ aria-label={value ? 'Clear search' : 'Close'}
+ >
+ <X class="h-4 w-4" />
+ </button>
+ {/if}
+</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 @@
+<script lang="ts">
+ import hljs from 'highlight.js';
+ import { browser } from '$app/environment';
+ import { mode } from 'mode-watcher';
+
+ import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+ import githubLightCss from 'highlight.js/styles/github.css?inline';
+
+ interface Props {
+ code: string;
+ language?: string;
+ class?: string;
+ maxHeight?: string;
+ maxWidth?: string;
+ }
+
+ let {
+ code,
+ language = 'text',
+ class: className = '',
+ maxHeight = '60vh',
+ maxWidth = ''
+ }: Props = $props();
+
+ let highlightedHtml = $state('');
+
+ function loadHighlightTheme(isDark: boolean) {
+ if (!browser) return;
+
+ const existingThemes = document.querySelectorAll('style[data-highlight-theme-preview]');
+ existingThemes.forEach((style) => style.remove());
+
+ const style = document.createElement('style');
+ style.setAttribute('data-highlight-theme-preview', 'true');
+ style.textContent = isDark ? githubDarkCss : githubLightCss;
+
+ document.head.appendChild(style);
+ }
+
+ $effect(() => {
+ const currentMode = mode.current;
+ const isDark = currentMode === 'dark';
+
+ loadHighlightTheme(isDark);
+ });
+
+ $effect(() => {
+ if (!code) {
+ highlightedHtml = '';
+ return;
+ }
+
+ try {
+ // Check if the language is supported
+ const lang = language.toLowerCase();
+ const isSupported = hljs.getLanguage(lang);
+
+ if (isSupported) {
+ const result = hljs.highlight(code, { language: lang });
+ highlightedHtml = result.value;
+ } else {
+ // Try auto-detection or fallback to plain text
+ const result = hljs.highlightAuto(code);
+ highlightedHtml = result.value;
+ }
+ } catch {
+ // Fallback to escaped plain text
+ highlightedHtml = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+ }
+ });
+</script>
+
+<div
+ class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}"
+ style="max-height: {maxHeight}; max-width: {maxWidth};"
+>
+ <!-- Needs to be formatted as single line for proper rendering -->
+ <pre class="m-0 overflow-x-auto p-4"><code class="hljs text-sm leading-relaxed"
+ >{@html highlightedHtml}</code
+ ></pre>
+</div>
+
+<style>
+ .code-preview-wrapper {
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
+ 'Liberation Mono', Menlo, monospace;
+ }
+
+ .code-preview-wrapper pre {
+ background: transparent;
+ }
+
+ .code-preview-wrapper code {
+ background: transparent;
+ }
+</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 @@
+<script lang="ts">
+ import { Package } from '@lucide/svelte';
+ import { BadgeInfo, CopyToClipboardIcon } from '$lib/components/app';
+ import { modelsStore } from '$lib/stores/models.svelte';
+ import { serverStore } from '$lib/stores/server.svelte';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+
+ interface Props {
+ class?: string;
+ model?: string;
+ onclick?: () => void;
+ showCopyIcon?: boolean;
+ showTooltip?: boolean;
+ }
+
+ let {
+ class: className = '',
+ model: modelProp,
+ onclick,
+ showCopyIcon = false,
+ showTooltip = false
+ }: Props = $props();
+
+ let model = $derived(modelProp || modelsStore.singleModelName);
+ let isModelMode = $derived(serverStore.isModelMode);
+</script>
+
+{#snippet badgeContent()}
+ <BadgeInfo class={className} {onclick}>
+ {#snippet icon()}
+ <Package class="h-3 w-3" />
+ {/snippet}
+
+ {model}
+
+ {#if showCopyIcon}
+ <CopyToClipboardIcon text={model || ''} ariaLabel="Copy model name" />
+ {/if}
+ </BadgeInfo>
+{/snippet}
+
+{#if model && isModelMode}
+ {#if showTooltip}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ {@render badgeContent()}
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ {onclick ? 'Click for model details' : model}
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {:else}
+ {@render badgeContent()}
+ {/if}
+{/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 @@
+<script lang="ts">
+ import { onMount, tick } from 'svelte';
+ import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import * as Popover from '$lib/components/ui/popover';
+ import { cn } from '$lib/components/ui/utils';
+ import {
+ modelsStore,
+ modelOptions,
+ modelsLoading,
+ modelsUpdating,
+ selectedModelId,
+ routerModels,
+ propsCacheVersion,
+ singleModelName
+ } from '$lib/stores/models.svelte';
+ import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
+ import { ServerModelStatus } from '$lib/enums';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import { DialogModelInformation, SearchInput } from '$lib/components/app';
+ import type { ModelOption } from '$lib/types/models';
+
+ interface Props {
+ class?: string;
+ currentModel?: string | null;
+ /** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
+ onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
+ disabled?: boolean;
+ forceForegroundText?: boolean;
+ /** When true, user's global selection takes priority over currentModel (for form selector) */
+ useGlobalSelection?: boolean;
+ /**
+ * When provided, only consider modalities from messages BEFORE this message.
+ * Used for regeneration - allows selecting models that don't support modalities
+ * used in later messages.
+ */
+ upToMessageId?: string;
+ }
+
+ let {
+ class: className = '',
+ currentModel = null,
+ onModelChange,
+ disabled = false,
+ forceForegroundText = false,
+ useGlobalSelection = false,
+ upToMessageId
+ }: Props = $props();
+
+ let options = $derived(modelOptions());
+ let loading = $derived(modelsLoading());
+ let updating = $derived(modelsUpdating());
+ let activeId = $derived(selectedModelId());
+ let isRouter = $derived(isRouterMode());
+ let serverModel = $derived(singleModelName());
+
+ // Reactive router models state - needed for proper reactivity of status checks
+ let currentRouterModels = $derived(routerModels());
+
+ let requiredModalities = $derived(
+ upToMessageId ? conversationsStore.getModalitiesUpToMessage(upToMessageId) : usedModalities()
+ );
+
+ function getModelStatus(modelId: string): ServerModelStatus | null {
+ const model = currentRouterModels.find((m) => m.id === modelId);
+ return (model?.status?.value as ServerModelStatus) ?? null;
+ }
+
+ /**
+ * Checks if a model supports all modalities used in the conversation.
+ * Returns true if the model can be selected, false if it should be disabled.
+ */
+ function isModelCompatible(option: ModelOption): boolean {
+ void propsCacheVersion();
+
+ const modelModalities = modelsStore.getModelModalities(option.model);
+
+ if (!modelModalities) {
+ const status = getModelStatus(option.model);
+
+ if (status === ServerModelStatus.LOADED) {
+ if (requiredModalities.vision || requiredModalities.audio) return false;
+ }
+
+ return true;
+ }
+
+ if (requiredModalities.vision && !modelModalities.vision) return false;
+ if (requiredModalities.audio && !modelModalities.audio) return false;
+
+ return true;
+ }
+
+ /**
+ * Gets missing modalities for a model.
+ * Returns object with vision/audio booleans indicating what's missing.
+ */
+ function getMissingModalities(option: ModelOption): { vision: boolean; audio: boolean } | null {
+ void propsCacheVersion();
+
+ const modelModalities = modelsStore.getModelModalities(option.model);
+
+ if (!modelModalities) {
+ const status = getModelStatus(option.model);
+
+ if (status === ServerModelStatus.LOADED) {
+ const missing = {
+ vision: requiredModalities.vision,
+ audio: requiredModalities.audio
+ };
+
+ if (missing.vision || missing.audio) return missing;
+ }
+
+ return null;
+ }
+
+ const missing = {
+ vision: requiredModalities.vision && !modelModalities.vision,
+ audio: requiredModalities.audio && !modelModalities.audio
+ };
+
+ if (!missing.vision && !missing.audio) return null;
+
+ return missing;
+ }
+
+ let isHighlightedCurrentModelActive = $derived(
+ !isRouter || !currentModel
+ ? false
+ : (() => {
+ const currentOption = options.find((option) => option.model === currentModel);
+
+ return currentOption ? currentOption.id === activeId : false;
+ })()
+ );
+
+ let isCurrentModelInCache = $derived(() => {
+ if (!isRouter || !currentModel) return true;
+
+ return options.some((option) => option.model === currentModel);
+ });
+
+ let searchTerm = $state('');
+ let searchInputRef = $state<HTMLInputElement | null>(null);
+ let highlightedIndex = $state<number>(-1);
+
+ let filteredOptions: ModelOption[] = $derived(
+ (() => {
+ const term = searchTerm.trim().toLowerCase();
+ if (!term) return options;
+
+ return options.filter(
+ (option) =>
+ option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term)
+ );
+ })()
+ );
+
+ // Get indices of compatible options for keyboard navigation
+ let compatibleIndices = $derived(
+ filteredOptions
+ .map((option, index) => (isModelCompatible(option) ? index : -1))
+ .filter((i) => i !== -1)
+ );
+
+ // Reset highlighted index when search term changes
+ $effect(() => {
+ void searchTerm;
+ highlightedIndex = -1;
+ });
+
+ let isOpen = $state(false);
+ let showModelDialog = $state(false);
+
+ onMount(() => {
+ modelsStore.fetch().catch((error) => {
+ console.error('Unable to load models:', error);
+ });
+ });
+
+ // Handle changes to the model selector pop-down or the model dialog, depending on if the server is in
+ // router mode or not.
+ function handleOpenChange(open: boolean) {
+ if (loading || updating) return;
+
+ if (isRouter) {
+ if (open) {
+ isOpen = true;
+ searchTerm = '';
+ highlightedIndex = -1;
+
+ // Focus search input after popover opens
+ tick().then(() => {
+ requestAnimationFrame(() => searchInputRef?.focus());
+ });
+
+ modelsStore.fetchRouterModels().then(() => {
+ modelsStore.fetchModalitiesForLoadedModels();
+ });
+ } else {
+ isOpen = false;
+ searchTerm = '';
+ highlightedIndex = -1;
+ }
+ } else {
+ showModelDialog = open;
+ }
+ }
+
+ export function open() {
+ handleOpenChange(true);
+ }
+
+ function handleSearchKeyDown(event: KeyboardEvent) {
+ if (event.isComposing) return;
+
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ if (compatibleIndices.length === 0) return;
+
+ const currentPos = compatibleIndices.indexOf(highlightedIndex);
+ if (currentPos === -1 || currentPos === compatibleIndices.length - 1) {
+ highlightedIndex = compatibleIndices[0];
+ } else {
+ highlightedIndex = compatibleIndices[currentPos + 1];
+ }
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ if (compatibleIndices.length === 0) return;
+
+ const currentPos = compatibleIndices.indexOf(highlightedIndex);
+ if (currentPos === -1 || currentPos === 0) {
+ highlightedIndex = compatibleIndices[compatibleIndices.length - 1];
+ } else {
+ highlightedIndex = compatibleIndices[currentPos - 1];
+ }
+ } else if (event.key === 'Enter') {
+ event.preventDefault();
+ if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
+ const option = filteredOptions[highlightedIndex];
+ if (isModelCompatible(option)) {
+ handleSelect(option.id);
+ }
+ } else if (compatibleIndices.length > 0) {
+ // No selection - highlight first compatible option
+ highlightedIndex = compatibleIndices[0];
+ }
+ }
+ }
+
+ async function handleSelect(modelId: string) {
+ const option = options.find((opt) => opt.id === modelId);
+ if (!option) return;
+
+ let shouldCloseMenu = true;
+
+ if (onModelChange) {
+ // If callback provided, use it (for regenerate functionality)
+ const result = await onModelChange(option.id, option.model);
+
+ // If callback returns false, keep menu open (validation failed)
+ if (result === false) {
+ shouldCloseMenu = false;
+ }
+ } else {
+ // Update global selection
+ await modelsStore.selectModelById(option.id);
+
+ // Load the model if not already loaded (router mode)
+ if (isRouter && getModelStatus(option.model) !== ServerModelStatus.LOADED) {
+ try {
+ await modelsStore.loadModel(option.model);
+ } catch (error) {
+ console.error('Failed to load model:', error);
+ }
+ }
+ }
+
+ if (shouldCloseMenu) {
+ handleOpenChange(false);
+
+ // Focus the chat textarea after model selection
+ requestAnimationFrame(() => {
+ const textarea = document.querySelector<HTMLTextAreaElement>(
+ '[data-slot="chat-form"] textarea'
+ );
+ textarea?.focus();
+ });
+ }
+ }
+
+ function getDisplayOption(): ModelOption | undefined {
+ if (!isRouter) {
+ if (serverModel) {
+ return {
+ id: 'current',
+ model: serverModel,
+ name: serverModel.split('/').pop() || serverModel,
+ capabilities: [] // Empty array for single model mode
+ };
+ }
+
+ return undefined;
+ }
+
+ // When useGlobalSelection is true (form selector), prioritize user selection
+ // Otherwise (message display), prioritize currentModel
+ if (useGlobalSelection && activeId) {
+ const selected = options.find((option) => option.id === activeId);
+ if (selected) return selected;
+ }
+
+ // Show currentModel (from message payload or conversation)
+ if (currentModel) {
+ if (!isCurrentModelInCache()) {
+ return {
+ id: 'not-in-cache',
+ model: currentModel,
+ name: currentModel.split('/').pop() || currentModel,
+ capabilities: []
+ };
+ }
+
+ return options.find((option) => option.model === currentModel);
+ }
+
+ // Fallback to user selection (for new chats before first message)
+ if (activeId) {
+ return options.find((option) => option.id === activeId);
+ }
+
+ // No selection - return undefined to show "Select model"
+ return undefined;
+ }
+</script>
+
+<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
+ {#if loading && options.length === 0 && isRouter}
+ <div class="flex items-center gap-2 text-xs text-muted-foreground">
+ <Loader2 class="h-3.5 w-3.5 animate-spin" />
+ Loading models…
+ </div>
+ {:else if options.length === 0 && isRouter}
+ <p class="text-xs text-muted-foreground">No models available.</p>
+ {:else}
+ {@const selectedOption = getDisplayOption()}
+
+ {#if isRouter}
+ <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
+ <Popover.Trigger
+ class={cn(
+ `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`,
+ !isCurrentModelInCache()
+ ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
+ : forceForegroundText
+ ? 'text-foreground'
+ : isHighlightedCurrentModelActive
+ ? 'text-foreground'
+ : 'text-muted-foreground',
+ isOpen ? 'text-foreground' : ''
+ )}
+ style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
+ disabled={disabled || updating}
+ >
+ <Package class="h-3.5 w-3.5" />
+
+ <span class="truncate font-medium">
+ {selectedOption?.model || 'Select model'}
+ </span>
+
+ {#if updating}
+ <Loader2 class="h-3 w-3.5 animate-spin" />
+ {:else}
+ <ChevronDown class="h-3 w-3.5" />
+ {/if}
+ </Popover.Trigger>
+
+ <Popover.Content
+ class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
+ align="end"
+ sideOffset={8}
+ collisionPadding={16}
+ >
+ <div class="flex max-h-[50dvh] flex-col overflow-hidden">
+ <div
+ 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"
+ >
+ <SearchInput
+ id="model-search"
+ placeholder="Search models..."
+ bind:value={searchTerm}
+ bind:ref={searchInputRef}
+ onClose={() => handleOpenChange(false)}
+ onKeyDown={handleSearchKeyDown}
+ />
+ </div>
+ <div
+ class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
+ >
+ {#if !isCurrentModelInCache() && currentModel}
+ <!-- Show unavailable model as first option (disabled) -->
+ <button
+ type="button"
+ class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
+ role="option"
+ aria-selected="true"
+ aria-disabled="true"
+ disabled
+ >
+ <span class="truncate">{selectedOption?.name || currentModel}</span>
+ <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
+ </button>
+ <div class="my-1 h-px bg-border"></div>
+ {/if}
+ {#if filteredOptions.length === 0}
+ <p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
+ {/if}
+ {#each filteredOptions as option, index (option.id)}
+ {@const status = getModelStatus(option.model)}
+ {@const isLoaded = status === ServerModelStatus.LOADED}
+ {@const isLoading = status === ServerModelStatus.LOADING}
+ {@const isSelected = currentModel === option.model || activeId === option.id}
+ {@const isCompatible = isModelCompatible(option)}
+ {@const isHighlighted = index === highlightedIndex}
+ {@const missingModalities = getMissingModalities(option)}
+
+ <div
+ class={cn(
+ 'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
+ isCompatible
+ ? 'cursor-pointer hover:bg-muted focus:bg-muted'
+ : 'cursor-not-allowed opacity-50',
+ isSelected || isHighlighted
+ ? 'bg-accent text-accent-foreground'
+ : isCompatible
+ ? 'hover:bg-accent hover:text-accent-foreground'
+ : '',
+ isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
+ )}
+ role="option"
+ aria-selected={isSelected || isHighlighted}
+ aria-disabled={!isCompatible}
+ tabindex={isCompatible ? 0 : -1}
+ onclick={() => isCompatible && handleSelect(option.id)}
+ onmouseenter={() => (highlightedIndex = index)}
+ onkeydown={(e) => {
+ if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault();
+ handleSelect(option.id);
+ }
+ }}
+ >
+ <span class="min-w-0 flex-1 truncate">{option.model}</span>
+
+ {#if missingModalities}
+ <span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
+ {#if missingModalities.vision}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <EyeOff class="h-3.5 w-3.5" />
+ </Tooltip.Trigger>
+ <Tooltip.Content class="z-[9999]">
+ <p>No vision support</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+ {#if missingModalities.audio}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <MicOff class="h-3.5 w-3.5" />
+ </Tooltip.Trigger>
+ <Tooltip.Content class="z-[9999]">
+ <p>No audio support</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+ </span>
+ {/if}
+
+ {#if isLoading}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
+ </Tooltip.Trigger>
+ <Tooltip.Content class="z-[9999]">
+ <p>Loading model...</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {:else if isLoaded}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <button
+ type="button"
+ class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
+ onclick={(e) => {
+ e.stopPropagation();
+ modelsStore.unloadModel(option.model);
+ }}
+ >
+ <span
+ class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
+ ></span>
+ <Power
+ class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
+ />
+ </button>
+ </Tooltip.Trigger>
+ <Tooltip.Content class="z-[9999]">
+ <p>Unload model</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {:else}
+ <span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </div>
+ </Popover.Content>
+ </Popover.Root>
+ {:else}
+ <button
+ class={cn(
+ `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`,
+ !isCurrentModelInCache()
+ ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
+ : forceForegroundText
+ ? 'text-foreground'
+ : isHighlightedCurrentModelActive
+ ? 'text-foreground'
+ : 'text-muted-foreground',
+ isOpen ? 'text-foreground' : ''
+ )}
+ style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
+ onclick={() => handleOpenChange(true)}
+ disabled={disabled || updating}
+ >
+ <Package class="h-3.5 w-3.5" />
+
+ <span class="truncate font-medium">
+ {selectedOption?.model}
+ </span>
+
+ {#if updating}
+ <Loader2 class="h-3 w-3.5 animate-spin" />
+ {/if}
+ </button>
+ {/if}
+ {/if}
+</div>
+
+{#if showModelDialog && !isRouter}
+ <DialogModelInformation bind:open={showModelDialog} />
+{/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 @@
+<script lang="ts">
+ import { base } from '$app/paths';
+ import { AlertTriangle, RefreshCw, Key, CheckCircle, XCircle } from '@lucide/svelte';
+ import { goto } from '$app/navigation';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+ import Label from '$lib/components/ui/label/label.svelte';
+ import { serverStore, serverLoading } from '$lib/stores/server.svelte';
+ import { config, settingsStore } from '$lib/stores/settings.svelte';
+ import { fade, fly, scale } from 'svelte/transition';
+
+ interface Props {
+ class?: string;
+ error: string;
+ onRetry?: () => void;
+ showRetry?: boolean;
+ showTroubleshooting?: boolean;
+ }
+
+ let {
+ class: className = '',
+ error,
+ onRetry,
+ showRetry = true,
+ showTroubleshooting = false
+ }: Props = $props();
+
+ let isServerLoading = $derived(serverLoading());
+ let isAccessDeniedError = $derived(
+ error.toLowerCase().includes('access denied') ||
+ error.toLowerCase().includes('invalid api key') ||
+ error.toLowerCase().includes('unauthorized') ||
+ error.toLowerCase().includes('401') ||
+ error.toLowerCase().includes('403')
+ );
+
+ let apiKeyInput = $state('');
+ let showApiKeyInput = $state(false);
+ let apiKeyState = $state<'idle' | 'validating' | 'success' | 'error'>('idle');
+ let apiKeyError = $state('');
+
+ function handleRetryConnection() {
+ if (onRetry) {
+ onRetry();
+ } else {
+ serverStore.fetch();
+ }
+ }
+
+ function handleShowApiKeyInput() {
+ showApiKeyInput = true;
+ // Pre-fill with current API key if it exists
+ const currentConfig = config();
+ apiKeyInput = currentConfig.apiKey?.toString() || '';
+ }
+
+ async function handleSaveApiKey() {
+ if (!apiKeyInput.trim()) return;
+
+ apiKeyState = 'validating';
+ apiKeyError = '';
+
+ try {
+ // Update the API key in settings first
+ settingsStore.updateConfig('apiKey', apiKeyInput.trim());
+
+ // Test the API key by making a real request to the server
+ const response = await fetch(`${base}/props`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKeyInput.trim()}`
+ }
+ });
+
+ if (response.ok) {
+ // API key is valid - User Story B
+ apiKeyState = 'success';
+
+ // Show success state briefly, then navigate to home
+ setTimeout(() => {
+ goto(`#/`);
+ }, 1000);
+ } else {
+ // API key is invalid - User Story A
+ apiKeyState = 'error';
+
+ if (response.status === 401 || response.status === 403) {
+ apiKeyError = 'Invalid API key - please check and try again';
+ } else {
+ apiKeyError = `Authentication failed (${response.status})`;
+ }
+
+ // Reset to idle state after showing error (don't reload UI)
+ setTimeout(() => {
+ apiKeyState = 'idle';
+ }, 3000);
+ }
+ } catch (error) {
+ // Network or other errors - User Story A
+ apiKeyState = 'error';
+
+ if (error instanceof Error) {
+ if (error.message.includes('fetch')) {
+ apiKeyError = 'Cannot connect to server - check if server is running';
+ } else {
+ apiKeyError = error.message;
+ }
+ } else {
+ apiKeyError = 'Connection error - please try again';
+ }
+
+ // Reset to idle state after showing error (don't reload UI)
+ setTimeout(() => {
+ apiKeyState = 'idle';
+ }, 3000);
+ }
+ }
+
+ function handleApiKeyKeydown(event: KeyboardEvent) {
+ if (event.key === 'Enter') {
+ handleSaveApiKey();
+ }
+ }
+</script>
+
+<div class="flex h-full items-center justify-center {className}">
+ <div class="w-full max-w-md px-4 text-center">
+ <div class="mb-6" in:fade={{ duration: 300 }}>
+ <div
+ class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10"
+ >
+ <AlertTriangle class="h-8 w-8 text-destructive" />
+ </div>
+
+ <h2 class="mb-2 text-xl font-semibold">Server Connection Error</h2>
+
+ <p class="mb-4 text-sm text-muted-foreground">
+ {error}
+ </p>
+ </div>
+
+ {#if isAccessDeniedError && !showApiKeyInput}
+ <div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4">
+ <Button onclick={handleShowApiKeyInput} variant="outline" class="w-full">
+ <Key class="h-4 w-4" />
+ Enter API Key
+ </Button>
+ </div>
+ {/if}
+
+ {#if showApiKeyInput}
+ <div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4 space-y-3 text-left">
+ <div class="space-y-2">
+ <Label for="api-key-input" class="text-sm font-medium">API Key</Label>
+
+ <div class="relative">
+ <Input
+ id="api-key-input"
+ placeholder="Enter your API key..."
+ bind:value={apiKeyInput}
+ onkeydown={handleApiKeyKeydown}
+ class="w-full pr-10 {apiKeyState === 'error'
+ ? 'border-destructive'
+ : apiKeyState === 'success'
+ ? 'border-green-500'
+ : ''}"
+ disabled={apiKeyState === 'validating'}
+ />
+ {#if apiKeyState === 'validating'}
+ <div class="absolute top-1/2 right-3 -translate-y-1/2">
+ <RefreshCw class="h-4 w-4 animate-spin text-muted-foreground" />
+ </div>
+ {:else if apiKeyState === 'success'}
+ <div
+ class="absolute top-1/2 right-3 -translate-y-1/2"
+ in:scale={{ duration: 200, start: 0.8 }}
+ >
+ <CheckCircle class="h-4 w-4 text-green-500" />
+ </div>
+ {:else if apiKeyState === 'error'}
+ <div
+ class="absolute top-1/2 right-3 -translate-y-1/2"
+ in:scale={{ duration: 200, start: 0.8 }}
+ >
+ <XCircle class="h-4 w-4 text-destructive" />
+ </div>
+ {/if}
+ </div>
+ {#if apiKeyError}
+ <p class="text-sm text-destructive" in:fly={{ y: -10, duration: 200 }}>
+ {apiKeyError}
+ </p>
+ {/if}
+ {#if apiKeyState === 'success'}
+ <p class="text-sm text-green-600" in:fly={{ y: -10, duration: 200 }}>
+ ✓ API key validated successfully! Connecting...
+ </p>
+ {/if}
+ </div>
+ <div class="flex gap-2">
+ <Button
+ onclick={handleSaveApiKey}
+ disabled={!apiKeyInput.trim() ||
+ apiKeyState === 'validating' ||
+ apiKeyState === 'success'}
+ class="flex-1"
+ >
+ {#if apiKeyState === 'validating'}
+ <RefreshCw class="h-4 w-4 animate-spin" />
+ Validating...
+ {:else if apiKeyState === 'success'}
+ Success!
+ {:else}
+ Save & Retry
+ {/if}
+ </Button>
+ <Button
+ onclick={() => {
+ showApiKeyInput = false;
+ apiKeyState = 'idle';
+ apiKeyError = '';
+ }}
+ variant="outline"
+ class="flex-1"
+ disabled={apiKeyState === 'validating'}
+ >
+ Cancel
+ </Button>
+ </div>
+ </div>
+ {/if}
+
+ {#if showRetry}
+ <div in:fly={{ y: 10, duration: 300, delay: 200 }}>
+ <Button onclick={handleRetryConnection} disabled={isServerLoading} class="w-full">
+ {#if isServerLoading}
+ <RefreshCw class="h-4 w-4 animate-spin" />
+
+ Connecting...
+ {:else}
+ <RefreshCw class="h-4 w-4" />
+
+ Retry Connection
+ {/if}
+ </Button>
+ </div>
+ {/if}
+
+ {#if showTroubleshooting}
+ <div class="mt-4 text-left" in:fly={{ y: 10, duration: 300, delay: 400 }}>
+ <details class="text-sm">
+ <summary class="cursor-pointer text-muted-foreground hover:text-foreground">
+ Troubleshooting
+ </summary>
+
+ <div class="mt-2 space-y-3 text-xs text-muted-foreground">
+ <div class="space-y-2">
+ <p class="mb-4 font-medium">Start the llama-server:</p>
+
+ <div class="rounded bg-muted/50 px-2 py-1 font-mono text-xs">
+ <p>llama-server -hf ggml-org/gemma-3-4b-it-GGUF</p>
+ </div>
+
+ <p>or</p>
+
+ <div class="rounded bg-muted/50 px-2 py-1 font-mono text-xs">
+ <p class="mt-1">llama-server -m locally-stored-model.gguf</p>
+ </div>
+ </div>
+ <ul class="list-disc space-y-1 pl-4">
+ <li>Check that the server is accessible at the correct URL</li>
+
+ <li>Verify your network connection</li>
+
+ <li>Check server logs for any error messages</li>
+ </ul>
+ </div>
+ </details>
+ </div>
+ {/if}
+ </div>
+</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 @@
+<script lang="ts">
+ import { Server } from '@lucide/svelte';
+ import { ServerStatus } from '$lib/components/app';
+ import { fade } from 'svelte/transition';
+
+ interface Props {
+ class?: string;
+ message?: string;
+ }
+
+ let { class: className = '', message = 'Initializing connection to llama.cpp server...' }: Props =
+ $props();
+</script>
+
+<div class="flex h-full items-center justify-center {className}">
+ <div class="text-center">
+ <div class="mb-4" in:fade={{ duration: 300 }}>
+ <div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
+ <Server class="h-8 w-8 animate-pulse text-muted-foreground" />
+ </div>
+
+ <h2 class="mb-2 text-xl font-semibold">Connecting to Server</h2>
+
+ <p class="text-sm text-muted-foreground">
+ {message}
+ </p>
+ </div>
+
+ <div class="mt-4">
+ <ServerStatus class="justify-center" />
+ </div>
+ </div>
+</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 @@
+<script lang="ts">
+ import { AlertTriangle, Server } from '@lucide/svelte';
+ import { Badge } from '$lib/components/ui/badge';
+ import { Button } from '$lib/components/ui/button';
+ import { serverProps, serverLoading, serverError } from '$lib/stores/server.svelte';
+ import { singleModelName } from '$lib/stores/models.svelte';
+
+ interface Props {
+ class?: string;
+ showActions?: boolean;
+ }
+
+ let { class: className = '', showActions = false }: Props = $props();
+
+ let error = $derived(serverError());
+ let loading = $derived(serverLoading());
+ let model = $derived(singleModelName());
+ let serverData = $derived(serverProps());
+
+ function getStatusColor() {
+ if (loading) return 'bg-yellow-500';
+ if (error) return 'bg-red-500';
+ if (serverData) return 'bg-green-500';
+
+ return 'bg-gray-500';
+ }
+
+ function getStatusText() {
+ if (loading) return 'Connecting...';
+ if (error) return 'Connection Error';
+ if (serverData) return 'Connected';
+
+ return 'Unknown';
+ }
+</script>
+
+<div class="flex items-center space-x-3 {className}">
+ <div class="flex items-center space-x-2">
+ <div class="h-2 w-2 rounded-full {getStatusColor()}"></div>
+
+ <span class="text-sm text-muted-foreground">{getStatusText()}</span>
+ </div>
+
+ {#if serverData && !error}
+ <Badge variant="outline" class="text-xs">
+ <Server class="mr-1 h-3 w-3" />
+
+ {model || 'Unknown Model'}
+ </Badge>
+
+ {#if serverData.default_generation_settings.n_ctx}
+ <Badge variant="secondary" class="text-xs">
+ ctx: {serverData.default_generation_settings.n_ctx.toLocaleString()}
+ </Badge>
+ {/if}
+ {/if}
+
+ {#if showActions && error}
+ <Button variant="outline" size="sm" class="text-destructive">
+ <AlertTriangle class="h-4 w-4" />
+
+ {error}
+ </Button>
+ {/if}
+</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 @@
+<script lang="ts">
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
+ import { buttonVariants } from '$lib/components/ui/button/index.js';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: AlertDialogPrimitive.ActionProps = $props();
+</script>
+
+<AlertDialogPrimitive.Action
+ bind:ref
+ data-slot="alert-dialog-action"
+ class={cn(buttonVariants(), className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
+ import { buttonVariants } from '$lib/components/ui/button/index.js';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: AlertDialogPrimitive.CancelProps = $props();
+</script>
+
+<AlertDialogPrimitive.Cancel
+ bind:ref
+ data-slot="alert-dialog-cancel"
+ class={cn(buttonVariants({ variant: 'outline' }), className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
+ import AlertDialogOverlay from './alert-dialog-overlay.svelte';
+ import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ portalProps,
+ ...restProps
+ }: WithoutChild<AlertDialogPrimitive.ContentProps> & {
+ portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
+ } = $props();
+</script>
+
+<AlertDialogPrimitive.Portal {...portalProps}>
+ <AlertDialogOverlay />
+ <AlertDialogPrimitive.Content
+ bind:ref
+ data-slot="alert-dialog-content"
+ class={cn(
+ 'fixed z-[999999] grid w-full gap-4 border bg-background p-6 shadow-lg duration-200',
+ // Mobile: Bottom sheet behavior
+ 'right-0 bottom-0 left-0 max-h-[100dvh] translate-x-0 translate-y-0 overflow-y-auto rounded-t-lg',
+ 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-bottom-full',
+ 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-bottom-full',
+ // Desktop: Centered dialog behavior
+ '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',
+ 'sm:data-[state=closed]:slide-out-to-bottom-0 sm:data-[state=closed]:zoom-out-95',
+ 'sm:data-[state=open]:slide-in-from-bottom-0 sm:data-[state=open]:zoom-in-95',
+ className
+ )}
+ {...restProps}
+ />
+</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 @@
+<script lang="ts">
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: AlertDialogPrimitive.DescriptionProps = $props();
+</script>
+
+<AlertDialogPrimitive.Description
+ bind:ref
+ data-slot="alert-dialog-description"
+ class={cn('text-sm text-muted-foreground', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="alert-dialog-footer"
+ class={cn(
+ 'mt-6 flex flex-row gap-2 sm:mt-0 sm:justify-end [&>*]:flex-1 sm:[&>*]:flex-none',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="alert-dialog-header"
+ class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: AlertDialogPrimitive.OverlayProps = $props();
+</script>
+
+<AlertDialogPrimitive.Overlay
+ bind:ref
+ data-slot="alert-dialog-overlay"
+ class={cn(
+ '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',
+ className
+ )}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: AlertDialogPrimitive.TitleProps = $props();
+</script>
+
+<AlertDialogPrimitive.Title
+ bind:ref
+ data-slot="alert-dialog-title"
+ class={cn('text-lg font-semibold', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
+</script>
+
+<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 @@
+import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
+import Trigger from './alert-dialog-trigger.svelte';
+import Title from './alert-dialog-title.svelte';
+import Action from './alert-dialog-action.svelte';
+import Cancel from './alert-dialog-cancel.svelte';
+import Footer from './alert-dialog-footer.svelte';
+import Header from './alert-dialog-header.svelte';
+import Overlay from './alert-dialog-overlay.svelte';
+import Content from './alert-dialog-content.svelte';
+import Description from './alert-dialog-description.svelte';
+
+const Root = AlertDialogPrimitive.Root;
+const Portal = AlertDialogPrimitive.Portal;
+
+export {
+ Root,
+ Title,
+ Action,
+ Cancel,
+ Portal,
+ Footer,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ //
+ Root as AlertDialog,
+ Title as AlertDialogTitle,
+ Action as AlertDialogAction,
+ Cancel as AlertDialogCancel,
+ Portal as AlertDialogPortal,
+ Footer as AlertDialogFooter,
+ Header as AlertDialogHeader,
+ Trigger as AlertDialogTrigger,
+ Overlay as AlertDialogOverlay,
+ Content as AlertDialogContent,
+ Description as AlertDialogDescription
+};
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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="alert-description"
+ class={cn(
+ 'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="alert-title"
+ class={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts" module>
+ import { type VariantProps, tv } from 'tailwind-variants';
+
+ export const alertVariants = tv({
+ 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',
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current'
+ }
+ },
+ defaultVariants: {
+ variant: 'default'
+ }
+ });
+
+ export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
+</script>
+
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ variant = 'default',
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
+ variant?: AlertVariant;
+ } = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="alert"
+ class={cn(alertVariants({ variant }), className)}
+ {...restProps}
+ role="alert"
+>
+ {@render children?.()}
+</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 @@
+import Root from './alert.svelte';
+import Description from './alert-description.svelte';
+import Title from './alert-title.svelte';
+export { alertVariants, type AlertVariant } from './alert.svelte';
+
+export {
+ Root,
+ Description,
+ Title,
+ //
+ Root as Alert,
+ Description as AlertDescription,
+ Title as AlertTitle
+};
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 @@
+<script lang="ts" module>
+ import { type VariantProps, tv } from 'tailwind-variants';
+
+ export const badgeVariants = tv({
+ 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',
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
+ secondary:
+ 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
+ destructive:
+ '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',
+ outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
+ }
+ },
+ defaultVariants: {
+ variant: 'default'
+ }
+ });
+
+ export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
+</script>
+
+<script lang="ts">
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ href,
+ class: className,
+ variant = 'default',
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAnchorAttributes> & {
+ variant?: BadgeVariant;
+ } = $props();
+</script>
+
+<svelte:element
+ this={href ? 'a' : 'span'}
+ bind:this={ref}
+ data-slot="badge"
+ {href}
+ class={cn(badgeVariants({ variant }), className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+export { default as Badge } from './badge.svelte';
+export { 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 @@
+<script lang="ts" module>
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
+ import { type VariantProps, tv } from 'tailwind-variants';
+
+ export const buttonVariants = tv({
+ 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",
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
+ destructive:
+ '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',
+ outline:
+ 'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
+ secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline'
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9'
+ }
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default'
+ }
+ });
+
+ export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
+ export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
+
+ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
+ WithElementRef<HTMLAnchorAttributes> & {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ };
+</script>
+
+<script lang="ts">
+ let {
+ class: className,
+ variant = 'default',
+ size = 'default',
+ ref = $bindable(null),
+ href = undefined,
+ type = 'button',
+ disabled,
+ children,
+ ...restProps
+ }: ButtonProps = $props();
+</script>
+
+{#if href}
+ <a
+ bind:this={ref}
+ data-slot="button"
+ class={cn(buttonVariants({ variant, size }), className)}
+ href={disabled ? undefined : href}
+ aria-disabled={disabled}
+ role={disabled ? 'link' : undefined}
+ tabindex={disabled ? -1 : undefined}
+ {...restProps}
+ >
+ {@render children?.()}
+ </a>
+{:else}
+ <button
+ bind:this={ref}
+ data-slot="button"
+ class={cn(buttonVariants({ variant, size }), className)}
+ {type}
+ {disabled}
+ {...restProps}
+ >
+ {@render children?.()}
+ </button>
+{/if}
+
+<style>
+ a,
+ button {
+ cursor: pointer;
+ }
+</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 @@
+import Root, {
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+ buttonVariants
+} from './button.svelte';
+
+export {
+ Root,
+ type ButtonProps as Props,
+ //
+ Root as Button,
+ buttonVariants,
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant
+};
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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card-action"
+ class={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div bind:this={ref} data-slot="card-content" class={cn('px-6', className)} {...restProps}>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
+</script>
+
+<p
+ bind:this={ref}
+ data-slot="card-description"
+ class={cn('text-sm text-muted-foreground', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card-footer"
+ class={cn('flex items-center px-6 [.border-t]:pt-6', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card-header"
+ class={cn(
+ '@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',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card-title"
+ class={cn('leading-none font-semibold', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card"
+ class={cn(
+ 'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+import Root from './card.svelte';
+import Content from './card-content.svelte';
+import Description from './card-description.svelte';
+import Footer from './card-footer.svelte';
+import Header from './card-header.svelte';
+import Title from './card-title.svelte';
+import Action from './card-action.svelte';
+
+export {
+ Root,
+ Content,
+ Description,
+ Footer,
+ Header,
+ Title,
+ Action,
+ //
+ Root as Card,
+ Content as CardContent,
+ Description as CardDescription,
+ Footer as CardFooter,
+ Header as CardHeader,
+ Title as CardTitle,
+ Action as CardAction
+};
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 @@
+<script lang="ts">
+ import { Checkbox as CheckboxPrimitive } from 'bits-ui';
+ import CheckIcon from '@lucide/svelte/icons/check';
+ import MinusIcon from '@lucide/svelte/icons/minus';
+ import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ checked = $bindable(false),
+ indeterminate = $bindable(false),
+ class: className,
+ ...restProps
+ }: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
+</script>
+
+<CheckboxPrimitive.Root
+ bind:ref
+ data-slot="checkbox"
+ class={cn(
+ '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',
+ className
+ )}
+ bind:checked
+ bind:indeterminate
+ {...restProps}
+>
+ {#snippet children({ checked, indeterminate })}
+ <div data-slot="checkbox-indicator" class="text-current transition-none">
+ {#if checked}
+ <CheckIcon class="size-3.5" />
+ {:else if indeterminate}
+ <MinusIcon class="size-3.5" />
+ {/if}
+ </div>
+ {/snippet}
+</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 @@
+import Root from './checkbox.svelte';
+export {
+ Root,
+ //
+ Root as Checkbox
+};
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 @@
+<script lang="ts">
+ import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
+</script>
+
+<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 @@
+<script lang="ts">
+ import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
+</script>
+
+<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 @@
+<script lang="ts">
+ import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
+
+ let {
+ ref = $bindable(null),
+ open = $bindable(false),
+ ...restProps
+ }: CollapsiblePrimitive.RootProps = $props();
+</script>
+
+<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 @@
+import Root from './collapsible.svelte';
+import Trigger from './collapsible-trigger.svelte';
+import Content from './collapsible-content.svelte';
+
+export {
+ Root,
+ Content,
+ Trigger,
+ //
+ Root as Collapsible,
+ Content as CollapsibleContent,
+ Trigger as CollapsibleTrigger
+};
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 @@
+<script lang="ts">
+ import { Dialog as DialogPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
+</script>
+
+<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 @@
+<script lang="ts">
+ import { Dialog as DialogPrimitive } from 'bits-ui';
+ import XIcon from '@lucide/svelte/icons/x';
+ import type { Snippet } from 'svelte';
+ import * as Dialog from './index.js';
+ import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ portalProps,
+ children,
+ showCloseButton = true,
+ ...restProps
+ }: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
+ portalProps?: DialogPrimitive.PortalProps;
+ children: Snippet;
+ showCloseButton?: boolean;
+ } = $props();
+</script>
+
+<Dialog.Portal {...portalProps}>
+ <Dialog.Overlay />
+ <DialogPrimitive.Content
+ bind:ref
+ data-slot="dialog-content"
+ class={cn(
+ `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]`,
+ className
+ )}
+ {...restProps}
+ >
+ {@render children?.()}
+ {#if showCloseButton}
+ <DialogPrimitive.Close
+ 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"
+ >
+ <XIcon />
+ <span class="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ {/if}
+ </DialogPrimitive.Content>
+</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 @@
+<script lang="ts">
+ import { Dialog as DialogPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: DialogPrimitive.DescriptionProps = $props();
+</script>
+
+<DialogPrimitive.Description
+ bind:ref
+ data-slot="dialog-description"
+ class={cn('text-sm text-muted-foreground', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="dialog-footer"
+ class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="dialog-header"
+ class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { Dialog as DialogPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: DialogPrimitive.OverlayProps = $props();
+</script>
+
+<DialogPrimitive.Overlay
+ bind:ref
+ data-slot="dialog-overlay"
+ class={cn(
+ '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',
+ className
+ )}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { Dialog as DialogPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: DialogPrimitive.TitleProps = $props();
+</script>
+
+<DialogPrimitive.Title
+ bind:ref
+ data-slot="dialog-title"
+ class={cn('text-lg leading-none font-semibold', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { Dialog as DialogPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
+</script>
+
+<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 @@
+import { Dialog as DialogPrimitive } from 'bits-ui';
+
+import Title from './dialog-title.svelte';
+import Footer from './dialog-footer.svelte';
+import Header from './dialog-header.svelte';
+import Overlay from './dialog-overlay.svelte';
+import Content from './dialog-content.svelte';
+import Description from './dialog-description.svelte';
+import Trigger from './dialog-trigger.svelte';
+import Close from './dialog-close.svelte';
+
+const Root = DialogPrimitive.Root;
+const Portal = DialogPrimitive.Portal;
+
+export {
+ Root,
+ Title,
+ Portal,
+ Footer,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ Close,
+ //
+ Root as Dialog,
+ Title as DialogTitle,
+ Portal as DialogPortal,
+ Footer as DialogFooter,
+ Header as DialogHeader,
+ Trigger as DialogTrigger,
+ Overlay as DialogOverlay,
+ Content as DialogContent,
+ Description as DialogDescription,
+ Close as DialogClose
+};
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 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+ import CheckIcon from '@lucide/svelte/icons/check';
+ import MinusIcon from '@lucide/svelte/icons/minus';
+ import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import type { Snippet } from 'svelte';
+
+ let {
+ ref = $bindable(null),
+ checked = $bindable(false),
+ indeterminate = $bindable(false),
+ class: className,
+ children: childrenProp,
+ ...restProps
+ }: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
+ children?: Snippet;
+ } = $props();
+</script>
+
+<DropdownMenuPrimitive.CheckboxItem
+ bind:ref
+ bind:checked
+ bind:indeterminate
+ data-slot="dropdown-menu-checkbox-item"
+ class={cn(
+ "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",
+ className
+ )}
+ {...restProps}
+>
+ {#snippet children({ checked, indeterminate })}
+ <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+ {#if indeterminate}
+ <MinusIcon class="size-4" />
+ {:else}
+ <CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
+ {/if}
+ </span>
+ {@render childrenProp?.()}
+ {/snippet}
+</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 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+
+ let {
+ ref = $bindable(null),
+ sideOffset = 4,
+ portalProps,
+ class: className,
+ ...restProps
+ }: DropdownMenuPrimitive.ContentProps & {
+ portalProps?: DropdownMenuPrimitive.PortalProps;
+ } = $props();
+</script>
+
+<DropdownMenuPrimitive.Portal {...portalProps}>
+ <DropdownMenuPrimitive.Content
+ bind:ref
+ data-slot="dropdown-menu-content"
+ {sideOffset}
+ class={cn(
+ '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',
+ className
+ )}
+ {...restProps}
+ />
+</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 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+ import type { ComponentProps } from 'svelte';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ inset,
+ ...restProps
+ }: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
+ inset?: boolean;
+ } = $props();
+</script>
+
+<DropdownMenuPrimitive.GroupHeading
+ bind:ref
+ data-slot="dropdown-menu-group-heading"
+ data-inset={inset}
+ class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
+</script>
+
+<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 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ inset,
+ variant = 'default',
+ ...restProps
+ }: DropdownMenuPrimitive.ItemProps & {
+ inset?: boolean;
+ variant?: 'default' | 'destructive';
+ } = $props();
+</script>
+
+<DropdownMenuPrimitive.Item
+ bind:ref
+ data-slot="dropdown-menu-item"
+ data-inset={inset}
+ data-variant={variant}
+ class={cn(
+ "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",
+ className
+ )}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ inset,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
+ inset?: boolean;
+ } = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="dropdown-menu-label"
+ data-inset={inset}
+ class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+
+ let {
+ ref = $bindable(null),
+ value = $bindable(),
+ ...restProps
+ }: DropdownMenuPrimitive.RadioGroupProps = $props();
+</script>
+
+<DropdownMenuPrimitive.RadioGroup
+ bind:ref
+ bind:value
+ data-slot="dropdown-menu-radio-group"
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+ import CircleIcon from '@lucide/svelte/icons/circle';
+ import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children: childrenProp,
+ ...restProps
+ }: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
+</script>
+
+<DropdownMenuPrimitive.RadioItem
+ bind:ref
+ data-slot="dropdown-menu-radio-item"
+ class={cn(
+ "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",
+ className
+ )}
+ {...restProps}
+>
+ {#snippet children({ checked })}
+ <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+ {#if checked}
+ <CircleIcon class="size-2 fill-current" />
+ {/if}
+ </span>
+ {@render childrenProp?.({ checked })}
+ {/snippet}
+</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 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: DropdownMenuPrimitive.SeparatorProps = $props();
+</script>
+
+<DropdownMenuPrimitive.Separator
+ bind:ref
+ data-slot="dropdown-menu-separator"
+ class={cn('-mx-1 my-1 h-px bg-border/20', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
+</script>
+
+<span
+ bind:this={ref}
+ data-slot="dropdown-menu-shortcut"
+ class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: DropdownMenuPrimitive.SubContentProps = $props();
+</script>
+
+<DropdownMenuPrimitive.SubContent
+ bind:ref
+ data-slot="dropdown-menu-sub-content"
+ class={cn(
+ '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',
+ className
+ )}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+ import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ inset,
+ children,
+ ...restProps
+ }: DropdownMenuPrimitive.SubTriggerProps & {
+ inset?: boolean;
+ } = $props();
+</script>
+
+<DropdownMenuPrimitive.SubTrigger
+ bind:ref
+ data-slot="dropdown-menu-sub-trigger"
+ data-inset={inset}
+ class={cn(
+ "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",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+ <ChevronRightIcon class="ml-auto size-4" />
+</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 @@
+<script lang="ts">
+ import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
+</script>
+
+<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 @@
+import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
+import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
+import Content from './dropdown-menu-content.svelte';
+import Group from './dropdown-menu-group.svelte';
+import Item from './dropdown-menu-item.svelte';
+import Label from './dropdown-menu-label.svelte';
+import RadioGroup from './dropdown-menu-radio-group.svelte';
+import RadioItem from './dropdown-menu-radio-item.svelte';
+import Separator from './dropdown-menu-separator.svelte';
+import Shortcut from './dropdown-menu-shortcut.svelte';
+import Trigger from './dropdown-menu-trigger.svelte';
+import SubContent from './dropdown-menu-sub-content.svelte';
+import SubTrigger from './dropdown-menu-sub-trigger.svelte';
+import GroupHeading from './dropdown-menu-group-heading.svelte';
+const Sub = DropdownMenuPrimitive.Sub;
+const Root = DropdownMenuPrimitive.Root;
+
+export {
+ CheckboxItem,
+ Content,
+ Root as DropdownMenu,
+ CheckboxItem as DropdownMenuCheckboxItem,
+ Content as DropdownMenuContent,
+ Group as DropdownMenuGroup,
+ Item as DropdownMenuItem,
+ Label as DropdownMenuLabel,
+ RadioGroup as DropdownMenuRadioGroup,
+ RadioItem as DropdownMenuRadioItem,
+ Separator as DropdownMenuSeparator,
+ Shortcut as DropdownMenuShortcut,
+ Sub as DropdownMenuSub,
+ SubContent as DropdownMenuSubContent,
+ SubTrigger as DropdownMenuSubTrigger,
+ Trigger as DropdownMenuTrigger,
+ GroupHeading as DropdownMenuGroupHeading,
+ Group,
+ GroupHeading,
+ Item,
+ Label,
+ RadioGroup,
+ RadioItem,
+ Root,
+ Separator,
+ Shortcut,
+ Sub,
+ SubContent,
+ SubTrigger,
+ Trigger
+};
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 @@
+import Root from './input.svelte';
+
+export {
+ Root,
+ //
+ Root as Input
+};
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 @@
+<script lang="ts">
+ import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils';
+
+ type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
+
+ type Props = WithElementRef<
+ Omit<HTMLInputAttributes, 'type'> &
+ ({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
+ >;
+
+ let {
+ ref = $bindable(null),
+ value = $bindable(),
+ type,
+ files = $bindable(),
+ class: className,
+ ...restProps
+ }: Props = $props();
+</script>
+
+{#if type === 'file'}
+ <input
+ bind:this={ref}
+ data-slot="input"
+ class={cn(
+ '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',
+ 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
+ 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
+ className
+ )}
+ type="file"
+ bind:files
+ bind:value
+ {...restProps}
+ />
+{:else}
+ <input
+ bind:this={ref}
+ data-slot="input"
+ class={cn(
+ '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',
+ 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
+ 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
+ className
+ )}
+ {type}
+ bind:value
+ {...restProps}
+ />
+{/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 @@
+import Root from './label.svelte';
+
+export {
+ Root,
+ //
+ Root as Label
+};
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 @@
+<script lang="ts">
+ import { Label as LabelPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: LabelPrimitive.RootProps = $props();
+</script>
+
+<LabelPrimitive.Root
+ bind:ref
+ data-slot="label"
+ class={cn(
+ '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',
+ className
+ )}
+ {...restProps}
+/>
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 @@
+import Root from './popover.svelte';
+import Close from './popover-close.svelte';
+import Content from './popover-content.svelte';
+import Trigger from './popover-trigger.svelte';
+import Portal from './popover-portal.svelte';
+
+export {
+ Root,
+ Content,
+ Trigger,
+ Close,
+ Portal,
+ //
+ Root as Popover,
+ Content as PopoverContent,
+ Trigger as PopoverTrigger,
+ Close as PopoverClose,
+ Portal as PopoverPortal
+};
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 @@
+<script lang="ts">
+ import { Popover as PopoverPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
+</script>
+
+<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 @@
+<script lang="ts">
+ import { Popover as PopoverPrimitive } from 'bits-ui';
+ import PopoverPortal from './popover-portal.svelte';
+ import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import type { ComponentProps } from 'svelte';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ sideOffset = 4,
+ side,
+ align = 'center',
+ collisionPadding = 8,
+ avoidCollisions = true,
+ portalProps,
+ ...restProps
+ }: PopoverPrimitive.ContentProps & {
+ portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
+ } = $props();
+</script>
+
+<PopoverPortal {...portalProps}>
+ <PopoverPrimitive.Content
+ bind:ref
+ data-slot="popover-content"
+ {sideOffset}
+ {side}
+ {align}
+ {collisionPadding}
+ {avoidCollisions}
+ class={cn(
+ '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',
+ className
+ )}
+ {...restProps}
+ />
+</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 @@
+<script lang="ts">
+ import { Popover as PopoverPrimitive } from 'bits-ui';
+
+ let { ...restProps }: PopoverPrimitive.PortalProps = $props();
+</script>
+
+<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 @@
+<script lang="ts">
+ import { cn } from '$lib/components/ui/utils.js';
+ import { Popover as PopoverPrimitive } from 'bits-ui';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: PopoverPrimitive.TriggerProps = $props();
+</script>
+
+<PopoverPrimitive.Trigger
+ bind:ref
+ data-slot="popover-trigger"
+ class={cn('', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { Popover as PopoverPrimitive } from 'bits-ui';
+
+ let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
+</script>
+
+<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 @@
+import Scrollbar from './scroll-area-scrollbar.svelte';
+import Root from './scroll-area.svelte';
+
+export {
+ Root,
+ Scrollbar,
+ //,
+ Root as ScrollArea,
+ Scrollbar as ScrollAreaScrollbar
+};
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 @@
+<script lang="ts">
+ import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
+ import { cn, type WithoutChild } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ orientation = 'vertical',
+ children,
+ ...restProps
+ }: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
+</script>
+
+<ScrollAreaPrimitive.Scrollbar
+ bind:ref
+ data-slot="scroll-area-scrollbar"
+ {orientation}
+ class={cn(
+ 'flex touch-none p-px transition-colors select-none',
+ orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
+ orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+ <ScrollAreaPrimitive.Thumb
+ data-slot="scroll-area-thumb"
+ class="relative flex-1 rounded-full bg-border"
+ />
+</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 @@
+<script lang="ts">
+ import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
+ import { Scrollbar } from './index.js';
+ import { cn, type WithoutChild } from '$lib/components/ui/utils';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ orientation = 'vertical',
+ scrollbarXClasses = '',
+ scrollbarYClasses = '',
+ children,
+ ...restProps
+ }: WithoutChild<ScrollAreaPrimitive.RootProps> & {
+ orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
+ scrollbarXClasses?: string | undefined;
+ scrollbarYClasses?: string | undefined;
+ } = $props();
+</script>
+
+<ScrollAreaPrimitive.Root
+ bind:ref
+ data-slot="scroll-area"
+ class={cn('relative', className)}
+ {...restProps}
+>
+ <ScrollAreaPrimitive.Viewport
+ data-slot="scroll-area-viewport"
+ 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"
+ >
+ {@render children?.()}
+ </ScrollAreaPrimitive.Viewport>
+ {#if orientation === 'vertical' || orientation === 'both'}
+ <Scrollbar orientation="vertical" class={scrollbarYClasses} />
+ {/if}
+ {#if orientation === 'horizontal' || orientation === 'both'}
+ <Scrollbar orientation="horizontal" class={scrollbarXClasses} />
+ {/if}
+ <ScrollAreaPrimitive.Corner />
+</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 @@
+import { Select as SelectPrimitive } from 'bits-ui';
+
+import Group from './select-group.svelte';
+import Label from './select-label.svelte';
+import Item from './select-item.svelte';
+import Content from './select-content.svelte';
+import Trigger from './select-trigger.svelte';
+import Separator from './select-separator.svelte';
+import ScrollDownButton from './select-scroll-down-button.svelte';
+import ScrollUpButton from './select-scroll-up-button.svelte';
+import GroupHeading from './select-group-heading.svelte';
+
+const Root = SelectPrimitive.Root;
+
+export {
+ Root,
+ Group,
+ Label,
+ Item,
+ Content,
+ Trigger,
+ Separator,
+ ScrollDownButton,
+ ScrollUpButton,
+ GroupHeading,
+ //
+ Root as Select,
+ Group as SelectGroup,
+ Label as SelectLabel,
+ Item as SelectItem,
+ Content as SelectContent,
+ Trigger as SelectTrigger,
+ Separator as SelectSeparator,
+ ScrollDownButton as SelectScrollDownButton,
+ ScrollUpButton as SelectScrollUpButton,
+ GroupHeading as SelectGroupHeading
+};
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 @@
+<script lang="ts">
+ import { onDestroy, onMount } from 'svelte';
+ import { Select as SelectPrimitive } from 'bits-ui';
+ import SelectScrollUpButton from './select-scroll-up-button.svelte';
+ import SelectScrollDownButton from './select-scroll-down-button.svelte';
+ import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ sideOffset = 4,
+ portalProps,
+ children,
+ ...restProps
+ }: WithoutChild<SelectPrimitive.ContentProps> & {
+ portalProps?: SelectPrimitive.PortalProps;
+ } = $props();
+
+ let cleanupInternalListeners: (() => void) | undefined;
+
+ onMount(() => {
+ const listenerOptions: AddEventListenerOptions = { passive: false };
+
+ const blockOutsideWheel = (event: WheelEvent) => {
+ if (!ref) {
+ return;
+ }
+
+ const target = event.target as Node | null;
+
+ if (!target || !ref.contains(target)) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ };
+
+ const blockOutsideTouchMove = (event: TouchEvent) => {
+ if (!ref) {
+ return;
+ }
+
+ const target = event.target as Node | null;
+
+ if (!target || !ref.contains(target)) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ };
+
+ document.addEventListener('wheel', blockOutsideWheel, listenerOptions);
+ document.addEventListener('touchmove', blockOutsideTouchMove, listenerOptions);
+
+ return () => {
+ document.removeEventListener('wheel', blockOutsideWheel, listenerOptions);
+ document.removeEventListener('touchmove', blockOutsideTouchMove, listenerOptions);
+ };
+ });
+
+ $effect(() => {
+ const element = ref;
+
+ cleanupInternalListeners?.();
+
+ if (!element) {
+ return;
+ }
+
+ const stopWheelPropagation = (event: WheelEvent) => {
+ event.stopPropagation();
+ };
+
+ const stopTouchPropagation = (event: TouchEvent) => {
+ event.stopPropagation();
+ };
+
+ element.addEventListener('wheel', stopWheelPropagation);
+ element.addEventListener('touchmove', stopTouchPropagation);
+
+ cleanupInternalListeners = () => {
+ element.removeEventListener('wheel', stopWheelPropagation);
+ element.removeEventListener('touchmove', stopTouchPropagation);
+ };
+ });
+
+ onDestroy(() => {
+ cleanupInternalListeners?.();
+ });
+</script>
+
+<SelectPrimitive.Portal {...portalProps}>
+ <SelectPrimitive.Content
+ bind:ref
+ {sideOffset}
+ data-slot="select-content"
+ class={cn(
+ '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',
+ className
+ )}
+ {...restProps}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.Viewport
+ class={cn(
+ 'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1'
+ )}
+ >
+ {@render children?.()}
+ </SelectPrimitive.Viewport>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Content>
+</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 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+ import type { ComponentProps } from 'svelte';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
+</script>
+
+<SelectPrimitive.GroupHeading
+ bind:ref
+ data-slot="select-group-heading"
+ class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
+</script>
+
+<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 @@
+<script lang="ts">
+ import CheckIcon from '@lucide/svelte/icons/check';
+ import { Select as SelectPrimitive } from 'bits-ui';
+ import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ value,
+ label,
+ children: childrenProp,
+ ...restProps
+ }: WithoutChild<SelectPrimitive.ItemProps> = $props();
+</script>
+
+<SelectPrimitive.Item
+ bind:ref
+ {value}
+ data-slot="select-item"
+ class={cn(
+ "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",
+ className
+ )}
+ {...restProps}
+>
+ {#snippet children({ selected, highlighted })}
+ <span class="absolute right-2 flex size-3.5 items-center justify-center">
+ {#if selected}
+ <CheckIcon class="size-4" />
+ {/if}
+ </span>
+ {#if childrenProp}
+ {@render childrenProp({ selected, highlighted })}
+ {:else}
+ {label || value}
+ {/if}
+ {/snippet}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="select-label"
+ class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
+ import { Select as SelectPrimitive } from 'bits-ui';
+ import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
+</script>
+
+<SelectPrimitive.ScrollDownButton
+ bind:ref
+ data-slot="select-scroll-down-button"
+ class={cn('flex cursor-default items-center justify-center py-1', className)}
+ {...restProps}
+>
+ <ChevronDownIcon class="size-4" />
+</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 @@
+<script lang="ts">
+ import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
+ import { Select as SelectPrimitive } from 'bits-ui';
+ import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
+</script>
+
+<SelectPrimitive.ScrollUpButton
+ bind:ref
+ data-slot="select-scroll-up-button"
+ class={cn('flex cursor-default items-center justify-center py-1', className)}
+ {...restProps}
+>
+ <ChevronUpIcon class="size-4" />
+</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 @@
+<script lang="ts">
+ import type { Separator as SeparatorPrimitive } from 'bits-ui';
+ import { Separator } from '$lib/components/ui/separator/index.js';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: SeparatorPrimitive.RootProps = $props();
+</script>
+
+<Separator
+ bind:ref
+ data-slot="select-separator"
+ class={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from 'bits-ui';
+ import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
+ import { cn, type WithoutChild } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ size = 'default',
+ variant = 'default',
+ ...restProps
+ }: WithoutChild<SelectPrimitive.TriggerProps> & {
+ size?: 'sm' | 'default';
+ variant?: 'default' | 'plain';
+ } = $props();
+
+ const baseClasses = $derived(
+ variant === 'plain'
+ ? "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"
+ : "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"
+ );
+
+ const chevronClasses = $derived(
+ variant === 'plain'
+ ? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
+ : 'size-4 opacity-50'
+ );
+</script>
+
+<SelectPrimitive.Trigger
+ bind:ref
+ data-slot="select-trigger"
+ data-size={size}
+ class={cn(baseClasses, className)}
+ {...restProps}
+>
+ {@render children?.()}
+ <ChevronDownIcon class={chevronClasses} />
+</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 @@
+import Root from './separator.svelte';
+
+export {
+ Root,
+ //
+ Root as Separator
+};
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 @@
+<script lang="ts">
+ import { Separator as SeparatorPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: SeparatorPrimitive.RootProps = $props();
+</script>
+
+<SeparatorPrimitive.Root
+ bind:ref
+ data-slot="separator"
+ class={cn(
+ 'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
+ className
+ )}
+ {...restProps}
+/>
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 @@
+import { Dialog as SheetPrimitive } from 'bits-ui';
+import Trigger from './sheet-trigger.svelte';
+import Close from './sheet-close.svelte';
+import Overlay from './sheet-overlay.svelte';
+import Content from './sheet-content.svelte';
+import Header from './sheet-header.svelte';
+import Footer from './sheet-footer.svelte';
+import Title from './sheet-title.svelte';
+import Description from './sheet-description.svelte';
+
+const Root = SheetPrimitive.Root;
+const Portal = SheetPrimitive.Portal;
+
+export {
+ Root,
+ Close,
+ Trigger,
+ Portal,
+ Overlay,
+ Content,
+ Header,
+ Footer,
+ Title,
+ Description,
+ //
+ Root as Sheet,
+ Close as SheetClose,
+ Trigger as SheetTrigger,
+ Portal as SheetPortal,
+ Overlay as SheetOverlay,
+ Content as SheetContent,
+ Header as SheetHeader,
+ Footer as SheetFooter,
+ Title as SheetTitle,
+ Description as SheetDescription
+};
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 @@
+<script lang="ts">
+ import { Dialog as SheetPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
+</script>
+
+<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 @@
+<script lang="ts" module>
+ import { tv, type VariantProps } from 'tailwind-variants';
+ export const sheetVariants = tv({
+ 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',
+ variants: {
+ side: {
+ top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
+ bottom:
+ 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
+ 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',
+ right:
+ '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'
+ }
+ },
+ defaultVariants: {
+ side: 'right'
+ }
+ });
+
+ export type Side = VariantProps<typeof sheetVariants>['side'];
+</script>
+
+<script lang="ts">
+ import { Dialog as SheetPrimitive } from 'bits-ui';
+ import XIcon from '@lucide/svelte/icons/x';
+ import type { Snippet } from 'svelte';
+ import SheetOverlay from './sheet-overlay.svelte';
+ import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ side = 'right',
+ portalProps,
+ children,
+ ...restProps
+ }: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
+ portalProps?: SheetPrimitive.PortalProps;
+ side?: Side;
+ children: Snippet;
+ } = $props();
+</script>
+
+<SheetPrimitive.Portal {...portalProps}>
+ <SheetOverlay />
+ <SheetPrimitive.Content
+ bind:ref
+ data-slot="sheet-content"
+ class={cn(sheetVariants({ side }), className)}
+ {...restProps}
+ >
+ {@render children?.()}
+ <SheetPrimitive.Close
+ 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"
+ >
+ <XIcon class="size-4" />
+ <span class="sr-only">Close</span>
+ </SheetPrimitive.Close>
+ </SheetPrimitive.Content>
+</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 @@
+<script lang="ts">
+ import { Dialog as SheetPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: SheetPrimitive.DescriptionProps = $props();
+</script>
+
+<SheetPrimitive.Description
+ bind:ref
+ data-slot="sheet-description"
+ class={cn('text-sm text-muted-foreground', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="sheet-footer"
+ class={cn('mt-auto flex flex-col gap-2 p-4', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="sheet-header"
+ class={cn('flex flex-col gap-1.5 p-4', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { Dialog as SheetPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: SheetPrimitive.OverlayProps = $props();
+</script>
+
+<SheetPrimitive.Overlay
+ bind:ref
+ data-slot="sheet-overlay"
+ class={cn(
+ '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',
+ className
+ )}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { Dialog as SheetPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: SheetPrimitive.TitleProps = $props();
+</script>
+
+<SheetPrimitive.Title
+ bind:ref
+ data-slot="sheet-title"
+ class={cn('font-semibold text-foreground', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { Dialog as SheetPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
+</script>
+
+<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 @@
+export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
+export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+export const SIDEBAR_WIDTH = '18rem';
+export const SIDEBAR_WIDTH_MOBILE = '18rem';
+export const SIDEBAR_WIDTH_ICON = '3rem';
+export 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 @@
+import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
+import { getContext, setContext } from 'svelte';
+import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js';
+
+type Getter<T> = () => T;
+
+export type SidebarStateProps = {
+ /**
+ * A getter function that returns the current open state of the sidebar.
+ * We use a getter function here to support `bind:open` on the `Sidebar.Provider`
+ * component.
+ */
+ open: Getter<boolean>;
+
+ /**
+ * A function that sets the open state of the sidebar. To support `bind:open`, we need
+ * a source of truth for changing the open state to ensure it will be synced throughout
+ * the sub-components and any `bind:` references.
+ */
+ setOpen: (open: boolean) => void;
+};
+
+class SidebarState {
+ readonly props: SidebarStateProps;
+ open = $derived.by(() => this.props.open());
+ openMobile = $state(false);
+ setOpen: SidebarStateProps['setOpen'];
+ #isMobile: IsMobile;
+ state = $derived.by(() => (this.open ? 'expanded' : 'collapsed'));
+
+ constructor(props: SidebarStateProps) {
+ this.setOpen = props.setOpen;
+ this.#isMobile = new IsMobile();
+ this.props = props;
+ }
+
+ // Convenience getter for checking if the sidebar is mobile
+ // without this, we would need to use `sidebar.isMobile.current` everywhere
+ get isMobile() {
+ return this.#isMobile.current;
+ }
+
+ // Event handler to apply to the `<svelte:window>`
+ handleShortcutKeydown = (e: KeyboardEvent) => {
+ if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ this.toggle();
+ }
+ };
+
+ setOpenMobile = (value: boolean) => {
+ this.openMobile = value;
+ };
+
+ toggle = () => {
+ return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open);
+ };
+}
+
+const SYMBOL_KEY = 'scn-sidebar';
+
+/**
+ * Instantiates a new `SidebarState` instance and sets it in the context.
+ *
+ * @param props The constructor props for the `SidebarState` class.
+ * @returns The `SidebarState` instance.
+ */
+export function setSidebar(props: SidebarStateProps): SidebarState {
+ return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
+}
+
+/**
+ * Retrieves the `SidebarState` instance from the context. This is a class instance,
+ * so you cannot destructure it.
+ * @returns The `SidebarState` instance.
+ */
+export function useSidebar(): SidebarState {
+ return getContext(Symbol.for(SYMBOL_KEY));
+}
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 @@
+import { useSidebar } from './context.svelte.js';
+import Content from './sidebar-content.svelte';
+import Footer from './sidebar-footer.svelte';
+import GroupAction from './sidebar-group-action.svelte';
+import GroupContent from './sidebar-group-content.svelte';
+import GroupLabel from './sidebar-group-label.svelte';
+import Group from './sidebar-group.svelte';
+import Header from './sidebar-header.svelte';
+import Input from './sidebar-input.svelte';
+import Inset from './sidebar-inset.svelte';
+import MenuAction from './sidebar-menu-action.svelte';
+import MenuBadge from './sidebar-menu-badge.svelte';
+import MenuButton from './sidebar-menu-button.svelte';
+import MenuItem from './sidebar-menu-item.svelte';
+import MenuSkeleton from './sidebar-menu-skeleton.svelte';
+import MenuSubButton from './sidebar-menu-sub-button.svelte';
+import MenuSubItem from './sidebar-menu-sub-item.svelte';
+import MenuSub from './sidebar-menu-sub.svelte';
+import Menu from './sidebar-menu.svelte';
+import Provider from './sidebar-provider.svelte';
+import Rail from './sidebar-rail.svelte';
+import Separator from './sidebar-separator.svelte';
+import Trigger from './sidebar-trigger.svelte';
+import Root from './sidebar.svelte';
+
+export {
+ Content,
+ Footer,
+ Group,
+ GroupAction,
+ GroupContent,
+ GroupLabel,
+ Header,
+ Input,
+ Inset,
+ Menu,
+ MenuAction,
+ MenuBadge,
+ MenuButton,
+ MenuItem,
+ MenuSkeleton,
+ MenuSub,
+ MenuSubButton,
+ MenuSubItem,
+ Provider,
+ Rail,
+ Root,
+ Separator,
+ //
+ Root as Sidebar,
+ Content as SidebarContent,
+ Footer as SidebarFooter,
+ Group as SidebarGroup,
+ GroupAction as SidebarGroupAction,
+ GroupContent as SidebarGroupContent,
+ GroupLabel as SidebarGroupLabel,
+ Header as SidebarHeader,
+ Input as SidebarInput,
+ Inset as SidebarInset,
+ Menu as SidebarMenu,
+ MenuAction as SidebarMenuAction,
+ MenuBadge as SidebarMenuBadge,
+ MenuButton as SidebarMenuButton,
+ MenuItem as SidebarMenuItem,
+ MenuSkeleton as SidebarMenuSkeleton,
+ MenuSub as SidebarMenuSub,
+ MenuSubButton as SidebarMenuSubButton,
+ MenuSubItem as SidebarMenuSubItem,
+ Provider as SidebarProvider,
+ Rail as SidebarRail,
+ Separator as SidebarSeparator,
+ Trigger as SidebarTrigger,
+ Trigger,
+ useSidebar
+};
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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="sidebar-content"
+ data-sidebar="content"
+ class={cn(
+ 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="sidebar-footer"
+ data-sidebar="footer"
+ class={cn('flex flex-col gap-2 p-2', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { Snippet } from 'svelte';
+ import type { HTMLButtonAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ child,
+ ...restProps
+ }: WithElementRef<HTMLButtonAttributes> & {
+ child?: Snippet<[{ props: Record<string, unknown> }]>;
+ } = $props();
+
+ const mergedProps = $derived({
+ class: cn(
+ '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',
+ // Increases the hit area of the button on mobile.
+ 'after:absolute after:-inset-2 md:after:hidden',
+ 'group-data-[collapsible=icon]:hidden',
+ className
+ ),
+ 'data-slot': 'sidebar-group-action',
+ 'data-sidebar': 'group-action',
+ ...restProps
+ });
+</script>
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+ <button bind:this={ref} {...mergedProps}>
+ {@render children?.()}
+ </button>
+{/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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="sidebar-group-content"
+ data-sidebar="group-content"
+ class={cn('w-full text-sm', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { Snippet } from 'svelte';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ children,
+ child,
+ class: className,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> & {
+ child?: Snippet<[{ props: Record<string, unknown> }]>;
+ } = $props();
+
+ const mergedProps = $derived({
+ class: cn(
+ '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',
+ 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
+ className
+ ),
+ 'data-slot': 'sidebar-group-label',
+ 'data-sidebar': 'group-label',
+ ...restProps
+ });
+</script>
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+ <div bind:this={ref} {...mergedProps}>
+ {@render children?.()}
+ </div>
+{/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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="sidebar-group"
+ data-sidebar="group"
+ class={cn('relative flex w-full min-w-0 flex-col p-2', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="sidebar-header"
+ data-sidebar="header"
+ class={cn('flex flex-col gap-2 p-2', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { ComponentProps } from 'svelte';
+ import { Input } from '$lib/components/ui/input/index.js';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ value = $bindable(''),
+ class: className,
+ ...restProps
+ }: ComponentProps<typeof Input> = $props();
+</script>
+
+<Input
+ bind:ref
+ bind:value
+ data-slot="sidebar-input"
+ data-sidebar="input"
+ class={cn('h-8 w-full bg-background shadow-none', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<main
+ bind:this={ref}
+ data-slot="sidebar-inset"
+ class={cn(
+ 'relative flex w-full flex-1 flex-col',
+ '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',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { Snippet } from 'svelte';
+ import type { HTMLButtonAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ showOnHover = false,
+ children,
+ child,
+ ...restProps
+ }: WithElementRef<HTMLButtonAttributes> & {
+ child?: Snippet<[{ props: Record<string, unknown> }]>;
+ showOnHover?: boolean;
+ } = $props();
+
+ const mergedProps = $derived({
+ class: cn(
+ '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',
+ // Increases the hit area of the button on mobile.
+ 'after:absolute after:-inset-2 md:after:hidden',
+ 'peer-data-[size=sm]/menu-button:top-1',
+ 'peer-data-[size=default]/menu-button:top-1.5',
+ 'peer-data-[size=lg]/menu-button:top-2.5',
+ 'group-data-[collapsible=icon]:hidden',
+ showOnHover &&
+ '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',
+ className
+ ),
+ 'data-slot': 'sidebar-menu-action',
+ 'data-sidebar': 'menu-action',
+ ...restProps
+ });
+</script>
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+ <button bind:this={ref} {...mergedProps}>
+ {@render children?.()}
+ </button>
+{/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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="sidebar-menu-badge"
+ data-sidebar="menu-badge"
+ class={cn(
+ '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',
+ 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
+ 'peer-data-[size=sm]/menu-button:top-1',
+ 'peer-data-[size=default]/menu-button:top-1.5',
+ 'peer-data-[size=lg]/menu-button:top-2.5',
+ 'group-data-[collapsible=icon]:hidden',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts" module>
+ import { tv, type VariantProps } from 'tailwind-variants';
+
+ export const sidebarMenuButtonVariants = tv({
+ 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',
+ variants: {
+ variant: {
+ default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
+ outline:
+ '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)]'
+ },
+ size: {
+ default: 'h-8 text-sm',
+ sm: 'h-7 text-xs',
+ lg: 'group-data-[collapsible=icon]:p-0! h-12 text-sm'
+ }
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default'
+ }
+ });
+
+ export type SidebarMenuButtonVariant = VariantProps<typeof sidebarMenuButtonVariants>['variant'];
+ export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>['size'];
+</script>
+
+<script lang="ts">
+ import * as Tooltip from '$lib/components/ui/tooltip/index.js';
+ import {
+ cn,
+ type WithElementRef,
+ type WithoutChildrenOrChild
+ } from '$lib/components/ui/utils.js';
+ import { mergeProps } from 'bits-ui';
+ import type { ComponentProps, Snippet } from 'svelte';
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { useSidebar } from './context.svelte.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ child,
+ variant = 'default',
+ size = 'default',
+ isActive = false,
+ tooltipContent,
+ tooltipContentProps,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
+ isActive?: boolean;
+ variant?: SidebarMenuButtonVariant;
+ size?: SidebarMenuButtonSize;
+ tooltipContent?: Snippet | string;
+ tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>;
+ child?: Snippet<[{ props: Record<string, unknown> }]>;
+ } = $props();
+
+ const sidebar = useSidebar();
+
+ const buttonProps = $derived({
+ class: cn(sidebarMenuButtonVariants({ variant, size }), className),
+ 'data-slot': 'sidebar-menu-button',
+ 'data-sidebar': 'menu-button',
+ 'data-size': size,
+ 'data-active': isActive,
+ ...restProps
+ });
+</script>
+
+{#snippet Button({ props }: { props?: Record<string, unknown> })}
+ {@const mergedProps = mergeProps(buttonProps, props)}
+ {#if child}
+ {@render child({ props: mergedProps })}
+ {:else}
+ <button bind:this={ref} {...mergedProps}>
+ {@render children?.()}
+ </button>
+ {/if}
+{/snippet}
+
+{#if !tooltipContent}
+ {@render Button({})}
+{:else}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ {#snippet child({ props })}
+ {@render Button({ props })}
+ {/snippet}
+ </Tooltip.Trigger>
+
+ <Tooltip.Content
+ side="right"
+ align="center"
+ hidden={sidebar.state !== 'collapsed' || sidebar.isMobile}
+ {...tooltipContentProps}
+ >
+ {#if typeof tooltipContent === 'string'}
+ {tooltipContent}
+ {:else if tooltipContent}
+ {@render tooltipContent()}
+ {/if}
+ </Tooltip.Content>
+ </Tooltip.Root>
+{/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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLLIElement>, HTMLLIElement> = $props();
+</script>
+
+<li
+ bind:this={ref}
+ data-slot="sidebar-menu-item"
+ data-sidebar="menu-item"
+ class={cn('group/menu-item relative', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import { Skeleton } from '$lib/components/ui/skeleton/index.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ showIcon = false,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> & {
+ showIcon?: boolean;
+ } = $props();
+
+ // Random width between 50% and 90%
+ const width = `${Math.floor(Math.random() * 40) + 50}%`;
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="sidebar-menu-skeleton"
+ data-sidebar="menu-skeleton"
+ class={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
+ {...restProps}
+>
+ {#if showIcon}
+ <Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
+ {/if}
+ <Skeleton
+ class="h-4 max-w-(--skeleton-width) flex-1"
+ data-sidebar="menu-skeleton-text"
+ style="--skeleton-width: {width};"
+ />
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { Snippet } from 'svelte';
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ children,
+ child,
+ class: className,
+ size = 'md',
+ isActive = false,
+ ...restProps
+ }: WithElementRef<HTMLAnchorAttributes> & {
+ child?: Snippet<[{ props: Record<string, unknown> }]>;
+ size?: 'sm' | 'md';
+ isActive?: boolean;
+ } = $props();
+
+ const mergedProps = $derived({
+ class: cn(
+ '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',
+ 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
+ size === 'sm' && 'text-xs',
+ size === 'md' && 'text-sm',
+ 'group-data-[collapsible=icon]:hidden',
+ className
+ ),
+ 'data-slot': 'sidebar-menu-sub-button',
+ 'data-sidebar': 'menu-sub-button',
+ 'data-size': size,
+ 'data-active': isActive,
+ ...restProps
+ });
+</script>
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+ <a bind:this={ref} {...mergedProps}>
+ {@render children?.()}
+ </a>
+{/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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ children,
+ class: className,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
+</script>
+
+<li
+ bind:this={ref}
+ data-slot="sidebar-menu-sub-item"
+ data-sidebar="menu-sub-item"
+ class={cn('group/menu-sub-item relative', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
+</script>
+
+<ul
+ bind:this={ref}
+ data-slot="sidebar-menu-sub"
+ data-sidebar="menu-sub"
+ class={cn(
+ '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',
+ 'group-data-[collapsible=icon]:hidden',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLUListElement>, HTMLUListElement> = $props();
+</script>
+
+<ul
+ bind:this={ref}
+ data-slot="sidebar-menu"
+ data-sidebar="menu"
+ class={cn('flex w-full min-w-0 flex-col gap-1', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+ import {
+ SIDEBAR_COOKIE_MAX_AGE,
+ SIDEBAR_COOKIE_NAME,
+ SIDEBAR_WIDTH,
+ SIDEBAR_WIDTH_ICON
+ } from './constants.js';
+ import { setSidebar } from './context.svelte.js';
+
+ let {
+ ref = $bindable(null),
+ open = $bindable(true),
+ onOpenChange = () => {},
+ class: className,
+ style,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ } = $props();
+
+ const sidebar = setSidebar({
+ open: () => open,
+ setOpen: (value: boolean) => {
+ open = value;
+ onOpenChange(value);
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+ }
+ });
+</script>
+
+<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
+
+<div
+ data-slot="sidebar-wrapper"
+ style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
+ class={cn(
+ 'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
+ className
+ )}
+ bind:this={ref}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { useSidebar } from './context.svelte.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
+
+ const sidebar = useSidebar();
+</script>
+
+<button
+ bind:this={ref}
+ data-sidebar="rail"
+ data-slot="sidebar-rail"
+ aria-label="Toggle Sidebar"
+ tabIndex={-1}
+ onclick={sidebar.toggle}
+ title="Toggle Sidebar"
+ class={cn(
+ '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',
+ 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
+ '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
+ 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
+ '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
+ '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { Separator } from '$lib/components/ui/separator/index.js';
+ import { cn } from '$lib/components/ui/utils.js';
+ import type { ComponentProps } from 'svelte';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: ComponentProps<typeof Separator> = $props();
+</script>
+
+<Separator
+ bind:ref
+ data-slot="sidebar-separator"
+ data-sidebar="separator"
+ class={cn('bg-sidebar-border', className)}
+ {...restProps}
+/>
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 @@
+<script lang="ts">
+ import { Button } from '$lib/components/ui/button/index.js';
+ import { cn } from '$lib/components/ui/utils.js';
+ import PanelLeftIcon from '@lucide/svelte/icons/panel-left';
+ import type { ComponentProps } from 'svelte';
+ import { useSidebar } from './context.svelte.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ onclick,
+ ...restProps
+ }: ComponentProps<typeof Button> & {
+ onclick?: (e: MouseEvent) => void;
+ } = $props();
+
+ const sidebar = useSidebar();
+</script>
+
+<Button
+ data-sidebar="trigger"
+ data-slot="sidebar-trigger"
+ variant="ghost"
+ size="icon"
+ class={cn('size-7', className)}
+ type="button"
+ onclick={(e) => {
+ onclick?.(e);
+ sidebar.toggle();
+ }}
+ {...restProps}
+>
+ <PanelLeftIcon />
+ <span class="sr-only">Toggle Sidebar</span>
+</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 @@
+<script lang="ts">
+ import * as Sheet from '$lib/components/ui/sheet/index.js';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+ import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
+ import { useSidebar } from './context.svelte.js';
+
+ let {
+ ref = $bindable(null),
+ side = 'left',
+ variant = 'sidebar',
+ collapsible = 'offcanvas',
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
+ side?: 'left' | 'right';
+ variant?: 'sidebar' | 'floating' | 'inset';
+ collapsible?: 'offcanvas' | 'icon' | 'none';
+ } = $props();
+
+ const sidebar = useSidebar();
+</script>
+
+{#if collapsible === 'none'}
+ <div
+ class={cn(
+ 'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
+ className
+ )}
+ bind:this={ref}
+ {...restProps}
+ >
+ {@render children?.()}
+ </div>
+{:else if sidebar.isMobile}
+ <Sheet.Root bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}>
+ <Sheet.Content
+ data-sidebar="sidebar"
+ data-slot="sidebar"
+ data-mobile="true"
+ class="z-99999 w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground sm:z-99 [&>button]:hidden"
+ style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
+ {side}
+ >
+ <Sheet.Header class="sr-only">
+ <Sheet.Title>Sidebar</Sheet.Title>
+ <Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
+ </Sheet.Header>
+ <div class="flex h-full w-full flex-col">
+ {@render children?.()}
+ </div>
+ </Sheet.Content>
+ </Sheet.Root>
+{:else}
+ <div
+ bind:this={ref}
+ class="group peer hidden text-sidebar-foreground md:block"
+ data-state={sidebar.state}
+ data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
+ data-variant={variant}
+ data-side={side}
+ data-slot="sidebar"
+ >
+ <!-- This is what handles the sidebar gap on desktop -->
+ <div
+ data-slot="sidebar-gap"
+ class={cn(
+ 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
+ 'group-data-[collapsible=offcanvas]:w-0',
+ 'group-data-[side=right]:rotate-180',
+ variant === 'floating' || variant === 'inset'
+ ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
+ : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
+ )}
+ ></div>
+ <div
+ data-slot="sidebar-container"
+ class={cn(
+ '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',
+ side === 'left'
+ ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
+ : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
+ // Adjust the padding for floating and inset variants.
+ variant === 'floating' || variant === 'inset'
+ ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
+ : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
+ className
+ )}
+ {...restProps}
+ >
+ <div
+ data-sidebar="sidebar"
+ data-slot="sidebar-inner"
+ 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"
+ >
+ {@render children?.()}
+ </div>
+ </div>
+ </div>
+{/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 @@
+import Root from './skeleton.svelte';
+
+export {
+ Root,
+ //
+ Root as Skeleton
+};
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 @@
+<script lang="ts">
+ import { cn, type WithElementRef, type WithoutChildren } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="skeleton"
+ class={cn('animate-pulse rounded-md bg-accent', className)}
+ {...restProps}
+></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 @@
+import Root from './switch.svelte';
+
+export {
+ Root,
+ //
+ Root as Switch
+};
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 @@
+<script lang="ts">
+ import { Switch as SwitchPrimitive } from 'bits-ui';
+ import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ checked = $bindable(false),
+ ...restProps
+ }: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
+</script>
+
+<SwitchPrimitive.Root
+ bind:ref
+ bind:checked
+ data-slot="switch"
+ class={cn(
+ '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',
+ className
+ )}
+ {...restProps}
+>
+ <SwitchPrimitive.Thumb
+ data-slot="switch-thumb"
+ class={cn(
+ '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'
+ )}
+ />
+</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 @@
+import Root from './table.svelte';
+import Body from './table-body.svelte';
+import Caption from './table-caption.svelte';
+import Cell from './table-cell.svelte';
+import Footer from './table-footer.svelte';
+import Head from './table-head.svelte';
+import Header from './table-header.svelte';
+import Row from './table-row.svelte';
+
+export {
+ Root,
+ Body,
+ Caption,
+ Cell,
+ Footer,
+ Head,
+ Header,
+ Row,
+ //
+ Root as Table,
+ Body as TableBody,
+ Caption as TableCaption,
+ Cell as TableCell,
+ Footer as TableFooter,
+ Head as TableHead,
+ Header as TableHeader,
+ Row as TableRow
+};
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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
+</script>
+
+<tbody
+ bind:this={ref}
+ data-slot="table-body"
+ class={cn('[&_tr:last-child]:border-0', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<caption
+ bind:this={ref}
+ data-slot="table-caption"
+ class={cn('mt-4 text-sm text-muted-foreground', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLTdAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLTdAttributes> = $props();
+</script>
+
+<td
+ bind:this={ref}
+ data-slot="table-cell"
+ class={cn(
+ 'bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
+</script>
+
+<tfoot
+ bind:this={ref}
+ data-slot="table-footer"
+ class={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLThAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLThAttributes> = $props();
+</script>
+
+<th
+ bind:this={ref}
+ data-slot="table-head"
+ class={cn(
+ 'h-10 bg-clip-padding px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pe-0',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
+</script>
+
+<thead
+ bind:this={ref}
+ data-slot="table-header"
+ class={cn('[&_tr]:border-b', className)}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+ import type { HTMLAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
+</script>
+
+<tr
+ bind:this={ref}
+ data-slot="table-row"
+ class={cn(
+ 'border-b transition-colors data-[state=selected]:bg-muted hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</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 @@
+<script lang="ts">
+ import type { HTMLTableAttributes } from 'svelte/elements';
+ import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLTableAttributes> = $props();
+</script>
+
+<div data-slot="table-container" class="relative w-full overflow-x-auto">
+ <table
+ bind:this={ref}
+ data-slot="table"
+ class={cn('w-full caption-bottom text-sm', className)}
+ {...restProps}
+ >
+ {@render children?.()}
+ </table>
+</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 @@
+import Root from './textarea.svelte';
+
+export {
+ Root,
+ //
+ Root as Textarea
+};
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 @@
+<script lang="ts">
+ import { cn, type WithElementRef, type WithoutChildren } from '$lib/components/ui/utils';
+ import type { HTMLTextareaAttributes } from 'svelte/elements';
+
+ let {
+ ref = $bindable(null),
+ value = $bindable(),
+ class: className,
+ ...restProps
+ }: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
+</script>
+
+<textarea
+ bind:this={ref}
+ data-slot="textarea"
+ class={cn(
+ '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',
+ className
+ )}
+ bind:value
+ {...restProps}
+></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 @@
+import { Tooltip as TooltipPrimitive } from 'bits-ui';
+import Trigger from './tooltip-trigger.svelte';
+import Content from './tooltip-content.svelte';
+
+const Root = TooltipPrimitive.Root;
+const Provider = TooltipPrimitive.Provider;
+const Portal = TooltipPrimitive.Portal;
+
+export {
+ Root,
+ Trigger,
+ Content,
+ Provider,
+ Portal,
+ //
+ Root as Tooltip,
+ Content as TooltipContent,
+ Trigger as TooltipTrigger,
+ Provider as TooltipProvider,
+ Portal as TooltipPortal
+};
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 @@
+<script lang="ts">
+ import { Tooltip as TooltipPrimitive } from 'bits-ui';
+ import { cn } from '$lib/components/ui/utils.js';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ sideOffset = 0,
+ side = 'top',
+ children,
+ arrowClasses,
+ ...restProps
+ }: TooltipPrimitive.ContentProps & {
+ arrowClasses?: string;
+ } = $props();
+</script>
+
+<TooltipPrimitive.Portal>
+ <TooltipPrimitive.Content
+ bind:ref
+ data-slot="tooltip-content"
+ {sideOffset}
+ {side}
+ class={cn(
+ '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',
+ className
+ )}
+ {...restProps}
+ >
+ {@render children?.()}
+ <TooltipPrimitive.Arrow>
+ {#snippet child({ props })}
+ <div
+ class={cn(
+ 'z-50 size-2.5 rotate-45 rounded-[2px] bg-primary',
+ 'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
+ 'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
+ 'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
+ 'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
+ arrowClasses
+ )}
+ {...props}
+ ></div>
+ {/snippet}
+ </TooltipPrimitive.Arrow>
+ </TooltipPrimitive.Content>
+</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 @@
+<script lang="ts">
+ import { Tooltip as TooltipPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
+</script>
+
+<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 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
+export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
+export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };