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