Refine Notebook UI: improved layout, added stats and model info

This commit is contained in:
Leszek Hanusz 2026-01-31 23:59:04 +01:00
parent 6d96745375
commit 3af9b34aa2
4 changed files with 103 additions and 29 deletions

Binary file not shown.

View File

@ -124,7 +124,7 @@
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
onclick={handleMobileSidebarItemClick}
>
<span class="i-lucide-book-open h-4 w-4"></span>
<span class="i-lucide-file-text h-4 w-4"></span>
Notebook
</a>
</div>

View File

@ -2,42 +2,96 @@
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 } from '@lucide/svelte';
import { Play, Square, Settings, Info } from '@lucide/svelte';
import { config } from '$lib/stores/settings.svelte';
import ChatMessageStatistics from '$lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte';
import DialogChatSettings from '$lib/components/app/dialogs/DialogChatSettings.svelte';
import DialogModelInformation from '$lib/components/app/dialogs/DialogModelInformation.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
let { content } = $state(notebookStore);
let settingsOpen = $state(false);
let modelInfoOpen = $state(false);
let inputContent = $state(content);
// Sync local input with store content
$effect(() => {
inputContent = notebookStore.content;
});
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
notebookStore.content = target.value;
}
async function handleGenerate() {
await notebookStore.generate();
}
function handleStop() {
notebookStore.stop();
}
let currentModel = $derived(
modelsStore.models.find((m) => m.id === config().model) || modelsStore.models[0]
);
</script>
<div class="flex h-full flex-col p-4 md:p-6">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-semibold">Notebook</h1>
<div class="flex gap-2">
{#if notebookStore.isGenerating}
<Button variant="destructive" onclick={() => notebookStore.stop()}>
<Square class="mr-2 h-4 w-4" />
Stop
<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 p-4 md:p-6">
<Textarea
value={inputContent}
oninput={handleInput}
class="h-full min-h-[500px] w-full resize-none rounded-xl border-none bg-muted p-4 text-base focus-visible:ring-0 md:p-6"
placeholder="Enter your prompt here..."
/>
</div>
<div class="border-t border-border/40 bg-background p-4 md:px-6 md:py-4">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Button
onclick={notebookStore.isGenerating ? handleStop : handleGenerate}
size="sm"
variant={notebookStore.isGenerating ? 'destructive' : 'default'}
class="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>
{:else}
<Button onclick={() => notebookStore.generate()}>
<Play class="mr-2 h-4 w-4" />
Generate
<Button variant="ghost" size="icon" onclick={() => (modelInfoOpen = true)}>
<Info class="h-4 w-4" />
</Button>
{/if}
</div>
<ChatMessageStatistics
predictedTokens={notebookStore.predictedTokens}
predictedMs={notebookStore.predictedMs}
promptTokens={notebookStore.promptTokens}
promptMs={notebookStore.promptMs}
isLive={notebookStore.isGenerating}
/>
</div>
</div>
<div class="flex-1 overflow-hidden rounded-lg border bg-background shadow-sm">
<textarea
class="h-full w-full resize-none border-0 bg-transparent p-4 font-mono text-sm focus:ring-0 focus-visible:ring-0"
placeholder="Enter your text here..."
bind:value={notebookStore.content}
></textarea>
</div>
<div class="mt-4 text-xs text-muted-foreground">
<p>
Model: {config().model || 'Default'} | Temperature: {config().temperature ?? 0.8} | Max Tokens: {config()
.max_tokens ?? -1}
</p>
</div>
<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
<DialogModelInformation open={modelInfoOpen} onOpenChange={(open) => (modelInfoOpen = open)} />
</div>

View File

@ -6,12 +6,24 @@ export class NotebookStore {
isGenerating = $state(false);
abortController: AbortController | null = null;
// Statistics
promptTokens = $state(0);
promptMs = $state(0);
predictedTokens = $state(0);
predictedMs = $state(0);
async generate(model?: string) {
if (this.isGenerating) return;
this.isGenerating = true;
this.abortController = new AbortController();
// Reset stats
this.promptTokens = 0;
this.promptMs = 0;
this.predictedTokens = 0;
this.predictedMs = 0;
try {
const currentConfig = config();
await ChatService.sendCompletion(
@ -23,6 +35,14 @@ export class NotebookStore {
onChunk: (chunk) => {
this.content += chunk;
},
onTimings: (timings) => {
if (timings) {
if (timings.prompt_n) this.promptTokens = timings.prompt_n;
if (timings.prompt_ms) this.promptMs = timings.prompt_ms;
if (timings.predicted_n) this.predictedTokens = timings.predicted_n;
if (timings.predicted_ms) this.predictedMs = timings.predicted_ms;
}
},
onComplete: () => {
this.isGenerating = false;
},