ui: migrate chat playground and stats to shadcn

- ChatInterface controls, settings, input use Button/Input/Textarea/Label
- ExpandableTextarea and ModelSelector restyled on shadcn tokens
- ActivityStats wrapped in Card; Tooltip uses shadcn tooltip

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
This commit is contained in:
Claude
2026-06-27 11:56:31 +00:00
parent 8dd91e99e8
commit 746c083a87
5 changed files with 101 additions and 131 deletions
+27 -26
View File
@@ -3,6 +3,9 @@
import { persistentStore } from "../stores/persistent"; import { persistentStore } from "../stores/persistent";
import { calculateHistogramData } from "../lib/histogram"; import { calculateHistogramData } from "../lib/histogram";
import TokenHistogram from "./TokenHistogram.svelte"; import TokenHistogram from "./TokenHistogram.svelte";
import { ChevronDown, X } from "@lucide/svelte";
import * as Card from "$lib/components/ui/card/index.js";
import { Button } from "$lib/components/ui/button/index.js";
const nf = new Intl.NumberFormat(); const nf = new Intl.NumberFormat();
const histogramCollapsed = persistentStore<boolean>("activity-histogram-collapsed", false); const histogramCollapsed = persistentStore<boolean>("activity-histogram-collapsed", false);
@@ -35,26 +38,24 @@
}); });
</script> </script>
<div class="card relative p-3"> <Card.Root class="relative p-3">
<button <Button
class="absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-400 transition-colors" variant="ghost"
size="icon-xs"
class="text-muted-foreground absolute right-2 top-2 rounded-full"
onclick={() => ($histogramCollapsed = !$histogramCollapsed)} onclick={() => ($histogramCollapsed = !$histogramCollapsed)}
title={$histogramCollapsed ? "Show histograms" : "Hide histograms"} title={$histogramCollapsed ? "Show histograms" : "Hide histograms"}
> >
{#if $histogramCollapsed} {#if $histogramCollapsed}
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"> <ChevronDown />
<path d="M4.5 6l3.5 4 3.5-4H4.5z" />
</svg>
{:else} {:else}
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor"> <X />
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" />
</svg>
{/if} {/if}
</button> </Button>
{#if !$histogramCollapsed} {#if !$histogramCollapsed}
<div class="flex flex-col sm:flex-row gap-6 mb-3"> <div class="mb-3 flex flex-col gap-6 sm:flex-row">
<div class="w-full sm:w-1/2 min-w-0"> <div class="w-full min-w-0 sm:w-1/2">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prompt Processing</div> <div class="text-muted-foreground mb-1 text-sm font-medium">Prompt Processing</div>
{#if stats.promptHistogramData} {#if stats.promptHistogramData}
<TokenHistogram <TokenHistogram
data={stats.promptHistogramData} data={stats.promptHistogramData}
@@ -62,36 +63,36 @@
colorClass="text-amber-500 dark:text-amber-400" colorClass="text-amber-500 dark:text-amber-400"
/> />
{:else} {:else}
<div class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">No prompt speed data yet</div> <div class="text-muted-foreground py-6 text-center text-sm">No prompt speed data yet</div>
{/if} {/if}
</div> </div>
<div class="w-full sm:w-1/2 min-w-0"> <div class="w-full min-w-0 sm:w-1/2">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Token Generation</div> <div class="text-muted-foreground mb-1 text-sm font-medium">Token Generation</div>
{#if stats.genHistogramData} {#if stats.genHistogramData}
<TokenHistogram data={stats.genHistogramData} unit="tokens/sec" /> <TokenHistogram data={stats.genHistogramData} unit="tokens/sec" />
{:else} {:else}
<div class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">No generation speed data yet</div> <div class="text-muted-foreground py-6 text-center text-sm">No generation speed data yet</div>
{/if} {/if}
</div> </div>
</div> </div>
{/if} {/if}
<div class="grid grid-cols-4 gap-x-6 gap-y-1 text-sm"> <div class="grid grid-cols-4 gap-x-6 gap-y-1 text-sm">
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Requests</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Requests</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Cached</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Cached</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Processed</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Processed</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Generated</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Generated</div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalRequests)}</span> completed, <span class="font-semibold">{nf.format(stats.totalRequests)}</span> completed,
<span class="font-semibold">{nf.format(stats.inFlightRequests)}</span> waiting <span class="font-semibold">{nf.format(stats.inFlightRequests)}</span> waiting
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalCacheTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalCacheTokens)}</span> tokens
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalInputTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalInputTokens)}</span> tokens
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalOutputTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalOutputTokens)}</span> tokens
</div> </div>
</div> </div>
</div> </Card.Root>
+6 -12
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props { interface Props {
content: string; content: string;
} }
@@ -6,15 +8,7 @@
let { content }: Props = $props(); let { content }: Props = $props();
</script> </script>
<div class="relative group inline-block"> <Tooltip.Root>
<span class="cursor-help">&#9432;</span> <Tooltip.Trigger class="cursor-help align-middle normal-case">&#9432;</Tooltip.Trigger>
<div <Tooltip.Content>{content}</Tooltip.Content>
class="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 </Tooltip.Root>
px-3 py-2 bg-gray-900 text-white text-sm rounded-md
opacity-0 group-hover:opacity-100 transition-opacity
duration-200 pointer-events-none whitespace-nowrap z-50 normal-case"
>
{content}
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-b-gray-900"></div>
</div>
</div>
@@ -7,6 +7,11 @@
import ChatMessageComponent from "./ChatMessage.svelte"; import ChatMessageComponent from "./ChatMessage.svelte";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte"; import ExpandableTextarea from "./ExpandableTextarea.svelte";
import { Settings, Paperclip } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Label } from "$lib/components/ui/label/index.js";
const selectedModelStore = persistentStore<string>("playground-selected-model", ""); const selectedModelStore = persistentStore<string>("playground-selected-model", "");
const systemPromptStore = persistentStore<string>("playground-system-prompt", ""); const systemPromptStore = persistentStore<string>("playground-system-prompt", "");
@@ -303,29 +308,23 @@
<div class="shrink-0 flex flex-wrap gap-2 mb-4"> <div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a model..." disabled={isStreaming} /> <ModelSelector bind:value={$selectedModelStore} placeholder="Select a model..." disabled={isStreaming} />
<div class="flex gap-2"> <div class="flex gap-2">
<button <Button variant="outline" size="icon" onclick={() => (showSettings = !showSettings)} title="Settings">
class="btn" <Settings />
onclick={() => (showSettings = !showSettings)} </Button>
title="Settings" <Button variant="outline" onclick={newChat} disabled={messages.length === 0 && !isStreaming}>
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M8.34 1.804A1 1 0 0 1 9.32 1h1.36a1 1 0 0 1 .98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 0 1 1.262.125l.962.962a1 1 0 0 1 .125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.295a1 1 0 0 1 .804.98v1.36a1 1 0 0 1-.804.98l-1.473.295a6.95 6.95 0 0 1-.587 1.416l.834 1.25a1 1 0 0 1-.125 1.262l-.962.962a1 1 0 0 1-1.262.125l-1.25-.834a6.953 6.953 0 0 1-1.416.587l-.295 1.473a1 1 0 0 1-.98.804H9.32a1 1 0 0 1-.98-.804l-.295-1.473a6.957 6.957 0 0 1-1.416-.587l-1.25.834a1 1 0 0 1-1.262-.125l-.962-.962a1 1 0 0 1-.125-1.262l.834-1.25a6.957 6.957 0 0 1-.587-1.416l-1.473-.295A1 1 0 0 1 1 10.68V9.32a1 1 0 0 1 .804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 0 1 .125-1.262l.962-.962A1 1 0 0 1 5.38 3.03l1.25.834a6.957 6.957 0 0 1 1.416-.587l.294-1.473ZM13 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" clip-rule="evenodd" />
</svg>
</button>
<button class="btn" onclick={newChat} disabled={messages.length === 0 && !isStreaming}>
New Chat New Chat
</button> </Button>
</div> </div>
</div> </div>
<!-- Settings panel --> <!-- Settings panel -->
{#if showSettings} {#if showSettings}
<div class="shrink-0 mb-4 p-4 bg-surface border border-gray-200 dark:border-white/10 rounded"> <div class="bg-muted/40 mb-4 shrink-0 rounded-lg border p-4">
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-1" for="endpoint">Endpoint</label> <Label class="mb-1" for="endpoint">Endpoint</Label>
<select <select
id="endpoint" id="endpoint"
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary" class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 w-full rounded-md border px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-[3px] disabled:opacity-50"
bind:value={$endpointStore} bind:value={$endpointStore}
disabled={isStreaming} disabled={isStreaming}
> >
@@ -335,64 +334,57 @@
</select> </select>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-1" for="system-prompt">System Prompt</label> <Label class="mb-1" for="system-prompt">System Prompt</Label>
<textarea <Textarea
id="system-prompt" id="system-prompt"
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none" class="resize-none"
placeholder="You are a helpful assistant..." placeholder="You are a helpful assistant..."
rows="3" rows={3}
bind:value={$systemPromptStore} bind:value={$systemPromptStore}
disabled={isStreaming} disabled={isStreaming}
></textarea> />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-1" for="temperature"> <Label class="mb-1" for="temperature">
Temperature: {$temperatureStore.toFixed(2)} Temperature: {$temperatureStore.toFixed(2)}
</label> </Label>
<input <input
id="temperature" id="temperature"
type="range" type="range"
min="0" min="0"
max="2" max="2"
step="0.05" step="0.05"
class="w-full" class="accent-primary w-full"
bind:value={$temperatureStore} bind:value={$temperatureStore}
disabled={isStreaming} disabled={isStreaming}
/> />
<div class="flex justify-between text-xs text-txtsecondary mt-1"> <div class="text-muted-foreground mt-1 flex justify-between text-xs">
<span>Precise (0)</span> <span>Precise (0)</span>
<span>Creative (2)</span> <span>Creative (2)</span>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1" for="max-tokens">Max Tokens</label> <Label class="mb-1" for="max-tokens">Max Tokens</Label>
<input <Input id="max-tokens" type="number" min="1" bind:value={$maxTokensStore} disabled={isStreaming} />
id="max-tokens" <p class="text-muted-foreground mt-1 text-xs">Required for /v1/messages.</p>
type="number"
min="1"
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$maxTokensStore}
disabled={isStreaming}
/>
<p class="text-xs text-txtsecondary mt-1">Required for /v1/messages.</p>
</div> </div>
</div> </div>
{/if} {/if}
<!-- Empty state for no models configured --> <!-- Empty state for no models configured -->
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="text-muted-foreground flex flex-1 items-center justify-center">
<p>No models configured. Add models to your configuration to start chatting.</p> <p>No models configured. Add models to your configuration to start chatting.</p>
</div> </div>
{:else} {:else}
<!-- Messages area --> <!-- Messages area -->
<div <div
class="flex-1 overflow-y-auto mb-4 px-2" class="mb-4 flex-1 overflow-y-auto px-2"
bind:this={messagesContainer} bind:this={messagesContainer}
onscroll={handleMessagesScroll} onscroll={handleMessagesScroll}
> >
{#if messages.length === 0} {#if messages.length === 0}
<div class="h-full flex items-center justify-center text-txtsecondary"> <div class="text-muted-foreground flex h-full items-center justify-center">
<p>Start a conversation by typing a message below.</p> <p>Start a conversation by typing a message below.</p>
</div> </div>
{:else} {:else}
@@ -419,14 +411,14 @@
{#if attachedImages.length > 0} {#if attachedImages.length > 0}
<div class="mb-2 flex flex-wrap gap-2"> <div class="mb-2 flex flex-wrap gap-2">
{#each attachedImages as imageUrl, idx (idx)} {#each attachedImages as imageUrl, idx (idx)}
<div class="relative group"> <div class="group relative">
<img <img
src={imageUrl} src={imageUrl}
alt="Attached image {idx + 1}" alt="Attached image {idx + 1}"
class="w-20 h-20 object-cover rounded border border-gray-200 dark:border-white/10" class="h-20 w-20 rounded border object-cover"
/> />
<button <button
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" class="bg-destructive text-destructive-foreground absolute -right-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100"
onclick={() => removeImage(idx)} onclick={() => removeImage(idx)}
title="Remove image" title="Remove image"
> >
@@ -439,7 +431,7 @@
<!-- Error message --> <!-- Error message -->
{#if imageError} {#if imageError}
<div class="mb-2 p-2 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded text-sm"> <div class="bg-destructive/10 text-destructive mb-2 rounded p-2 text-sm">
{imageError} {imageError}
</div> </div>
{/if} {/if}
@@ -464,27 +456,23 @@
/> />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if isStreaming} {#if isStreaming}
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelStreaming}> <Button variant="destructive" onclick={cancelStreaming}>Cancel</Button>
Cancel
</button>
{:else} {:else}
<button <Button
class="btn" variant="outline"
size="icon"
onclick={() => fileInput?.click()} onclick={() => fileInput?.click()}
disabled={isStreaming || !$selectedModelStore} disabled={isStreaming || !$selectedModelStore}
title="Attach image" title="Attach image"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> <Paperclip />
<path fill-rule="evenodd" d="M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.219a.75.75 0 0 0-1.06 0l-1.91 1.909.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.091a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" clip-rule="evenodd" /> </Button>
</svg> <Button
</button>
<button
class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={sendMessage} onclick={sendMessage}
disabled={(!userInput.trim() && attachedImages.length === 0) || !$selectedModelStore} disabled={(!userInput.trim() && attachedImages.length === 0) || !$selectedModelStore}
> >
Send Send
</button> </Button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { Maximize2, X } from "lucide-svelte"; import { Maximize2, X } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
interface Props { interface Props {
value: string; value: string;
@@ -52,69 +54,54 @@
}); });
</script> </script>
<div class="flex-1 relative group flex items-stretch min-h-0"> <div class="group relative flex min-h-0 flex-1 items-stretch">
<textarea <Textarea
class="w-full px-3 py-2 pr-10 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary resize-none" class="resize-none pr-10"
{placeholder} {placeholder}
{rows} {rows}
bind:value bind:value
{onkeydown} {onkeydown}
{disabled} {disabled}
></textarea> />
<button <Button
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-60 md:opacity-0 group-hover:opacity-100 transition-opacity bg-surface/90 hover:bg-surface border border-gray-200 dark:border-white/10 shadow-sm" variant="outline"
size="icon-sm"
class="absolute right-2 top-2 opacity-60 transition-opacity group-hover:opacity-100 md:opacity-0"
onclick={openExpanded} onclick={openExpanded}
title="Expand to edit" title="Expand to edit"
type="button" type="button"
{disabled} {disabled}
> >
<Maximize2 class="w-4 h-4" /> <Maximize2 />
</button> </Button>
</div> </div>
{#if isExpanded} {#if isExpanded}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-4xl h-[80vh] flex flex-col bg-surface rounded-lg shadow-xl border border-gray-200 dark:border-white/10"> <div class="bg-card flex h-[80vh] w-full max-w-4xl flex-col rounded-lg border shadow-xl">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-white/10"> <div class="flex items-center justify-between border-b p-4">
<h3 class="font-medium">Edit Text</h3> <h3 class="pb-0 font-medium">Edit Text</h3>
<button <Button variant="ghost" size="icon-sm" onclick={closeExpanded} title="Close" type="button">
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10" <X />
onclick={closeExpanded} </Button>
title="Close"
type="button"
>
<X class="w-5 h-5" />
</button>
</div> </div>
<!-- Textarea --> <!-- Textarea -->
<div class="flex-1 p-4"> <div class="flex-1 p-4">
<textarea <Textarea
bind:this={expandedTextarea} bind:ref={expandedTextarea}
class="w-full h-full px-4 py-3 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none" class="h-full resize-none"
placeholder={placeholder} {placeholder}
bind:value={expandedValue} bind:value={expandedValue}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
></textarea> />
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-white/10"> <div class="flex justify-end gap-2 border-t p-4">
<button <Button variant="outline" onclick={closeExpanded} type="button">Cancel</Button>
class="btn" <Button onclick={saveExpanded} type="button">Done</Button>
onclick={closeExpanded}
type="button"
>
Cancel
</button>
<button
class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={saveExpanded}
type="button"
>
Done
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -19,7 +19,7 @@
{#if hasModels} {#if hasModels}
<select <select
class="min-w-0 flex-1 basis-48 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 min-w-0 flex-1 basis-48 rounded-md border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
bind:value bind:value
{disabled} {disabled}
> >