ui: migrate Models panel and Playground to shadcn
- ModelsPanel uses Card, Button, Badge and a dropdown menu for actions - Playground uses shadcn Tabs for the switcher while keeping every interface mounted to preserve state Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ArrowLeftRight, Eye, EyeOff, CircleArrowDown, MoreVertical } from "@lucide/svelte";
|
||||||
import { models, loadModel, unloadAllModels, unloadSingleModel } from "../stores/api";
|
import { models, loadModel, unloadAllModels, unloadSingleModel } from "../stores/api";
|
||||||
import { isNarrow } from "../stores/theme";
|
import { isNarrow } from "../stores/theme";
|
||||||
import { persistentStore } from "../stores/persistent";
|
import { persistentStore } from "../stores/persistent";
|
||||||
import type { Model } from "../lib/types";
|
import type { Model } from "../lib/types";
|
||||||
|
import * as Card from "$lib/components/ui/card/index.js";
|
||||||
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||||
|
|
||||||
let isUnloading = $state(false);
|
let isUnloading = $state(false);
|
||||||
let menuOpen = $state(false);
|
|
||||||
let pendingLoads = $state<Record<string, boolean>>({});
|
let pendingLoads = $state<Record<string, boolean>>({});
|
||||||
const loadControllers = new Map<string, AbortController>();
|
const loadControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
@@ -72,138 +76,110 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getModelDisplay(model: Model): string {
|
function getModelDisplay(model: Model): string {
|
||||||
return $showIdorNameStore === "id" ? model.id : (model.name || model.id);
|
return $showIdorNameStore === "id" ? model.id : model.name || model.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClasses(state: string): string {
|
||||||
|
if (state === "ready") return "border-success/30 bg-success/10 text-success";
|
||||||
|
if (state === "starting" || state === "stopping" || state === "queued")
|
||||||
|
return "border-warning/30 bg-warning/10 text-warning";
|
||||||
|
return "border-border bg-muted text-muted-foreground";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card h-full flex flex-col">
|
<Card.Root class="flex h-full flex-col gap-0 overflow-hidden py-0">
|
||||||
<div class="shrink-0">
|
<Card.Header class="shrink-0 gap-2 border-b px-4 py-3 [.border-b]:pb-3">
|
||||||
<div class="flex justify-between items-baseline">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<h2 class={$isNarrow ? "text-xl" : ""}>Models</h2>
|
<Card.Title class="text-lg">Models</Card.Title>
|
||||||
|
|
||||||
{#if $isNarrow}
|
{#if $isNarrow}
|
||||||
<div class="relative">
|
<DropdownMenu.Root>
|
||||||
<button class="btn text-base flex items-center gap-2 py-1" onclick={() => (menuOpen = !menuOpen)} aria-label="Toggle menu">
|
<DropdownMenu.Trigger>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
{#snippet child({ props })}
|
||||||
<path fill-rule="evenodd" d="M3 6.75A.75.75 0 0 1 3.75 6h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 6.75ZM3 12a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 12Zm0 5.25a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" />
|
<Button {...props} variant="outline" size="icon" aria-label="Model options">
|
||||||
</svg>
|
<MoreVertical />
|
||||||
</button>
|
</Button>
|
||||||
{#if menuOpen}
|
{/snippet}
|
||||||
<div class="absolute right-0 mt-2 w-48 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-20">
|
</DropdownMenu.Trigger>
|
||||||
<button
|
<DropdownMenu.Content align="end">
|
||||||
class="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
|
<DropdownMenu.Item onSelect={toggleIdorName}>
|
||||||
onclick={() => { toggleIdorName(); menuOpen = false; }}
|
<ArrowLeftRight />
|
||||||
>
|
{$showIdorNameStore === "id" ? "Show Name" : "Show ID"}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
</DropdownMenu.Item>
|
||||||
<path fill-rule="evenodd" d="M15.97 2.47a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 1 1-1.06-1.06l3.22-3.22H7.5a.75.75 0 0 1 0-1.5h11.69l-3.22-3.22a.75.75 0 0 1 0-1.06Zm-7.94 9a.75.75 0 0 1 0 1.06l-3.22 3.22H16.5a.75.75 0 0 1 0 1.5H4.81l3.22 3.22a.75.75 0 1 1-1.06 1.06l-4.5-4.5a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
<DropdownMenu.Item onSelect={toggleShowUnlisted}>
|
||||||
</svg>
|
{#if $showUnlistedStore}<EyeOff />{:else}<Eye />{/if}
|
||||||
{$showIdorNameStore === "id" ? "Show Name" : "Show ID"}
|
{$showUnlistedStore ? "Hide Unlisted" : "Show Unlisted"}
|
||||||
</button>
|
</DropdownMenu.Item>
|
||||||
<button
|
<DropdownMenu.Separator />
|
||||||
class="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
|
<DropdownMenu.Item onSelect={handleUnloadAllModels} disabled={isUnloading}>
|
||||||
onclick={() => { toggleShowUnlisted(); menuOpen = false; }}
|
<CircleArrowDown />
|
||||||
>
|
{isUnloading ? "Unloading..." : "Unload All"}
|
||||||
{#if $showUnlistedStore}
|
</DropdownMenu.Item>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
</DropdownMenu.Content>
|
||||||
<path d="M3.53 2.47a.75.75 0 0 0-1.06 1.06l18 18a.75.75 0 1 0 1.06-1.06l-18-18ZM22.676 12.553a11.249 11.249 0 0 1-2.631 4.31l-3.099-3.099a5.25 5.25 0 0 0-6.71-6.71L7.759 4.577a11.217 11.217 0 0 1 4.242-.827c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113Z" />
|
</DropdownMenu.Root>
|
||||||
<path d="M15.75 12c0 .18-.013.357-.037.53l-4.244-4.243A3.75 3.75 0 0 1 15.75 12ZM12.53 15.713l-4.243-4.244a3.75 3.75 0 0 0 4.244 4.243Z" />
|
{:else}
|
||||||
<path d="M6.75 12c0-.619.107-1.213.304-1.764l-3.1-3.1a11.25 11.25 0 0 0-2.63 4.31c-.12.362-.12.752 0 1.114 1.489 4.467 5.704 7.69 10.675 7.69 1.5 0 2.933-.294 4.242-.827l-2.477-2.477A5.25 5.25 0 0 1 6.75 12Z" />
|
<div class="flex items-center gap-2">
|
||||||
</svg>
|
<Button variant="outline" size="sm" onclick={toggleIdorName}>
|
||||||
{:else}
|
<ArrowLeftRight />
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
{$showIdorNameStore === "id" ? "ID" : "Name"}
|
||||||
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
</Button>
|
||||||
<path fill-rule="evenodd" d="M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 0 1 0-1.113ZM17.25 12a5.25 5.25 0 1 1-10.5 0 5.25 5.25 0 0 1 10.5 0Z" clip-rule="evenodd" />
|
<Button variant="outline" size="sm" onclick={toggleShowUnlisted}>
|
||||||
</svg>
|
{#if $showUnlistedStore}<Eye />{:else}<EyeOff />{/if}
|
||||||
{/if}
|
unlisted
|
||||||
{$showUnlistedStore ? "Hide Unlisted" : "Show Unlisted"}
|
</Button>
|
||||||
</button>
|
<Button variant="outline" size="sm" onclick={handleUnloadAllModels} disabled={isUnloading}>
|
||||||
<button
|
<CircleArrowDown />
|
||||||
class="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
|
{isUnloading ? "Unloading..." : "Unload All"}
|
||||||
onclick={() => { handleUnloadAllModels(); menuOpen = false; }}
|
</Button>
|
||||||
disabled={isUnloading}
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
|
|
||||||
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm.53 5.47a.75.75 0 0 0-1.06 0l-3 3a.75.75 0 1 0 1.06 1.06l1.72-1.72v5.69a.75.75 0 0 0 1.5 0v-5.69l1.72 1.72a.75.75 0 1 0 1.06-1.06l-3-3Z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
{isUnloading ? "Unloading..." : "Unload All"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !$isNarrow}
|
</Card.Header>
|
||||||
<div class="flex justify-between">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button class="btn text-base flex items-center gap-2" onclick={toggleIdorName} style="line-height: 1.2">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
|
||||||
<path fill-rule="evenodd" d="M15.97 2.47a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 1 1-1.06-1.06l3.22-3.22H7.5a.75.75 0 0 1 0-1.5h11.69l-3.22-3.22a.75.75 0 0 1 0-1.06Zm-7.94 9a.75.75 0 0 1 0 1.06l-3.22 3.22H16.5a.75.75 0 0 1 0 1.5H4.81l3.22 3.22a.75.75 0 1 1-1.06 1.06l-4.5-4.5a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
{$showIdorNameStore === "id" ? "ID" : "Name"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="btn text-base flex items-center gap-2" onclick={toggleShowUnlisted} style="line-height: 1.2">
|
<Card.Content class="flex-1 overflow-y-auto p-0">
|
||||||
{#if $showUnlistedStore}
|
<table class="w-full text-sm">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
<thead class="bg-card sticky top-0 z-10">
|
||||||
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
<tr class="text-muted-foreground border-b text-left">
|
||||||
<path fill-rule="evenodd" d="M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 0 1 0-1.113ZM17.25 12a5.25 5.25 0 1 1-10.5 0 5.25 5.25 0 0 1 10.5 0Z" clip-rule="evenodd" />
|
<th class="px-4 py-2 font-medium">{$showIdorNameStore === "id" ? "Model ID" : "Name"}</th>
|
||||||
</svg>
|
<th class="px-4 py-2"></th>
|
||||||
{:else}
|
<th class="px-4 py-2 font-medium">State</th>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
|
||||||
<path d="M3.53 2.47a.75.75 0 0 0-1.06 1.06l18 18a.75.75 0 1 0 1.06-1.06l-18-18ZM22.676 12.553a11.249 11.249 0 0 1-2.631 4.31l-3.099-3.099a5.25 5.25 0 0 0-6.71-6.71L7.759 4.577a11.217 11.217 0 0 1 4.242-.827c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113Z" />
|
|
||||||
<path d="M15.75 12c0 .18-.013.357-.037.53l-4.244-4.243A3.75 3.75 0 0 1 15.75 12ZM12.53 15.713l-4.243-4.244a3.75 3.75 0 0 0 4.244 4.243Z" />
|
|
||||||
<path d="M6.75 12c0-.619.107-1.213.304-1.764l-3.1-3.1a11.25 11.25 0 0 0-2.63 4.31c-.12.362-.12.752 0 1.114 1.489 4.467 5.704 7.69 10.675 7.69 1.5 0 2.933-.294 4.242-.827l-2.477-2.477A5.25 5.25 0 0 1 6.75 12Z" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
unlisted
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button class="btn text-base flex items-center gap-2" onclick={handleUnloadAllModels} disabled={isUnloading}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
|
|
||||||
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm.53 5.47a.75.75 0 0 0-1.06 0l-3 3a.75.75 0 1 0 1.06 1.06l1.72-1.72v5.69a.75.75 0 0 0 1.5 0v-5.69l1.72 1.72a.75.75 0 1 0 1.06-1.06l-3-3Z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
{isUnloading ? "Unloading..." : "Unload All"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead class="sticky top-0 bg-card z-10">
|
|
||||||
<tr class="text-left border-b border-gray-200 dark:border-white/10 bg-surface">
|
|
||||||
<th>{$showIdorNameStore === "id" ? "Model ID" : "Name"}</th>
|
|
||||||
<th></th>
|
|
||||||
<th>State</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each filteredModels.regularModels as model (model.id)}
|
{#each filteredModels.regularModels as model (model.id)}
|
||||||
<tr class="border-b hover:bg-secondary-hover border-gray-200">
|
<tr class="hover:bg-muted/50 border-b transition-colors">
|
||||||
<td class={model.unlisted ? "text-txtsecondary" : ""}>
|
<td class="px-4 py-2 {model.unlisted ? 'text-muted-foreground' : ''}">
|
||||||
<a href="/upstream/{model.id}/" class="font-semibold" target="_blank">
|
<a href="/upstream/{model.id}/" class="font-semibold hover:underline" target="_blank">
|
||||||
{getModelDisplay(model)}
|
{getModelDisplay(model)}
|
||||||
</a>
|
</a>
|
||||||
{#if model.description}
|
{#if model.description}
|
||||||
<p class={model.unlisted ? "text-opacity-70" : ""}><em>{model.description}</em></p>
|
<p class="text-muted-foreground"><em>{model.description}</em></p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if model.aliases && model.aliases.length > 0}
|
{#if model.aliases && model.aliases.length > 0}
|
||||||
<p class="text-xs text-txtsecondary">Aliases: {model.aliases.join(", ")}</p>
|
<p class="text-muted-foreground text-xs">Aliases: {model.aliases.join(", ")}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="w-12">
|
<td class="w-12 px-4 py-2">
|
||||||
{#if model.state === "stopped" && pendingLoads[model.id]}
|
{#if model.state === "stopped" && pendingLoads[model.id]}
|
||||||
<button class="btn btn--sm" onclick={() => cancelLoad(model.id)}>Cancel</button>
|
<Button variant="outline" size="xs" onclick={() => cancelLoad(model.id)}>Cancel</Button>
|
||||||
{:else if model.state === "stopped"}
|
{:else if model.state === "stopped"}
|
||||||
<button class="btn btn--sm" onclick={() => handleLoadModel(model.id)}>Load</button>
|
<Button variant="outline" size="xs" onclick={() => handleLoadModel(model.id)}>Load</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn btn--sm" onclick={() => unloadSingleModel(model.id)} disabled={model.state !== "ready"}>Unload</button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
onclick={() => unloadSingleModel(model.id)}
|
||||||
|
disabled={model.state !== "ready"}
|
||||||
|
>
|
||||||
|
Unload
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="w-20">
|
<td class="w-24 px-4 py-2">
|
||||||
{#if model.state === "stopped" && pendingLoads[model.id]}
|
{#if model.state === "stopped" && pendingLoads[model.id]}
|
||||||
<span class="w-16 text-center status status--queued">queued</span>
|
<Badge variant="outline" class={statusClasses("queued")}>queued</Badge>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="w-16 text-center status status--{model.state}">{model.state}</span>
|
<Badge variant="outline" class={statusClasses(model.state)}>{model.state}</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -212,19 +188,19 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
{#if Object.keys(filteredModels.peerModelsByPeerId).length > 0}
|
{#if Object.keys(filteredModels.peerModelsByPeerId).length > 0}
|
||||||
<h3 class="mt-8 mb-2">Peer Models</h3>
|
<h3 class="px-4 pt-6 pb-2 text-base">Peer Models</h3>
|
||||||
{#each Object.entries(filteredModels.peerModelsByPeerId).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
|
{#each Object.entries(filteredModels.peerModelsByPeerId).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<table class="w-full">
|
<table class="w-full text-sm">
|
||||||
<thead class="sticky top-0 bg-card z-10">
|
<thead class="bg-card sticky top-0 z-10">
|
||||||
<tr class="text-left border-b border-gray-200 dark:border-white/10 bg-surface">
|
<tr class="text-muted-foreground border-b text-left">
|
||||||
<th class="font-semibold">{peerId}</th>
|
<th class="px-4 py-2 font-semibold">{peerId}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each peerModels as model (model.id)}
|
{#each peerModels as model (model.id)}
|
||||||
<tr class="border-b hover:bg-secondary-hover border-gray-200">
|
<tr class="hover:bg-muted/50 border-b transition-colors">
|
||||||
<td class="pl-8 {model.unlisted ? 'text-txtsecondary' : ''}">
|
<td class="px-4 py-2 pl-8 {model.unlisted ? 'text-muted-foreground' : ''}">
|
||||||
<span>{model.id}</span>
|
<span>{model.id}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -234,5 +210,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</Card.Content>
|
||||||
</div>
|
</Card.Root>
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
import SpeechInterface from "../components/playground/SpeechInterface.svelte";
|
import SpeechInterface from "../components/playground/SpeechInterface.svelte";
|
||||||
import RerankInterface from "../components/playground/RerankInterface.svelte";
|
import RerankInterface from "../components/playground/RerankInterface.svelte";
|
||||||
import ConcurrencyInterface from "../components/playground/ConcurrencyInterface.svelte";
|
import ConcurrencyInterface from "../components/playground/ConcurrencyInterface.svelte";
|
||||||
|
import * as Card from "$lib/components/ui/card/index.js";
|
||||||
|
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||||
|
|
||||||
type Tab = "chat" | "images" | "speech" | "audio" | "rerank" | "concurrency";
|
type Tab = "chat" | "images" | "speech" | "audio" | "rerank" | "concurrency";
|
||||||
|
|
||||||
const selectedTabStore = persistentStore<Tab>("playground-selected-tab", "chat");
|
const selectedTabStore = persistentStore<Tab>("playground-selected-tab", "chat");
|
||||||
let mobileMenuOpen = $state(false);
|
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string }[] = [
|
const tabs: { id: Tab; label: string }[] = [
|
||||||
{ id: "chat", label: "Chat" },
|
{ id: "chat", label: "Chat" },
|
||||||
@@ -20,95 +21,39 @@
|
|||||||
{ id: "rerank", label: "Rerank" },
|
{ id: "rerank", label: "Rerank" },
|
||||||
{ id: "concurrency", label: "Load Test" },
|
{ id: "concurrency", label: "Load Test" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function selectTab(tab: Tab) {
|
|
||||||
selectedTabStore.set(tab);
|
|
||||||
mobileMenuOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTabLabel(tabId: Tab): string {
|
|
||||||
return tabs.find((t) => t.id === tabId)?.label || "";
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card h-full flex flex-col">
|
<Card.Root class="flex h-full flex-col gap-0 overflow-hidden p-4">
|
||||||
<!-- Tab navigation -->
|
<!-- Tab navigation: triggers update the store; content stays mounted to preserve state -->
|
||||||
<div class="shrink-0 mb-4">
|
<div class="mb-4 shrink-0">
|
||||||
<!-- Mobile: Dropdown menu (hidden on md and up) -->
|
<Tabs.Root bind:value={() => $selectedTabStore, (v) => selectedTabStore.set(v as Tab)}>
|
||||||
<div class="block md:hidden relative">
|
<Tabs.List class="h-auto flex-wrap">
|
||||||
<button
|
{#each tabs as tab (tab.id)}
|
||||||
class="w-full px-4 py-2 rounded font-medium transition-colors flex items-center justify-between bg-surface hover:bg-secondary-hover border border-gray-200 dark:border-white/10"
|
<Tabs.Trigger value={tab.id}>{tab.label}</Tabs.Trigger>
|
||||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
{/each}
|
||||||
>
|
</Tabs.List>
|
||||||
<span>{getTabLabel($selectedTabStore)}</span>
|
</Tabs.Root>
|
||||||
<svg
|
|
||||||
class="w-5 h-5 transition-transform {mobileMenuOpen ? 'rotate-180' : ''}"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{#if mobileMenuOpen}
|
|
||||||
<div
|
|
||||||
class="absolute top-full left-0 right-0 mt-1 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-10"
|
|
||||||
>
|
|
||||||
{#each tabs as tab (tab.id)}
|
|
||||||
<button
|
|
||||||
class="w-full px-4 py-2 text-left hover:bg-secondary-hover transition-colors first:rounded-t last:rounded-b {$selectedTabStore ===
|
|
||||||
tab.id
|
|
||||||
? 'bg-primary/10 font-medium'
|
|
||||||
: ''}"
|
|
||||||
onclick={() => selectTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop: Tab buttons (shown on md and up) -->
|
|
||||||
<div class="hidden md:flex flex-wrap gap-2">
|
|
||||||
{#each tabs as tab (tab.id)}
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 rounded font-medium transition-colors {$selectedTabStore === tab.id
|
|
||||||
? 'bg-primary text-btn-primary-text'
|
|
||||||
: 'bg-surface hover:bg-secondary-hover border border-gray-200 dark:border-white/10'}"
|
|
||||||
onclick={() => selectTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab content -->
|
<!-- Tab content (all interfaces stay mounted) -->
|
||||||
<div class="flex-1 overflow-hidden relative">
|
<div class="relative flex-1 overflow-hidden">
|
||||||
<div class="h-full" class:tab-hidden={$selectedTabStore !== "chat"}>
|
<div class="h-full" class:hidden={$selectedTabStore !== "chat"}>
|
||||||
<ChatInterface />
|
<ChatInterface />
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full" class:tab-hidden={$selectedTabStore !== "images"}>
|
<div class="h-full" class:hidden={$selectedTabStore !== "images"}>
|
||||||
<ImageInterface />
|
<ImageInterface />
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full" class:tab-hidden={$selectedTabStore !== "speech"}>
|
<div class="h-full" class:hidden={$selectedTabStore !== "speech"}>
|
||||||
<SpeechInterface />
|
<SpeechInterface />
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full" class:tab-hidden={$selectedTabStore !== "audio"}>
|
<div class="h-full" class:hidden={$selectedTabStore !== "audio"}>
|
||||||
<AudioInterface />
|
<AudioInterface />
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full" class:tab-hidden={$selectedTabStore !== "rerank"}>
|
<div class="h-full" class:hidden={$selectedTabStore !== "rerank"}>
|
||||||
<RerankInterface />
|
<RerankInterface />
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full" class:tab-hidden={$selectedTabStore !== "concurrency"}>
|
<div class="h-full" class:hidden={$selectedTabStore !== "concurrency"}>
|
||||||
<ConcurrencyInterface />
|
<ConcurrencyInterface />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card.Root>
|
||||||
|
|
||||||
<style>
|
|
||||||
.tab-hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user