mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-11 15:29:12 +02:00
fdf4c64604
* ui: Remove dead code from stores - persisted() helper was exported but never used - messageUpdateCallback / registerMessageUpdateCallback were never wired up - conversationsStore.initialize() alias, single caller moved to init() * ui: Merge device, theme and viewport into a single deviceStore All three are reactive browser-environment signals, now exposed as one class store: deviceStore.isMobile, deviceStore.isIOSDevice / isIOSSafari / isWKWebView / isStandalone and deviceStore.systemTheme.isDark. The systemTheme name disambiguates the OS preference from the user theme preference in settingsStore. Drops the unused viewport export (only isMobile was consumed). * ui: Merge build info into version store One VersionStore class with build (llama.cpp build number from build.json) and frontend (PWA version from _app/version.json), matching the class pattern of the other stores. * ui: Colocate context gauge popup state with its components The gauge popup state is local UI state shared only by the ChatFormContextGauge subtree, so it lives next to its consumers instead of the app-scope stores barrel.
377 lines
11 KiB
Svelte
377 lines
11 KiB
Svelte
<script lang="ts">
|
|
import ChatScreenActionScrollDown from './ChatScreenActionScrollDown.svelte';
|
|
import ChatScreenDialogsAndAlerts from './ChatScreenDialogsAndAlerts.svelte';
|
|
import ChatScreenGreeting from './ChatScreenGreeting.svelte';
|
|
import { page } from '$app/state';
|
|
import {
|
|
ChatMessages,
|
|
ChatScreenDragOverlay,
|
|
ChatScreenForm,
|
|
ChatScreenServerError,
|
|
ChatScreenStreamResumeStatus,
|
|
ServerLoadingSplash
|
|
} from '$lib/components/app';
|
|
import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
|
|
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
|
import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
|
|
import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
|
|
import { useChatScreenFileUpload } from '$lib/hooks/use-chat-screen-file-upload.svelte';
|
|
import { useChatScreenScroll } from '$lib/hooks/use-chat-screen-scroll.svelte';
|
|
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
|
import {
|
|
chatStore,
|
|
conversationsStore,
|
|
deviceStore,
|
|
serverStore,
|
|
settingsStore
|
|
} from '$lib/stores';
|
|
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
|
import { onDestroy, onMount, tick } from 'svelte';
|
|
|
|
let { showCenteredEmpty = false } = $props();
|
|
|
|
let disableAutoScroll = $derived(
|
|
Boolean(settingsStore.config.disableAutoScroll) || deviceStore.isMobile
|
|
);
|
|
let isMobileUserScrolledUp = $state(false);
|
|
let mobileScrollDownHint = $state(false);
|
|
let mobileScrollDownHintLockedUntil = $state(0);
|
|
let emptyFileNames = $state<string[]>([]);
|
|
let initialMessage = $state('');
|
|
let showDeleteDialog = $state(false);
|
|
let showEmptyFileDialog = $state(false);
|
|
let isEmpty = $derived(
|
|
showCenteredEmpty &&
|
|
!conversationsStore.activeConversation &&
|
|
conversationsStore.activeMessages.length === 0 &&
|
|
!chatStore.isLoading
|
|
);
|
|
let activeErrorDialog = $derived(chatStore.errorDialogState);
|
|
let isServerLoading = $derived(serverStore.loading);
|
|
let hasPropsError = $derived(!!serverStore.error);
|
|
let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
|
|
let chatFormBottomPosition = $derived.by(() => {
|
|
if (!deviceStore.isMobile) return '1rem';
|
|
|
|
if (deviceStore.isStandalone) return '1.5rem';
|
|
|
|
if (deviceStore.isIOSSafari) return '0.25rem';
|
|
|
|
return '0.5rem';
|
|
});
|
|
|
|
const autoScroll = createAutoScrollController();
|
|
const scroll = useChatScreenScroll(autoScroll);
|
|
const activeModel = useChatScreenActiveModel();
|
|
const fileUpload = useChatScreenFileUpload({
|
|
activeModelId: () => activeModel.activeModelId,
|
|
capabilities: () => ({
|
|
hasAudio: activeModel.hasAudioModality,
|
|
hasVideo: activeModel.hasVideoModality,
|
|
hasVision: activeModel.hasVisionModality
|
|
})
|
|
});
|
|
const dragAndDrop = useChatScreenDragAndDrop({
|
|
onDrop: fileUpload.handleFileUpload
|
|
});
|
|
const { handleKeydown } = useKeyboardShortcuts({
|
|
deleteActiveConversation: () => {
|
|
if (conversationsStore.activeConversation) {
|
|
showDeleteDialog = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
function handleMobileScroll() {
|
|
if (!deviceStore.isMobile) return;
|
|
|
|
const container = scroll.chatScrollContainer;
|
|
|
|
if (!container) return;
|
|
|
|
const distanceFromBottom =
|
|
container.scrollHeight - container.clientHeight - container.scrollTop;
|
|
|
|
isMobileUserScrolledUp = distanceFromBottom > 300;
|
|
}
|
|
|
|
async function handleDeleteConfirm() {
|
|
const conversation = conversationsStore.activeConversation;
|
|
|
|
if (conversation) {
|
|
await conversationsStore.deleteConversation(conversation.id);
|
|
}
|
|
|
|
showDeleteDialog = false;
|
|
}
|
|
|
|
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
|
|
const plainFiles = files ? $state.snapshot(files) : undefined;
|
|
const result = plainFiles
|
|
? await parseFilesToMessageExtras(plainFiles, activeModel.activeModelId ?? undefined)
|
|
: undefined;
|
|
|
|
if (result?.emptyFiles && result.emptyFiles.length > 0) {
|
|
emptyFileNames = result.emptyFiles;
|
|
showEmptyFileDialog = true;
|
|
|
|
if (files) {
|
|
const emptyFileNamesSet = new Set(result.emptyFiles);
|
|
|
|
fileUpload.uploadedFiles = fileUpload.uploadedFiles.filter(
|
|
(file) => !emptyFileNamesSet.has(file.name)
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
handleSendLikeScroll();
|
|
|
|
await chatStore.sendMessage(message, result?.extras);
|
|
|
|
return true;
|
|
}
|
|
|
|
let lastScrolledConversationId: string | null = null;
|
|
|
|
// Lands at the bottom of a conversation the first time its messages
|
|
// render, whether the route comes from another conversation or from a
|
|
// non-conversation route. The page keeps growing after the first pin
|
|
// without DOM mutations (content-visibility size realizations, syntax
|
|
// highlight passes), so the instant pin repeats every frame until the
|
|
// height settles, bailing out on user scroll or conversation change.
|
|
async function handleMessagesReady(messageCount: number) {
|
|
if (messageCount === 0) return;
|
|
|
|
const id = conversationsStore.activeConversation?.id ?? null;
|
|
|
|
if (!id || id === lastScrolledConversationId) return;
|
|
|
|
lastScrolledConversationId = id;
|
|
await tick();
|
|
autoScroll.scrollToBottom();
|
|
|
|
const container = scroll.chatScrollContainer;
|
|
|
|
if (!container) return;
|
|
|
|
const started = performance.now();
|
|
|
|
let stableFrames = 0;
|
|
let lastHeight = container.scrollHeight;
|
|
|
|
const settle = () => {
|
|
if (autoScroll.userScrolledUp) return;
|
|
|
|
if (conversationsStore.activeConversation?.id !== id) return;
|
|
|
|
autoScroll.scrollToBottom();
|
|
const height = container.scrollHeight;
|
|
|
|
stableFrames = height === lastHeight ? stableFrames + 1 : 0;
|
|
lastHeight = height;
|
|
|
|
if (stableFrames >= LANDING_STABLE_FRAMES) return;
|
|
|
|
if (performance.now() - started > LANDING_SETTLE_MAX_MS) return;
|
|
|
|
requestAnimationFrame(settle);
|
|
};
|
|
|
|
requestAnimationFrame(settle);
|
|
}
|
|
|
|
function handleSendLikeScroll() {
|
|
if (!deviceStore.isMobile) {
|
|
autoScroll.enable();
|
|
}
|
|
|
|
setTimeout(() => {
|
|
const container = scroll.chatScrollContainer;
|
|
|
|
if (!container) return;
|
|
|
|
const lastUserBubble = container.querySelector(
|
|
'.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
|
|
) as HTMLElement | null;
|
|
|
|
if (deviceStore.isMobile) {
|
|
// Keep the last user message bubble just above the input on mobile
|
|
const bubbleHeight = lastUserBubble?.scrollHeight ?? 0;
|
|
const baseHeight = container.scrollHeight - innerHeight;
|
|
|
|
container.scrollTo({
|
|
behavior: 'smooth',
|
|
top: bubbleHeight > 0 ? baseHeight - bubbleHeight : baseHeight
|
|
});
|
|
} else if (lastUserBubble) {
|
|
// On desktop, place the last user message near the top of the viewport
|
|
const topPadding = 24;
|
|
const bubbleRect = lastUserBubble.getBoundingClientRect();
|
|
|
|
container.scrollTo({
|
|
behavior: 'smooth',
|
|
top: Math.max(0, container.scrollTop + bubbleRect.top - topPadding)
|
|
});
|
|
} else {
|
|
autoScroll.scrollToBottom();
|
|
}
|
|
}, 100);
|
|
|
|
if (deviceStore.isMobile) {
|
|
autoScroll.setDisabled(disableAutoScroll);
|
|
mobileScrollDownHint = true;
|
|
mobileScrollDownHintLockedUntil = Date.now() + 500;
|
|
}
|
|
}
|
|
|
|
function handleErrorDialogOpenChange(open: boolean) {
|
|
if (!open) {
|
|
chatStore.dismissErrorDialog();
|
|
}
|
|
}
|
|
|
|
async function handleSystemPromptAdd(draft: { message: string; files: ChatUploadedFile[] }) {
|
|
if (draft.message || draft.files.length > 0) {
|
|
chatStore.savePendingDraft(draft.message, draft.files);
|
|
}
|
|
|
|
await chatStore.addSystemPrompt();
|
|
}
|
|
|
|
$effect(() => {
|
|
const shouldDisableAutoScroll =
|
|
settingsStore.config.disableAutoScroll ||
|
|
(deviceStore.isMobile && isCurrentConversationLoading);
|
|
|
|
autoScroll.setDisabled(shouldDisableAutoScroll);
|
|
|
|
if (!shouldDisableAutoScroll) {
|
|
autoScroll.enable();
|
|
}
|
|
});
|
|
|
|
onMount(() => {
|
|
const pendingDraft = chatStore.consumePendingDraft();
|
|
|
|
if (pendingDraft) {
|
|
initialMessage = pendingDraft.message;
|
|
fileUpload.uploadedFiles = pendingDraft.files;
|
|
}
|
|
|
|
autoScroll.startObserving();
|
|
|
|
if (!disableAutoScroll) {
|
|
autoScroll.enable();
|
|
}
|
|
|
|
if (deviceStore.isMobile && isCurrentConversationLoading) {
|
|
mobileScrollDownHint = true;
|
|
mobileScrollDownHintLockedUntil = Date.now() + 500;
|
|
}
|
|
|
|
handleMobileScroll();
|
|
});
|
|
|
|
onDestroy(() => autoScroll.destroy());
|
|
</script>
|
|
|
|
{#if dragAndDrop.isDragOver}
|
|
<ChatScreenDragOverlay />
|
|
{/if}
|
|
|
|
<svelte:window
|
|
onkeydown={handleKeydown}
|
|
onscroll={(e) => {
|
|
scroll.handleScroll(e);
|
|
handleMobileScroll();
|
|
|
|
if (e.isTrusted && Date.now() > mobileScrollDownHintLockedUntil) {
|
|
mobileScrollDownHint = false;
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{#if isServerLoading}
|
|
<ServerLoadingSplash />
|
|
{:else}
|
|
<div
|
|
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-full px-4 md:py-0 pt-12 pb-48 md:pb-4"
|
|
style:--chat-form-bottom-position={chatFormBottomPosition}
|
|
ondragenter={dragAndDrop.dragHandlers.dragenter}
|
|
ondragleave={dragAndDrop.dragHandlers.dragleave}
|
|
ondragover={dragAndDrop.dragHandlers.dragover}
|
|
ondrop={dragAndDrop.dragHandlers.drop}
|
|
role="main"
|
|
>
|
|
{#if !isEmpty}
|
|
<ChatMessages
|
|
messages={conversationsStore.activeMessages}
|
|
onMessagesReady={handleMessagesReady}
|
|
onUserAction={() => {
|
|
handleSendLikeScroll();
|
|
}}
|
|
/>
|
|
{/if}
|
|
|
|
<div
|
|
class={[
|
|
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
|
|
deviceStore.isStandalone
|
|
? 'bottom-6 right-4 left-4'
|
|
: deviceStore.isIOSSafari
|
|
? 'bottom-1 left-2 right-2'
|
|
: 'bottom-2 right-2 left-2',
|
|
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
|
|
]}
|
|
style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
|
|
>
|
|
<ChatScreenGreeting {isEmpty} />
|
|
|
|
<ChatScreenServerError />
|
|
|
|
{#if page.params.id}
|
|
<ChatScreenStreamResumeStatus />
|
|
{/if}
|
|
|
|
<div class="pointer-events-none flex flex-col gap-6 items-center w-full">
|
|
{#if (deviceStore.isMobile ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
|
|
<ChatScreenActionScrollDown
|
|
onclick={() => {
|
|
mobileScrollDownHint = false;
|
|
scroll.chatScrollContainer?.scrollTo({
|
|
behavior: 'smooth',
|
|
top: scroll.chatScrollContainer.scrollHeight
|
|
});
|
|
}}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<ChatScreenForm
|
|
class="pointer-events-auto conversation-chat-form"
|
|
disabled={hasPropsError || chatStore.isEditing()}
|
|
{initialMessage}
|
|
isLoading={isCurrentConversationLoading}
|
|
onFileRemove={fileUpload.handleFileRemove}
|
|
onFileUpload={fileUpload.handleFileUpload}
|
|
onSend={handleSendMessage}
|
|
onStop={() => chatStore.stopGeneration()}
|
|
onSystemPromptAdd={handleSystemPromptAdd}
|
|
bind:uploadedFiles={fileUpload.uploadedFiles}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<ChatScreenDialogsAndAlerts
|
|
{showDeleteDialog}
|
|
{handleDeleteConfirm}
|
|
{showEmptyFileDialog}
|
|
{emptyFileNames}
|
|
{activeErrorDialog}
|
|
{handleErrorDialogOpenChange}
|
|
{fileUpload}
|
|
/>
|