llama.cpp/tools/server/webui/src/lib/components/app/notebook/NotebookScreen.svelte

347 lines
9.5 KiB
Svelte

<script lang="ts">
import { notebookStore } from '$lib/stores/notebook.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
import { Play, Square, Settings, Undo, Redo, RulerDimensionLine } from '@lucide/svelte';
import { config } from '$lib/stores/settings.svelte';
import DialogChatSettings from '$lib/components/app/dialogs/DialogChatSettings.svelte';
import {
ChatMessageStatistics,
DialogChatError,
KeyboardShortcutInfo,
ModelsSelector
} from '$lib/components/app';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
let settingsOpen = $state(false);
import {
AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
AUTO_SCROLL_INTERVAL,
INITIAL_SCROLL_DELAY
} from '$lib/constants/auto-scroll';
import { onMount } from 'svelte';
let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
let showMessageStats = $derived(config().showMessageStats);
let autoScrollEnabled = $state(true);
let scrollContainer: HTMLTextAreaElement | null = $state(null);
let lastScrollTop = $state(0);
let scrollInterval: ReturnType<typeof setInterval> | undefined;
let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
let userScrolledUp = $state(false);
let isRouter = $derived(isRouterMode());
let errorDialog = $derived(notebookStore.error);
let canUndo = $derived(notebookStore.previousContent !== null && !notebookStore.isGenerating);
let canRedo = $derived(notebookStore.undoneContent !== null && !notebookStore.isGenerating);
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
notebookStore.content = target.value;
notebookStore.resetUndoRedo();
if (activeModelId || !isRouter) {
notebookStore.updateTokenCount(activeModelId);
}
}
function handleErrorDialogOpenChange(open: boolean) {
if (!open) {
notebookStore.dismissError();
}
}
async function handleGenerate() {
if (!disableAutoScroll) {
userScrolledUp = false;
autoScrollEnabled = true;
scrollToBottom();
}
if (activeModelId) {
await notebookStore.generate(activeModelId);
}
}
function handleUndo() {
notebookStore.undo();
}
function handleRedo() {
notebookStore.redo();
}
function handleStop() {
notebookStore.stop();
}
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;
}
return null;
});
let hasModelSelected = $derived(!isRouter || !!selectedModelId());
let generateTooltip = $derived.by(() => {
if (!hasModelSelected) {
return 'Please select a model first';
}
if (notebookStore.content.length == 0) {
return 'Input some text first';
}
return '';
});
let canGenerate = $derived(notebookStore.content.length > 0 && hasModelSelected);
let isDisabled = $derived(!canGenerate);
function handleScroll() {
if (disableAutoScroll || !scrollContainer) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
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;
}
function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
if (disableAutoScroll) return;
scrollContainer?.scrollTo({
top: scrollContainer?.scrollHeight,
behavior
});
}
onMount(() => {
if (!disableAutoScroll) {
setTimeout(() => scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
}
});
$effect(() => {
if (disableAutoScroll) {
autoScrollEnabled = false;
if (scrollInterval) {
clearInterval(scrollInterval);
scrollInterval = undefined;
}
return;
}
if (notebookStore.isGenerating && autoScrollEnabled) {
scrollInterval = setInterval(() => scrollToBottom(), AUTO_SCROLL_INTERVAL);
} else if (scrollInterval) {
clearInterval(scrollInterval);
scrollInterval = undefined;
}
});
function handleBeforeUnload(event: BeforeUnloadEvent) {
// This should prevent the browser from closing the tab if there is content in the notebook
if (notebookStore.content.length > 0) {
event.preventDefault();
event.returnValue = '';
}
}
function handleKeydown(event: KeyboardEvent) {
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
if (event.shiftKey && event.key === 'Enter') {
event.preventDefault();
if (notebookStore.isGenerating) {
handleStop();
} else if (canGenerate) {
handleGenerate();
}
}
if (isCtrlOrCmd && event.key === 'z') {
event.preventDefault();
if (canUndo) {
handleUndo();
}
}
if (isCtrlOrCmd && event.key === 'y') {
event.preventDefault();
if (canRedo) {
handleRedo();
}
}
}
</script>
<svelte:window onbeforeunload={handleBeforeUnload} onkeydown={handleKeydown} />
<div class="flex h-full flex-col">
<header
class="flex items-center justify-between border-b border-border/40 bg-background/95 px-6 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<div class="w-10"></div>
<!-- Spacer for centering -->
<h1 class="text-lg font-semibold">Notebook</h1>
<Button variant="ghost" size="icon" onclick={() => (settingsOpen = true)}>
<Settings class="h-5 w-5" />
</Button>
</header>
<div class="flex-1 overflow-y-auto px-2 pt-2 pb-0 md:px-4 md:pt-4">
<Textarea
bind:ref={scrollContainer}
onscroll={handleScroll}
value={notebookStore.content}
oninput={handleInput}
class="h-full min-h-[100px] w-full resize-none rounded-xl border-none bg-muted p-4 text-base focus-visible:ring-0 md:p-6"
placeholder="Enter your text here..."
/>
</div>
<div class="bg-background p-2 md:p-4">
<div class="flex flex-col-reverse gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex items-center gap-2">
<Tooltip.Root>
<Tooltip.Trigger>
<Button variant="ghost" size="icon" disabled={!canUndo} onclick={handleUndo}>
<Undo class="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Undo last generation</p>
<KeyboardShortcutInfo keys={['ctrl', 'z']} class="w-full justify-center opacity-100" />
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<Button variant="ghost" size="icon" disabled={!canRedo} onclick={handleRedo}>
<Redo class="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Redo last generation</p>
<KeyboardShortcutInfo keys={['ctrl', 'y']} class="w-full justify-center opacity-100" />
</Tooltip.Content>
</Tooltip.Root>
{#snippet generateButton()}
<Button
disabled={isDisabled}
onclick={notebookStore.isGenerating ? handleStop : handleGenerate}
size="sm"
variant={notebookStore.isGenerating ? 'destructive' : 'default'}
class="min-w-[120px] gap-2"
>
{#if notebookStore.isGenerating}
<Square class="h-4 w-4 fill-current" />
Stop
{:else}
<Play class="h-4 w-4 fill-current" />
Generate
{/if}
</Button>
{/snippet}
<Tooltip.Root>
<Tooltip.Trigger>
{@render generateButton()}
</Tooltip.Trigger>
<Tooltip.Content>
{#if generateTooltip}
<p>{generateTooltip}</p>
{:else}
<div class="flex items-center justify-center py-1">
<KeyboardShortcutInfo keys={['shift', 'enter']} class="opacity-100" />
</div>
{/if}
</Tooltip.Content>
</Tooltip.Root>
<ModelsSelector
forceForegroundText={true}
useGlobalSelection={true}
disabled={notebookStore.isGenerating}
/>
</div>
{#if showMessageStats}
<div class="flex w-full flex-col items-end justify-center gap-0.5 md:w-auto">
{#if notebookStore.totalTokens > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<div class="flex items-center gap-1.5 pr-3.5 text-xs text-muted-foreground">
<RulerDimensionLine class="h-3.5 w-3.5" />
<span>{notebookStore.totalTokens} tokens</span>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Total tokens</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if notebookStore.promptTokens > 0 || notebookStore.predictedTokens > 0}
<ChatMessageStatistics
promptTokens={notebookStore.promptTokens}
promptMs={notebookStore.promptMs}
predictedTokens={notebookStore.predictedTokens}
predictedMs={notebookStore.predictedMs}
isLive={notebookStore.isGenerating}
isProcessingPrompt={notebookStore.isGenerating && notebookStore.predictedTokens === 0}
/>
{/if}
</div>
{/if}
</div>
</div>
<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
<DialogChatError
message={errorDialog?.message ?? ''}
contextInfo={errorDialog?.contextInfo}
onOpenChange={handleErrorDialogOpenChange}
open={Boolean(errorDialog)}
type={errorDialog?.type ?? 'server'}
/>
</div>