ui-svelte: big convert to shadcn components

This commit is contained in:
Benson Wong
2026-06-28 01:53:19 +00:00
parent d1e4c8ee77
commit 8b5a62d92a
19 changed files with 342 additions and 348 deletions
+1 -1
View File
@@ -60,7 +60,7 @@
</div>
<h1
contenteditable="true"
class="truncate pb-0 text-base font-semibold outline-none rounded px-1 hover:bg-sidebar-accent group-data-[collapsible=icon]:hidden"
class="truncate pb-0 text-base font-semibold outline-none rounded-md px-1 hover:bg-sidebar-accent group-data-[collapsible=icon]:hidden"
onblur={handleBlur}
onkeydown={handleKeyDown}
>
+37 -54
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { ReqRespCapture } from "../lib/types";
import { Button } from "$lib/components/ui/button/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
interface Props {
capture: ReqRespCapture | null;
@@ -10,22 +11,12 @@
let { capture, open, onclose }: Props = $props();
let dialogEl: HTMLDialogElement | undefined = $state();
type BodyTab = "raw" | "pretty" | "chat";
let reqBodyTab: BodyTab = $state("pretty");
let respBodyTab: BodyTab = $state("pretty");
let copiedReq = $state(false);
let copiedResp = $state(false);
$effect(() => {
if (open && dialogEl) {
dialogEl.showModal();
} else if (!open && dialogEl) {
dialogEl.close();
}
});
// Reset tabs when capture changes
$effect(() => {
if (capture) {
@@ -40,10 +31,6 @@
}
});
function handleDialogClose() {
onclose();
}
function decodeBody(body: string | null | undefined): string {
if (!body) return "";
try {
@@ -191,26 +178,22 @@
});
</script>
<dialog
bind:this={dialogEl}
onclose={handleDialogClose}
class="bg-background text-foreground rounded-lg shadow-xl max-w-[80%] w-full max-h-[90vh] p-0 backdrop:bg-black/50 m-auto"
<Dialog.Root
{open}
onOpenChange={(v) => {
if (!v) onclose();
}}
>
{#if capture}
<div class="flex flex-col max-h-[90vh]">
<div
class="flex justify-between items-center p-4 border-b border-border"
>
<h2 class="text-xl font-bold pb-0">Capture #{capture.id + 1}{#if capture.req_path} <span class="text-base font-mono font-normal text-muted-foreground">{capture.req_path}</span>{/if}</h2>
<button
onclick={() => dialogEl?.close()}
class="text-muted-foreground hover:text-foreground text-2xl leading-none"
>
&times;
</button>
</div>
<Dialog.Content class="max-h-[90vh] w-full max-w-[80%] gap-0 p-0">
{#if capture}
<Dialog.Header class="border-b border-border px-4 py-3">
<Dialog.Title class="text-lg font-bold">
Capture #{capture.id + 1}{#if capture.req_path}
<span class="font-mono text-base font-normal text-muted-foreground">{capture.req_path}</span>{/if}
</Dialog.Title>
</Dialog.Header>
<div class="overflow-y-auto flex-1 p-4 space-y-4">
<div class="overflow-y-auto flex-1 space-y-4 p-4">
<!-- Request Headers -->
<details class="group" open>
<summary
@@ -219,7 +202,7 @@
Request Headers
</summary>
<div
class="mt-2 bg-background rounded border border-border overflow-auto max-h-48"
class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-48"
>
<table class="w-full text-sm">
<tbody>
@@ -272,14 +255,14 @@
</button>
</div>
<div
class="mt-1 bg-background rounded border border-border overflow-auto max-h-96"
class="mt-1 bg-background rounded-md border border-border overflow-auto max-h-96"
>
<pre
class="p-3 text-sm font-mono whitespace-pre-wrap break-all">{displayedRequestBody}</pre>
</div>
{:else}
<div
class="mt-2 bg-background rounded border border-border overflow-auto max-h-96"
class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-96"
>
<pre class="p-3 text-sm font-mono whitespace-pre-wrap break-all"
>(empty)</pre
@@ -296,7 +279,7 @@
Response Headers
</summary>
<div
class="mt-2 bg-background rounded border border-border overflow-auto max-h-48"
class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-48"
>
<table class="w-full text-sm">
<tbody>
@@ -322,7 +305,7 @@
</summary>
{#if isResponseImage && capture.resp_body}
<div
class="mt-2 bg-background rounded border border-border overflow-auto max-h-96"
class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-96"
>
<div class="p-3 flex justify-center">
<img
@@ -369,7 +352,7 @@
</button>
</div>
<div
class="mt-1 bg-background rounded border border-border overflow-auto max-h-96"
class="mt-1 bg-background rounded-md border border-border overflow-auto max-h-96"
>
{#if respBodyTab === "chat"}
<div class="p-3 text-sm space-y-3">
@@ -408,7 +391,7 @@
</div>
{:else if responseBodyRaw}
<div
class="mt-2 bg-background rounded border border-border overflow-auto max-h-96"
class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-96"
>
<div class="p-3 text-sm text-muted-foreground italic">
(binary data - {responseContentType || "unknown content type"})
@@ -416,7 +399,7 @@
</div>
{:else}
<div
class="mt-2 bg-background rounded border border-border overflow-auto max-h-96"
class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-96"
>
<pre class="p-3 text-sm font-mono">(empty)</pre>
</div>
@@ -424,26 +407,26 @@
</details>
</div>
<div class="p-4 border-t border-border flex justify-end">
<Button variant="outline" onclick={() => dialogEl?.close()}>Close</Button>
<Dialog.Footer class="border-t border-border px-4 py-3 sm:justify-end">
<Button variant="outline" onclick={onclose}>Close</Button>
</Dialog.Footer>
{:else}
<div class="flex flex-col items-center justify-center p-12">
<p class="text-lg text-muted-foreground">Capture not found</p>
<p class="text-sm text-muted-foreground mt-1">The capture may have expired or was never recorded.</p>
<div class="mt-4">
<Button variant="outline" onclick={onclose}>Close</Button>
</div>
</div>
</div>
{:else}
<div class="flex flex-col items-center justify-center p-12">
<p class="text-lg text-muted-foreground">Capture not found</p>
<p class="text-sm text-muted-foreground mt-1">The capture may have expired or was never recorded.</p>
<div class="mt-4">
<Button variant="outline" onclick={() => dialogEl?.close()}>Close</Button>
</div>
</div>
{/if}
</dialog>
{/if}
</Dialog.Content>
</Dialog.Root>
<style>
.tab-btn {
padding: 2px 10px;
font-size: 0.75rem;
border-radius: 4px;
border-radius: 0;
color: var(--muted-foreground);
cursor: pointer;
border: 1px solid transparent;
+1 -1
View File
@@ -85,7 +85,7 @@
});
</script>
<Card.Root class="bg-muted/30 h-full w-full gap-0 overflow-hidden rounded-none py-0">
<Card.Root class="bg-muted/30 h-full w-full gap-0 overflow-hidden py-0">
<Card.Header class="border-b px-4 py-2">
<Card.Title class="text-sm font-semibold">{title}</Card.Title>
<Card.Action>
+22 -70
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Snippet } from "svelte";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props {
metadata: Record<string, string> | undefined;
@@ -9,77 +10,28 @@
let { metadata, children }: Props = $props();
let entries = $derived(Object.entries(metadata || {}));
let triggerEl: HTMLElement | undefined = $state();
let tooltipEl: HTMLDivElement | undefined = $state();
let show = $state(false);
let tooltipStyle = $state("");
function positionTooltip() {
if (!triggerEl || !tooltipEl) return;
const triggerRect = triggerEl.getBoundingClientRect();
const tooltipRect = tooltipEl.getBoundingClientRect();
const margin = 8;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = triggerRect.left;
let top = triggerRect.bottom + margin;
// Keep tooltip within horizontal viewport bounds
if (left + tooltipRect.width > viewportWidth - margin) {
left = triggerRect.right - tooltipRect.width;
}
if (left < margin) {
left = margin;
}
// Flip above trigger if it would overflow the bottom
if (top + tooltipRect.height > viewportHeight - margin) {
top = triggerRect.top - tooltipRect.height - margin;
}
tooltipStyle = `left: ${left}px; top: ${top}px; max-width: calc(100vw - ${margin * 2}px);`;
}
function onEnter() {
show = true;
requestAnimationFrame(positionTooltip);
}
function onLeave() {
show = false;
}
</script>
<span
bind:this={triggerEl}
onmouseenter={onEnter}
onmouseleave={onLeave}
onfocus={onEnter}
onblur={onLeave}
class="inline-flex"
role="button"
tabindex="0"
aria-label="Show metadata"
>
{#if entries.length > 0}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{@render children()}
</Tooltip.Trigger>
<Tooltip.Content class="min-w-[12rem] max-w-[24rem] normal-case">
<table class="w-full text-left">
<tbody>
{#each entries as [key, value]}
<tr class="border-b border-white/10 last:border-0">
<td class="py-1 pr-3 font-medium whitespace-nowrap text-primary">{key}</td>
<td class="py-1 break-all">{value}</td>
</tr>
{/each}
</tbody>
</table>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{:else}
{@render children()}
</span>
{#if show && entries.length > 0}
<div
bind:this={tooltipEl}
style={tooltipStyle}
class="fixed px-3 py-2 bg-gray-900 text-white text-sm rounded-md z-50 normal-case min-w-[12rem] max-w-[24rem] shadow-lg whitespace-normal"
>
<table class="w-full text-left">
<tbody>
{#each entries as [key, value]}
<tr class="border-b border-white/10 last:border-0">
<td class="py-1 pr-3 font-medium whitespace-nowrap text-primary">{key}</td>
<td class="py-1 break-all">{value}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
@@ -2,6 +2,7 @@
import { onMount } from "svelte";
import { Chart, registerables } from "chart.js";
import { isDarkMode } from "../stores/theme";
import * as Card from "$lib/components/ui/card/index.js";
Chart.register(...registerables);
@@ -143,6 +144,8 @@
});
</script>
<div class="bg-card text-card-foreground h-[300px] rounded-xl border p-4 shadow-sm">
<canvas bind:this={canvas}></canvas>
</div>
<Card.Root class="h-[300px] py-0">
<Card.Content class="h-full p-4">
<canvas bind:this={canvas}></canvas>
</Card.Content>
</Card.Root>
@@ -135,7 +135,7 @@
<div
role="separator"
tabindex="0"
class="{handleClass} bg-primary hover:bg-success transition-colors rounded flex-shrink-0"
class="{handleClass} bg-primary hover:bg-success transition-colors rounded-md flex-shrink-0"
onmousedown={handleMouseDown}
ontouchstart={handleTouchStart}
onkeydown={handleKeyDown}
@@ -157,7 +157,7 @@
</div>
{:else}
<!-- File upload / Result display area -->
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-background border border-border rounded">
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-background border border-border rounded-md">
{#if isTranscribing}
<div class="text-center text-muted-foreground">
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
@@ -185,7 +185,7 @@
{/if}
</Button>
</div>
<div class="flex-1 overflow-auto p-3 rounded border border-border bg-background whitespace-pre-wrap">
<div class="flex-1 overflow-auto p-3 rounded-md border border-border bg-background whitespace-pre-wrap">
{transcriptionResult}
</div>
</div>
@@ -12,6 +12,8 @@
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";
import * as Select from "$lib/components/ui/select/index.js";
import { X } from "@lucide/svelte";
const selectedModelStore = persistentStore<string>("playground-selected-model", "");
const systemPromptStore = persistentStore<string>("playground-system-prompt", "");
@@ -322,16 +324,18 @@
<div class="bg-muted/40 mb-4 shrink-0 rounded-lg border p-4">
<div class="mb-4">
<Label class="mb-1" for="endpoint">Endpoint</Label>
<select
id="endpoint"
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}
disabled={isStreaming}
<Select.Root
type="single"
value={$endpointStore}
onValueChange={(v) => v && endpointStore.set(v as Endpoint)}
>
<option value="v1/chat/completions">/v1/chat/completions</option>
<option value="v1/messages">/v1/messages</option>
<option value="v1/responses">/v1/responses</option>
</select>
<Select.Trigger class="w-full">/{$endpointStore}</Select.Trigger>
<Select.Content>
<Select.Item value="v1/chat/completions">/v1/chat/completions</Select.Item>
<Select.Item value="v1/messages">/v1/messages</Select.Item>
<Select.Item value="v1/responses">/v1/responses</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="mb-4">
<Label class="mb-1" for="system-prompt">System Prompt</Label>
@@ -415,15 +419,17 @@
<img
src={imageUrl}
alt="Attached image {idx + 1}"
class="h-20 w-20 rounded border object-cover"
class="h-20 w-20 rounded-md border object-cover"
/>
<button
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"
<Button
variant="destructive"
size="icon-sm"
class="absolute -right-2 -top-2 h-6 w-6 rounded-full opacity-0 transition-opacity group-hover:opacity-100"
onclick={() => removeImage(idx)}
title="Remove image"
>
×
</button>
<X class="size-3" />
</Button>
</div>
{/each}
</div>
@@ -431,7 +437,7 @@
<!-- Error message -->
{#if imageError}
<div class="bg-destructive/10 text-destructive mb-2 rounded p-2 text-sm">
<div class="bg-destructive/10 text-destructive mb-2 rounded-md p-2 text-sm">
{imageError}
</div>
{/if}
@@ -169,7 +169,7 @@
>
{#if role === "assistant"}
{#if reasoning_content || isReasoning}
<div class="mb-3 overflow-hidden rounded border">
<div class="mb-3 overflow-hidden rounded-md border">
<button
class="bg-muted/50 hover:bg-muted flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors"
onclick={() => showReasoning = !showReasoning}
@@ -203,12 +203,12 @@
{#each imageUrls as imageUrl, idx (idx)}
<button
onclick={() => openModal(imageUrl)}
class="cursor-pointer rounded border transition-opacity hover:opacity-80"
class="cursor-pointer rounded-md border transition-opacity hover:opacity-80"
>
<img
src={imageUrl}
alt="Image {idx + 1}"
class="max-h-64 rounded"
class="max-h-64 rounded-md"
/>
</button>
{/each}
@@ -277,12 +277,12 @@
{#each imageUrls as imageUrl, idx (idx)}
<button
onclick={() => openModal(imageUrl)}
class="cursor-pointer rounded border border-white/20 transition-opacity hover:opacity-80"
class="cursor-pointer rounded-md border border-white/20 transition-opacity hover:opacity-80"
>
<img
src={imageUrl}
alt="Image {idx + 1}"
class="max-w-[200px] rounded"
class="max-w-[200px] rounded-md"
/>
</button>
{/each}
@@ -322,7 +322,7 @@
<img
src={modalImageUrl}
alt=""
class="max-w-full max-h-full rounded pointer-events-none"
class="max-w-full max-h-full rounded-md pointer-events-none"
/>
</div>
{/if}
@@ -3,6 +3,9 @@
import { persistentStore } from "../../stores/persistent";
import { streamChatCompletion } from "../../lib/chatApi";
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 { X } from "@lucide/svelte";
type Status = "waiting" | "streaming" | "done" | "error";
type Phase = "waiting" | "loading" | "reasoning" | "content";
@@ -389,22 +392,23 @@
<div class="text-xs font-medium text-muted-foreground mb-1">
Models <span class="text-[10px] font-normal">— click to queue (add the same model more than once to test parallel requests)</span>
</div>
<div class="flex-1 border border-border rounded overflow-y-auto min-h-0">
<div class="flex-1 border border-border rounded-md overflow-y-auto min-h-0">
{#if !hasModels}
<div class="p-3 text-sm text-muted-foreground text-center">No models configured.</div>
{:else}
<ul class="divide-y divide-gray-100 dark:divide-white/5">
{#each availableModels as m (m.id)}
<li>
<button
class="w-full text-left px-2 py-1.5 text-sm hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
<Button
variant="ghost"
class="w-full justify-start px-2 py-1.5 text-sm h-auto font-normal"
onclick={() => addModel(m.id)}
disabled={isRunning}
title="Add {m.id}"
>
<span class="text-primary" aria-hidden="true">+</span>
<span class="truncate flex-1">{m.id}</span>
</button>
</Button>
</li>
{/each}
</ul>
@@ -416,27 +420,29 @@
<div class="flex flex-col gap-2 border-t border-border pt-3">
<div class="flex items-center justify-between">
<label for="concurrency-prompt" class="text-xs font-medium text-muted-foreground">Prompt</label>
<button
class="text-[10px] text-muted-foreground hover:text-foreground underline"
<Button
variant="link"
size="sm"
class="h-auto p-0 text-[10px]"
onclick={resetDefaults}
disabled={isRunning}
>
reset defaults
</button>
</Button>
</div>
<textarea
<Textarea
id="concurrency-prompt"
class="w-full px-2 py-1.5 text-sm rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows="3"
class="resize-none text-sm"
rows={3}
bind:value={$promptStore}
disabled={isRunning}
></textarea>
></Textarea>
<label for="concurrency-max-tokens" class="text-xs font-medium text-muted-foreground">max_tokens</label>
<input
<Input
id="concurrency-max-tokens"
type="number"
min="1"
class="w-full px-2 py-1.5 text-sm rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
class="h-8 text-sm"
bind:value={$maxTokensStore}
disabled={isRunning}
/>
@@ -463,9 +469,9 @@
</div>
{:else}
<!-- Gantt-style timeline -->
<div class="mb-3 border border-border rounded">
<div class="mb-3 border border-border rounded-md">
<button
class="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent transition-colors {$timelineCollapsedStore ? 'rounded' : 'rounded-t border-b border-border'}"
class="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent transition-colors {$timelineCollapsedStore ? 'rounded-md' : 'rounded-t border-b border-border'}"
onclick={() => timelineCollapsedStore.update((v) => !v)}
aria-expanded={!$timelineCollapsedStore}
>
@@ -574,7 +580,7 @@
{@const run = runs[entry.id]}
{@const status = run?.status ?? "waiting"}
<div
class="border rounded flex flex-col min-h-0 transition-colors {dragOverIndex === i && dragIndex !== i
class="border rounded-md flex flex-col min-h-0 transition-colors {dragOverIndex === i && dragIndex !== i
? 'border-primary ring-2 ring-primary/40'
: 'border-border'} {dragIndex === i ? 'opacity-40' : ''}"
style="height: 280px;"
@@ -600,19 +606,21 @@
{run ? formatElapsed(run.elapsedMs) : "—"}
</span>
<span class="status text-[10px] {statusBadgeClass(status)}">{status}</span>
<button
class="w-5 h-5 flex items-center justify-center text-muted-foreground hover:text-red-500 transition-colors rounded disabled:opacity-30 disabled:cursor-not-allowed"
<Button
variant="ghost"
size="icon-sm"
class="h-5 w-5 text-muted-foreground hover:text-red-500"
onclick={() => removeEntry(entry.id)}
disabled={isRunning}
aria-label="Remove"
tabindex="-1"
tabindex={-1}
>
×
</button>
<X class="size-3" />
</Button>
</div>
<div class="flex-1 min-h-0 overflow-y-auto font-mono text-xs px-2 py-1.5">
{#if run?.loadingText}
<div class="bg-secondary/40 dark:bg-white/5 text-muted-foreground rounded px-2 py-1 mb-2 whitespace-pre-wrap">{run.loadingText.trim()}</div>
<div class="bg-secondary/40 dark:bg-white/5 text-muted-foreground rounded-md px-2 py-1 mb-2 whitespace-pre-wrap">{run.loadingText.trim()}</div>
{/if}
{#if run?.reasoningContent}
<div class="text-purple-700 dark:text-purple-300 whitespace-pre-wrap">{run.reasoningContent}</div>
@@ -8,6 +8,10 @@
import ExpandableTextarea from "./ExpandableTextarea.svelte";
import type { ImageApiMode, SdApiLora, SdApiLoraRef } from "../../lib/types";
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 * as Select from "$lib/components/ui/select/index.js";
import { Download, X } from "@lucide/svelte";
const selectedModelStore = persistentStore<string>("playground-image-model", "");
const selectedSizeStore = persistentStore<string>("playground-image-size", "1024x1024");
@@ -62,18 +66,6 @@
}
}
function addLora(event: Event) {
const select = event.target as HTMLSelectElement;
const path = select.value;
if (!path) return;
const lora = availableLoras.find((l) => l.path === path);
if (lora && !selectedLoras.some((l) => l.path === path)) {
selectedLoras = [...selectedLoras, { path: lora.path, multiplier: 1.0 }];
}
select.value = "";
}
function removeLora(path: string) {
selectedLoras = selectedLoras.filter((l) => l.path !== path);
}
@@ -196,35 +188,46 @@
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an image model..." disabled={isGenerating} capabilities={["image_generation", "image_to_image"]} matchAny={true} />
<select
class="px-3 py-2 rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$apiModeStore}
disabled={isGenerating}
<Select.Root
type="single"
value={$apiModeStore}
onValueChange={(v) => v && apiModeStore.set(v as ImageApiMode)}
>
<option value="openai">OpenAI</option>
<option value="sdapi">SDAPI</option>
</select>
<Select.Trigger class="h-9 w-32">{$apiModeStore}</Select.Trigger>
<Select.Content>
<Select.Item value="openai">OpenAI</Select.Item>
<Select.Item value="sdapi">SDAPI</Select.Item>
</Select.Content>
</Select.Root>
<select
class="px-3 py-2 rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$selectedSizeStore}
disabled={isGenerating}
<Select.Root
type="single"
value={$selectedSizeStore}
onValueChange={(v) => v && selectedSizeStore.set(v)}
>
<optgroup label="Square">
<option value="512x512">512x512</option>
<option value="1024x1024">1024x1024</option>
</optgroup>
<optgroup label="Landscape">
<option value="1024x768">1024x768 (4:3)</option>
<option value="1280x720">1280x720 (16:9)</option>
<option value="1792x1024">1792x1024 (SDXL)</option>
</optgroup>
<optgroup label="Portrait">
<option value="768x1024">768x1024 (3:4)</option>
<option value="720x1280">720x1280 (9:16)</option>
<option value="1024x1792">1024x1792 (SDXL)</option>
</optgroup>
</select>
<Select.Trigger class="h-9 w-40">{$selectedSizeStore}</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Square</Select.Label>
<Select.Item value="512x512">512x512</Select.Item>
<Select.Item value="1024x1024">1024x1024</Select.Item>
</Select.Group>
<Select.Separator />
<Select.Group>
<Select.Label>Landscape</Select.Label>
<Select.Item value="1024x768">1024x768 (4:3)</Select.Item>
<Select.Item value="1280x720">1280x720 (16:9)</Select.Item>
<Select.Item value="1792x1024">1792x1024 (SDXL)</Select.Item>
</Select.Group>
<Select.Separator />
<Select.Group>
<Select.Label>Portrait</Select.Label>
<Select.Item value="768x1024">768x1024 (3:4)</Select.Item>
<Select.Item value="720x1280">720x1280 (9:16)</Select.Item>
<Select.Item value="1024x1792">1024x1792 (SDXL)</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
{#if isSdapi}
<Button variant="outline" onclick={() => showSettings = !showSettings}>
@@ -235,13 +238,13 @@
<!-- SDAPI Settings Panel -->
{#if isSdapi && showSettings}
<div class="shrink-0 mb-4 p-4 rounded border border-border bg-background">
<div class="shrink-0 mb-4 p-4 rounded-md border border-border bg-background">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">Steps</span>
<input
<Input
type="number"
class="px-2 py-1 rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
class="h-8"
bind:value={$sdStepsStore}
min="1"
max="150"
@@ -249,9 +252,9 @@
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">CFG Scale</span>
<input
<Input
type="number"
class="px-2 py-1 rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
class="h-8"
bind:value={$sdCfgScaleStore}
min="1"
max="30"
@@ -260,18 +263,18 @@
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">Seed (-1 = random)</span>
<input
<Input
type="number"
class="px-2 py-1 rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
class="h-8"
bind:value={$sdSeedStore}
min="-1"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">Batch Size</span>
<input
<Input
type="number"
class="px-2 py-1 rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
class="h-8"
bind:value={$sdBatchSizeStore}
min="1"
max="8"
@@ -279,49 +282,56 @@
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">Sampler</span>
<select
class="px-2 py-1 rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$sdSamplerStore}
<Select.Root
type="single"
value={$sdSamplerStore}
onValueChange={(v) => sdSamplerStore.set(v ?? "")}
>
<option value="">Default</option>
<option value="euler_a">euler_a</option>
<option value="euler">euler</option>
<option value="heun">heun</option>
<option value="dpm2">dpm2</option>
<option value="dpmpp2s_a">dpmpp2s_a</option>
<option value="dpmpp2m">dpmpp2m</option>
<option value="dpmpp2mv2">dpmpp2mv2</option>
<option value="ipndm">ipndm</option>
<option value="ipndm_v">ipndm_v</option>
<option value="lcm">lcm</option>
<option value="ddim_trailing">ddim_trailing</option>
<option value="tcd">tcd</option>
</select>
<Select.Trigger class="h-8">{$sdSamplerStore || "Default"}</Select.Trigger>
<Select.Content>
<Select.Item value="">Default</Select.Item>
<Select.Item value="euler_a">euler_a</Select.Item>
<Select.Item value="euler">euler</Select.Item>
<Select.Item value="heun">heun</Select.Item>
<Select.Item value="dpm2">dpm2</Select.Item>
<Select.Item value="dpmpp2s_a">dpmpp2s_a</Select.Item>
<Select.Item value="dpmpp2m">dpmpp2m</Select.Item>
<Select.Item value="dpmpp2mv2">dpmpp2mv2</Select.Item>
<Select.Item value="ipndm">ipndm</Select.Item>
<Select.Item value="ipndm_v">ipndm_v</Select.Item>
<Select.Item value="lcm">lcm</Select.Item>
<Select.Item value="ddim_trailing">ddim_trailing</Select.Item>
<Select.Item value="tcd">tcd</Select.Item>
</Select.Content>
</Select.Root>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">Scheduler</span>
<select
class="px-2 py-1 rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$sdSchedulerStore}
<Select.Root
type="single"
value={$sdSchedulerStore}
onValueChange={(v) => sdSchedulerStore.set(v ?? "")}
>
<option value="">Auto for model</option>
<option value="discrete">discrete</option>
<option value="karras">karras</option>
<option value="exponential">exponential</option>
<option value="ays">ays</option>
<option value="gits">gits</option>
</select>
<Select.Trigger class="h-8">{$sdSchedulerStore || "Auto for model"}</Select.Trigger>
<Select.Content>
<Select.Item value="">Auto for model</Select.Item>
<Select.Item value="discrete">discrete</Select.Item>
<Select.Item value="karras">karras</Select.Item>
<Select.Item value="exponential">exponential</Select.Item>
<Select.Item value="ays">ays</Select.Item>
<Select.Item value="gits">gits</Select.Item>
</Select.Content>
</Select.Root>
</label>
</div>
<label class="flex flex-col gap-1 mb-3">
<span class="text-xs text-muted-foreground">Negative Prompt</span>
<textarea
class="px-2 py-1 rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-y text-sm"
<Textarea
bind:value={$sdNegativePromptStore}
rows="2"
rows={2}
placeholder="Elements to avoid..."
></textarea>
></Textarea>
</label>
<!-- LoRA Selection -->
@@ -337,15 +347,25 @@
{isLoadingLoras ? "Loading..." : lorasLoaded ? "Reload LoRAs" : "Load LoRAs"}
</Button>
{#if lorasLoaded && availableLoras.length > 0}
<select
class="flex-1 px-2 py-1.5 text-sm rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
onchange={addLora}
<Select.Root
type="single"
value=""
onValueChange={(v) => {
if (v) {
const lora = availableLoras.find((l) => l.path === v);
if (lora && !selectedLoras.some((s) => s.path === v)) {
selectedLoras = [...selectedLoras, { path: lora.path, multiplier: 1.0 }];
}
}
}}
>
<option value="">Add a LoRA...</option>
{#each availableLoras.filter((l) => !selectedLoras.some((s) => s.path === l.path)) as lora}
<option value={lora.path}>{lora.name}</option>
{/each}
</select>
<Select.Trigger class="h-8 flex-1">Add a LoRA...</Select.Trigger>
<Select.Content>
{#each availableLoras.filter((l) => !selectedLoras.some((s) => s.path === l.path)) as lora (lora.path)}
<Select.Item value={lora.path}>{lora.name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
</div>
{#if loraError}
@@ -359,22 +379,24 @@
{#each selectedLoras as lora}
<div class="flex items-center gap-2 text-sm">
<span class="flex-1 truncate">{getLoraName(lora.path)}</span>
<input
<Input
type="number"
class="w-20 px-1.5 py-1 text-xs rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary"
class="h-7 w-20 text-xs"
value={lora.multiplier}
oninput={(e) => updateLoraMultiplier(lora.path, parseFloat((e.target as HTMLInputElement).value) || 1)}
min="0"
max="2"
step="0.1"
/>
<button
class="px-1.5 py-0.5 text-xs rounded border border-border hover:bg-red-500 hover:text-white hover:border-red-500 transition-colors"
<Button
variant="outline"
size="sm"
class="h-7 px-1.5 text-xs hover:bg-destructive hover:text-destructive-foreground"
onclick={() => removeLora(lora.path)}
aria-label="Remove LoRA"
>
x
</button>
<X class="size-3" />
</Button>
</div>
{/each}
</div>
@@ -390,7 +412,7 @@
</div>
{:else}
<!-- Image display area -->
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-background border border-border rounded">
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-background border border-border rounded-md">
{#if isGenerating}
<div class="text-center text-muted-foreground">
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
@@ -402,7 +424,6 @@
<p class="text-sm mt-1">{error}</p>
</div>
{:else if generatedImages.length > 1}
<!-- Grid for multiple images (batch) -->
<div class="grid grid-cols-2 gap-2 p-2 w-full h-full overflow-auto">
{#each generatedImages as img, i}
<div class="relative flex items-center justify-center">
@@ -417,15 +438,15 @@
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
/>
</button>
<button
class="absolute bottom-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
<Button
variant="secondary"
size="icon"
class="absolute bottom-2 right-2 h-8 w-8 bg-black/60 hover:bg-black/80 text-white"
onclick={(e) => { e.stopPropagation(); downloadImage(i); }}
aria-label="Download image"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
</button>
<Download class="size-4" />
</Button>
</div>
{/each}
</div>
@@ -442,15 +463,15 @@
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
/>
</button>
<button
class="absolute bottom-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
<Button
variant="secondary"
size="icon"
class="absolute bottom-2 right-2 bg-black/60 hover:bg-black/80 text-white"
onclick={(e) => { e.stopPropagation(); downloadImage(0); }}
aria-label="Download image"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
</button>
<Download class="size-5" />
</Button>
</div>
{:else}
<div class="text-center text-muted-foreground">
@@ -505,13 +526,15 @@
aria-modal="true"
tabindex="-1"
>
<button
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
<Button
variant="secondary"
size="icon"
class="absolute top-4 right-4 bg-black/60 hover:bg-black/80 text-white"
onclick={() => closeFullscreen()}
aria-label="Close fullscreen"
>
×
</button>
<X class="size-6" />
</Button>
<img
src={generatedImages[fullscreenIndex]}
alt="AI generated content"
@@ -1,6 +1,7 @@
<script lang="ts">
import { models } from "../../stores/api";
import { groupModels } from "../../lib/modelUtils";
import * as Select from "$lib/components/ui/select/index.js";
interface Props {
value: string;
@@ -18,42 +19,51 @@
</script>
{#if hasModels}
<select
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
<Select.Root
type="single"
{value}
onValueChange={(v) => v !== undefined && (value = v)}
{disabled}
>
<option value="">{placeholder}</option>
{#if hasMatching}
<optgroup label="Matching Capabilities">
{#each grouped.localMatching as model (model.id)}
<option value={model.id}>{model.id}</option>
{#if model.aliases}
{#each model.aliases as alias (alias)}
<option value={alias}> {alias}</option>
{/each}
{/if}
{/each}
</optgroup>
{/if}
{#if grouped.local.length > 0}
<optgroup label="Local">
{#each grouped.local as model (model.id)}
<option value={model.id}>{model.id}</option>
{#if model.aliases}
{#each model.aliases as alias (alias)}
<option value={alias}> {alias}</option>
{/each}
{/if}
{/each}
</optgroup>
{/if}
{#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
<optgroup label="Peer: {peerId}">
{#each peerModels as model (model.id)}
<option value={model.id}>{model.id}</option>
{/each}
</optgroup>
{/each}
</select>
<Select.Trigger class="min-w-0 flex-1 basis-48">{value || placeholder}</Select.Trigger>
<Select.Content>
<Select.Item value="">{placeholder}</Select.Item>
{#if hasMatching}
<Select.Group>
<Select.Label>Matching Capabilities</Select.Label>
{#each grouped.localMatching as model (model.id)}
<Select.Item value={model.id}>{model.id}</Select.Item>
{#if model.aliases}
{#each model.aliases as alias (alias)}
<Select.Item value={alias}> {alias}</Select.Item>
{/each}
{/if}
{/each}
</Select.Group>
<Select.Separator />
{/if}
{#if grouped.local.length > 0}
<Select.Group>
<Select.Label>Local</Select.Label>
{#each grouped.local as model (model.id)}
<Select.Item value={model.id}>{model.id}</Select.Item>
{#if model.aliases}
{#each model.aliases as alias (alias)}
<Select.Item value={alias}> {alias}</Select.Item>
{/each}
{/if}
{/each}
</Select.Group>
<Select.Separator />
{/if}
{#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
<Select.Group>
<Select.Label>Peer: {peerId}</Select.Label>
{#each peerModels as model (model.id)}
<Select.Item value={model.id}>{model.id}</Select.Item>
{/each}
</Select.Group>
{/each}
</Select.Content>
</Select.Root>
{/if}
@@ -335,9 +335,9 @@
{#each displayRows as { row, i } (i)}
<tr class="border-b last:border-0">
<td class="px-3 py-1.5">
<input
<Input
type="text"
class="focus:ring-ring w-full rounded bg-transparent px-1 py-0.5 outline-none focus:ring-1"
class="border-0 focus-visible:ring-1 h-7 px-1 py-0.5 bg-transparent"
placeholder={i === rows.length - 1 ? "Add document..." : "Document text..."}
value={row.doc}
oninput={(e) => updateDoc(i, (e.target as HTMLInputElement).value)}
@@ -353,15 +353,17 @@
{/if}
</td>
<td class="px-2 py-1.5 text-center">
<button
class="text-muted-foreground hover:text-destructive flex h-7 w-7 items-center justify-center rounded transition-colors disabled:cursor-not-allowed disabled:opacity-30"
<Button
variant="ghost"
size="icon-sm"
class="h-7 w-7 text-muted-foreground hover:text-destructive"
onclick={() => deleteRow(i)}
disabled={rows.length <= 1}
tabindex="-1"
tabindex={-1}
aria-label="Remove row"
>
×
</button>
</Button>
</td>
</tr>
{/each}
@@ -6,6 +6,7 @@
import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { RefreshCw, Download } from "@lucide/svelte";
const selectedModelStore = persistentStore<string>("playground-speech-model", "");
@@ -108,8 +109,7 @@
}
}
function handleVoiceChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
function handleVoiceChange(value: string) {
if (value === "(refresh)") {
refreshVoices();
} else {
@@ -210,17 +210,19 @@
<div class="shrink-0 flex gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a speech model..." disabled={isGenerating} capabilities={["audio_speech"]} />
<div class="flex gap-2">
<select
class="shrink-0 px-3 py-2 rounded border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
<Select.Root
type="single"
value={$selectedVoiceStore}
onchange={handleVoiceChange}
disabled={isGenerating || isLoadingVoices || !$selectedModelStore}
onValueChange={(v) => v && handleVoiceChange(v)}
>
{#each availableVoices as voice (voice)}
<option value={voice}>{voice}</option>
{/each}
<option value="(refresh)">(refresh)</option>
</select>
<Select.Trigger class="h-9 w-40">{$selectedVoiceStore}</Select.Trigger>
<Select.Content>
{#each availableVoices as voice (voice)}
<Select.Item value={voice}>{voice}</Select.Item>
{/each}
<Select.Item value="(refresh)">(refresh)</Select.Item>
</Select.Content>
</Select.Root>
{#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]}
<Button
variant="outline"
@@ -243,7 +245,7 @@
</div>
{:else}
<!-- Audio display area -->
<div class="shrink-0 mb-4 bg-background border border-border rounded p-4 md:p-6">
<div class="shrink-0 mb-4 bg-background border border-border rounded-md p-4 md:p-6">
{#if isGenerating}
<div class="flex items-center justify-center text-muted-foreground py-8">
<div class="text-center">
+1 -1
View File
@@ -5,7 +5,7 @@
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--radius: 0;
/* shadcn base palette (zinc) */
--background: oklch(1 0 0);
@@ -2,7 +2,7 @@
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
base: "h-5 gap-1 rounded-none border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+1 -1
View File
@@ -271,7 +271,7 @@
type="checkbox"
checked={isColumnVisible(key)}
onchange={() => toggleColumn(key)}
class="accent-primary rounded"
class="accent-primary rounded-none"
/>
{col.label}
</label>
+5 -5
View File
@@ -303,7 +303,7 @@
<a href="/" class="text-primary hover:underline">Back to Playground</a>
</Card.Root>
{:else}
<Card.Root class="shrink-0 gap-0 overflow-hidden rounded-none py-0">
<Card.Root class="shrink-0 gap-0 overflow-hidden py-0">
<Card.Header class="shrink-0 gap-2 border-b px-4 py-3">
<div class="flex items-center gap-2">
<span class={`size-2.5 shrink-0 rounded-full ${statusDotColor(model)}`}></span>
@@ -352,9 +352,9 @@
<Tabs.Root value="activity" class="min-h-0 flex-1">
<Tabs.List>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="logs">Logs</Tabs.Trigger>
<Tabs.Trigger value="details">Details</Tabs.Trigger>
<Tabs.Trigger value="activity" class="data-active:bg-primary/15 data-active:text-primary border border-b-2 data-active:border-primary rounded-none shadow-none">Activity</Tabs.Trigger>
<Tabs.Trigger value="logs" class="data-active:bg-primary/15 data-active:text-primary border border-b-2 data-active:border-primary rounded-none shadow-none">Logs</Tabs.Trigger>
<Tabs.Trigger value="details" class="data-active:bg-primary/15 data-active:text-primary border border-b-2 data-active:border-primary rounded-none shadow-none">Details</Tabs.Trigger>
</Tabs.List>
<!-- Activity -->
@@ -427,7 +427,7 @@
type="checkbox"
checked={isColumnVisible(key)}
onchange={() => toggleColumn(key)}
class="accent-primary rounded"
class="accent-primary rounded-none"
/>
{col.label}
</label>
+6 -1
View File
@@ -4,6 +4,7 @@
import { persistentStore } from "../stores/persistent";
import type { SysStat, GpuStat } from "../lib/types";
import PerformanceChart from "../components/PerformanceChart.svelte";
import * as Card from "$lib/components/ui/card/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { RefreshCw } from "@lucide/svelte";
@@ -395,7 +396,11 @@
<section class="space-y-4">
<h3 class="text-lg font-medium text-foreground">GPU</h3>
{#if !hasGpuData}
<p class="text-muted-foreground bg-card rounded-xl border p-4 shadow-sm">No GPU data available</p>
<Card.Root class="py-0">
<Card.Content class="p-4">
<p class="text-muted-foreground">No GPU data available</p>
</Card.Content>
</Card.Root>
{:else}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<PerformanceChart