Compare commits

..
Author SHA1 Message Date
Benson Wong d567fa78cb npm audit fix 2026-06-28 04:38:45 +00:00
Benson Wong 187f1ae27a ui: fix logs tab height and column toggle dropdown
- Make ModelLogsTab fill available vertical space instead of fixed h-80
- Add min-h-0 flex-1 to Logs Tabs.Content so height propagates
- Set closeOnSelect=false on column visibility checkbox items to keep
  the dropdown open while toggling multiple columns
2026-06-28 04:36:56 +00:00
Benson Wong 0ae56b1eb9 ui: convert chat settings panel to a dialog
Replace the inline settings panel with a modal Dialog that pops up
over the chat interface, matching the CaptureDialog pattern.
2026-06-28 04:22:01 +00:00
Benson Wong e46cbeb2bf ui: refocus message input after chat generation completes
- Add ref prop to ExpandableTextarea to expose the underlying textarea
- Track streaming state transitions in ChatInterface and refocus the
  input via $effect when isStreaming flips to false
2026-06-28 04:16:23 +00:00
Benson Wong a0578f0007 ui: reorganize sidebar and add Settings page
Reorder sidebar menu to Activity, Playground, Models, Logs. Remove the
ll header icon and replace it with the connection status indicator
moved from the footer. Add a Settings page (gear icon) at the bottom
that surfaces the build information that was previously hidden behind
the status indicator's tooltip.

- move ConnectionStatus into Sidebar.Header, drop build-info tooltip
- add Settings.svelte route showing version/commit/build date
- register /settings route and title in App.svelte
2026-06-28 03:53:14 +00:00
Benson Wong d207a059a4 ui: enable pagination on Activity page and fix table reactivity
- add showPagination to Activity route's ActivityTable
- fix pagination reactivity: reassign pagination object in
  onPaginationChange so TanStack's effect.pre detects the change, and
  reset to first page only when pageSize changes
- move data-change page reset into untrack to avoid clobbering
  navigation
- render Cached/Prompt/Drafted headers with a dotted underline trigger
  instead of a separate info icon
2026-06-28 03:43:55 +00:00
Benson Wong 040ee1e284 ui: convert ActivityTable to shadcn-svelte data-table
Replace the hand-rolled table with a TanStack Table-backed shadcn
data-table using the FlexRender/createSvelteTable helpers, with
DropdownMenu column visibility, Select page-size, and icon-button
pagination. Column visibility and page size persist to localStorage.

Move tooltip usage to the canonical shadcn-svelte pattern by adding a
single root Tooltip.Provider in App.svelte and using Tooltip.Root/
Trigger/Content directly in the activity-table sub-components
(HeaderLabel, MetaCell), dropping the custom Tooltip/MetadataTooltip
wrappers.

- add @tanstack/table-core and shadcn data-table helpers
- split cell/header renderers into activity-table/* sub-components
- switch pagination/visibility to TanStack Table state driven by
  table.nextPage/previousPage/setPageIndex/setPageSize and
  column.toggleVisibility
2026-06-28 03:26:24 +00:00
Benson Wong 82cad1b84e ui: add ModelsDash route, clickable sidebar headings, and dialog tweaks
- Add /models route (ModelsDash) with unload-all, model list with
  start/stop buttons, and show-unlisted toggle
- Make sidebar Models and Playground headings navigate to their pages
  while the chevron independently expands/collapses the section
- Extract shared model load/unload orchestration into modelLoad store
- Left-align model names in the ConcurrencyInterface load-test list
- Widen CaptureDialog to 90% with flex-based scroll overflow

- Use sm:max-w-[90%] to override the shadcn dialog's sm:max-w-sm cap
2026-06-28 03:04:04 +00:00
Benson Wong 55c3678906 ui: extract shared ActivityTable and split ModelDetail into components
- Add ActivityTable component consolidating column customization,
  table rendering, pagination, and capture dialog previously
  duplicated between Activity.svelte and ModelDetail.svelte
- Split ModelDetail tabs into ModelActivityTab, ModelLogsTab, and
  ModelDetailsTab components under components/model/
- Reduce Activity.svelte and ModelDetail.svelte to thin shells

- ModelDetail tabs now reuse ActivityTable instead of duplicating
  column management, formatting, and capture logic
2026-06-28 02:27:05 +00:00
Benson Wong 8b5a62d92a ui-svelte: big convert to shadcn components 2026-06-28 01:53:19 +00:00
Benson Wong d1e4c8ee77 ui tweaks 2026-06-28 01:21:40 +00:00
Benson Wong 11f8afead8 ui: add collapsible Models section to sidebar
Move Models to the top of the sidebar as a collapsible item with each
model listed as a sub-item.

- add persistent modelsMenuOpen store for expand state
- show status dot per model (grey/yellow/green for stopped/changing/loaded)
- right-aligned load/unload button with Play/PowerOff/Loader2 icon
- button stops propagation so it doesn't trigger navigation
2026-06-27 23:54:18 +00:00
Benson Wong 749819ef47 ui: consolidate playground nav into sidebar
Move Playground tabs into the sidebar as collapsible sub-items and make
the sidebar the sole navigation for playground interfaces.

- add collapsible UI primitive (bits-ui wrapper)
- add playground store with selected tab and menu open state (persistent)
- make Playground menu item collapsible; whole button toggles expand state
- move playground sub-items (Chat/Images/Speech/etc) under Playground
- remove in-page Tabs from Playground.svelte
- update sectionTitle breadcrumb to reflect active sub-item
- remove bg-sidebar panel background so items sit on page background
- remove persistent data-active background tint on menu items

fixes #123
2026-06-27 16:46:10 +00:00
Claude 0ab9e74333 ui: finish shadcn migration and remove legacy shim
Convert the remaining .btn usages (Concurrency, Performance, CaptureDialog)
to shadcn Button, fix CaptureDialog/PerformanceChart styles to shadcn
tokens, and remove the transitional legacy palette aliases and component
classes from index.css. Drop the now-unused lucide-svelte and shadcn-svelte
dependencies.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 12:10:56 +00:00
Claude b20be6dcd1 ui: convert Image, Speech, Audio interfaces to shadcn buttons
Replace .btn elements and inline SVG icons with shadcn Button and
@lucide/svelte icons in the image, speech and audio playground tabs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 12:05:19 +00:00
Claude fc24722258 ui: migrate Rerank and normalize remaining views to shadcn tokens
- RerankInterface uses Button/Input/Textarea/ToggleGroup
- normalize legacy color utilities and lucide imports across the
  remaining playground interfaces, Performance and CaptureDialog

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 12:01:19 +00:00
Claude 2b087dffb1 ui: migrate ChatMessage to shadcn tokens
Use shadcn Button/Textarea, @lucide/svelte icons, and map prose/code-block
styles to shadcn CSS variables.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:58:24 +00:00
Claude 746c083a87 ui: migrate chat playground and stats to shadcn
- ChatInterface controls, settings, input use Button/Input/Textarea/Label
- ExpandableTextarea and ModelSelector restyled on shadcn tokens
- ActivityStats wrapped in Card; Tooltip uses shadcn tooltip

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:56:31 +00:00
Claude 8dd91e99e8 ui: migrate Activity, Logs views to shadcn
- Activity table wrapped in Card with restyled column menu and Button
- LogPanel toolbar uses Button/Input with lucide icons
- LogViewer source switch uses a ToggleGroup

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:52:11 +00:00
Claude 136dcdc25f 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:49:16 +00:00
Claude 767b8015fa ui: replace top navbar with shadcn sidebar layout
Add AppSidebar built from the shadcn sidebar primitives (collapsible icon
rail, editable title, nav with active states, footer theme toggle and
connection status) and wrap the app in a sidebar provider with an inset
top bar. Preserves the always-mounted Playground pattern.

- add src/components/AppSidebar.svelte
- restructure App.svelte around Sidebar.Provider/Inset
- remove Header.svelte

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:46:30 +00:00
Claude f0144a2361 ui: add shadcn-svelte foundation and theming
Set up shadcn-svelte components and adopt its design-token system as the
base for modernizing the UI. Switch dark mode from the data-theme attribute
to the .dark class so shadcn primitives theme correctly.

- add components.json, $lib alias (tsconfig + vite), cn() util
- install shadcn primitives under src/lib/components/ui
- rewrite index.css with shadcn tokens (zinc + brand teal accent)
- keep legacy utility/class aliases as a transitional shim
- toggle .dark class from theme store in App.svelte

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UmuGqwNBJNEAMaWsdCDqUC
2026-06-27 11:42:43 +00:00
225 changed files with 6479 additions and 4835 deletions
-89
View File
@@ -1,89 +0,0 @@
name: Build CUDA image (fork)
# Builds this fork's llama-swap (serial scheduler + embedded UI) from source and
# layers it on a pinned llama.cpp CUDA server base, then pushes to the Gitea
# container registry, e.g. gitea.stevedudenhoeffer.com/steve/llama-swap:v230-cuda-b9821
#
# Requires repo secrets: REGISTRY_USER, REGISTRY_PASSWORD (push to the registry).
on:
workflow_dispatch:
inputs:
llama_swap_version:
description: "llama-swap version label (image tag prefix)"
required: false
default: "v230"
llamacpp_build:
description: "llama.cpp CUDA server build (base image tag suffix)"
required: false
default: "b9821"
# Any change that lands in the shipped binary or image kicks off a fresh
# build (source, UI, deps, build definition). Docs/config-example edits
# don't.
push:
branches: [main]
paths:
- ".gitea/workflows/build-cuda-image.yml"
- "docker/fork-cuda.Containerfile"
- "**/*.go"
- "go.mod"
- "go.sum"
- "ui-svelte/**"
- "llama-swap.go"
env:
REGISTRY: gitea.stevedudenhoeffer.com
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compute image metadata
id: meta
run: |
LS_VER="${{ inputs.llama_swap_version || 'v230' }}"
LCPP="${{ inputs.llamacpp_build || 'b9821' }}"
SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)"
IMAGE="${REGISTRY}/${{ github.repository }}"
# Three tags per build: the version label (mutable, human-facing),
# an explicit moving main-cuda tag, and an immutable sha tag so a
# deployment can always pin the exact build.
{
echo "image=${IMAGE}"
echo "tag=${LS_VER}-cuda-${LCPP}"
echo "tags=${IMAGE}:${LS_VER}-cuda-${LCPP},${IMAGE}:main-cuda-${LCPP},${IMAGE}:sha-${SHORT_SHA}"
echo "base_tag=server-cuda-${LCPP}"
echo "ls_version=${LS_VER}"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/fork-cuda.Containerfile
push: true
provenance: false
build-args: |
BASE_TAG=${{ steps.meta.outputs.base_tag }}
LS_VERSION=${{ steps.meta.outputs.ls_version }}
GIT_HASH=${{ github.sha }}
BUILD_DATE=${{ steps.meta.outputs.build_date }}
tags: ${{ steps.meta.outputs.tags }}
- name: Summary
run: |
echo "Pushed ${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
+6 -4
View File
@@ -5,14 +5,16 @@ llama-swap is a light weight, transparent proxy server that provides automatic m
## Tech stack ## Tech stack
- golang - golang
- typescript, vite and svelte 5 for UI (located in ui-svelte/) - typescript, vite and svelt5 for UI (located in ui/)
## Workflow Tasks ## Workflow Tasks
- when summarizing changes only include details that require further action - when summarizing changes only include details that require further action
- just say "Done." when there is no further action
- use the github CLI `gh` to create pull requests and work with github
- Rules for creating pull requests: - Rules for creating pull requests:
- keep them short and focused on changes - keep them short and focused on changes.
- skip the test plan - never include a test plan
- write the summary using the same style rules as commit message - write the summary using the same style rules as commit message
## Testing ## Testing
@@ -28,7 +30,7 @@ llama-swap is a light weight, transparent proxy server that provides automatic m
### Commit message example format: ### Commit message example format:
``` ```
internal/server: add new feature proxy: add new feature
Add new feature that implements functionality X and Y. Add new feature that implements functionality X and Y.
-3
View File
@@ -213,9 +213,6 @@ Almost all configuration settings are optional and can be added one step at a ti
- `matrix` to run concurrent models with a custom swap logic DSL - `matrix` to run concurrent models with a custom swap logic DSL
- `hooks` to run things on startup - `hooks` to run things on startup
- `macros` reusable snippets - `macros` reusable snippets
- `routing.scheduler` to control how queued requests are ordered — see
[request priority](docs/request-priority.md) for the `X-LlamaSwap-Priority`
header that lets batch jobs yield to interactive requests
- Model customization - Model customization
- `ttl` to automatically unload models - `ttl` to automatically unload models
- `aliases` to use familiar model names (e.g., "gpt-4o-mini") - `aliases` to use familiar model names (e.g., "gpt-4o-mini")
+3 -24
View File
@@ -601,41 +601,20 @@
"use": { "use": {
"type": "string", "type": "string",
"enum": [ "enum": [
"serial",
"fifo" "fifo"
], ],
"default": "serial", "default": "fifo",
"description": "Scheduler to use. 'serial' (default on this fork): strict one-model-at-a-time, highest-priority request runs next, switching models evicts every other model first. 'fifo': throughput-oriented, batches same-model requests and allows parallel/co-resident models. Both honour the per-request X-LlamaSwap-Priority header." "description": "Scheduler to use. Only 'fifo' is currently supported."
}, },
"settings": { "settings": {
"type": "object", "type": "object",
"properties": { "properties": {
"serial": {
"type": "object",
"description": "Tunes how the serial scheduler scores waiting requests: score = X-LlamaSwap-Priority + swap affinity + aging. Ignored unless use is 'serial'.",
"properties": {
"agingDivisor": {
"type": "integer",
"minimum": 0,
"default": 60,
"description": "Seconds a request must wait to gain one priority point. 0 disables aging. Unbounded on purpose: aging is the only term allowed to promote a request across a priority band, which is what prevents starvation."
},
"swapAffinityBonus": {
"type": "integer",
"minimum": 0,
"maximum": 99,
"default": 10,
"description": "Bonus for a request that can be served without swapping models, so strict priority does not force a cold load on every dispatch. Capped below the 100-point band spacing so it can only break near-ties. 0 disables it, giving strict priority-then-arrival order."
}
},
"additionalProperties": false
},
"fifo": { "fifo": {
"type": "object", "type": "object",
"properties": { "properties": {
"priority": { "priority": {
"type": "object", "type": "object",
"description": "Per-model priority. Keys are model IDs, values are integers (default 0). Higher values are serviced first. Added to the caller's X-LlamaSwap-Priority.", "description": "Per-model priority. Keys are model IDs, values are integers (default 0). Higher values are serviced first.",
"additionalProperties": { "additionalProperties": {
"type": "integer" "type": "integer"
} }
+3 -52
View File
@@ -556,65 +556,16 @@ routing:
# expands to: [L] # expands to: [L]
full: "L" full: "L"
# scheduler: how queued requests are ordered and run. # scheduler: how queued requests are ordered.
# - optional, default on this fork: "serial" # The default and only valid scheduler is "fifo"
# - valid values:
# - "serial": strict one-model-at-a-time. Only one request runs at a time;
# switching to a different model evicts every other running model first so
# a single model occupies memory at a time. This ignores group/matrix
# co-residency entirely. The "fifo" settings below do not apply.
# - "fifo": throughput-oriented. Same-model requests are batched to reduce
# swaps and a model serves up to its concurrencyLimit in parallel; models
# in non-exclusive groups can run concurrently. Requests may be reordered.
#
# Callers set per-request priority with the X-LlamaSwap-Priority header
# (a signed integer; "interactive" = 100, "normal" = 0, "batch" = -100;
# absent or unparseable means 0). Both schedulers honour it. Scheduling is
# non-preemptive: priority applies when a job finishes, so an interactive
# request can still wait up to one full job duration.
scheduler: scheduler:
use: serial use: fifo
settings: settings:
# serial settings only apply when use: serial
#
# At each dispatch the serial scheduler runs the highest scoring waiting
# request, where:
#
# score = X-LlamaSwap-Priority + swap affinity + aging
#
# Priority bands sit 100 apart, which leaves room for a caller to add a
# small per-user offset (a subscription tier, say) without crossing a
# band: a "max member" batch job at -98 beats other batch work but still
# loses to every normal request at 0.
serial:
# agingDivisor: seconds a request must wait to gain one priority point
# - optional, default: 60 (one point per minute)
# - 0 disables aging
# - unbounded on purpose: aging is the only term allowed to promote a
# request across a band, which is what stops low-priority work from
# starving. At 60, a batch job at -100 overtakes normal traffic after
# ~100 minutes of waiting.
agingDivisor: 60
# swapAffinityBonus: bonus for a request that needs no model swap
# - optional, default: 10
# - must be 0..99, so it can only break near-ties and can never promote
# a request into the next band
# - 0 disables it, giving strict priority-then-arrival order
# - cold loads are expensive, so this keeps a run of same-model requests
# together instead of forcing a reload on every dispatch. Note this
# means equal-priority requests are NOT served in strict arrival
# order; set it to 0 if you need that.
swapAffinityBonus: 10
# fifo settings only apply when use: fifo
fifo: fifo:
# priority: a dictionary of model ID -> priority # priority: a dictionary of model ID -> priority
# - optional, default: empty dictionary # - optional, default: empty dictionary
# - models default to priority 0 # - models default to priority 0
# - higher priority requests are serviced first in the queue # - higher priority requests are serviced first in the queue
# - added to the caller's X-LlamaSwap-Priority, so a model priority of
# 10 and a header of "batch" (-100) queue at -90
priority: priority:
A: 10 A: 10
B: 5 B: 5
-74
View File
@@ -1,74 +0,0 @@
# Build a CUDA llama-swap image FROM THIS FORK's source (includes the serial
# scheduler) and layer it on a pinned llama.cpp CUDA server base. Produces e.g.:
# gitea.stevedudenhoeffer.com/steve/llama-swap:v230-cuda-b9821
#
# BASE_TAG selects the llama.cpp CUDA runtime + llama-server build, e.g.
# "server-cuda-b9821". The llama-swap binary (with the embedded Svelte UI) is
# compiled from the repo at build time, so no GitHub release is required.
#
# Build context is the repo root:
# docker build -f docker/fork-cuda.Containerfile \
# --build-arg BASE_TAG=server-cuda-b9821 -t llama-swap:v230-cuda-b9821 .
ARG BASE_IMAGE=ghcr.io/ggml-org/llama.cpp
ARG BASE_TAG=server-cuda-b9821
# ---- Stage 1: build the Svelte UI (embedded into the binary) ----
FROM node:22-bookworm-slim AS ui
WORKDIR /src/ui-svelte
# Install deps first for layer caching. .npmrc carries legacy-peer-deps=true,
# which the project relies on (tailwind/vite peer ranges), so copy it before
# npm ci or the strict resolver fails with ERESOLVE.
COPY ui-svelte/package.json ui-svelte/package-lock.json ui-svelte/.npmrc ./
RUN npm ci
COPY ui-svelte/ ./
# `npm run build` is `vite build --emptyOutDir`; vite.config.ts writes to
# ../internal/server/ui_dist, which //go:embed picks up in the next stage.
RUN mkdir -p /src/internal/server && npm run build
# ---- Stage 2: build the llama-swap binary with the embedded UI ----
FROM golang:1.26-bookworm AS build
WORKDIR /src
# Cache modules independently of source churn.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Overlay the freshly built UI so //go:embed ui_dist ships the real assets
# instead of the committed placeholder.
COPY --from=ui /src/internal/server/ui_dist/ ./internal/server/ui_dist/
ARG LS_VERSION=v230
ARG GIT_HASH=unknown
ARG BUILD_DATE=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-X main.version=${LS_VERSION} -X main.commit=${GIT_HASH} -X main.date=${BUILD_DATE}" \
-o /out/llama-swap .
# ---- Stage 3: runtime image on the pinned llama.cpp CUDA base ----
FROM ${BASE_IMAGE}:${BASE_TAG}
# Run as root by default to match the upstream `vNNN-cuda-bNNNN` (non-suffixed)
# image that ragnaros pulls today: it needs root to reach the mounted docker
# socket for container-backed models (sd-server). Override UID/GID at build time
# for a non-root variant.
ARG UID=0
ARG GID=0
ARG USER_HOME=/root
ENV HOME=$USER_HOME
RUN set -eux; \
if [ "$UID" -ne 0 ]; then \
if [ "$GID" -ne 0 ]; then groupadd --system --gid "$GID" app; fi; \
useradd --system --uid "$UID" --gid "$GID" --home "$USER_HOME" app; \
fi; \
mkdir --parents "$HOME" /app; \
chown --recursive "$UID:$GID" "$HOME" /app
COPY --from=build --chown=$UID:$GID /out/llama-swap /app/llama-swap
COPY --chown=$UID:$GID docker/config.example.yaml /app/config.yaml
USER $UID:$GID
WORKDIR /app
ENV PATH="/app:${PATH}"
HEALTHCHECK CMD curl -f http://localhost:8080/ || exit 1
ENTRYPOINT [ "/app/llama-swap", "-config", "/app/config.yaml" ]
-151
View File
@@ -1,151 +0,0 @@
# Request priority
A GPU is a size-1 resource: one job at a time. Without priorities, a single long
job monopolises the box for its whole duration and every interactive request
queues behind it. A 16-minute render blocking a chat completion is
uncomfortable; a 12-hour batch render blocking everything for a day is not
workable.
Callers declare intent with a header, and the scheduler dispatches by score.
## The header
```
X-LlamaSwap-Priority: 0 # normal — the default
X-LlamaSwap-Priority: 100 # interactive, a human is waiting
X-LlamaSwap-Priority: -100 # batch, nobody is watching
```
Priority is a **signed integer**; higher is more urgent. An absent or
unparseable header means `0`. Values are not clamped — llama-swap trusts the
caller.
Named aliases resolve to numbers for convenience, but **the number is the
interface**: callers may send any value.
| alias | value |
| ------------- | -----: |
| `interactive` | `100` |
| `normal` | `0` |
| `batch` | `-100` |
Priority is a property of the *caller's intent*, not of the model — the same
model serves both an interactive request and a batch job — which is why it
travels as a header rather than as per-model config. It also leaves the
OpenAI-compatible request body untouched.
```bash
curl http://localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'X-LlamaSwap-Priority: interactive' \
-d '{"model":"qwen3","messages":[{"role":"user","content":"hi"}]}'
```
## Bands and offsets
The three anchors sit 100 apart. That spacing is deliberate: it leaves room for
a caller to add a small per-user offset without ever crossing a band.
| tier | offset |
| ---- | -----: |
| free | `+0` |
| pro | `+1` |
| max | `+2` |
A max member's batch job is `-100 + 2 = -98`: ahead of pro's `-99` and free's
`-100`, and still **below** any normal request at `0`. A tier bonus breaks ties
*inside* a band and can never promote across one.
> **Invariant:** band spacing (100) must stay far larger than the largest offset
> a caller adds plus `swapAffinityBonus`. Otherwise a max member's batch work
> could outrank a free member's interactive request.
## How the serial scheduler dispatches
Scheduling is **non-preemptive**. A generation cannot be interrupted mid-sample
without discarding the work, so priority applies at *dispatch* time: when a job
finishes, the best-scoring waiting request goes next.
```
score = X-LlamaSwap-Priority + swap affinity + aging
```
| term | range | crosses bands? |
| --------------------- | ------- | ----------------------- |
| **request priority** | any | — it *is* the band |
| **swap affinity** | `0..99` | **never** |
| **aging** | `0..∞` | **yes, deliberately** |
Equal scores keep arrival order.
**Swap affinity** is a bounded bonus for a request that can run without a model
swap. Cold loads are expensive, so this keeps a run of same-model requests
together instead of forcing a reload on every dispatch. Because it is bounded
well below the band spacing, it can only break near-ties — it can never promote
batch work above interactive.
**Aging** is `waited_seconds / agingDivisor`, and is unbounded on purpose: a
batch job that has waited long enough *should* eventually beat normal traffic,
or it starves. At the default divisor of 60 (one point per minute), a batch job
at `-100` overtakes normal traffic after ~100 minutes of waiting.
That asymmetry is the design: **bounded offsets express policy, unbounded aging
prevents starvation.**
### What priority does not fix
Because dispatch is non-preemptive, an interactive request can still wait **up
to one full job duration**. The complementary lever is client-side: a batch
producer should submit **one unit at a time** and re-queue, so gaps are frequent.
For a long render that means one shot per submission, not one 12-hour chain.
Priority is also not fair-share. If one caller submits 200 batch jobs they all
sit at the same priority, and llama-swap will work through them in order. Per-user
fairness belongs in the client that submits the work.
## Configuration
```yaml
routing:
scheduler:
use: serial
settings:
serial:
# Seconds of waiting per priority point. 0 disables aging.
agingDivisor: 60
# Bonus for a request needing no model swap. 0..99; 0 disables it.
swapAffinityBonus: 10
```
Setting `swapAffinityBonus: 0` gives strict priority-then-arrival order — with
uniform priorities that is exact FIFO.
The `fifo` scheduler honours the header too, adding it to the model's configured
priority: a model at `10` with a `batch` header queues at `-90`.
## Observability
`GET /metrics` exports the queue in Prometheus text format, labelled by band
(`interactive`, `normal`, `batch`):
| metric | type | meaning |
| --------------------------------------------- | ------- | ---------------------------------------------------- |
| `llamaswap_scheduler_queue_depth` | gauge | requests waiting |
| `llamaswap_scheduler_queue_oldest_wait_seconds`| gauge | how long the longest-waiting request has waited |
| `llamaswap_scheduler_dispatched_total` | counter | requests dispatched |
| `llamaswap_scheduler_reorders_total` | counter | dispatches where `aging` or `swap_affinity` changed the pick |
The reorder counters are what make `agingDivisor` and `swapAffinityBonus`
tunable rather than guesswork: they say how often each term actually changed
which request went next.
Every dispatched request also records its decision in the activity log:
| key | meaning |
| ---------------------- | ------------------------------------------ |
| `serial_priority` | the caller's priority |
| `serial_band` | which band it fell in |
| `serial_score` | the score it won with |
| `serial_queue_wait_ms` | how long it waited before dispatch |
That is what answers "why did my request take 20 minutes" after the fact.
-54
View File
@@ -168,66 +168,12 @@ type SchedulerConfig struct {
type SchedulerSettings struct { type SchedulerSettings struct {
Fifo FifoConfig `yaml:"fifo"` Fifo FifoConfig `yaml:"fifo"`
Serial SerialConfig `yaml:"serial"`
} }
type FifoConfig struct { type FifoConfig struct {
Priority map[string]int `yaml:"priority"` // model ID -> priority, default 0 Priority map[string]int `yaml:"priority"` // model ID -> priority, default 0
} }
// Serial scheduler scoring defaults. See SerialConfig.
const (
// DefaultAgingDivisor gives a waiting request one priority point per
// minute, so a batch job at -100 overtakes normal traffic after ~100
// minutes rather than starving behind it forever.
DefaultAgingDivisor = 60
// DefaultSwapAffinityBonus is large enough to break near-ties in favour
// of the already-loaded model (cold loads cost 2-3x) and far too small to
// promote a request across a 100-point priority band.
DefaultSwapAffinityBonus = 10
)
// SerialConfig tunes how the serial scheduler scores queued requests. At each
// dispatch it picks the highest scoring waiting request, where
//
// score = request priority + swap affinity + aging
//
// Both terms are pointers so an explicit 0 (disable) is distinguishable from
// "unset" (use the default); read them through the accessors below.
type SerialConfig struct {
// AgingDivisor is how many seconds a request must wait to gain one
// priority point. Aging is unbounded on purpose — it is the only term
// allowed to promote a request across a band, which is what stops a
// low-priority job from starving. 0 disables aging.
AgingDivisor *int `yaml:"agingDivisor"`
// SwapAffinityBonus is added to a request that can be served without
// swapping models, so strict priority does not force a cold load on every
// dispatch. Bounded well below the 100-point band spacing so it can only
// break near-ties, never promote batch work above interactive. 0 disables
// it, restoring strict priority-then-arrival order.
SwapAffinityBonus *int `yaml:"swapAffinityBonus"`
}
// GetAgingDivisor returns the configured aging divisor in seconds, or
// DefaultAgingDivisor when unset.
func (c SerialConfig) GetAgingDivisor() int {
if c.AgingDivisor == nil {
return DefaultAgingDivisor
}
return *c.AgingDivisor
}
// GetSwapAffinityBonus returns the configured swap affinity bonus, or
// DefaultSwapAffinityBonus when unset.
func (c SerialConfig) GetSwapAffinityBonus() int {
if c.SwapAffinityBonus == nil {
return DefaultSwapAffinityBonus
}
return *c.SwapAffinityBonus
}
type RouterConfig struct { type RouterConfig struct {
Use string `yaml:"use"` // "group" (default) | "matrix" Use string `yaml:"use"` // "group" (default) | "matrix"
Settings RouterSettings `yaml:"settings"` Settings RouterSettings `yaml:"settings"`
+1 -1
View File
@@ -277,7 +277,7 @@ groups:
}, },
}, },
Scheduler: SchedulerConfig{ Scheduler: SchedulerConfig{
Use: "serial", Use: "fifo",
}, },
}, },
} }
+2 -76
View File
@@ -1572,7 +1572,7 @@ groups:
assert.Equal(t, "group", cfg.Routing.Router.Use) assert.Equal(t, "group", cfg.Routing.Router.Use)
// default group injected for orphaned models (none here) still leaves g1 // default group injected for orphaned models (none here) still leaves g1
assert.Contains(t, cfg.Routing.Router.Settings.Groups, "g1") assert.Contains(t, cfg.Routing.Router.Settings.Groups, "g1")
assert.Equal(t, "serial", cfg.Routing.Scheduler.Use) assert.Equal(t, "fifo", cfg.Routing.Scheduler.Use)
} }
func TestConfig_Routing_LegacyTopLevelMatrix(t *testing.T) { func TestConfig_Routing_LegacyTopLevelMatrix(t *testing.T) {
@@ -1631,7 +1631,7 @@ func TestConfig_Routing_DefaultsToGroup(t *testing.T) {
cfg, err := LoadConfigFromReader(strings.NewReader(twoModels)) cfg, err := LoadConfigFromReader(strings.NewReader(twoModels))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "group", cfg.Routing.Router.Use) assert.Equal(t, "group", cfg.Routing.Router.Use)
assert.Equal(t, "serial", cfg.Routing.Scheduler.Use) assert.Equal(t, "fifo", cfg.Routing.Scheduler.Use)
} }
func TestConfig_Routing_LegacyAndRoutingConflict(t *testing.T) { func TestConfig_Routing_LegacyAndRoutingConflict(t *testing.T) {
@@ -1720,77 +1720,3 @@ routing:
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 5, cfg.Routing.Scheduler.Settings.Fifo.Priority["gemma"]) assert.Equal(t, 5, cfg.Routing.Scheduler.Settings.Fifo.Priority["gemma"])
} }
func TestConfig_Routing_SerialDefaults(t *testing.T) {
cfg, err := LoadConfigFromReader(strings.NewReader(twoModels))
require.NoError(t, err)
serial := cfg.Routing.Scheduler.Settings.Serial
assert.Nil(t, serial.AgingDivisor, "unset should stay nil so the default applies")
assert.Equal(t, DefaultAgingDivisor, serial.GetAgingDivisor())
assert.Equal(t, DefaultSwapAffinityBonus, serial.GetSwapAffinityBonus())
}
// TestConfig_Routing_SerialExplicitZero verifies an explicit 0 disables a term
// rather than falling back to the default — the reason both fields are pointers.
func TestConfig_Routing_SerialExplicitZero(t *testing.T) {
yaml := twoModels + `
routing:
scheduler:
use: serial
settings:
serial:
agingDivisor: 0
swapAffinityBonus: 0
`
cfg, err := LoadConfigFromReader(strings.NewReader(yaml))
require.NoError(t, err)
serial := cfg.Routing.Scheduler.Settings.Serial
assert.Equal(t, 0, serial.GetAgingDivisor())
assert.Equal(t, 0, serial.GetSwapAffinityBonus())
}
func TestConfig_Routing_SerialSettings(t *testing.T) {
yaml := twoModels + `
routing:
scheduler:
use: serial
settings:
serial:
agingDivisor: 30
swapAffinityBonus: 5
`
cfg, err := LoadConfigFromReader(strings.NewReader(yaml))
require.NoError(t, err)
serial := cfg.Routing.Scheduler.Settings.Serial
assert.Equal(t, 30, serial.GetAgingDivisor())
assert.Equal(t, 5, serial.GetSwapAffinityBonus())
}
func TestConfig_Routing_SerialInvalidSettings(t *testing.T) {
cases := []struct {
name string
settings string
wantErr string
}{
{"negative aging divisor", "agingDivisor: -1", "agingDivisor"},
{"negative affinity bonus", "swapAffinityBonus: -1", "swapAffinityBonus"},
// 100 would let the bonus promote a request into the next band.
{"affinity bonus reaches a band", "swapAffinityBonus: 100", "swapAffinityBonus"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
yaml := twoModels + `
routing:
scheduler:
settings:
serial:
` + c.settings + "\n"
_, err := LoadConfigFromReader(strings.NewReader(yaml))
require.Error(t, err)
assert.Contains(t, err.Error(), c.wantErr)
})
}
}
+1 -1
View File
@@ -266,7 +266,7 @@ groups:
}, },
}, },
Scheduler: SchedulerConfig{ Scheduler: SchedulerConfig{
Use: "serial", Use: "fifo",
}, },
}, },
} }
+3 -18
View File
@@ -358,32 +358,17 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
config.Routing.Router.Settings.Matrix = config.Matrix config.Routing.Router.Settings.Matrix = config.Matrix
config.Routing.Router.Settings.Groups = config.Groups config.Routing.Router.Settings.Groups = config.Groups
// This fork defaults to the "serial" scheduler: one model loaded at a time,
// requests served in strict arrival order. Set use: fifo for the upstream
// throughput-oriented behavior that batches same-model requests.
if config.Routing.Scheduler.Use == "" { if config.Routing.Scheduler.Use == "" {
config.Routing.Scheduler.Use = "serial" config.Routing.Scheduler.Use = "fifo"
} }
switch config.Routing.Scheduler.Use { if config.Routing.Scheduler.Use != "fifo" {
case "fifo", "serial": return Config{}, fmt.Errorf("routing.scheduler.use: unknown scheduler %q (valid: fifo)", config.Routing.Scheduler.Use)
default:
return Config{}, fmt.Errorf("routing.scheduler.use: unknown scheduler %q (valid: fifo, serial)", config.Routing.Scheduler.Use)
} }
for modelID := range config.Routing.Scheduler.Settings.Fifo.Priority { for modelID := range config.Routing.Scheduler.Settings.Fifo.Priority {
if _, found := config.RealModelName(modelID); !found { if _, found := config.RealModelName(modelID); !found {
return Config{}, fmt.Errorf("routing.scheduler.settings.fifo.priority references unknown model %q", modelID) return Config{}, fmt.Errorf("routing.scheduler.settings.fifo.priority references unknown model %q", modelID)
} }
} }
serialCfg := config.Routing.Scheduler.Settings.Serial
if d := serialCfg.AgingDivisor; d != nil && *d < 0 {
return Config{}, fmt.Errorf("routing.scheduler.settings.serial.agingDivisor: must be >= 0, got %d (0 disables aging)", *d)
}
// Capped below the 100-point band spacing so the bonus can only break
// near-ties. A bonus that could reach the next band would let a batch job
// on the loaded model outrank an interactive request that needs a swap.
if b := serialCfg.SwapAffinityBonus; b != nil && (*b < 0 || *b >= 100) {
return Config{}, fmt.Errorf("routing.scheduler.settings.serial.swapAffinityBonus: must be between 0 and 99, got %d (priority bands are 100 apart and the bonus must never cross one)", *b)
}
// Clean up hooks preload // Clean up hooks preload
if len(config.Hooks.OnStartup.Preload) > 0 { if len(config.Hooks.OnStartup.Preload) > 0 {
+2 -3
View File
@@ -14,7 +14,6 @@ var validModalities = map[string]struct{}{
"text": {}, "text": {},
"audio": {}, "audio": {},
"image": {}, "image": {},
"video": {},
} }
// ModelCapConfig defines what modalities and features a model supports. // ModelCapConfig defines what modalities and features a model supports.
@@ -38,12 +37,12 @@ func (c ModelCapConfig) Empty() bool {
func (c ModelCapConfig) Validate() error { func (c ModelCapConfig) Validate() error {
for _, m := range c.In { for _, m := range c.In {
if _, ok := validModalities[m]; !ok { if _, ok := validModalities[m]; !ok {
return fmt.Errorf("capabilities.in: invalid modality %q, must be one of: text, audio, image, video", m) return fmt.Errorf("capabilities.in: invalid modality %q, must be one of: text, audio, image", m)
} }
} }
for _, m := range c.Out { for _, m := range c.Out {
if _, ok := validModalities[m]; !ok { if _, ok := validModalities[m]; !ok {
return fmt.Errorf("capabilities.out: invalid modality %q, must be one of: text, audio, image, video", m) return fmt.Errorf("capabilities.out: invalid modality %q, must be one of: text, audio, image", m)
} }
} }
if c.Context < 0 { if c.Context < 0 {
+6 -11
View File
@@ -296,25 +296,20 @@ func TestConfig_ModelCapabilities_Validate(t *testing.T) {
assert.NoError(t, caps.Validate()) assert.NoError(t, caps.Validate())
}) })
t.Run("valid_video_modality", func(t *testing.T) {
caps := ModelCapConfig{In: []string{"text", "image"}, Out: []string{"video"}}
assert.NoError(t, caps.Validate())
})
t.Run("invalid_in_modality", func(t *testing.T) { t.Run("invalid_in_modality", func(t *testing.T) {
caps := ModelCapConfig{In: []string{"smell"}} caps := ModelCapConfig{In: []string{"video"}}
err := caps.Validate() err := caps.Validate()
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "capabilities.in") assert.Contains(t, err.Error(), "capabilities.in")
assert.Contains(t, err.Error(), "smell") assert.Contains(t, err.Error(), "video")
}) })
t.Run("invalid_out_modality", func(t *testing.T) { t.Run("invalid_out_modality", func(t *testing.T) {
caps := ModelCapConfig{Out: []string{"smell"}} caps := ModelCapConfig{Out: []string{"video"}}
err := caps.Validate() err := caps.Validate()
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "capabilities.out") assert.Contains(t, err.Error(), "capabilities.out")
assert.Contains(t, err.Error(), "smell") assert.Contains(t, err.Error(), "video")
}) })
t.Run("negative_context", func(t *testing.T) { t.Run("negative_context", func(t *testing.T) {
@@ -332,10 +327,10 @@ models:
capabilities: capabilities:
in: in:
- text - text
- smell - video
` `
_, err := LoadConfigFromReader(strings.NewReader(content)) _, err := LoadConfigFromReader(strings.NewReader(content))
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "smell") assert.Contains(t, err.Error(), "video")
}) })
} }
-12
View File
@@ -338,17 +338,6 @@ func (b *baseRouter) Handles(model string) bool {
return ok return ok
} }
// SchedulerStats returns a snapshot of the scheduler's queue. ok is false when
// the configured scheduler does not report stats (only "serial" does today), in
// which case callers should omit the metrics rather than emit zeroes.
func (b *baseRouter) SchedulerStats() (scheduler.QueueStats, bool) {
reporter, ok := b.schedule.(scheduler.StatsReporter)
if !ok {
return scheduler.QueueStats{}, false
}
return reporter.QueueStats(), true
}
func (b *baseRouter) ProcessLogger(modelID string) (*logmon.Monitor, bool) { func (b *baseRouter) ProcessLogger(modelID string) (*logmon.Monitor, bool) {
if p, ok := b.processes[modelID]; ok { if p, ok := b.processes[modelID]; ok {
return p.Logger(), true return p.Logger(), true
@@ -432,7 +421,6 @@ func (b *baseRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
hr := scheduler.HandlerReq{ hr := scheduler.HandlerReq{
Model: data.ModelID, Model: data.ModelID,
Priority: data.Priority,
Ctx: req.Context(), Ctx: req.Context(),
// Unbuffered: a successful send on Respond proves the waiter is // Unbuffered: a successful send on Respond proves the waiter is
// alive and consuming. grant() relies on this to avoid handing a // alive and consuming. grant() relies on this to avoid handing a
-72
View File
@@ -5,7 +5,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync"
"testing" "testing"
"time" "time"
@@ -13,7 +12,6 @@ import (
"github.com/mostlygeek/llama-swap/internal/logmon" "github.com/mostlygeek/llama-swap/internal/logmon"
"github.com/mostlygeek/llama-swap/internal/process" "github.com/mostlygeek/llama-swap/internal/process"
"github.com/mostlygeek/llama-swap/internal/router/scheduler" "github.com/mostlygeek/llama-swap/internal/router/scheduler"
"github.com/mostlygeek/llama-swap/internal/shared"
) )
// These tests cover baseRouter's own machinery — the run loop, process // These tests cover baseRouter's own machinery — the run loop, process
@@ -220,76 +218,6 @@ func TestBaseRouter_ContextCancel(t *testing.T) {
} }
} }
// TestBaseRouter_SerialPriorityHeader is the end-to-end path for issue #9: the
// X-LlamaSwap-Priority header on an HTTP request reaches the serial scheduler
// and changes which queued request runs next.
//
// "hold" occupies the single slot. batch and urgent queue behind it in that
// order; when the slot frees, urgent must go first despite arriving later.
// urgent blocks inside its handler, so observing urgent's handler entry proves
// the scheduler chose it — if batch had won, batch's (unblocked) handler would
// already have run by then.
func TestBaseRouter_SerialPriorityHeader(t *testing.T) {
hold := newFakeProcess("hold")
hold.autoReady = true
hold.serveBlock = make(chan struct{})
batch := newFakeProcess("batch")
batch.autoReady = true
urgent := newFakeProcess("urgent")
urgent.autoReady = true
urgent.serveBlock = make(chan struct{})
conf := config.Config{HealthCheckTimeout: 5}
conf.Routing.Scheduler.Use = "serial"
b, err := newBaseRouter("test", conf, map[string]process.Process{
"hold": hold, "batch": batch, "urgent": urgent,
}, logmon.NewWriter(io.Discard), &stubPlanner{})
if err != nil {
t.Fatalf("newBaseRouter: %v", err)
}
b.testProcessed = make(chan struct{}, 64)
go b.run()
releaseHold := sync.OnceFunc(func() { close(hold.serveBlock) })
releaseUrgent := sync.OnceFunc(func() { close(urgent.serveBlock) })
t.Cleanup(func() {
releaseHold()
releaseUrgent()
if !b.shuttingDown.Load() {
_ = b.Shutdown(time.Second)
}
})
serve := func(model, priority string) {
r := newRequest(model)
if priority != "" {
r.Header.Set(shared.PriorityHeader, priority)
}
go b.ServeHTTP(httptest.NewRecorder(), r)
}
serve("hold", "")
waitProcessed(t, b.testProcessed, 2) // OnRequest, then the swap completing
<-hold.serveStarted
// Both queue behind hold; batch arrives first.
serve("batch", "batch")
waitProcessed(t, b.testProcessed, 1)
serve("urgent", "interactive")
waitProcessed(t, b.testProcessed, 1)
releaseHold()
select {
case <-urgent.serveStarted:
case <-time.After(2 * time.Second):
t.Fatal("interactive request never started")
}
if got := batch.serveCalls.Load(); got != 0 {
t.Errorf("batch serveCalls=%d want 0 — the batch job ran before the interactive request", got)
}
}
func TestBaseRouter_ModelNotFound(t *testing.T) { func TestBaseRouter_ModelNotFound(t *testing.T) {
a := newFakeProcess("a") a := newFakeProcess("a")
b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{}) b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})
-5
View File
@@ -6,7 +6,6 @@ import (
"github.com/mostlygeek/llama-swap/internal/logmon" "github.com/mostlygeek/llama-swap/internal/logmon"
"github.com/mostlygeek/llama-swap/internal/process" "github.com/mostlygeek/llama-swap/internal/process"
"github.com/mostlygeek/llama-swap/internal/router/scheduler"
"github.com/mostlygeek/llama-swap/internal/shared" "github.com/mostlygeek/llama-swap/internal/shared"
) )
@@ -50,8 +49,4 @@ type LocalRouter interface {
// modelID must be a real (non-alias) config key. Returns false when the // modelID must be a real (non-alias) config key. Returns false when the
// model is not known to this router. // model is not known to this router.
ProcessLogger(modelID string) (*logmon.Monitor, bool) ProcessLogger(modelID string) (*logmon.Monitor, bool)
// SchedulerStats returns a snapshot of the scheduler's queue for metrics.
// ok is false when the configured scheduler does not report stats.
SchedulerStats() (scheduler.QueueStats, bool)
} }
+6 -13
View File
@@ -280,7 +280,7 @@ func (s *FIFO) grantHandler(req HandlerReq, modelID string) {
return return
} }
if err := shared.SetReqData(req.Ctx, "fifo_priority", strconv.Itoa(s.priorityOf(req))); err != nil { if err := shared.SetReqData(req.Ctx, "fifo_priority", strconv.Itoa(s.cfg.Priority[req.Model])); err != nil {
s.logger.Debugf("failed to set fifo_priority metadata: %v", err) s.logger.Debugf("failed to set fifo_priority metadata: %v", err)
} }
@@ -311,22 +311,15 @@ func (s *FIFO) startSwap(initial HandlerReq, evict, running []string) {
s.effects.StartSwap(initial.Model, evict) s.effects.StartSwap(initial.Model, evict)
} }
// priorityOf is a request's effective queue priority: the caller's
// X-LlamaSwap-Priority plus the model's configured priority. Both default to 0,
// so a deployment that uses neither keeps plain FIFO order.
func (s *FIFO) priorityOf(req HandlerReq) int {
return req.Priority + s.cfg.Priority[req.Model]
}
// enqueue inserts req into the queue in priority order: it goes just before the // enqueue inserts req into the queue in priority order: it goes just before the
// first queued item whose priority is strictly lower, so higher-priority // first queued item whose priority is strictly lower, so higher-priority models
// requests are serviced first while equal-priority requests keep their arrival // are serviced first while equal-priority requests keep their arrival (FIFO)
// (FIFO) order. // order. Priorities come from the FifoConfig; unlisted models default to 0.
func (s *FIFO) enqueue(req HandlerReq) { func (s *FIFO) enqueue(req HandlerReq) {
p := s.priorityOf(req) p := s.cfg.Priority[req.Model]
i := len(s.queued) i := len(s.queued)
for j, q := range s.queued { for j, q := range s.queued {
if s.priorityOf(q) < p { if s.cfg.Priority[q.Model] < p {
i = j i = j
break break
} }
-28
View File
@@ -571,34 +571,6 @@ func TestFIFO_PriorityQueueOrder(t *testing.T) {
} }
} }
// TestFIFO_RequestPriorityOrder verifies the caller's X-LlamaSwap-Priority is
// added to the model's configured priority, so a batch caller for a
// high-priority model still queues behind an interactive caller for a
// low-priority one.
func TestFIFO_RequestPriorityOrder(t *testing.T) {
eff := newFakeEffects()
for _, m := range []string{"z", "hot", "cold"} {
eff.states[m] = process.StateStopped
}
planner := &stubPlanner{evict: map[string][]string{"z": {"hot", "cold"}}}
cfg := config.FifoConfig{Priority: map[string]int{"hot": 10}}
s := NewFIFO("test", logmon.NewWriter(io.Discard), planner, cfg, nil, eff)
s.OnRequest(req("z")) // StartSwap(z, [hot, cold]) — everything else queues
s.OnRequest(HandlerReq{Model: "hot", Priority: shared.PriorityBatch}) // 10 - 100 = -90
s.OnRequest(HandlerReq{Model: "cold", Priority: shared.PriorityInteractive}) // 0 + 100 = 100
got := make([]string, len(s.queued))
for i, q := range s.queued {
got[i] = q.Model
}
want := []string{"cold", "hot"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("queue=%v want %v", got, want)
}
}
// TestFIFO_OnCancel_QueuedRequest verifies that cancelling a queued request // TestFIFO_OnCancel_QueuedRequest verifies that cancelling a queued request
// prevents drainQueue from ever starting a model load for it. Without OnCancel // prevents drainQueue from ever starting a model load for it. Without OnCancel
// the dead request would sit in the queue until a drain triggers a wasted swap. // the dead request would sit in the queue until a drain triggers a wasted swap.
+3 -49
View File
@@ -92,14 +92,9 @@ type Effects interface {
StopProcesses(timeout time.Duration, ids []string) StopProcesses(timeout time.Duration, ids []string)
} }
// New returns a Scheduler selected by conf.Routing.Scheduler.Use, configured from // New returns a Scheduler selected by conf.Routing.Scheduler.Use, configured
// conf and bound to the given planner and effects. Supported values are "fifo" // from conf and bound to the given planner and effects. Currently only "fifo"
// (throughput-oriented, batches same-model requests) and "serial" (strict // (the default) is supported.
// one-model-at-a-time, highest-priority-first).
//
// The deployment default is applied by config loading (LoadConfig sets Use to
// "serial" when unset). The "" fallback here is the library default and remains
// "fifo" so callers that build a Config directly keep the original behavior.
func New(conf config.Config, name string, logger *logmon.Monitor, planner Swapper, eff Effects) (Scheduler, error) { func New(conf config.Config, name string, logger *logmon.Monitor, planner Swapper, eff Effects) (Scheduler, error) {
use := conf.Routing.Scheduler.Use use := conf.Routing.Scheduler.Use
if use == "" { if use == "" {
@@ -108,9 +103,6 @@ func New(conf config.Config, name string, logger *logmon.Monitor, planner Swappe
switch use { switch use {
case "fifo": case "fifo":
return NewFIFO(name, logger, planner, conf.Routing.Scheduler.Settings.Fifo, conf.Models, eff), nil return NewFIFO(name, logger, planner, conf.Routing.Scheduler.Settings.Fifo, conf.Models, eff), nil
case "serial":
// Serial ignores the group planner: it always evicts every other model.
return NewSerial(name, logger, conf.Routing.Scheduler.Settings.Serial, eff), nil
default: default:
return nil, fmt.Errorf("unsupported scheduler type: %q", use) return nil, fmt.Errorf("unsupported scheduler type: %q", use)
} }
@@ -119,49 +111,11 @@ func New(conf config.Config, name string, logger *logmon.Monitor, planner Swappe
// HandlerReq is one in-flight ServeHTTP request waiting for a routing decision. // HandlerReq is one in-flight ServeHTTP request waiting for a routing decision.
type HandlerReq struct { type HandlerReq struct {
Model string Model string
// Priority is the caller's scheduling priority from the
// X-LlamaSwap-Priority header. Higher is more urgent, 0 is normal, and
// negative is batch work. Schedulers use it to order their queues.
Priority int
Ctx context.Context Ctx context.Context
Respond chan HandlerResp Respond chan HandlerResp
PositionCh chan int PositionCh chan int
} }
// BandStats is the queue state for one priority band.
type BandStats struct {
// Depth is how many requests are waiting in this band.
Depth int
// OldestWait is how long the longest-waiting request in this band has
// been queued, or 0 when the band is empty.
OldestWait time.Duration
// Dispatched counts requests in this band handed off since start.
Dispatched uint64
}
// QueueStats is a point-in-time snapshot of a scheduler's queue, for metrics.
// The reorder counters answer "did the scoring function actually change
// anything?" — without them, tuning aging and swap affinity is guesswork.
type QueueStats struct {
// Bands is keyed by shared.PriorityBand and always carries an entry for
// every band in shared.PriorityBands, so metric series stay stable.
Bands map[string]BandStats
// AgingReorders counts dispatches where the aging term changed which
// request was picked.
AgingReorders uint64
// AffinityReorders counts dispatches where the swap-affinity term changed
// which request was picked.
AffinityReorders uint64
}
// StatsReporter is implemented by schedulers that can report queue statistics.
// It is optional: callers type-assert and skip schedulers that do not provide
// it. Unlike the Scheduler methods, QueueStats is called from arbitrary
// goroutines (the /metrics handler) and must be safe for concurrent use.
type StatsReporter interface {
QueueStats() QueueStats
}
// HandlerResp is the routing decision returned to a HandlerReq's caller: either // HandlerResp is the routing decision returned to a HandlerReq's caller: either
// a handler to serve with, or an error. // a handler to serve with, or an error.
type HandlerResp struct { type HandlerResp struct {
-536
View File
@@ -1,536 +0,0 @@
package scheduler
import (
"fmt"
"sort"
"strconv"
"sync"
"time"
"github.com/mostlygeek/llama-swap/internal/config"
"github.com/mostlygeek/llama-swap/internal/logmon"
"github.com/mostlygeek/llama-swap/internal/process"
"github.com/mostlygeek/llama-swap/internal/shared"
)
// Serial is a strict one-model-at-a-time scheduler for a size-1 resource: at
// most one request runs at any instant, and when the next request targets a
// model other than the one loaded, every other running model is evicted and the
// target is loaded before it runs. A single model occupies memory at a time, at
// the cost of throughput.
//
// Serial ignores group/eviction policy entirely: it always evicts every other
// running model, regardless of how groups are configured. That is what makes
// the single-model guarantee a property of the scheduler rather than of the
// config.
//
// # Dispatch order
//
// Scheduling is non-preemptive — a running job is never interrupted, because a
// generation cannot be stopped mid-sample without discarding the work. Priority
// applies at dispatch time: whenever the slot frees, the highest scoring
// waiting request goes next, where
//
// score = request priority + swap affinity + aging
//
// - request priority is the caller's X-LlamaSwap-Priority (0 by default).
// Bands sit 100 apart: +100 interactive, 0 normal, -100 batch.
// - swap affinity is a bounded bonus for a request that can run without a
// model swap. Bounded well below the band spacing, so it only breaks
// near-ties and can never promote batch work above interactive.
// - aging is waited_seconds / agingDivisor, and is unbounded on purpose: it
// is the only term allowed to cross a band, which is what stops a batch job
// from starving behind an endless stream of normal traffic.
//
// Equal scores keep arrival order, so with default priorities and swap affinity
// disabled this degrades to exact FIFO.
//
// The consequence of being non-preemptive is worth stating plainly: an
// interactive request can still wait up to one full job duration. Priority
// alone does not fix that — the complementary lever is client-side, where a
// batch producer submits one unit at a time and re-queues so gaps are frequent.
//
// Like FIFO, every Scheduler method runs on the router's single run-loop
// goroutine, so no internal locking is needed for queue state. QueueStats is
// the exception: it is read from the /metrics handler, so the stats mirror it
// reads is guarded by a mutex.
type Serial struct {
name string
logger *logmon.Monitor
effects Effects
// agingDivisor is how long a request must wait to gain one priority
// point. Zero disables aging.
agingDivisor time.Duration
// swapAffinityBonus is added to a request that needs no model swap. Zero
// disables the term.
swapAffinityBonus int
// now is the clock, injectable so tests can drive aging deterministically.
now func() time.Time
// queued holds waiting requests in arrival order. Dispatch picks by score
// rather than popping the head, but the slice itself is never reordered so
// index order remains arrival order — which is what makes equal scores
// break in favour of the earlier request.
queued []queuedReq
// active is the one request currently being processed (loading or serving),
// or nil when idle. phase is meaningful only while active != nil.
active *HandlerReq
phase serialPhase
mu sync.Mutex
stats serialStats
}
// queuedReq is one waiting request plus the arrival time aging is measured from.
type queuedReq struct {
req HandlerReq
enqueued time.Time
}
// serialStats mirrors queue state and dispatch counters for QueueStats. It is
// written on the run loop and read by the metrics handler, both under mu.
type serialStats struct {
depth map[string]int
oldest map[string]time.Time
dispatched map[string]uint64
agingReorders uint64
affinityReorders uint64
}
// serialPhase is the lifecycle stage of the active request.
type serialPhase int
const (
phaseIdle serialPhase = iota
phaseSwapping // waiting for OnSwapDone for active.Model
phaseServing // waiting for OnServeDone for active.Model
)
// NewSerial builds a Serial scheduler. It takes no Swapper: eviction is always
// "stop every other running model", so the group planner is not consulted.
func NewSerial(name string, logger *logmon.Monitor, cfg config.SerialConfig, eff Effects) *Serial {
return &Serial{
name: name,
logger: logger,
effects: eff,
agingDivisor: time.Duration(cfg.GetAgingDivisor()) * time.Second,
swapAffinityBonus: cfg.GetSwapAffinityBonus(),
now: time.Now,
stats: serialStats{
depth: make(map[string]int),
oldest: make(map[string]time.Time),
dispatched: make(map[string]uint64),
},
}
}
// OnRequest validates the model and appends the request to the queue, stamping
// its arrival time so aging can be measured, then tries to start the next job.
// Unknown models fail immediately.
func (s *Serial) OnRequest(req HandlerReq) {
if _, ok := s.effects.ModelState(req.Model); !ok {
s.logger.Debugf("%s: model %s not handled by this router", s.name, req.Model)
s.effects.GrantError(req, ErrModelNotFound)
return
}
s.queued = append(s.queued, queuedReq{req: req, enqueued: s.now()})
s.queueChanged()
s.startNext()
}
// startNext begins processing the highest scoring waiting request when nothing
// is active. It fast-paths a request whose model is already the sole
// loaded-and-ready process; otherwise it launches a swap that evicts every other
// running model first. The loop skips over requests for models that vanished
// (e.g. a config reload) and requests whose caller disconnected before they
// could be served.
func (s *Serial) startNext() {
if s.active != nil {
return // a job is already loading or serving
}
for len(s.queued) > 0 {
idx, score := s.pick()
q := s.queued[idx]
s.queued = append(s.queued[:idx], s.queued[idx+1:]...)
s.queueChanged()
req := q.req
state, ok := s.effects.ModelState(req.Model)
if !ok {
s.effects.GrantError(req, ErrModelNotFound)
continue
}
waited := s.now().Sub(q.enqueued)
s.annotate(req, score, waited)
r := req
s.active = &r
evict := s.otherRunning(req.Model)
if state == process.StateReady && len(evict) == 0 {
// Already loaded and the only model running — serve immediately.
s.logger.Debugf("%s: serving model %s (already loaded, priority %d, score %d, waited %s)",
s.name, req.Model, req.Priority, score, waited.Round(time.Millisecond))
if s.serve() {
s.recordDispatch(req.Priority)
return
}
continue // caller gone; pick the next request
}
s.logger.Debugf("%s: swapping to model %s (priority %d, score %d, waited %s), evicting %v",
s.name, req.Model, req.Priority, score, waited.Round(time.Millisecond), evict)
s.phase = phaseSwapping
s.recordDispatch(req.Priority)
s.effects.StartSwap(req.Model, evict)
return
}
}
// pick returns the index of the highest scoring queued request and its score.
// It also accounts for whether the aging and swap-affinity terms changed the
// outcome: each is recomputed with that term removed, and a different winner
// means the term reordered this dispatch. The queue is never empty here.
func (s *Serial) pick() (int, int) {
now := s.now()
resident, _ := s.noSwapModel()
idx, score := s.best(now, resident, true, true)
if len(s.queued) > 1 {
if other, _ := s.best(now, resident, false, true); other != idx {
s.countReorder(true)
}
if other, _ := s.best(now, resident, true, false); other != idx {
s.countReorder(false)
}
}
return idx, score
}
// best returns the index and score of the highest scoring queued request, with
// the aging and swap-affinity terms individually switchable so pick can measure
// their effect. Ties keep the earliest arrival because the queue is in arrival
// order and the comparison is strictly greater-than.
func (s *Serial) best(now time.Time, resident string, withAging, withAffinity bool) (int, int) {
bestIdx, bestScore := 0, 0
for i, q := range s.queued {
score := q.req.Priority
if withAffinity && resident != "" && q.req.Model == resident {
score += s.swapAffinityBonus
}
if withAging {
score += s.aging(q, now)
}
if i == 0 || score > bestScore {
bestIdx, bestScore = i, score
}
}
return bestIdx, bestScore
}
// aging converts how long a request has waited into priority points. It is
// unbounded: a request that waits long enough eventually outranks anything.
func (s *Serial) aging(q queuedReq, now time.Time) int {
if s.agingDivisor <= 0 {
return 0
}
waited := now.Sub(q.enqueued)
if waited <= 0 {
return 0
}
return int(waited / s.agingDivisor)
}
// noSwapModel returns the model that can be served without any swap: the sole
// running process, when it is ready. Anything else — nothing loaded, a process
// still loading, or more than one running — means every request costs a swap,
// so no request earns the affinity bonus.
func (s *Serial) noSwapModel() (string, bool) {
if s.swapAffinityBonus == 0 {
return "", false
}
running := s.effects.RunningModels()
if len(running) != 1 {
return "", false
}
for id, state := range running {
if state == process.StateReady {
return id, true
}
}
return "", false
}
// annotate records the dispatch decision on the request's context so it reaches
// the activity log. This is how "why did my request take 20 minutes" gets
// answered after the fact.
func (s *Serial) annotate(req HandlerReq, score int, waited time.Duration) {
if req.Ctx == nil {
return
}
fields := [...]struct{ key, value string }{
{"serial_priority", strconv.Itoa(req.Priority)},
{"serial_band", shared.PriorityBand(req.Priority)},
{"serial_score", strconv.Itoa(score)},
{"serial_queue_wait_ms", strconv.FormatInt(waited.Milliseconds(), 10)},
}
for _, f := range fields {
if err := shared.SetReqData(req.Ctx, f.key, f.value); err != nil {
// Every key shares one context, so the first failure means none
// of them will land.
s.logger.Debugf("%s: failed to set %s metadata: %v", s.name, f.key, err)
return
}
}
}
// serve hands the active request its tracked handler. It returns true when the
// request is now serving (await OnServeDone); false when the caller had already
// disconnected, in which case active is cleared so the next job can start.
func (s *Serial) serve() bool {
if s.effects.GrantServe(*s.active, s.active.Model) {
s.phase = phaseServing
return true
}
s.logger.Debugf("%s: caller for model %s gone before serve", s.name, s.active.Model)
s.active = nil
s.phase = phaseIdle
return false
}
// OnSwapDone fires when the load for the active request completes. On success the
// request is served; on failure its caller receives the error and the queue
// advances. A SwapDone that does not match the active load (e.g. its request was
// unloaded or cancelled mid-load) is ignored.
func (s *Serial) OnSwapDone(ev SwapDone) {
if s.active == nil || s.phase != phaseSwapping || s.active.Model != ev.ModelID {
return
}
if ev.Err != nil {
s.logger.Debugf("%s: swap for model %s failed: %v", s.name, ev.ModelID, ev.Err)
s.effects.GrantError(*s.active, ev.Err)
s.active = nil
s.phase = phaseIdle
s.startNext()
return
}
if !s.serve() {
s.startNext() // caller vanished while the model loaded; move on
}
}
// OnServeDone fires when the active request's handler returns. The slot is freed
// and the next queued request begins.
func (s *Serial) OnServeDone(ev ServeDoneEvent) {
if s.active == nil || s.phase != phaseServing {
return
}
s.active = nil
s.phase = phaseIdle
s.startNext()
}
// OnCancel removes a disconnected client's request from the queue. A request that
// is already active is left to finish: if it was loading, OnSwapDone's serve()
// will find the caller gone (GrantServe false) and advance; if it was serving,
// its handler returns normally and reaches OnServeDone.
func (s *Serial) OnCancel(req HandlerReq) {
if len(s.queued) == 0 {
return
}
kept := s.queued[:0]
removed := false
for _, q := range s.queued {
if q.req.Respond == req.Respond {
removed = true
continue
}
kept = append(kept, q)
}
s.queued = kept
if removed {
s.logger.Debugf("%s: cancelled request for model %s pruned from queue", s.name, req.Model)
s.queueChanged()
}
}
// OnUnload reconciles state for an unload, stops the targeted processes, and
// advances the queue. It mirrors the FIFO contract: queued requests for unloaded
// models are failed; an active *loading* request for an unloaded model is failed
// (its swap goroutine is left to finish and its SwapDone is then ignored); an
// active *serving* request is left for its handler to end when StopProcesses
// kills the upstream. The Stop is synchronous so callers of Unload can rely on
// the processes being stopped on return.
func (s *Serial) OnUnload(targets []string, timeout time.Duration) {
unloadErr := fmt.Errorf("%s: model unloaded", s.name)
targetSet := make(map[string]bool, len(targets))
for _, id := range targets {
targetSet[id] = true
}
if s.active != nil && s.phase == phaseSwapping && targetSet[s.active.Model] {
s.effects.GrantError(*s.active, unloadErr)
s.active = nil
s.phase = phaseIdle
}
if len(s.queued) > 0 {
kept := s.queued[:0]
for _, q := range s.queued {
if targetSet[q.req.Model] {
s.effects.GrantError(q.req, unloadErr)
continue
}
kept = append(kept, q)
}
s.queued = kept
s.queueChanged()
}
s.effects.StopProcesses(timeout, targets)
// A still-serving active request advances via OnServeDone when its killed
// handler returns; only start the next job when nothing is active now.
if s.active == nil {
s.startNext()
}
}
// OnShutdown grants err to every request the scheduler still holds: an active
// loading request and all queued requests. A serving request is torn down with
// its process by the baseRouter.
func (s *Serial) OnShutdown(err error) {
if s.active != nil && s.phase == phaseSwapping {
s.effects.GrantError(*s.active, err)
s.active = nil
s.phase = phaseIdle
}
for _, q := range s.queued {
s.effects.GrantError(q.req, err)
}
s.queued = nil
s.queueChanged()
}
// otherRunning returns every running model except target, sorted for
// deterministic eviction.
func (s *Serial) otherRunning(target string) []string {
var out []string
for id := range s.effects.RunningModels() {
if id != target {
out = append(out, id)
}
}
sort.Strings(out)
return out
}
// queueChanged is called after every mutation of s.queued. It refreshes the
// stats mirror and tells each waiter its new position.
func (s *Serial) queueChanged() {
s.syncStats()
s.broadcastPositions()
}
// broadcastPositions sends each waiter its 1-indexed place in dispatch order
// rather than arrival order, so a queued caller sees the position priority
// actually earned it. The ranking is a snapshot: aging keeps moving, so a
// position only holds until the next queue change.
func (s *Serial) broadcastPositions() {
if len(s.queued) == 0 {
return
}
now := s.now()
resident, _ := s.noSwapModel()
scores := make([]int, len(s.queued))
order := make([]int, len(s.queued))
for i, q := range s.queued {
score := q.req.Priority
if resident != "" && q.req.Model == resident {
score += s.swapAffinityBonus
}
scores[i] = score + s.aging(q, now)
order[i] = i
}
// Stable so equal scores keep arrival order, matching best().
sort.SliceStable(order, func(a, b int) bool { return scores[order[a]] > scores[order[b]] })
ranked := make([]HandlerReq, len(order))
for rank, i := range order {
ranked[rank] = s.queued[i].req
}
broadcastQueuePositions(ranked)
}
// syncStats refreshes the per-band depth and oldest-arrival mirror that
// QueueStats reads. Enqueue times are stored rather than durations so the wait
// is computed fresh at read time instead of going stale between queue changes.
func (s *Serial) syncStats() {
depth := make(map[string]int, len(shared.PriorityBands))
oldest := make(map[string]time.Time, len(shared.PriorityBands))
for _, q := range s.queued {
band := shared.PriorityBand(q.req.Priority)
depth[band]++
if t, ok := oldest[band]; !ok || q.enqueued.Before(t) {
oldest[band] = q.enqueued
}
}
s.mu.Lock()
defer s.mu.Unlock()
s.stats.depth = depth
s.stats.oldest = oldest
}
// recordDispatch counts one request handed off, bucketed by band.
func (s *Serial) recordDispatch(priority int) {
band := shared.PriorityBand(priority)
s.mu.Lock()
defer s.mu.Unlock()
s.stats.dispatched[band]++
}
// countReorder records that aging (or swap affinity) changed which request a
// dispatch picked.
func (s *Serial) countReorder(aging bool) {
s.mu.Lock()
defer s.mu.Unlock()
if aging {
s.stats.agingReorders++
} else {
s.stats.affinityReorders++
}
}
// QueueStats implements StatsReporter. It is safe to call from any goroutine.
func (s *Serial) QueueStats() QueueStats {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
bands := make(map[string]BandStats, len(shared.PriorityBands))
for _, band := range shared.PriorityBands {
bs := BandStats{
Depth: s.stats.depth[band],
Dispatched: s.stats.dispatched[band],
}
if t, ok := s.stats.oldest[band]; ok {
if wait := now.Sub(t); wait > 0 {
bs.OldestWait = wait
}
}
bands[band] = bs
}
return QueueStats{
Bands: bands,
AgingReorders: s.stats.agingReorders,
AffinityReorders: s.stats.affinityReorders,
}
}
-731
View File
@@ -1,731 +0,0 @@
package scheduler
import (
"context"
"errors"
"io"
"testing"
"time"
"github.com/mostlygeek/llama-swap/internal/config"
"github.com/mostlygeek/llama-swap/internal/logmon"
"github.com/mostlygeek/llama-swap/internal/process"
"github.com/mostlygeek/llama-swap/internal/shared"
)
// Serial methods all run on the router's single run-loop goroutine, so these
// tests drive them directly and synchronously, reusing fakeEffects and the
// req/reqCh helpers from fifo_test.go. A load completes via OnSwapDone and a
// served request finishes via OnServeDone — the events the run loop delivers.
// newSerial builds a Serial with the production defaults: aging at one point
// per minute, swap affinity at +10.
func newSerial(eff Effects) *Serial {
return NewSerial("test", logmon.NewWriter(io.Discard), config.SerialConfig{}, eff)
}
// newSerialCfg builds a Serial with explicit scoring settings. Passing 0 for
// either term disables it.
func newSerialCfg(eff Effects, agingDivisor, swapAffinityBonus int) *Serial {
return NewSerial("test", logmon.NewWriter(io.Discard), config.SerialConfig{
AgingDivisor: &agingDivisor,
SwapAffinityBonus: &swapAffinityBonus,
}, eff)
}
// fakeClock replaces a Serial's clock with one the test advances by hand, so
// aging is exercised without sleeping.
type fakeClock struct{ t time.Time }
func newFakeClock(s *Serial) *fakeClock {
c := &fakeClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
s.now = func() time.Time { return c.t }
return c
}
func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) }
// reqP is a HandlerReq carrying an explicit caller priority.
func reqP(model string, priority int) HandlerReq {
return HandlerReq{Model: model, Priority: priority}
}
// lastStart returns the most recent StartSwap record.
func lastStart(t *testing.T, eff *fakeEffects) startRec {
t.Helper()
if len(eff.starts) == 0 {
t.Fatal("no StartSwap recorded")
}
return eff.starts[len(eff.starts)-1]
}
func sameSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
m := map[string]int{}
for _, x := range a {
m[x]++
}
for _, x := range b {
m[x]--
}
for _, v := range m {
if v != 0 {
return false
}
}
return true
}
// servedOrder returns the model IDs of every successful serve grant in order.
func servedOrder(eff *fakeEffects) []string {
var out []string
for _, g := range eff.grants {
if g.err == nil && g.serve {
out = append(out, g.model)
}
}
return out
}
func TestSerial_FastPath_AlreadyLoaded(t *testing.T) {
eff := newFakeEffects()
eff.states["a"] = process.StateReady
s := newSerial(eff)
s.OnRequest(req("a"))
if got := len(eff.starts); got != 0 {
t.Errorf("StartSwap calls=%d want 0 (already loaded, no swap)", got)
}
if got := eff.served("a"); got != 1 {
t.Errorf("served(a)=%d want 1", got)
}
}
func TestSerial_ColdStart_LoadsThenServes(t *testing.T) {
eff := newFakeEffects()
eff.states["a"] = process.StateStopped
s := newSerial(eff)
s.OnRequest(req("a"))
if got := eff.startsFor("a"); got != 1 {
t.Fatalf("StartSwap(a)=%d want 1", got)
}
if got := eff.served("a"); got != 0 {
t.Errorf("served(a)=%d want 0 before load completes", got)
}
eff.states["a"] = process.StateReady
s.OnSwapDone(SwapDone{ModelID: "a"})
if got := eff.served("a"); got != 1 {
t.Errorf("served(a)=%d want 1 after load", got)
}
}
func TestSerial_UnknownModel(t *testing.T) {
eff := newFakeEffects() // no states => unknown
s := newSerial(eff)
s.OnRequest(req("ghost"))
if len(eff.starts) != 0 {
t.Errorf("StartSwap calls=%d want 0", len(eff.starts))
}
if eff.errored("ghost") != 1 {
t.Fatalf("errored(ghost)=%d want 1", eff.errored("ghost"))
}
if !errors.Is(eff.grants[0].err, ErrModelNotFound) {
t.Errorf("err=%v want ErrModelNotFound", eff.grants[0].err)
}
}
func TestSerial_EvictsEveryOtherModel(t *testing.T) {
eff := newFakeEffects()
eff.states["x"] = process.StateReady // already running
eff.states["y"] = process.StateReady // also running (e.g. left over)
eff.states["a"] = process.StateStopped
s := newSerial(eff)
s.OnRequest(req("a"))
st := lastStart(t, eff)
if st.model != "a" {
t.Fatalf("loading %s want a", st.model)
}
if !sameSet(st.evict, []string{"x", "y"}) {
t.Errorf("evict=%v want [x y] (serial evicts ALL other models)", st.evict)
}
}
// TestSerial_OneJobAtATime verifies a second request waits while the first is
// serving, and only starts after the first finishes.
func TestSerial_OneJobAtATime(t *testing.T) {
eff := newFakeEffects()
eff.states["a"] = process.StateReady
eff.states["b"] = process.StateStopped
s := newSerial(eff)
s.OnRequest(req("a")) // served immediately
s.OnRequest(req("b")) // must wait — a is serving
if got := eff.startsFor("b"); got != 0 {
t.Fatalf("StartSwap(b)=%d want 0 while a is serving", got)
}
if got := eff.served("a"); got != 1 {
t.Fatalf("served(a)=%d want 1", got)
}
// a finishes -> b may now load (evicting a).
s.OnServeDone(ServeDoneEvent{ModelID: "a"})
if got := eff.startsFor("b"); got != 1 {
t.Fatalf("StartSwap(b)=%d want 1 after a finished", got)
}
if st := lastStart(t, eff); !sameSet(st.evict, []string{"a"}) {
t.Errorf("b evict=%v want [a]", st.evict)
}
}
// TestSerial_SameModelConsecutive_NoReload verifies back-to-back requests for the
// already-loaded model run without a reload, one after another.
func TestSerial_SameModelConsecutive_NoReload(t *testing.T) {
eff := newFakeEffects()
eff.states["a"] = process.StateStopped
s := newSerial(eff)
s.OnRequest(req("a")) // cold load
s.OnRequest(req("a")) // queued behind the first
eff.states["a"] = process.StateReady
s.OnSwapDone(SwapDone{ModelID: "a"}) // first serves
if got := eff.served("a"); got != 1 {
t.Fatalf("served(a)=%d want 1 (one at a time)", got)
}
s.OnServeDone(ServeDoneEvent{ModelID: "a"}) // first done -> second serves
if got := eff.served("a"); got != 2 {
t.Fatalf("served(a)=%d want 2", got)
}
if got := eff.startsFor("a"); got != 1 {
t.Errorf("StartSwap(a)=%d want 1 (second request must not reload)", got)
}
}
// TestSerial_StrictArrivalOrder covers the scoring function's degenerate case:
// with equal priorities and swap affinity disabled, qwen36, qwen35, sdxl,
// qwen36 execute in EXACTLY that order with evictions between each model
// switch, including reloading qwen36 at the end even though it ran first.
// Aging cannot reorder them either — they all arrive at the same instant, so
// they age at the same rate.
func TestSerial_StrictArrivalOrder(t *testing.T) {
eff := newFakeEffects()
for _, m := range []string{"qwen36", "qwen35", "sdxl"} {
eff.states[m] = process.StateStopped
}
s := newSerialCfg(eff, config.DefaultAgingDivisor, 0)
for _, m := range []string{"qwen36", "qwen35", "sdxl", "qwen36"} {
s.OnRequest(req(m))
}
// Only the first job starts loading; the rest wait their turn.
if len(eff.starts) != 1 || eff.starts[0].model != "qwen36" {
t.Fatalf("starts=%+v want only [qwen36] loading first", eff.starts)
}
// step completes the current model's load+serve and returns control to the
// scheduler, which must start the next queued model.
step := func(model string, wantEvict []string) {
t.Helper()
st := lastStart(t, eff)
if st.model != model {
t.Fatalf("loading %q want %q", st.model, model)
}
if !sameSet(st.evict, wantEvict) {
t.Fatalf("loading %q evict=%v want %v", model, st.evict, wantEvict)
}
// Simulate the eviction + load actually happening.
for _, e := range st.evict {
eff.states[e] = process.StateStopped
}
eff.states[model] = process.StateReady
s.OnSwapDone(SwapDone{ModelID: model})
s.OnServeDone(ServeDoneEvent{ModelID: model})
}
step("qwen36", nil) // cold load, nothing else running
step("qwen35", []string{"qwen36"}) // evict qwen36
step("sdxl", []string{"qwen35"}) // evict qwen35
step("qwen36", []string{"sdxl"}) // RELOAD qwen36, evict sdxl
want := []string{"qwen36", "qwen35", "sdxl", "qwen36"}
if got := servedOrder(eff); !sameOrder(got, want) {
t.Fatalf("serve order=%v want %v", got, want)
}
}
func sameOrder(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// stepModel completes the current load+serve for model and returns control to
// the scheduler so it dispatches the next queued request.
func stepModel(t *testing.T, s *Serial, eff *fakeEffects, model string) {
t.Helper()
if len(eff.starts) > 0 {
if st := eff.starts[len(eff.starts)-1]; st.model == model {
for _, e := range st.evict {
eff.states[e] = process.StateStopped
}
eff.states[model] = process.StateReady
s.OnSwapDone(SwapDone{ModelID: model})
}
}
s.OnServeDone(ServeDoneEvent{ModelID: model})
}
// TestSerial_HigherPriorityDispatchesFirst is the point of the whole exercise:
// a batch job already queued must yield to an interactive request that arrives
// later, because dispatch order is by score, not arrival.
func TestSerial_HigherPriorityDispatchesFirst(t *testing.T) {
eff := newFakeEffects()
for _, m := range []string{"running", "batch", "interactive"} {
eff.states[m] = process.StateStopped
}
s := newSerial(eff)
s.OnRequest(reqP("running", shared.PriorityNormal)) // dispatches immediately
s.OnRequest(reqP("batch", shared.PriorityBatch)) // queued first...
s.OnRequest(reqP("interactive", shared.PriorityInteractive)) // ...but this jumps it
stepModel(t, s, eff, "running")
if got := eff.startsFor("interactive"); got != 1 {
t.Fatalf("StartSwap(interactive)=%d want 1 (must overtake the queued batch job)", got)
}
if got := eff.startsFor("batch"); got != 0 {
t.Fatalf("StartSwap(batch)=%d want 0 (still waiting behind interactive)", got)
}
stepModel(t, s, eff, "interactive")
if got := eff.startsFor("batch"); got != 1 {
t.Fatalf("StartSwap(batch)=%d want 1 once nothing outranks it", got)
}
}
// TestSerial_TierOffsetBreaksTieWithinBand verifies the composition rule the
// design depends on: a small per-caller offset orders requests inside a band
// and never promotes one across a band. A "max member" batch job at -98 beats
// other batch work but still loses to every normal request at 0.
func TestSerial_TierOffsetBreaksTieWithinBand(t *testing.T) {
eff := newFakeEffects()
for _, m := range []string{"running", "free", "max", "normal"} {
eff.states[m] = process.StateStopped
}
s := newSerialCfg(eff, 0, 0) // isolate the priority term
s.OnRequest(reqP("running", 0))
s.OnRequest(reqP("free", shared.PriorityBatch)) // -100
s.OnRequest(reqP("max", shared.PriorityBatch+2)) // -98, max tier
s.OnRequest(reqP("normal", shared.PriorityNormal)) // 0
stepModel(t, s, eff, "running")
if got := eff.startsFor("normal"); got != 1 {
t.Fatalf("StartSwap(normal)=%d want 1 (a tier bonus must not cross a band)", got)
}
stepModel(t, s, eff, "normal")
if got := eff.startsFor("max"); got != 1 {
t.Fatalf("StartSwap(max)=%d want 1 (max tier outranks free within the batch band)", got)
}
stepModel(t, s, eff, "max")
if got := eff.startsFor("free"); got != 1 {
t.Fatalf("StartSwap(free)=%d want 1 (last)", got)
}
}
// TestSerial_AgingPreventsStarvation verifies the one term allowed to cross
// bands: a batch job that has waited long enough eventually beats an
// interactive request that arrived just now.
func TestSerial_AgingPreventsStarvation(t *testing.T) {
eff := newFakeEffects()
for _, m := range []string{"running", "batch", "interactive"} {
eff.states[m] = process.StateStopped
}
s := newSerialCfg(eff, 60, 0) // one point per minute, no affinity
clock := newFakeClock(s)
s.OnRequest(reqP("running", shared.PriorityNormal))
s.OnRequest(reqP("batch", shared.PriorityBatch)) // -100
// The batch job waits long enough to gain 201 points: -100 + 201 = 101,
// just past a fresh interactive request at +100.
clock.advance(201 * time.Minute)
s.OnRequest(reqP("interactive", shared.PriorityInteractive))
stepModel(t, s, eff, "running")
if got := eff.startsFor("batch"); got != 1 {
t.Fatalf("StartSwap(batch)=%d want 1 (aging must eventually beat a fresh interactive request)", got)
}
if got := eff.startsFor("interactive"); got != 0 {
t.Fatalf("StartSwap(interactive)=%d want 0 (outranked by the aged batch job)", got)
}
}
// TestSerial_AgingCannotCrossBandTooEarly is the other half of aging: a batch
// job that has waited only a little still loses to interactive traffic.
func TestSerial_AgingCannotCrossBandTooEarly(t *testing.T) {
eff := newFakeEffects()
for _, m := range []string{"running", "batch", "interactive"} {
eff.states[m] = process.StateStopped
}
s := newSerialCfg(eff, 60, 0)
clock := newFakeClock(s)
s.OnRequest(reqP("running", shared.PriorityNormal))
s.OnRequest(reqP("batch", shared.PriorityBatch))
clock.advance(30 * time.Minute) // -100 + 30 = -70
s.OnRequest(reqP("interactive", shared.PriorityInteractive))
stepModel(t, s, eff, "running")
if got := eff.startsFor("interactive"); got != 1 {
t.Fatalf("StartSwap(interactive)=%d want 1 (30 minutes is not enough aging)", got)
}
}
// TestSerial_SwapAffinity_PrefersResidentModel verifies the bounded bonus keeps
// the model cache from thrashing: with equal priorities, a queued request for
// the already-loaded model runs before earlier requests that would each force a
// cold load.
func TestSerial_SwapAffinity_PrefersResidentModel(t *testing.T) {
eff := newFakeEffects()
for _, m := range []string{"qwen36", "qwen35", "sdxl"} {
eff.states[m] = process.StateStopped
}
s := newSerial(eff) // affinity +10 by default
for _, m := range []string{"qwen36", "qwen35", "sdxl", "qwen36"} {
s.OnRequest(req(m))
}
stepModel(t, s, eff, "qwen36") // qwen36 now resident and ready
if got := eff.served("qwen36"); got != 2 {
t.Fatalf("served(qwen36)=%d want 2 (the trailing qwen36 should skip the swap queue)", got)
}
if len(eff.starts) != 1 {
t.Fatalf("starts=%+v want only the initial qwen36 load (no reload)", eff.starts)
}
stepModel(t, s, eff, "qwen36")
if got := eff.startsFor("qwen35"); got != 1 {
t.Fatalf("StartSwap(qwen35)=%d want 1 once no resident request remains", got)
}
}
// TestSerial_SwapAffinity_CannotCrossBand verifies the bonus is bounded: an
// interactive request that needs a cold load still beats a batch job for the
// already-resident model.
func TestSerial_SwapAffinity_CannotCrossBand(t *testing.T) {
eff := newFakeEffects()
eff.states["resident"] = process.StateReady
eff.states["cold"] = process.StateStopped
s := newSerialCfg(eff, 0, 99) // the largest bonus config permits
s.OnRequest(reqP("resident", shared.PriorityNormal)) // dispatches immediately
s.OnRequest(reqP("resident", shared.PriorityBatch)) // queued, would get +99
s.OnRequest(reqP("cold", shared.PriorityInteractive))
s.OnServeDone(ServeDoneEvent{ModelID: "resident"})
if got := eff.startsFor("cold"); got != 1 {
t.Fatalf("StartSwap(cold)=%d want 1 (-100+99 must still lose to +100)", got)
}
}
// TestSerial_DispatchSetsMetadata verifies the dispatch decision is recorded on
// the request context, which is what puts it in the activity log.
func TestSerial_DispatchSetsMetadata(t *testing.T) {
eff := newFakeEffects()
eff.states["hold"] = process.StateReady
eff.states["a"] = process.StateStopped
s := newSerialCfg(eff, 60, 0)
clock := newFakeClock(s)
s.OnRequest(req("hold")) // serves immediately, occupying the slot
ctx := shared.SetContext(context.Background(), shared.ReqContextData{ModelID: "a", Metadata: make(map[string]string)})
s.OnRequest(HandlerReq{Model: "a", Priority: shared.PriorityBatch, Ctx: ctx})
clock.advance(2 * time.Minute) // -100 + 2 aging points
s.OnServeDone(ServeDoneEvent{ModelID: "hold"})
// a is dispatched (and annotated) here; finish its load so it is granted.
eff.states["hold"] = process.StateStopped
eff.states["a"] = process.StateReady
s.OnSwapDone(SwapDone{ModelID: "a"})
data, ok := shared.ReadContext(eff.lastServeReq.Ctx)
if !ok {
t.Fatal("context data missing from granted request")
}
want := map[string]string{
"serial_priority": "-100",
"serial_band": shared.BandBatch,
"serial_score": "-98",
"serial_queue_wait_ms": "120000",
}
for k, v := range want {
if got := data.Metadata[k]; got != v {
t.Errorf("%s = %q, want %q", k, got, v)
}
}
}
// TestSerial_QueueStats checks the numbers the /metrics endpoint reports:
// per-band depth and wait while queued, dispatch counts after the fact, and the
// reorder counters that say whether the scoring terms changed anything.
func TestSerial_QueueStats(t *testing.T) {
eff := newFakeEffects()
for _, m := range []string{"running", "batch", "interactive"} {
eff.states[m] = process.StateStopped
}
s := newSerialCfg(eff, 60, 0)
clock := newFakeClock(s)
s.OnRequest(reqP("running", shared.PriorityNormal)) // dispatched, not queued
s.OnRequest(reqP("batch", shared.PriorityBatch))
clock.advance(5 * time.Minute)
s.OnRequest(reqP("interactive", shared.PriorityInteractive))
stats := s.QueueStats()
if got := stats.Bands[shared.BandBatch].Depth; got != 1 {
t.Errorf("batch depth=%d want 1", got)
}
if got := stats.Bands[shared.BandBatch].OldestWait; got != 5*time.Minute {
t.Errorf("batch oldest wait=%s want 5m", got)
}
if got := stats.Bands[shared.BandInteractive].Depth; got != 1 {
t.Errorf("interactive depth=%d want 1", got)
}
if got := stats.Bands[shared.BandNormal].Depth; got != 0 {
t.Errorf("normal depth=%d want 0 (it dispatched immediately)", got)
}
if got := stats.Bands[shared.BandNormal].Dispatched; got != 1 {
t.Errorf("normal dispatched=%d want 1", got)
}
// Dispatching interactive over the older batch job is priority doing its
// job, not aging: with only 5 minutes of aging the winner is unchanged.
stepModel(t, s, eff, "running")
stats = s.QueueStats()
if got := stats.Bands[shared.BandInteractive].Dispatched; got != 1 {
t.Errorf("interactive dispatched=%d want 1", got)
}
if stats.AgingReorders != 0 {
t.Errorf("aging reorders=%d want 0", stats.AgingReorders)
}
if stats.AffinityReorders != 0 {
t.Errorf("affinity reorders=%d want 0 (affinity disabled)", stats.AffinityReorders)
}
}
// TestSerial_QueueStats_CountsReorders verifies each scoring term is credited
// when it actually changes the dispatch decision.
func TestSerial_QueueStats_CountsReorders(t *testing.T) {
t.Run("aging", func(t *testing.T) {
eff := newFakeEffects()
for _, m := range []string{"running", "old", "new"} {
eff.states[m] = process.StateStopped
}
s := newSerialCfg(eff, 60, 0)
clock := newFakeClock(s)
s.OnRequest(reqP("running", 0))
s.OnRequest(reqP("old", -10))
clock.advance(30 * time.Minute) // old: -10 + 30 = 20
s.OnRequest(reqP("new", 0)) // new: 0
stepModel(t, s, eff, "running")
if got := eff.startsFor("old"); got != 1 {
t.Fatalf("StartSwap(old)=%d want 1 (aging promoted it)", got)
}
if got := s.QueueStats().AgingReorders; got != 1 {
t.Errorf("aging reorders=%d want 1", got)
}
})
t.Run("swap affinity", func(t *testing.T) {
eff := newFakeEffects()
eff.states["resident"] = process.StateReady
eff.states["cold"] = process.StateStopped
s := newSerialCfg(eff, 0, 10)
s.OnRequest(req("resident")) // dispatches immediately
s.OnRequest(reqP("cold", 5)) // higher priority, but needs a swap
s.OnRequest(reqP("resident", 0)) // +10 affinity beats it
s.OnServeDone(ServeDoneEvent{ModelID: "resident"})
if got := eff.served("resident"); got != 2 {
t.Fatalf("served(resident)=%d want 2 (affinity outranked the cold request)", got)
}
if got := s.QueueStats().AffinityReorders; got != 1 {
t.Errorf("affinity reorders=%d want 1", got)
}
})
}
func TestSerial_SwapError_FailsCallerAndAdvances(t *testing.T) {
eff := newFakeEffects()
eff.states["a"] = process.StateStopped
eff.states["b"] = process.StateStopped
s := newSerial(eff)
s.OnRequest(req("a"))
s.OnRequest(req("b")) // queued behind a
// a's load fails: its caller is errored and b proceeds.
s.OnSwapDone(SwapDone{ModelID: "a", Err: errors.New("boom")})
if eff.errored("a") != 1 {
t.Fatalf("errored(a)=%d want 1", eff.errored("a"))
}
if got := eff.startsFor("b"); got != 1 {
t.Fatalf("StartSwap(b)=%d want 1 after a's load failed", got)
}
}
// TestSerial_GrantServeFalse_Advances verifies that when the active request's
// caller has disconnected by serve time, the queue advances to the next request.
func TestSerial_GrantServeFalse_Advances(t *testing.T) {
eff := newFakeEffects()
eff.states["a"] = process.StateStopped
eff.states["b"] = process.StateStopped
eff.serveResult["a"] = false // a's caller is gone by grant time
s := newSerial(eff)
s.OnRequest(req("a"))
s.OnRequest(req("b")) // queued
eff.states["a"] = process.StateReady
s.OnSwapDone(SwapDone{ModelID: "a"}) // grant fails -> advance to b
if got := eff.served("a"); got != 0 {
t.Errorf("served(a)=%d want 0 (caller gone)", got)
}
if got := eff.startsFor("b"); got != 1 {
t.Fatalf("StartSwap(b)=%d want 1 (advanced after gone caller)", got)
}
}
func TestSerial_OnCancel_QueuedRequest(t *testing.T) {
eff := newFakeEffects()
eff.states["a"] = process.StateStopped
eff.states["b"] = process.StateStopped
s := newSerial(eff)
s.OnRequest(reqCh("a")) // starts loading a
cancelled := reqCh("b")
s.OnRequest(cancelled) // queued behind a
if len(s.queued) != 1 {
t.Fatalf("queued=%d want 1", len(s.queued))
}
s.OnCancel(cancelled)
if len(s.queued) != 0 {
t.Fatalf("queued=%d want 0 after cancel", len(s.queued))
}
// a completes; b is gone, so nothing starts for it.
eff.states["a"] = process.StateReady
s.OnSwapDone(SwapDone{ModelID: "a"})
s.OnServeDone(ServeDoneEvent{ModelID: "a"})
if got := eff.startsFor("b"); got != 0 {
t.Errorf("StartSwap(b)=%d want 0 (cancelled before its turn)", got)
}
}
func TestSerial_OnShutdown_FailsQueuedAndActiveLoad(t *testing.T) {
eff := newFakeEffects()
eff.states["a"] = process.StateStopped
eff.states["b"] = process.StateStopped
eff.states["c"] = process.StateStopped
s := newSerial(eff)
s.OnRequest(req("a")) // active (loading)
s.OnRequest(req("b")) // queued
s.OnRequest(req("c")) // queued
s.OnShutdown(errors.New("shutting down"))
if got := eff.errored(""); got != 3 {
t.Errorf("error grants=%d want 3 (active load + 2 queued)", got)
}
if len(s.queued) != 0 {
t.Errorf("queued=%d want 0 after shutdown", len(s.queued))
}
}
// TestSerial_OnUnload_WhileServing verifies that unloading the model that is
// actively serving does not strand the queue: OnUnload stops the process but
// leaves the active request to end via OnServeDone, which then advances.
func TestSerial_OnUnload_WhileServing(t *testing.T) {
eff := newFakeEffects()
eff.states["a"] = process.StateReady
eff.states["b"] = process.StateStopped
s := newSerial(eff)
s.OnRequest(req("a")) // served immediately (a ready)
s.OnRequest(req("b")) // queued behind a
if got := eff.served("a"); got != 1 {
t.Fatalf("served(a)=%d want 1", got)
}
// Unload a while it is serving: the process is stopped, but the queue must
// not advance yet — the active serve is still outstanding.
s.OnUnload([]string{"a"}, time.Second)
if len(eff.stops) != 1 || !sameSet(eff.stops[0].ids, []string{"a"}) {
t.Errorf("StopProcesses=%+v want one call stopping [a]", eff.stops)
}
if got := eff.startsFor("b"); got != 0 {
t.Fatalf("StartSwap(b)=%d want 0 before the serving request ends", got)
}
// The killed handler returns -> OnServeDone advances to b.
eff.states["a"] = process.StateStopped
s.OnServeDone(ServeDoneEvent{ModelID: "a"})
if got := eff.startsFor("b"); got != 1 {
t.Fatalf("StartSwap(b)=%d want 1 after the serving request ended", got)
}
}
func TestSerial_OnUnload_DropsQueuedAndStops(t *testing.T) {
eff := newFakeEffects()
eff.states["a"] = process.StateStopped
eff.states["b"] = process.StateStopped
s := newSerial(eff)
s.OnRequest(req("a")) // active (loading a)
s.OnRequest(req("b")) // queued
// Unload a: its active load is failed and a is stopped.
s.OnUnload([]string{"a"}, time.Second)
if eff.errored("a") != 1 {
t.Errorf("errored(a)=%d want 1 (active load failed)", eff.errored("a"))
}
if len(eff.stops) != 1 || !sameSet(eff.stops[0].ids, []string{"a"}) {
t.Errorf("StopProcesses=%+v want one call stopping [a]", eff.stops)
}
// b was queued and not unloaded; with a's load cancelled it now starts.
if got := eff.startsFor("b"); got != 1 {
t.Errorf("StartSwap(b)=%d want 1 after unload advanced the queue", got)
}
}
+3 -19
View File
@@ -88,12 +88,6 @@ func renderCapabilities(caps config.ModelCapConfig) (arch map[string]any, capsMa
if contains(caps.In, "image") && contains(caps.Out, "image") { if contains(caps.In, "image") && contains(caps.Out, "image") {
capsMap["image_to_image"] = true capsMap["image_to_image"] = true
} }
if contains(caps.In, "text") && contains(caps.Out, "video") {
capsMap["video_generation"] = true
}
if contains(caps.In, "image") && contains(caps.Out, "video") {
capsMap["image_to_video"] = true
}
} }
if caps.Tools { if caps.Tools {
@@ -293,26 +287,16 @@ func (s *Server) startPreload() {
}() }()
} }
// handleMetrics serves Prometheus-format metrics: system/GPU performance plus // handleMetrics serves Prometheus-format performance metrics. Returns 503 when
// the scheduler's request queue. Returns 503 only when neither source is // performance monitoring is disabled.
// available — performance monitoring disabled and a scheduler that does not
// report queue stats.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
queueStats, hasQueueStats := s.local.SchedulerStats() if s.perf == nil {
if s.perf == nil && !hasQueueStats {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("# performance monitor not available\n")) w.Write([]byte("# performance monitor not available\n"))
return return
} }
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
if s.perf != nil {
s.perf.MetricsHandler().ServeHTTP(w, r) s.perf.MetricsHandler().ServeHTTP(w, r)
} }
if hasQueueStats {
writeSchedulerMetrics(w, queueStats)
}
}
func handleHealth(w http.ResponseWriter, r *http.Request) { func handleHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
-42
View File
@@ -8,12 +8,10 @@ import (
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"time"
"github.com/mostlygeek/llama-swap/internal/config" "github.com/mostlygeek/llama-swap/internal/config"
"github.com/mostlygeek/llama-swap/internal/logmon" "github.com/mostlygeek/llama-swap/internal/logmon"
"github.com/mostlygeek/llama-swap/internal/process" "github.com/mostlygeek/llama-swap/internal/process"
"github.com/mostlygeek/llama-swap/internal/router/scheduler"
"github.com/mostlygeek/llama-swap/internal/shared" "github.com/mostlygeek/llama-swap/internal/shared"
) )
@@ -303,8 +301,6 @@ func TestServer_HandleUpstream_MetricsSkipsGET(t *testing.T) {
} }
func TestServer_HandleMetrics_Unavailable(t *testing.T) { func TestServer_HandleMetrics_Unavailable(t *testing.T) {
// No perf monitor and a scheduler that reports no queue stats: nothing to
// serve.
s := newTestServer(newStubRouter(nil, ""), newStubRouter(nil, "")) s := newTestServer(newStubRouter(nil, ""), newStubRouter(nil, ""))
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -314,44 +310,6 @@ func TestServer_HandleMetrics_Unavailable(t *testing.T) {
} }
} }
// TestServer_HandleMetrics_SchedulerQueue verifies the scheduler queue is
// exported even when performance monitoring is off, and that every band gets a
// series so the label set stays stable while the queue drains.
func TestServer_HandleMetrics_SchedulerQueue(t *testing.T) {
local := newStubRouter(nil, "")
local.queueStats = &scheduler.QueueStats{
Bands: map[string]scheduler.BandStats{
shared.BandInteractive: {Depth: 2, OldestWait: 3 * time.Second, Dispatched: 41},
shared.BandBatch: {Depth: 1, Dispatched: 7},
},
AgingReorders: 5,
AffinityReorders: 9,
}
s := newTestServer(local, newStubRouter(nil, ""))
w := httptest.NewRecorder()
s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
`llamaswap_scheduler_queue_depth{band="interactive"} 2`,
`llamaswap_scheduler_queue_depth{band="normal"} 0`,
`llamaswap_scheduler_queue_depth{band="batch"} 1`,
`llamaswap_scheduler_queue_oldest_wait_seconds{band="interactive"} 3`,
`llamaswap_scheduler_dispatched_total{band="interactive"} 41`,
`llamaswap_scheduler_dispatched_total{band="batch"} 7`,
`llamaswap_scheduler_reorders_total{cause="aging"} 5`,
`llamaswap_scheduler_reorders_total{cause="swap_affinity"} 9`,
} {
if !strings.Contains(body, want) {
t.Errorf("missing %q in:\n%s", want, body)
}
}
}
func TestServer_Redirects(t *testing.T) { func TestServer_Redirects(t *testing.T) {
s := newTestServer(newStubRouter(nil, ""), newStubRouter(nil, "")) s := newTestServer(newStubRouter(nil, ""), newStubRouter(nil, ""))
+1 -5
View File
@@ -37,8 +37,7 @@ const (
) )
// captureFieldsByPath overrides the default capture mask for routes carrying // captureFieldsByPath overrides the default capture mask for routes carrying
// large binary payloads (audio/image/video) where storing the full body is // large binary payloads (audio/image) where storing the full body is wasteful.
// wasteful.
var captureFieldsByPath = map[string]captureFields{ var captureFieldsByPath = map[string]captureFields{
"/v1/audio/speech": captureReqAll | captureRespHeaders, "/v1/audio/speech": captureReqAll | captureRespHeaders,
"/v1/audio/voices": captureReqHeaders | captureRespAll, "/v1/audio/voices": captureReqHeaders | captureRespAll,
@@ -47,9 +46,6 @@ var captureFieldsByPath = map[string]captureFields{
"/v1/images/edits": captureReqHeaders | captureRespHeaders, "/v1/images/edits": captureReqHeaders | captureRespHeaders,
"/sdapi/v1/txt2img": captureReqAll | captureRespHeaders, "/sdapi/v1/txt2img": captureReqAll | captureRespHeaders,
"/sdapi/v1/img2img": captureReqHeaders | captureRespHeaders, "/sdapi/v1/img2img": captureReqHeaders | captureRespHeaders,
// video: the request multipart can carry a conditioning frame and the
// response body IS the clip — headers only, both directions.
"/v1/videos/sync": captureReqHeaders | captureRespHeaders,
} }
// captureFieldsFor returns the capture mask for a request path. Unlisted routes // captureFieldsFor returns the capture mask for a request path. Unlisted routes
-39
View File
@@ -1,39 +0,0 @@
package server
import (
"fmt"
"io"
"github.com/mostlygeek/llama-swap/internal/router/scheduler"
"github.com/mostlygeek/llama-swap/internal/shared"
)
// writeSchedulerMetrics emits the request queue in Prometheus text format.
//
// The reorder counters are the ones that matter for tuning: they say how often
// aging or swap affinity actually changed which request went next. Without
// them, choosing agingDivisor and swapAffinityBonus is guesswork.
func writeSchedulerMetrics(w io.Writer, stats scheduler.QueueStats) {
fmt.Fprintf(w, "# HELP llamaswap_scheduler_queue_depth Requests waiting in the scheduler queue, by priority band\n")
fmt.Fprintf(w, "# TYPE llamaswap_scheduler_queue_depth gauge\n")
for _, band := range shared.PriorityBands {
fmt.Fprintf(w, "llamaswap_scheduler_queue_depth{band=%q} %d\n", band, stats.Bands[band].Depth)
}
fmt.Fprintf(w, "# HELP llamaswap_scheduler_queue_oldest_wait_seconds How long the longest-waiting queued request has waited, by priority band\n")
fmt.Fprintf(w, "# TYPE llamaswap_scheduler_queue_oldest_wait_seconds gauge\n")
for _, band := range shared.PriorityBands {
fmt.Fprintf(w, "llamaswap_scheduler_queue_oldest_wait_seconds{band=%q} %g\n", band, stats.Bands[band].OldestWait.Seconds())
}
fmt.Fprintf(w, "# HELP llamaswap_scheduler_dispatched_total Requests dispatched by the scheduler, by priority band\n")
fmt.Fprintf(w, "# TYPE llamaswap_scheduler_dispatched_total counter\n")
for _, band := range shared.PriorityBands {
fmt.Fprintf(w, "llamaswap_scheduler_dispatched_total{band=%q} %d\n", band, stats.Bands[band].Dispatched)
}
fmt.Fprintf(w, "# HELP llamaswap_scheduler_reorders_total Dispatches where a scoring term changed which request was picked\n")
fmt.Fprintf(w, "# TYPE llamaswap_scheduler_reorders_total counter\n")
fmt.Fprintf(w, "llamaswap_scheduler_reorders_total{cause=\"aging\"} %d\n", stats.AgingReorders)
fmt.Fprintf(w, "llamaswap_scheduler_reorders_total{cause=\"swap_affinity\"} %d\n", stats.AffinityReorders)
}
-9
View File
@@ -80,15 +80,6 @@ var modelPostJSONRoutes = []string{
var modelPostFormRoutes = []string{ var modelPostFormRoutes = []string{
"/v1/audio/transcriptions", "/v1/audio/transcriptions",
"/v1/images/edits", "/v1/images/edits",
// video generation: ONLY the blocking sync shape (vLLM-Omni
// /v1/videos/sync — the response body is the clip). The async
// OpenAI-style POST /v1/videos is deliberately NOT dispatched: its
// poll/download companions (GET /v1/videos/{id}[/content]) carry no
// model field to route by, so registering creation alone would start
// unretrievable upstream jobs. Extraction is content-type driven, so
// JSON bodies dispatch here too.
"/v1/videos/sync",
} }
// modelGetRoutes are model-dispatched GET endpoints (the model arrives as a // modelGetRoutes are model-dispatched GET endpoints (the model arrives as a
-64
View File
@@ -1,11 +1,9 @@
package server package server
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -18,7 +16,6 @@ import (
"github.com/mostlygeek/llama-swap/internal/logmon" "github.com/mostlygeek/llama-swap/internal/logmon"
"github.com/mostlygeek/llama-swap/internal/process" "github.com/mostlygeek/llama-swap/internal/process"
"github.com/mostlygeek/llama-swap/internal/router" "github.com/mostlygeek/llama-swap/internal/router"
"github.com/mostlygeek/llama-swap/internal/router/scheduler"
"github.com/mostlygeek/llama-swap/internal/shared" "github.com/mostlygeek/llama-swap/internal/shared"
) )
@@ -30,7 +27,6 @@ type stubRouter struct {
running map[string]process.ProcessState running map[string]process.ProcessState
unloadCalls atomic.Int32 unloadCalls atomic.Int32
loggers map[string]*logmon.Monitor loggers map[string]*logmon.Monitor
queueStats *scheduler.QueueStats
} }
func newStubRouter(models []string, response string) *stubRouter { func newStubRouter(models []string, response string) *stubRouter {
@@ -59,13 +55,6 @@ func (s *stubRouter) ProcessLogger(modelID string) (*logmon.Monitor, bool) {
return nil, false return nil, false
} }
func (s *stubRouter) SchedulerStats() (scheduler.QueueStats, bool) {
if s.queueStats == nil {
return scheduler.QueueStats{}, false
}
return *s.queueStats, true
}
// newTestServer wires a Server with stub routers and a built mux. // newTestServer wires a Server with stub routers and a built mux.
func newTestServer(local router.LocalRouter, peer router.Router) *Server { func newTestServer(local router.LocalRouter, peer router.Router) *Server {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -350,56 +339,3 @@ func TestServer_LogStream_UnknownID_Returns400(t *testing.T) {
t.Errorf("status=%d want 400", w.Code) t.Errorf("status=%d want 400", w.Code)
} }
} }
func TestServer_VideoRoutesDispatch(t *testing.T) {
s := newTestServer(
newStubRouter([]string{"videogen-model"}, "video response"),
newStubRouter(nil, ""),
)
// Multipart form on /v1/videos/sync.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("model", "videogen-model")
mw.WriteField("prompt", "a cat")
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/v1/videos/sync", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK || w.Body.String() != "video response" {
t.Fatalf("/v1/videos/sync: status=%d body=%q", w.Code, w.Body.String())
}
// JSON bodies dispatch too (extraction is content-type driven).
jsonReq := httptest.NewRequest(http.MethodPost, "/v1/videos/sync",
strings.NewReader(`{"model":"videogen-model","prompt":"a cat"}`))
jsonReq.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
s.ServeHTTP(w, jsonReq)
if w.Code != http.StatusOK || w.Body.String() != "video response" {
t.Fatalf("/v1/videos/sync json: status=%d body=%q", w.Code, w.Body.String())
}
// Unknown model on the video route still 404s.
nopeReq := httptest.NewRequest(http.MethodPost, "/v1/videos/sync",
strings.NewReader(`{"model":"nope","prompt":"a cat"}`))
nopeReq.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
s.ServeHTTP(w, nopeReq)
if w.Code != http.StatusNotFound {
t.Fatalf("/v1/videos/sync unknown model: status=%d want 404", w.Code)
}
// The async job-creation route is NOT dispatched (no poll routes to
// complete the flow) — a stray OpenAI-videos client gets a clean 404
// instead of an unretrievable upstream job.
asyncReq := httptest.NewRequest(http.MethodPost, "/v1/videos",
strings.NewReader(`{"model":"videogen-model","prompt":"a cat"}`))
asyncReq.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
s.ServeHTTP(w, asyncReq)
if w.Code != http.StatusNotFound {
t.Fatalf("POST /v1/videos: status=%d want 404 (async family not routed)", w.Code)
}
}
-5
View File
@@ -26,9 +26,6 @@ type ReqContextData struct {
ModelID string ModelID string
Streaming bool Streaming bool
SendLoadingState bool SendLoadingState bool
// Priority is the caller's scheduling priority, read from PriorityHeader.
// Higher is more urgent; 0 (PriorityNormal) is the default. See priority.go.
Priority int
// Metadata is a request-scoped key/value bag that handlers may mutate // Metadata is a request-scoped key/value bag that handlers may mutate
// while processing. The metrics middleware copies it into ActivityLogEntry. // while processing. The metrics middleware copies it into ActivityLogEntry.
Metadata map[string]string Metadata map[string]string
@@ -121,7 +118,6 @@ func FetchContext(r *http.Request, cfg config.Config) (ReqContextData, error) {
if mc, ok := cfg.Models[realName]; ok { if mc, ok := cfg.Models[realName]; ok {
data.SendLoadingState = mc.SendLoadingState != nil && *mc.SendLoadingState data.SendLoadingState = mc.SendLoadingState != nil && *mc.SendLoadingState
} }
data.Priority = RequestPriority(r)
*r = *r.WithContext(SetContext(r.Context(), data)) *r = *r.WithContext(SetContext(r.Context(), data))
return data, nil return data, nil
} }
@@ -141,7 +137,6 @@ func extractUpstreamContext(r *http.Request, cfg config.Config) (ReqContextData,
ApiKey: ExtractAPIKey(r), ApiKey: ExtractAPIKey(r),
Streaming: r.URL.Query().Get("stream") == "true", Streaming: r.URL.Query().Get("stream") == "true",
SendLoadingState: sendLoadingState(cfg, realName), SendLoadingState: sendLoadingState(cfg, realName),
Priority: RequestPriority(r),
Metadata: make(map[string]string), Metadata: make(map[string]string),
}, true }, true
} }
-89
View File
@@ -1,89 +0,0 @@
package shared
import (
"net/http"
"strconv"
"strings"
)
// PriorityHeader carries the caller's scheduling priority. Priority is a
// property of the caller's intent, not of the model — the same model serves
// both an interactive request and a batch job — so it travels as a header
// rather than as per-model configuration, and leaves the OpenAI-compatible
// request body untouched.
const PriorityHeader = "X-LlamaSwap-Priority"
// Priority band anchors. The number is the interface: callers may send any
// signed integer, and these names are conveniences that resolve to a value.
//
// Bands are spaced 100 apart so a consumer can add a small per-caller offset
// (a subscription tier, say) on top of a band without ever promoting a request
// across one: a "max member" batch job at -98 still loses to every normal
// request at 0.
const (
PriorityInteractive = 100 // a human is waiting
PriorityNormal = 0 // the default
PriorityBatch = -100
)
// bandHalfWidth is how far a priority may sit from a band anchor and still be
// reported as that band. It is half the 100-point band spacing, so every
// integer belongs to exactly one band.
const bandHalfWidth = 50
// Band names used for metrics labels, ordered most to least urgent.
const (
BandInteractive = "interactive"
BandNormal = "normal"
BandBatch = "batch"
)
// PriorityBands is every band name, in descending order of urgency. Metrics
// emit a series per band regardless of whether any request currently occupies
// it, so the label set stays stable.
var PriorityBands = []string{BandInteractive, BandNormal, BandBatch}
var priorityAliases = map[string]int{
"interactive": PriorityInteractive,
"normal": PriorityNormal,
"batch": PriorityBatch,
}
// ParsePriority resolves a PriorityHeader value to a signed integer. A numeric
// value is used as-is; a recognised alias resolves to its anchor. Anything
// absent or unparseable is PriorityNormal, so a malformed header degrades to
// the default rather than failing the request.
//
// Values are deliberately not clamped: the caller composes band, tier offset
// and anything else it wants into one number, and llama-swap honours it.
func ParsePriority(value string) int {
value = strings.TrimSpace(value)
if value == "" {
return PriorityNormal
}
if n, err := strconv.Atoi(value); err == nil {
return n
}
if n, ok := priorityAliases[strings.ToLower(value)]; ok {
return n
}
return PriorityNormal
}
// RequestPriority reads PriorityHeader off r and resolves it. See ParsePriority.
func RequestPriority(r *http.Request) int {
return ParsePriority(r.Header.Get(PriorityHeader))
}
// PriorityBand buckets a priority into the band it belongs to, for metrics
// labels. Values beyond the anchors saturate: +1000 is still "interactive".
func PriorityBand(priority int) string {
switch {
case priority >= PriorityInteractive-bandHalfWidth:
return BandInteractive
case priority <= PriorityBatch+bandHalfWidth:
return BandBatch
default:
return BandNormal
}
}
-118
View File
@@ -1,118 +0,0 @@
package shared
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mostlygeek/llama-swap/internal/config"
)
func TestShared_ParsePriority(t *testing.T) {
tests := []struct {
name string
value string
want int
}{
{"absent", "", PriorityNormal},
{"zero", "0", 0},
{"positive", "100", 100},
{"negative", "-100", -100},
{"explicit plus", "+42", 42},
{"surrounding space", " 100 ", 100},
{"large value is not clamped", "100000", 100000},
{"alias interactive", "interactive", PriorityInteractive},
{"alias normal", "normal", PriorityNormal},
{"alias batch", "batch", PriorityBatch},
{"alias is case insensitive", "Batch", PriorityBatch},
{"tier offset on a band", "-98", -98},
{"unknown alias falls back to normal", "urgent", PriorityNormal},
{"garbage falls back to normal", "!!", PriorityNormal},
{"float falls back to normal", "1.5", PriorityNormal},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := ParsePriority(tc.value); got != tc.want {
t.Errorf("ParsePriority(%q)=%d want %d", tc.value, got, tc.want)
}
})
}
}
func TestShared_RequestPriority(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
if got := RequestPriority(r); got != PriorityNormal {
t.Errorf("no header: got %d want %d", got, PriorityNormal)
}
r.Header.Set(PriorityHeader, "batch")
if got := RequestPriority(r); got != PriorityBatch {
t.Errorf("batch header: got %d want %d", got, PriorityBatch)
}
}
// TestShared_PriorityBand pins the bucketing the metrics labels rely on. The
// important cases are the tier offsets: a max member's batch job at -98 must
// still report as batch, not normal.
func TestShared_PriorityBand(t *testing.T) {
tests := []struct {
priority int
want string
}{
{PriorityInteractive, BandInteractive},
{PriorityInteractive + 2, BandInteractive}, // max tier, interactive
{1000, BandInteractive}, // saturates
{50, BandInteractive}, // lower edge
{49, BandNormal},
{PriorityNormal, BandNormal},
{2, BandNormal}, // max tier, normal
{-49, BandNormal},
{-50, BandBatch}, // upper edge
{PriorityBatch + 2, BandBatch},
{PriorityBatch, BandBatch},
{-1000, BandBatch}, // saturates
}
for _, tc := range tests {
if got := PriorityBand(tc.priority); got != tc.want {
t.Errorf("PriorityBand(%d)=%q want %q", tc.priority, got, tc.want)
}
}
}
// TestShared_FetchContext_Priority verifies the header reaches the request
// context, which is what carries it to the scheduler. Both entry points into
// FetchContext are covered: the normal body-parsed path and /upstream/<model>.
func TestShared_FetchContext_Priority(t *testing.T) {
cfg := config.Config{Models: map[string]config.ModelConfig{"m1": {}}}
cases := []struct {
name string
path string
header string
want int
}{
{"body path, no header", "/v1/chat/completions", "", PriorityNormal},
{"body path, alias", "/v1/chat/completions", "interactive", PriorityInteractive},
{"body path, number", "/v1/chat/completions", "-98", -98},
{"upstream path", "/upstream/m1/v1/chat/completions", "batch", PriorityBatch},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(`{"model":"m1"}`))
r.Header.Set("Content-Type", "application/json")
if c.header != "" {
r.Header.Set(PriorityHeader, c.header)
}
data, err := FetchContext(r, cfg)
if err != nil {
t.Fatalf("FetchContext: %v", err)
}
if data.Priority != c.want {
t.Errorf("Priority=%d want %d", data.Priority, c.want)
}
})
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/index.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"iconLibrary": "lucide"
}
+421 -120
View File
@@ -8,10 +8,10 @@
"name": "ui-svelte", "name": "ui-svelte",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/table-core": "^8.21.3",
"chart.js": "4.5.1", "chart.js": "4.5.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-svelte": "^0.563.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -23,14 +23,22 @@
"unist-util-visit": "^5.1.0" "unist-util-visit": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.21.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/node": "^25.1.0", "@types/node": "^25.1.0",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"paneforge": "^1.0.2",
"svelte": "^5.46.4", "svelte": "^5.46.4",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^8.0.0", "vite": "^8.0.0",
"vite-plugin-compression2": "^2.5.1", "vite-plugin-compression2": "^2.5.1",
@@ -38,21 +46,21 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.2", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.1", "@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.9.2", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -61,9 +69,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -71,6 +79,44 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true,
"license": "MIT"
},
"node_modules/@internationalized/date": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -127,15 +173,25 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lucide/svelte": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.21.0.tgz",
"integrity": "sha512-MEv//A7Jv3kHukZowv/DWp1MAtUzJKYwtJsmnQ7X98lCgtac3z3NbaToDl3Q6jO3gS9sougFpcD+t+YuxOkRMw==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"svelte": "^5"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@tybys/wasm-util": "^0.10.1" "@tybys/wasm-util": "^0.10.3"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -147,9 +203,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.124.0", "version": "0.137.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -157,9 +213,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -174,9 +230,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -191,9 +247,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -208,9 +264,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -225,9 +281,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -242,13 +298,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -259,13 +318,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -276,13 +338,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -293,13 +358,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -310,13 +378,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -327,13 +398,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -344,9 +418,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -361,9 +435,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@@ -371,18 +445,18 @@
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "1.9.2", "@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.9.2", "@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.3" "@napi-rs/wasm-runtime": "^1.1.6"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -397,9 +471,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -414,9 +488,9 @@
} }
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.15", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -480,6 +554,16 @@
"vite": "^8.0.0-beta.7 || ^8.0.0" "vite": "^8.0.0-beta.7 || ^8.0.0"
} }
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
@@ -752,6 +836,19 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tsconfig/svelte": { "node_modules/@tsconfig/svelte": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
@@ -760,9 +857,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1053,6 +1150,31 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/bits-ui": {
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz",
"integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.1",
"@floating-ui/dom": "^1.7.1",
"esm-env": "^1.1.2",
"runed": "^0.35.1",
"svelte-toolbelt": "^0.10.6",
"tabbable": "^6.2.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/huntabyte"
},
"peerDependencies": {
"@internationalized/date": "^3.8.1",
"svelte": "^5.33.0"
}
},
"node_modules/ccount": { "node_modules/ccount": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -1555,6 +1677,13 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"dev": true,
"license": "MIT"
},
"node_modules/is-plain-obj": { "node_modules/is-plain-obj": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -1881,13 +2010,14 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/lucide-svelte": { "node_modules/lz-string": {
"version": "0.563.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.563.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "ISC", "dev": true,
"peerDependencies": { "license": "MIT",
"svelte": "^3 || ^4 || ^5.0.0-next.42" "bin": {
"lz-string": "bin/bin.js"
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
@@ -2738,9 +2868,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2767,6 +2897,74 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/paneforge": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/paneforge/-/paneforge-1.0.2.tgz",
"integrity": "sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA==",
"dev": true,
"license": "MIT",
"dependencies": {
"runed": "^0.23.4",
"svelte-toolbelt": "^0.9.2"
},
"peerDependencies": {
"svelte": "^5.29.0"
}
},
"node_modules/paneforge/node_modules/runed": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/paneforge/node_modules/svelte-toolbelt": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.3.tgz",
"integrity": "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.29.0",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.30.2"
}
},
"node_modules/paneforge/node_modules/svelte-toolbelt/node_modules/runed": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz",
"integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -2807,9 +3005,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.12", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2827,7 +3025,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -2985,14 +3183,14 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.15", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.124.0", "@oxc-project/types": "=0.137.0",
"@rolldown/pluginutils": "1.0.0-rc.15" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@@ -3001,21 +3199,46 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-android-arm64": "1.1.3",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.1.3",
"@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.1.3",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.1.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.1.3",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.1.3",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.1.3",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.1.3",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.1.3",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.1.3",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.1.3",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.1.3",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.1.3",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" "@rolldown/binding-win32-x64-msvc": "1.1.3"
}
},
"node_modules/runed": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
"integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"esm-env": "^1.0.0",
"lz-string": "^1.5.0"
},
"peerDependencies": {
"@sveltejs/kit": "^2.21.0",
"svelte": "^5.7.0"
},
"peerDependenciesMeta": {
"@sveltejs/kit": {
"optional": true
}
} }
}, },
"node_modules/sade": { "node_modules/sade": {
@@ -3086,6 +3309,16 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/style-to-object": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.7"
}
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.55.7", "version": "5.55.7",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
@@ -3150,6 +3383,65 @@
"url": "https://github.com/sponsors/ItalyPaleAle" "url": "https://github.com/sponsors/ItalyPaleAle"
} }
}, },
"node_modules/svelte-toolbelt": {
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
"integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.35.1",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.30.2"
}
},
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwind-variants": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
"tailwind-merge": ">=3.0.0",
"tailwindcss": "*"
},
"peerDependenciesMeta": {
"tailwind-merge": {
"optional": true
}
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
@@ -3196,14 +3488,14 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3" "picomatch": "^4.0.4"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -3247,8 +3539,17 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "dev": true,
"license": "0BSD", "license": "0BSD"
"optional": true },
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
@@ -3429,17 +3730,17 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.8", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"postcss": "^8.5.8", "postcss": "^8.5.15",
"rolldown": "1.0.0-rc.15", "rolldown": "~1.1.2",
"tinyglobby": "^0.2.15" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
@@ -3455,7 +3756,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0", "@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",
+10 -2
View File
@@ -12,30 +12,38 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.21.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/node": "^25.1.0", "@types/node": "^25.1.0",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"paneforge": "^1.0.2",
"svelte": "^5.46.4", "svelte": "^5.46.4",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^8.0.0", "vite": "^8.0.0",
"vite-plugin-compression2": "^2.5.1", "vite-plugin-compression2": "^2.5.1",
"vitest": "^4.1.0" "vitest": "^4.1.0"
}, },
"dependencies": { "dependencies": {
"@tanstack/table-core": "^8.21.3",
"chart.js": "4.5.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-svelte": "^0.563.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"chart.js": "4.5.1",
"svelte-spa-router": "^4.0.1", "svelte-spa-router": "^4.0.1",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-visit": "^5.1.0" "unist-util-visit": "^5.1.0"
+56 -10
View File
@@ -1,33 +1,68 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import Router from "svelte-spa-router"; import Router from "svelte-spa-router";
import Header from "./components/Header.svelte"; import AppSidebar from "./components/AppSidebar.svelte";
import LogViewer from "./routes/LogViewer.svelte"; import LogViewer from "./routes/LogViewer.svelte";
import Models from "./routes/Models.svelte"; import ModelDetail from "./routes/ModelDetail.svelte";
import ModelsDash from "./routes/ModelsDash.svelte";
import Activity from "./routes/Activity.svelte"; import Activity from "./routes/Activity.svelte";
import Performance from "./routes/Performance.svelte"; import Performance from "./routes/Performance.svelte";
import Playground from "./routes/Playground.svelte"; import Playground from "./routes/Playground.svelte";
import PlaygroundStub from "./routes/PlaygroundStub.svelte"; import PlaygroundStub from "./routes/PlaygroundStub.svelte";
import Settings from "./routes/Settings.svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import { enableAPIEvents, checkPerformanceEnabled } from "./stores/api"; import { enableAPIEvents, checkPerformanceEnabled } from "./stores/api";
import { initScreenWidth, initSystemThemeListener, isDarkMode, appTitle, connectionState } from "./stores/theme"; import { initScreenWidth, initSystemThemeListener, isDarkMode, appTitle, connectionState } from "./stores/theme";
import { currentRoute } from "./stores/route"; import { currentRoute } from "./stores/route";
import { selectedPlaygroundTab, playgroundTabs } from "./stores/playground";
const routes = { const routes = {
"/": PlaygroundStub, "/": PlaygroundStub,
"/models": Models, "/models": ModelsDash,
"/models/:id": ModelDetail,
"/logs": LogViewer, "/logs": LogViewer,
"/activity": Activity, "/activity": Activity,
"/settings": Settings,
"/performance": Performance, "/performance": Performance,
"*": PlaygroundStub, "*": PlaygroundStub,
}; };
function handleRouteLoaded(event: { detail: { route: string | RegExp } }) { const routeTitles: Record<string, string> = {
"/": "Playground",
"/models": "Models",
"/activity": "Activity",
"/logs": "Logs",
"/settings": "Settings",
"/performance": "Performance",
};
let sectionTitle = $derived.by(() => {
if ($currentRoute === "/") {
const tab = playgroundTabs.find((t) => t.id === $selectedPlaygroundTab);
return `Playground / ${tab?.label ?? ""}`;
}
if ($currentRoute.startsWith("/models/")) {
const id = $currentRoute.slice("/models/".length);
return id ? `Models / ${decodeURIComponent(id)}` : "Models";
}
if ($currentRoute === "/models") {
return "Models";
}
return routeTitles[$currentRoute] ?? "Playground";
});
function handleRouteLoaded(event: { detail: { route: string | RegExp; location?: string } }) {
const route = event.detail.route; const route = event.detail.route;
currentRoute.set(typeof route === "string" ? route : "/"); // Prefer the actual URL path so parameterised routes (e.g. /models/:id)
// are reflected accurately in currentRoute for sidebar highlighting.
const loc = event.detail.location;
currentRoute.set(loc ?? (typeof route === "string" ? route : "/"));
} }
$effect(() => { $effect(() => {
document.documentElement.setAttribute("data-theme", $isDarkMode ? "dark" : "light"); document.documentElement.classList.toggle("dark", $isDarkMode);
}); });
$effect(() => { $effect(() => {
@@ -49,10 +84,19 @@
}); });
</script> </script>
<div class="flex flex-col h-screen"> <Tooltip.Provider>
<Header /> <Sidebar.Provider>
<AppSidebar />
<Sidebar.Inset class="h-screen min-w-0 overflow-hidden">
<header
class="bg-background sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b px-4"
>
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 !h-4" />
<h2 class="truncate pb-0 text-sm font-semibold">{sectionTitle}</h2>
</header>
<main class="flex-1 overflow-auto p-4"> <main class="min-h-0 flex-1 overflow-auto p-4">
<div class="h-full" class:hidden={$currentRoute !== "/"}> <div class="h-full" class:hidden={$currentRoute !== "/"}>
<Playground /> <Playground />
</div> </div>
@@ -60,4 +104,6 @@
<Router {routes} on:routeLoaded={handleRouteLoaded} /> <Router {routes} on:routeLoaded={handleRouteLoaded} />
</div> </div>
</main> </main>
</div> </Sidebar.Inset>
</Sidebar.Provider>
</Tooltip.Provider>
+27 -26
View File
@@ -3,6 +3,9 @@
import { persistentStore } from "../stores/persistent"; import { persistentStore } from "../stores/persistent";
import { calculateHistogramData } from "../lib/histogram"; import { calculateHistogramData } from "../lib/histogram";
import TokenHistogram from "./TokenHistogram.svelte"; import TokenHistogram from "./TokenHistogram.svelte";
import { ChevronDown, X } from "@lucide/svelte";
import * as Card from "$lib/components/ui/card/index.js";
import { Button } from "$lib/components/ui/button/index.js";
const nf = new Intl.NumberFormat(); const nf = new Intl.NumberFormat();
const histogramCollapsed = persistentStore<boolean>("activity-histogram-collapsed", false); const histogramCollapsed = persistentStore<boolean>("activity-histogram-collapsed", false);
@@ -35,26 +38,24 @@
}); });
</script> </script>
<div class="card relative p-3"> <Card.Root class="relative p-3">
<button <Button
class="absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-400 transition-colors" variant="ghost"
size="icon-xs"
class="text-muted-foreground absolute right-2 top-2 rounded-full"
onclick={() => ($histogramCollapsed = !$histogramCollapsed)} onclick={() => ($histogramCollapsed = !$histogramCollapsed)}
title={$histogramCollapsed ? "Show histograms" : "Hide histograms"} title={$histogramCollapsed ? "Show histograms" : "Hide histograms"}
> >
{#if $histogramCollapsed} {#if $histogramCollapsed}
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor"> <ChevronDown />
<path d="M4.5 6l3.5 4 3.5-4H4.5z" />
</svg>
{:else} {:else}
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor"> <X />
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" />
</svg>
{/if} {/if}
</button> </Button>
{#if !$histogramCollapsed} {#if !$histogramCollapsed}
<div class="flex flex-col sm:flex-row gap-6 mb-3"> <div class="mb-3 flex flex-col gap-6 sm:flex-row">
<div class="w-full sm:w-1/2 min-w-0"> <div class="w-full min-w-0 sm:w-1/2">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prompt Processing</div> <div class="text-muted-foreground mb-1 text-sm font-medium">Prompt Processing</div>
{#if stats.promptHistogramData} {#if stats.promptHistogramData}
<TokenHistogram <TokenHistogram
data={stats.promptHistogramData} data={stats.promptHistogramData}
@@ -62,36 +63,36 @@
colorClass="text-amber-500 dark:text-amber-400" colorClass="text-amber-500 dark:text-amber-400"
/> />
{:else} {:else}
<div class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">No prompt speed data yet</div> <div class="text-muted-foreground py-6 text-center text-sm">No prompt speed data yet</div>
{/if} {/if}
</div> </div>
<div class="w-full sm:w-1/2 min-w-0"> <div class="w-full min-w-0 sm:w-1/2">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Token Generation</div> <div class="text-muted-foreground mb-1 text-sm font-medium">Token Generation</div>
{#if stats.genHistogramData} {#if stats.genHistogramData}
<TokenHistogram data={stats.genHistogramData} unit="tokens/sec" /> <TokenHistogram data={stats.genHistogramData} unit="tokens/sec" />
{:else} {:else}
<div class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">No generation speed data yet</div> <div class="text-muted-foreground py-6 text-center text-sm">No generation speed data yet</div>
{/if} {/if}
</div> </div>
</div> </div>
{/if} {/if}
<div class="grid grid-cols-4 gap-x-6 gap-y-1 text-sm"> <div class="grid grid-cols-4 gap-x-6 gap-y-1 text-sm">
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Requests</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Requests</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Cached</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Cached</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Processed</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Processed</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Generated</div> <div class="text-muted-foreground text-xs uppercase tracking-wider">Generated</div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalRequests)}</span> completed, <span class="font-semibold">{nf.format(stats.totalRequests)}</span> completed,
<span class="font-semibold">{nf.format(stats.inFlightRequests)}</span> waiting <span class="font-semibold">{nf.format(stats.inFlightRequests)}</span> waiting
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalCacheTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalCacheTokens)}</span> tokens
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalInputTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalInputTokens)}</span> tokens
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm">
<span class="font-semibold">{nf.format(stats.totalOutputTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalOutputTokens)}</span> tokens
</div> </div>
</div> </div>
</div> </Card.Root>
@@ -0,0 +1,449 @@
<script lang="ts">
import { untrack } from "svelte";
import type { ActivityLogEntry, ReqRespCapture } from "../lib/types";
import { getCapture } from "../stores/api";
import { persistentStore } from "../stores/persistent";
import CaptureDialog from "./CaptureDialog.svelte";
import {
type ColumnDef,
type PaginationState,
type VisibilityState,
getCoreRowModel,
getPaginationRowModel,
} from "@tanstack/table-core";
import {
FlexRender,
createSvelteTable,
renderComponent,
} from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import {
Columns3,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "@lucide/svelte";
import HeaderLabel from "./activity-table/HeaderLabel.svelte";
import ViewCaptureButton from "./activity-table/ViewCaptureButton.svelte";
import MetaCell from "./activity-table/MetaCell.svelte";
interface Props {
metrics: ActivityLogEntry[];
storagePrefix: string;
showModelColumn?: boolean;
showPagination?: boolean;
title?: string;
compact?: boolean;
emptyMessage?: string;
cardClass?: string;
}
let {
metrics,
storagePrefix,
showModelColumn = true,
showPagination = false,
title,
compact = false,
emptyMessage = "No activity recorded",
cardClass = "",
}: Props = $props();
function formatSpeed(speed: number): string {
return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s";
}
function formatDuration(ms: number): string {
return (ms / 1000).toFixed(2) + "s";
}
function formatRelativeTime(timestamp: string): string {
const now = new Date();
const date = new Date(timestamp);
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 5) return "now";
if (diffInSeconds < 60) return `${diffInSeconds}s ago`;
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}h ago`;
return "a while ago";
}
function formatDrafted(drafted: number, accepted: number): string {
return drafted > 0
? ((accepted * 100) / drafted).toFixed(1) + "% (" + accepted + "/" + drafted + ")"
: "-";
}
interface ColMeta {
id: string;
label: string;
defaultVisible: boolean;
}
function buildColumnMeta(withModel: boolean): ColMeta[] {
const cols: ColMeta[] = [
{ id: "id", label: "ID", defaultVisible: true },
{ id: "time", label: "Time", defaultVisible: true },
];
if (withModel) cols.push({ id: "model", label: "Model", defaultVisible: true });
cols.push(
{ id: "req_path", label: "Path", defaultVisible: false },
{ id: "resp_status_code", label: "Status", defaultVisible: true },
{ id: "resp_content_type", label: "Content-Type", defaultVisible: false },
{ id: "cached", label: "Cached", defaultVisible: true },
{ id: "prompt", label: "Prompt", defaultVisible: true },
{ id: "generated", label: "Generated", defaultVisible: true },
{ id: "drafted", label: "Drafted", defaultVisible: false },
{ id: "prompt_speed", label: "Prompt Speed", defaultVisible: true },
{ id: "gen_speed", label: "Gen Speed", defaultVisible: true },
{ id: "duration", label: "Duration", defaultVisible: true },
{ id: "capture", label: "Capture", defaultVisible: true },
{ id: "meta", label: "Meta", defaultVisible: false }
);
return cols;
}
let columnMeta = $derived(buildColumnMeta(showModelColumn));
let columnLabelMap = $derived(
Object.fromEntries(columnMeta.map((c) => [c.id, c.label])) as Record<string, string>
);
let defaultVisibility = $derived.by(() => {
const v: VisibilityState = {};
for (const c of columnMeta) v[c.id] = c.defaultVisible;
return v;
});
// svelte-ignore state_referenced_locally
const storedVisibility = persistentStore<VisibilityState>(
`${storagePrefix}-columns`,
{}
);
// svelte-ignore state_referenced_locally
let columnVisibility = $state<VisibilityState>(
Object.keys($storedVisibility).length > 0 ? $storedVisibility : defaultVisibility
);
// svelte-ignore state_referenced_locally
const storedPageSize = persistentStore<number>(`${storagePrefix}-page-size`, 10);
// When not paginating, use a large page size so all rows render in one page.
// svelte-ignore state_referenced_locally
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: showPagination ? $storedPageSize : Number.MAX_SAFE_INTEGER,
});
// Reset to the first page when the data source changes. We deliberately do
// NOT track pagination here — page-size changes reset pageIndex inside
// onPaginationChange instead, to avoid clobbering page navigation.
$effect(() => {
metrics;
untrack(() => {
pagination = { ...pagination, pageIndex: 0 };
});
});
let selectedCapture = $state<ReqRespCapture | null>(null);
let dialogOpen = $state(false);
let loadingCaptureId = $state<number | null>(null);
async function viewCapture(id: number) {
loadingCaptureId = id;
const capture = await getCapture(id);
loadingCaptureId = null;
selectedCapture = capture;
dialogOpen = true;
}
function closeDialog() {
dialogOpen = false;
selectedCapture = null;
}
function buildColumns(withModel: boolean): ColumnDef<ActivityLogEntry>[] {
const cols: ColumnDef<ActivityLogEntry>[] = [
{
id: "id",
header: "ID",
cell: ({ row }) => String(row.original.id + 1),
},
{
id: "time",
header: "Time",
cell: ({ row }) => formatRelativeTime(row.original.timestamp),
},
];
if (withModel) {
cols.push({
id: "model",
header: "Model",
cell: ({ row }) => row.original.model ?? "-",
});
}
cols.push(
{
id: "req_path",
header: "Path",
cell: ({ row }) => row.original.req_path || "-",
},
{
id: "resp_status_code",
header: "Status",
cell: ({ row }) => String(row.original.resp_status_code || "-"),
},
{
id: "resp_content_type",
header: "Content-Type",
cell: ({ row }) => row.original.resp_content_type || "-",
},
{
id: "cached",
header: () => renderComponent(HeaderLabel, { label: "Cached", tooltip: "prompt tokens from cache" }),
cell: ({ row }) =>
row.original.tokens.cache_tokens > 0
? row.original.tokens.cache_tokens.toLocaleString()
: "-",
},
{
id: "prompt",
header: () => renderComponent(HeaderLabel, { label: "Prompt", tooltip: "new prompt tokens processed" }),
cell: ({ row }) => row.original.tokens.input_tokens.toLocaleString(),
},
{
id: "generated",
header: "Generated",
cell: ({ row }) => row.original.tokens.output_tokens.toLocaleString(),
},
{
id: "drafted",
header: () => renderComponent(HeaderLabel, { label: "Drafted", tooltip: "acceptance rate (accepted/drafted)" }),
cell: ({ row }) =>
formatDrafted(row.original.tokens.draft_tokens, row.original.tokens.draft_acc_tokens),
},
{
id: "prompt_speed",
header: "Prompt Speed",
cell: ({ row }) => formatSpeed(row.original.tokens.prompt_per_second),
},
{
id: "gen_speed",
header: "Gen Speed",
cell: ({ row }) => formatSpeed(row.original.tokens.tokens_per_second),
},
{
id: "duration",
header: "Duration",
cell: ({ row }) => formatDuration(row.original.duration_ms),
},
{
id: "capture",
header: "Capture",
cell: ({ row }) =>
renderComponent(ViewCaptureButton, {
hasCapture: row.original.has_capture,
loading: loadingCaptureId === row.original.id,
onclick: () => viewCapture(row.original.id),
}),
},
{
id: "meta",
header: "Meta",
cell: ({ row }) =>
renderComponent(MetaCell, { metadata: row.original.metadata }),
}
);
return cols;
}
let columns = $derived(buildColumns(showModelColumn));
const table = createSvelteTable({
get data() {
return metrics;
},
get columns() {
return columns;
},
state: {
get pagination() {
return pagination;
},
get columnVisibility() {
return columnVisibility;
},
},
onPaginationChange: (updater) => {
const prev = pagination;
const next =
typeof updater === "function" ? updater(prev) : updater;
// Reassign so the table's $effect.pre (which reads state.pagination)
// picks up the new value. Reset to first page when the page size
// changes so we don't land on an empty page.
pagination =
next.pageSize !== prev.pageSize
? { pageIndex: 0, pageSize: next.pageSize }
: next;
if (showPagination) storedPageSize.set(pagination.pageSize);
},
onColumnVisibilityChange: (updater) => {
columnVisibility =
typeof updater === "function" ? updater(columnVisibility) : updater;
storedVisibility.set(columnVisibility);
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
let thClass = $derived(compact ? "px-4 py-2 h-9" : "px-6 py-3 h-12");
let tdClass = $derived(compact ? "px-4 py-2" : "px-6 py-4");
</script>
<Card.Root class="shrink-0 gap-0 overflow-hidden py-0 {cardClass}">
<Card.Header class="flex items-center justify-between border-b px-4 py-2">
<div class="flex items-center gap-2">
{#if title}
<Card.Title class="text-sm font-semibold">
{title}
<span class="text-muted-foreground text-xs font-normal">({metrics.length})</span>
</Card.Title>
{/if}
</div>
<div class="flex items-center gap-2">
{#if showPagination}
<span class="text-muted-foreground text-xs">Rows</span>
<Select.Root
type="single"
value={String(pagination.pageSize)}
onValueChange={(v) => table.setPageSize(Number(v))}
>
<Select.Trigger size="sm" class="h-7 w-[4.5rem] text-xs">
{pagination.pageSize}
</Select.Trigger>
<Select.Content>
{#each [5, 10, 25, 50] as size (size)}
<Select.Item value={String(size)}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="hover:bg-muted inline-flex size-7 items-center justify-center rounded-[min(var(--radius-md),12px)]"
title="Select columns"
>
<Columns3 class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="min-w-[16rem] p-0">
<DropdownMenu.Label class="text-muted-foreground border-b px-3 py-2 text-xs font-medium uppercase tracking-wider">
Columns
</DropdownMenu.Label>
{#each table.getAllColumns() as column (column.id)}
{#if column.getCanHide()}
<DropdownMenu.CheckboxItem
checked={column.getIsVisible()}
onCheckedChange={(v) => column.toggleVisibility(!!v)}
closeOnSelect={false}
>
{columnLabelMap[column.id] ?? column.id}
</DropdownMenu.CheckboxItem>
{/if}
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</Card.Header>
<Card.Content class="overflow-x-auto p-0">
<Table.Root class="min-w-full">
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head class={thClass} colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell class={tdClass}>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class="text-muted-foreground py-6 text-center text-sm">
{emptyMessage}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{#if showPagination && metrics.length > 0}
<div class="flex items-center justify-between gap-2 border-t px-4 py-2 text-sm">
<span class="text-muted-foreground text-xs">
Page {pagination.pageIndex + 1} of {table.getPageCount()} · {metrics.length} total
</span>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onclick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
title="First page"
>
<ChevronsLeft />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
title="Previous page"
>
<ChevronLeft />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
title="Next page"
>
<ChevronRight />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
title="Last page"
>
<ChevronsRight />
</Button>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
<CaptureDialog capture={selectedCapture} open={dialogOpen} onclose={closeDialog} />
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { link } from "svelte-spa-router";
import { House, Boxes, Activity, ScrollText, Gauge, Sun, Moon, Monitor, ChevronRight, Settings } from "@lucide/svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { toggleTheme, themeMode, appTitle } from "../stores/theme";
import { currentRoute } from "../stores/route";
import { playgroundActivity } from "../stores/playgroundActivity";
import { performanceEnabled, models } from "../stores/api";
import { selectedPlaygroundTab, playgroundTabs, playgroundMenuOpen } from "../stores/playground";
import { modelsMenuOpen } from "../stores/sidebar";
import type { Model } from "../lib/types";
import ConnectionStatus from "./ConnectionStatus.svelte";
function handleTitleChange(newTitle: string): void {
const sanitized = newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap";
appTitle.set(sanitized);
}
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === "Enter") {
e.preventDefault();
const target = e.currentTarget as HTMLElement;
handleTitleChange(target.textContent || "(set title)");
target.blur();
}
}
function handleBlur(e: FocusEvent): void {
const target = e.currentTarget as HTMLElement;
handleTitleChange(target.textContent || "(set title)");
}
function isActive(path: string, current: string): boolean {
return path === "/" ? current === "/" : current.startsWith(path);
}
type DotColor = "grey" | "yellow" | "green";
function statusDotColor(model: Model): DotColor {
if (model.state === "ready") return "green";
if (model.state === "starting" || model.state === "stopping") return "yellow";
return "grey";
}
const dotClass: Record<DotColor, string> = {
grey: "bg-muted-foreground/40",
yellow: "bg-warning",
green: "bg-success",
};
</script>
<Sidebar.Root collapsible="icon">
<Sidebar.Header>
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex shrink-0 items-center justify-center">
<ConnectionStatus />
</div>
<h1
contenteditable="true"
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}
>
{$appTitle}
</h1>
</div>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupContent>
<Sidebar.Menu class="gap-1">
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive("/activity", $currentRoute)} tooltipContent="Activity">
{#snippet child({ props })}
<a href="/activity" use:link {...props}>
<Activity />
<span>Activity</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Collapsible.Root
open={$playgroundMenuOpen}
onOpenChange={(v) => playgroundMenuOpen.set(v)}
class="gap-0"
>
<Sidebar.MenuButton
isActive={isActive("/", $currentRoute)}
tooltipContent="Playground"
>
{#snippet child({ props })}
<a href="/" use:link {...props}>
<House />
<span class={$playgroundActivity ? "activity-link" : ""}>Playground</span>
<span
class="ml-auto transition-transform duration-200 {$playgroundMenuOpen ? 'rotate-90' : ''}"
role="button"
tabindex="0"
aria-label="Toggle playground section"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
playgroundMenuOpen.update((v) => !v);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
playgroundMenuOpen.update((v) => !v);
}
}}
>
<ChevronRight />
</span>
</a>
{/snippet}
</Sidebar.MenuButton>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each playgroundTabs as tab (tab.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
isActive={isActive("/", $currentRoute) && $selectedPlaygroundTab === tab.id}
>
{#snippet child({ props })}
<a
href="/"
use:link
{...props}
onclick={() => selectedPlaygroundTab.set(tab.id)}
>
<span>{tab.label}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Collapsible.Root>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Collapsible.Root
open={$modelsMenuOpen}
onOpenChange={(v) => modelsMenuOpen.set(v)}
class="gap-0"
>
<Sidebar.MenuButton
isActive={$currentRoute.startsWith("/models")}
tooltipContent="Models"
>
{#snippet child({ props })}
<a href="/models" use:link {...props}>
<Boxes />
<span>Models</span>
<span
class="ml-auto transition-transform duration-200 {$modelsMenuOpen ? 'rotate-90' : ''}"
role="button"
tabindex="0"
aria-label="Toggle models section"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
modelsMenuOpen.update((v) => !v);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
modelsMenuOpen.update((v) => !v);
}
}}
>
<ChevronRight />
</span>
</a>
{/snippet}
</Sidebar.MenuButton>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each $models as model (model.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
isActive={$currentRoute === `/models/${encodeURIComponent(model.id)}`}
>
{#snippet child({ props })}
<a href="/models/{encodeURIComponent(model.id)}" use:link {...props}>
<span class={`size-2 shrink-0 rounded-full ${dotClass[statusDotColor(model)]}`}></span>
<span class="flex-1 truncate">{model.id}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Collapsible.Root>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive("/logs", $currentRoute)} tooltipContent="Logs">
{#snippet child({ props })}
<a href="/logs" use:link {...props}>
<ScrollText />
<span>Logs</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{#if $performanceEnabled}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive("/performance", $currentRoute)} tooltipContent="Performance">
{#snippet child({ props })}
<a href="/performance" use:link {...props}>
<Gauge />
<span>Performance</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<div
class="flex items-center justify-between gap-2 px-1 group-data-[collapsible=icon]:flex-col-reverse"
>
<Sidebar.MenuButton
isActive={isActive("/settings", $currentRoute)}
tooltipContent="Settings"
>
{#snippet child({ props })}
<a href="/settings" use:link {...props}>
<Settings />
<span>Settings</span>
</a>
{/snippet}
</Sidebar.MenuButton>
<Button
variant="ghost"
size="icon"
onclick={toggleTheme}
title="Toggle theme (current: {$themeMode})"
>
{#if $themeMode === "system"}
<Monitor />
{:else if $themeMode === "light"}
<Sun />
{:else}
<Moon />
{/if}
<span class="sr-only">Toggle theme</span>
</Button>
</div>
</Sidebar.Footer>
<Sidebar.Rail />
</Sidebar.Root>
+48 -64
View File
@@ -1,5 +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 * as Dialog from "$lib/components/ui/dialog/index.js";
interface Props { interface Props {
capture: ReqRespCapture | null; capture: ReqRespCapture | null;
@@ -9,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) {
@@ -39,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 {
@@ -190,40 +178,36 @@
}); });
</script> </script>
<dialog <Dialog.Root
bind:this={dialogEl} {open}
onclose={handleDialogClose} onOpenChange={(v) => {
class="bg-surface text-txtmain rounded-lg shadow-xl max-w-[80%] w-full max-h-[90vh] p-0 backdrop:bg-black/50 m-auto" if (!v) onclose();
}}
> >
<Dialog.Content class="flex max-h-[90vh] w-[90%] sm:max-w-[90%] flex-col gap-0 p-0">
{#if capture} {#if capture}
<div class="flex flex-col max-h-[90vh]"> <Dialog.Header class="border-b border-border px-4 py-3">
<div <Dialog.Title class="text-lg font-bold">
class="flex justify-between items-center p-4 border-b border-card-border" Capture #{capture.id + 1}{#if capture.req_path}
> <span class="font-mono text-base font-normal text-muted-foreground">{capture.req_path}</span>{/if}
<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-txtsecondary">{capture.req_path}</span>{/if}</h2> </Dialog.Title>
<button </Dialog.Header>
onclick={() => dialogEl?.close()}
class="text-txtsecondary hover:text-txtmain text-2xl leading-none"
>
&times;
</button>
</div>
<div class="overflow-y-auto flex-1 p-4 space-y-4"> <div class="min-h-0 flex-1 overflow-y-auto space-y-4 p-4">
<!-- Request Headers --> <!-- Request Headers -->
<details class="group" open> <details class="group" open>
<summary <summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-muted-foreground hover:text-foreground"
> >
Request Headers Request Headers
</summary> </summary>
<div <div
class="mt-2 bg-background rounded border border-card-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>
{#each Object.entries(capture.req_headers || {}) as [key, value]} {#each Object.entries(capture.req_headers || {}) as [key, value]}
<tr class="border-b border-card-border-inner last:border-0"> <tr class="border-b border-border last:border-0">
<td class="px-3 py-1 font-mono text-primary whitespace-nowrap" <td class="px-3 py-1 font-mono text-primary whitespace-nowrap"
>{key}</td >{key}</td
> >
@@ -238,7 +222,7 @@
<!-- Request Body --> <!-- Request Body -->
<details class="group" open> <details class="group" open>
<summary <summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-muted-foreground hover:text-foreground"
> >
Request Body Request Body
</summary> </summary>
@@ -271,14 +255,14 @@
</button> </button>
</div> </div>
<div <div
class="mt-1 bg-background rounded border border-card-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-card-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
@@ -290,17 +274,17 @@
<!-- Response Headers --> <!-- Response Headers -->
<details class="group" open> <details class="group" open>
<summary <summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-muted-foreground hover:text-foreground"
> >
Response Headers Response Headers
</summary> </summary>
<div <div
class="mt-2 bg-background rounded border border-card-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>
{#each Object.entries(capture.resp_headers || {}) as [key, value]} {#each Object.entries(capture.resp_headers || {}) as [key, value]}
<tr class="border-b border-card-border-inner last:border-0"> <tr class="border-b border-border last:border-0">
<td class="px-3 py-1 font-mono text-primary whitespace-nowrap" <td class="px-3 py-1 font-mono text-primary whitespace-nowrap"
>{key}</td >{key}</td
> >
@@ -315,13 +299,13 @@
<!-- Response Body --> <!-- Response Body -->
<details class="group" open> <details class="group" open>
<summary <summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-muted-foreground hover:text-foreground"
> >
Response Body Response Body
</summary> </summary>
{#if isResponseImage && capture.resp_body} {#if isResponseImage && capture.resp_body}
<div <div
class="mt-2 bg-background rounded border border-card-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
@@ -368,26 +352,26 @@
</button> </button>
</div> </div>
<div <div
class="mt-1 bg-background rounded border border-card-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">
{#if sseChat.reasoning} {#if sseChat.reasoning}
<div> <div>
<div <div
class="text-xs font-semibold uppercase tracking-wider text-txtsecondary mb-1" class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1"
> >
Reasoning Reasoning
</div> </div>
<pre <pre
class="font-mono whitespace-pre-wrap break-all text-txtsecondary">{sseChat.reasoning}</pre> class="font-mono whitespace-pre-wrap break-all text-muted-foreground">{sseChat.reasoning}</pre>
</div> </div>
{/if} {/if}
{#if sseChat.content} {#if sseChat.content}
<div> <div>
{#if sseChat.reasoning} {#if sseChat.reasoning}
<div <div
class="text-xs font-semibold uppercase tracking-wider text-txtsecondary mb-1" class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1"
> >
Response Response
</div> </div>
@@ -407,15 +391,15 @@
</div> </div>
{:else if responseBodyRaw} {:else if responseBodyRaw}
<div <div
class="mt-2 bg-background rounded border border-card-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-txtsecondary italic"> <div class="p-3 text-sm text-muted-foreground italic">
(binary data - {responseContentType || "unknown content type"}) (binary data - {responseContentType || "unknown content type"})
</div> </div>
</div> </div>
{:else} {:else}
<div <div
class="mt-2 bg-background rounded border border-card-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>
@@ -423,39 +407,39 @@
</details> </details>
</div> </div>
<div class="p-4 border-t border-card-border flex justify-end"> <Dialog.Footer class="border-t border-border px-4 py-3 sm:justify-end">
<button onclick={() => dialogEl?.close()} class="btn"> Close </button> <Button variant="outline" onclick={onclose}>Close</Button>
</div> </Dialog.Footer>
</div>
{:else} {:else}
<div class="flex flex-col items-center justify-center p-12"> <div class="flex flex-col items-center justify-center p-12">
<p class="text-lg text-txtsecondary">Capture not found</p> <p class="text-lg text-muted-foreground">Capture not found</p>
<p class="text-sm text-txtsecondary mt-1">The capture may have expired or was never recorded.</p> <p class="text-sm text-muted-foreground mt-1">The capture may have expired or was never recorded.</p>
<div class="mt-4"> <div class="mt-4">
<button onclick={() => dialogEl?.close()} class="btn">Close</button> <Button variant="outline" onclick={onclose}>Close</Button>
</div> </div>
</div> </div>
{/if} {/if}
</dialog> </Dialog.Content>
</Dialog.Root>
<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(--color-txtsecondary); color: var(--muted-foreground);
cursor: pointer; cursor: pointer;
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
transition: all 0.15s; transition: all 0.15s;
} }
.tab-btn:hover { .tab-btn:hover {
color: var(--color-txtmain); color: var(--foreground);
background: var(--color-secondary); background: var(--accent);
} }
.tab-btn-active { .tab-btn-active {
color: var(--color-primary); color: var(--primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent); background: color-mix(in srgb, var(--primary) 12%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 25%, transparent); border-color: color-mix(in srgb, var(--primary) 25%, transparent);
} }
</style> </style>
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { connectionState } from "../stores/theme"; import { connectionState } from "../stores/theme";
import { versionInfo } from "../stores/api";
let eventStatusColor = $derived.by(() => { let eventStatusColor = $derived.by(() => {
switch ($connectionState) { switch ($connectionState) {
@@ -14,9 +13,7 @@
} }
}); });
let tooltipText = $derived( let tooltipText = $derived(`Event Stream: ${$connectionState ?? "unknown"}`);
`Event Stream: ${$connectionState ?? "unknown"}\nAPI Version: ${$versionInfo?.version ?? "unknown"}\nCommit Hash: ${$versionInfo?.commit?.substring(0, 7) ?? "unknown"}\nBuild Date: ${$versionInfo?.build_date ?? "unknown"}`
);
</script> </script>
<div class="flex items-center" title={tooltipText}> <div class="flex items-center" title={tooltipText}>
-144
View File
@@ -1,144 +0,0 @@
<script lang="ts">
import { link } from "svelte-spa-router";
import { screenWidth, toggleTheme, themeMode, appTitle, isNarrow } from "../stores/theme";
import { currentRoute } from "../stores/route";
import { playgroundActivity } from "../stores/playgroundActivity";
import { performanceEnabled } from "../stores/api";
import ConnectionStatus from "./ConnectionStatus.svelte";
function handleTitleChange(newTitle: string): void {
const sanitized = newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap";
appTitle.set(sanitized);
}
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === "Enter") {
e.preventDefault();
const target = e.currentTarget as HTMLElement;
handleTitleChange(target.textContent || "(set title)");
target.blur();
}
}
function handleBlur(e: FocusEvent): void {
const target = e.currentTarget as HTMLElement;
handleTitleChange(target.textContent || "(set title)");
}
function isActive(path: string, current: string): boolean {
return path === "/" ? current === "/" : current.startsWith(path);
}
</script>
<header
class="flex items-center justify-between bg-surface border-b border-border px-4 {$isNarrow
? 'py-1 h-[60px]'
: 'p-2 h-[75px]'}"
>
{#if $screenWidth !== "xs" && $screenWidth !== "sm"}
<h1
contenteditable="true"
class="p-0 outline-none hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
onblur={handleBlur}
onkeydown={handleKeyDown}
>
{$appTitle}
</h1>
{/if}
<menu class="flex items-center gap-4 overflow-x-auto">
<a
href="/"
use:link
class="p-1 whitespace-nowrap {isActive('/', $currentRoute) ? 'font-semibold underline underline-offset-4' : ''} {$playgroundActivity ? 'activity-link' : 'text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100'}"
>
Playground
</a>
<a
href="/models"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/models", $currentRoute)}
class:underline={isActive("/models", $currentRoute)}
class:underline-offset-4={isActive("/models", $currentRoute)}
>
Models
</a>
<a
href="/activity"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/activity", $currentRoute)}
class:underline={isActive("/activity", $currentRoute)}
class:underline-offset-4={isActive("/activity", $currentRoute)}
>
Activity
</a>
<a
href="/logs"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/logs", $currentRoute)}
class:underline={isActive("/logs", $currentRoute)}
class:underline-offset-4={isActive("/logs", $currentRoute)}
>
Logs
</a>
{#if $performanceEnabled}
<a
href="/performance"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/performance", $currentRoute)}
class:underline={isActive("/performance", $currentRoute)}
class:underline-offset-4={isActive("/performance", $currentRoute)}
>
Performance
</a>
{/if}
<button onclick={toggleTheme} title="Toggle theme (current: {$themeMode})">
{#if $themeMode === "system"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path d="M0,9c0-.552,.448-1,1-1H3.108c.147-.874,.472-1.721,1.006-2.471l-1.478-1.478c-.391-.391-.391-1.023,0-1.414s1.023-.391,1.414,0l1.478,1.478c.751-.534,1.598-.859,2.471-1.006V1c0-.552,.448-1,1-1s1,.448,1,1V3.108c.874,.147,1.725,.466,2.477,1.001l1.473-1.473c.391-.391,1.023-.391,1.414,0s.391,1.023,0,1.414L3.963,15.45c-.195,.195-.451,.293-.707,.293s-.512-.098-.707-.293c-.391-.391-.391-1.023,0-1.414l1.56-1.56c-.535-.751-.854-1.602-1.001-2.477H1c-.552,0-1-.448-1-1ZM23.707,.293c-.391-.391-1.023-.391-1.414,0L.293,22.293c-.391,.391-.391,1.023,0,1.414,.195,.195,.451,.293,.707,.293s.512-.098,.707-.293L23.707,1.707c.391-.391,.391-1.023,0-1.414Zm-.283,10.954c.32-.15,.538-.458,.572-.81,.034-.353-.121-.696-.407-.904-.858-.625-1.833-1.066-2.897-1.315-.335-.078-.69,.022-.934,.267l-8.392,8.391c-.244,.244-.345,.597-.267,.933,.843,3.646,4.047,6.191,7.792,6.191,1.695,0,3.32-.53,4.697-1.533,.286-.208,.441-.553,.407-.904-.034-.353-.251-.66-.572-.811-1.842-.861-3.033-2.727-3.033-4.752s1.19-3.891,3.033-4.753Z"/>
</svg>
{:else if $themeMode === "light"}
<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="M12 2.25a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM7.5 12a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM18.894 6.166a.75.75 0 0 0-1.06-1.06l-1.591 1.59a.75.75 0 1 0 1.06 1.061l1.591-1.59ZM21.75 12a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1 0-1.5H21a.75.75 0 0 1 .75.75ZM17.834 18.894a.75.75 0 0 0 1.06-1.06l-1.59-1.591a.75.75 0 1 0-1.061 1.06l1.591 1.591ZM12 18a.75.75 0 0 1 .75.75V21a.75.75 0 0 1-1.5 0v-2.25A.75.75 0 0 1 12 18ZM7.758 17.303a.75.75 0 0 0-1.061-1.06l-1.591 1.59a.75.75 0 0 0 1.06 1.061l1.591-1.59ZM6 12a.75.75 0 0 1-.75.75H3a.75.75 0 0 1 0-1.5h2.25A.75.75 0 0 1 6 12ZM6.697 7.757a.75.75 0 0 0 1.06-1.06l-1.59-1.591a.75.75 0 0 0-1.061 1.06l1.59 1.591Z"
/>
</svg>
{:else}
<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="M9.528 1.718a.75.75 0 0 1 .162.819A8.97 8.97 0 0 0 9 6a9 9 0 0 0 9 9 8.97 8.97 0 0 0 3.463-.69.75.75 0 0 1 .981.98 10.503 10.503 0 0 1-9.694 6.46c-5.799 0-10.5-4.7-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 0 1 .818.162Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
<ConnectionStatus />
</menu>
</header>
<style>
.activity-link {
background: linear-gradient(90deg, #6366f1, #8b5cf6, #a855f7, #8b5cf6, #6366f1);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 2s linear infinite;
}
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
</style>
+29 -52
View File
@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
import { persistentStore } from "../stores/persistent"; import { persistentStore } from "../stores/persistent";
import { Type, WrapText, Search, SearchX, CircleX } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import * as Card from "$lib/components/ui/card/index.js";
interface Props { interface Props {
id: string; id: string;
@@ -81,59 +85,32 @@
}); });
</script> </script>
<div class="rounded-lg overflow-hidden flex flex-col bg-gray-950/5 dark:bg-white/10 h-full w-full p-1"> <Card.Root class="bg-muted/30 h-full w-full gap-0 overflow-hidden py-0">
<div class="p-4"> <Card.Header class="border-b px-4 py-2">
<div class="flex items-center justify-between"> <Card.Title class="text-sm font-semibold">{title}</Card.Title>
<h3 class="m-0 text-lg p-0">{title}</h3> <Card.Action>
<div class="flex items-center gap-1">
<div class="flex gap-2 items-center"> <Button variant="ghost" size="icon-sm" onclick={toggleFontSize} title="Change font size">
<button class="btn border-0" onclick={toggleFontSize} title="Change font size"> <Type />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4"> </Button>
<path d="M2 4v3h5v12h3V7h5V4H2zm19 5h-9v3h3v7h3v-7h3V9z"/> <Button variant="ghost" size="icon-sm" onclick={toggleWrapText} title="Toggle text wrap">
</svg> <WrapText class={$wrapTextStore ? "text-primary" : ""} />
</button> </Button>
<button class="btn border-0" onclick={toggleWrapText} title="Toggle text wrap"> <Button variant="ghost" size="icon-sm" onclick={toggleFilter} title="Toggle filter">
{#if $wrapTextStore} {#if $showFilterStore}<SearchX />{:else}<Search />{/if}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4"> </Button>
<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" /> </div>
</svg> </Card.Action>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<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-.75h10.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" />
</svg>
{/if}
</button>
<button class="btn border-0" onclick={toggleFilter} title="Toggle filter">
{#if $showFilterStore} {#if $showFilterStore}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4"> <div class="flex w-full items-center gap-2 pt-2">
<path fill-rule="evenodd" d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z" clip-rule="evenodd" /> <Input type="text" class="h-8" placeholder="Filter logs (regex)..." bind:value={filterRegex} />
</svg> <Button variant="ghost" size="icon-sm" onclick={() => (filterRegex = "")} aria-label="Clear filter">
{:else} <CircleX />
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> </Button>
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
{/if}
</button>
</div>
</div>
{#if $showFilterStore}
<div class="mt-2 flex gap-2 items-center w-full">
<input
type="text"
class="w-full text-sm border border-gray-950/10 dark:border-white/5 p-2 rounded outline-none"
placeholder="Filter logs (regex)..."
bind:value={filterRegex}
/>
<button class="pl-2" onclick={() => (filterRegex = "")} aria-label="Clear filter">
<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-1.72 6.97a.75.75 0 1 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z" clip-rule="evenodd" />
</svg>
</button>
</div> </div>
{/if} {/if}
</div> </Card.Header>
<div class="rounded-lg bg-background font-mono text-sm flex-1 overflow-hidden"> <Card.Content class="bg-background min-h-0 flex-1 p-0 font-mono text-sm">
<pre bind:this={preElement} onscroll={handleScroll} class="{textWrapClass} {fontSizeClass} h-full overflow-auto p-4">{filteredLogs}</pre> <pre bind:this={preElement} onscroll={handleScroll} class="{textWrapClass} {fontSizeClass} h-full overflow-auto p-4">{filteredLogs}</pre>
</div> </Card.Content>
</div> </Card.Root>
+12 -60
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,68 +10,15 @@
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}
onblur={onLeave}
class="inline-flex"
role="button"
tabindex="0"
aria-label="Show metadata"
>
{@render children()} {@render children()}
</span> </Tooltip.Trigger>
<Tooltip.Content class="min-w-[12rem] max-w-[24rem] normal-case">
{#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"> <table class="w-full text-left">
<tbody> <tbody>
{#each entries as [key, value]} {#each entries as [key, value]}
@@ -81,5 +29,9 @@
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{:else}
{@render children()}
{/if} {/if}
-238
View File
@@ -1,238 +0,0 @@
<script lang="ts">
import { models, loadModel, unloadAllModels, unloadSingleModel } from "../stores/api";
import { isNarrow } from "../stores/theme";
import { persistentStore } from "../stores/persistent";
import type { Model } from "../lib/types";
let isUnloading = $state(false);
let menuOpen = $state(false);
let pendingLoads = $state<Record<string, boolean>>({});
const loadControllers = new Map<string, AbortController>();
const showUnlistedStore = persistentStore<boolean>("showUnlisted", true);
const showIdorNameStore = persistentStore<"id" | "name">("showIdorName", "id");
let filteredModels = $derived.by(() => {
const filtered = $models.filter((model) => $showUnlistedStore || !model.unlisted);
const peerModels = filtered.filter((m) => m.peerID);
// Group peer models by peerID
const grouped = peerModels.reduce(
(acc, model) => {
const peerId = model.peerID || "unknown";
if (!acc[peerId]) acc[peerId] = [];
acc[peerId].push(model);
return acc;
},
{} as Record<string, Model[]>
);
return {
regularModels: filtered.filter((m) => !m.peerID),
peerModelsByPeerId: grouped,
};
});
async function handleUnloadAllModels(): Promise<void> {
isUnloading = true;
try {
await unloadAllModels();
} catch (e) {
console.error(e);
} finally {
setTimeout(() => (isUnloading = false), 1000);
}
}
async function handleLoadModel(modelId: string): Promise<void> {
if (pendingLoads[modelId]) return;
const controller = new AbortController();
loadControllers.set(modelId, controller);
pendingLoads[modelId] = true;
try {
await loadModel(modelId, controller.signal);
} catch (e) {
console.error(e);
} finally {
loadControllers.delete(modelId);
delete pendingLoads[modelId];
}
}
function cancelLoad(modelId: string): void {
loadControllers.get(modelId)?.abort();
}
function toggleIdorName(): void {
showIdorNameStore.update((prev) => (prev === "name" ? "id" : "name"));
}
function toggleShowUnlisted(): void {
showUnlistedStore.update((prev) => !prev);
}
function getModelDisplay(model: Model): string {
return $showIdorNameStore === "id" ? model.id : (model.name || model.id);
}
</script>
<div class="card h-full flex flex-col">
<div class="shrink-0">
<div class="flex justify-between items-baseline">
<h2 class={$isNarrow ? "text-xl" : ""}>Models</h2>
{#if $isNarrow}
<div class="relative">
<button class="btn text-base flex items-center gap-2 py-1" onclick={() => (menuOpen = !menuOpen)} aria-label="Toggle menu">
<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="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" />
</svg>
</button>
{#if menuOpen}
<div class="absolute right-0 mt-2 w-48 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-20">
<button
class="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
onclick={() => { toggleIdorName(); menuOpen = false; }}
>
<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" ? "Show Name" : "Show ID"}
</button>
<button
class="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
onclick={() => { toggleShowUnlisted(); menuOpen = false; }}
>
{#if $showUnlistedStore}
<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>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<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" />
</svg>
{/if}
{$showUnlistedStore ? "Hide Unlisted" : "Show Unlisted"}
</button>
<button
class="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
onclick={() => { handleUnloadAllModels(); menuOpen = false; }}
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>
{/if}
</div>
{#if !$isNarrow}
<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">
{#if $showUnlistedStore}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<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" />
</svg>
{:else}
<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>
</thead>
<tbody>
{#each filteredModels.regularModels as model (model.id)}
<tr class="border-b hover:bg-secondary-hover border-gray-200">
<td class={model.unlisted ? "text-txtsecondary" : ""}>
<a href="/upstream/{model.id}/" class="font-semibold" target="_blank">
{getModelDisplay(model)}
</a>
{#if model.description}
<p class={model.unlisted ? "text-opacity-70" : ""}><em>{model.description}</em></p>
{/if}
{#if model.aliases && model.aliases.length > 0}
<p class="text-xs text-txtsecondary">Aliases: {model.aliases.join(", ")}</p>
{/if}
</td>
<td class="w-12">
{#if model.state === "stopped" && pendingLoads[model.id]}
<button class="btn btn--sm" onclick={() => cancelLoad(model.id)}>Cancel</button>
{:else if model.state === "stopped"}
<button class="btn btn--sm" onclick={() => handleLoadModel(model.id)}>Load</button>
{:else}
<button class="btn btn--sm" onclick={() => unloadSingleModel(model.id)} disabled={model.state !== "ready"}>Unload</button>
{/if}
</td>
<td class="w-20">
{#if model.state === "stopped" && pendingLoads[model.id]}
<span class="w-16 text-center status status--queued">queued</span>
{:else}
<span class="w-16 text-center status status--{model.state}">{model.state}</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{#if Object.keys(filteredModels.peerModelsByPeerId).length > 0}
<h3 class="mt-8 mb-2">Peer Models</h3>
{#each Object.entries(filteredModels.peerModelsByPeerId).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
<div class="mb-4">
<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 class="font-semibold">{peerId}</th>
</tr>
</thead>
<tbody>
{#each peerModels as model (model.id)}
<tr class="border-b hover:bg-secondary-hover border-gray-200">
<td class="pl-8 {model.unlisted ? 'text-txtsecondary' : ''}">
<span>{model.id}</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
{/if}
</div>
</div>
@@ -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="card p-4 h-[300px]"> <Card.Root class="h-[300px] py-0">
<Card.Content class="h-full p-4">
<canvas bind:this={canvas}></canvas> <canvas bind:this={canvas}></canvas>
</div> </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}
+6 -12
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props { interface Props {
content: string; content: string;
} }
@@ -6,15 +8,7 @@
let { content }: Props = $props(); let { content }: Props = $props();
</script> </script>
<div class="relative group inline-block"> <Tooltip.Root>
<span class="cursor-help">&#9432;</span> <Tooltip.Trigger class="cursor-help align-middle normal-case">&#9432;</Tooltip.Trigger>
<div <Tooltip.Content>{content}</Tooltip.Content>
class="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 </Tooltip.Root>
px-3 py-2 bg-gray-900 text-white text-sm rounded-md
opacity-0 group-hover:opacity-100 transition-opacity
duration-200 pointer-events-none whitespace-nowrap z-50 normal-case"
>
{content}
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-b-gray-900"></div>
</div>
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props {
label: string;
tooltip?: string;
}
let { label, tooltip }: Props = $props();
</script>
{#if tooltip}
<Tooltip.Root>
<Tooltip.Trigger class="cursor-help border-b border-dotted border-current align-middle">
{label}
</Tooltip.Trigger>
<Tooltip.Content>{tooltip}</Tooltip.Content>
</Tooltip.Root>
{:else}
{label}
{/if}
@@ -0,0 +1,33 @@
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props {
metadata: Record<string, string> | undefined;
}
let { metadata }: Props = $props();
let entries = $derived(Object.entries(metadata || {}));
</script>
{#if entries.length > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-muted-foreground hover:text-foreground cursor-help">...</span>
</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>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
interface Props {
hasCapture: boolean;
loading: boolean;
onclick: () => void;
}
let { hasCapture, loading, onclick }: Props = $props();
</script>
{#if hasCapture}
<Button variant="outline" size="xs" {onclick} disabled={loading}>
{loading ? "..." : "View"}
</Button>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
@@ -0,0 +1,24 @@
<script lang="ts">
import { metrics } from "../../stores/api";
import ActivityTable from "../ActivityTable.svelte";
interface Props {
modelId: string;
}
let { modelId }: Props = $props();
let modelMetrics = $derived(
[...$metrics].filter((m) => m.model === modelId).sort((a, b) => b.id - a.id)
);
</script>
<ActivityTable
metrics={modelMetrics}
storagePrefix="model-detail"
showModelColumn={false}
showPagination={true}
compact={true}
title="Recent Activity"
emptyMessage="No activity recorded for this model"
/>
@@ -0,0 +1,44 @@
<script lang="ts">
import type { Model } from "../../lib/types";
import * as Card from "$lib/components/ui/card/index.js";
interface Props {
model: Model;
}
let { model }: Props = $props();
const capabilityLabels: Record<string, string> = {
vision: "Vision",
audio_transcriptions: "Transcription",
audio_speech: "Speech",
image_generation: "Image Gen",
image_to_image: "Img→Img",
function_calling: "Function Calling",
reranker: "Reranker",
};
let capabilities = $derived.by(() => {
const caps = model?.capabilities ?? {};
return Object.entries(caps).filter(([, v]) => v);
});
</script>
<Card.Root class="shrink-0 gap-0 overflow-hidden py-0">
<Card.Header class="border-b px-4 py-2">
<Card.Title class="text-sm font-semibold">Capabilities</Card.Title>
</Card.Header>
<Card.Content class="p-3">
{#if capabilities.length === 0}
<span class="text-muted-foreground text-sm">No capabilities reported.</span>
{:else}
<div class="flex flex-wrap gap-1.5">
{#each capabilities as [key] (key)}
<span class="bg-muted text-muted-foreground rounded-md px-2 py-0.5 text-xs font-medium">
{capabilityLabels[key] ?? key}
</span>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,26 @@
<script lang="ts">
import { streamModelLog } from "../../stores/modelLogs";
import LogPanel from "../LogPanel.svelte";
interface Props {
modelId: string;
}
let { modelId }: Props = $props();
let logData = $state("");
$effect(() => {
const id = modelId;
if (!id) {
logData = "";
return;
}
const store = streamModelLog(id);
const unsub = store.subscribe((v) => (logData = v));
return () => unsub();
});
</script>
<div class="h-full">
<LogPanel id={`model-${modelId}`} title="Model Logs" {logData} />
</div>
@@ -4,6 +4,8 @@
import { transcribeAudio } from "../../lib/audioApi"; import { transcribeAudio } from "../../lib/audioApi";
import { playgroundStores } from "../../stores/playgroundActivity"; import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Copy, Check } from "@lucide/svelte";
const selectedModelStore = persistentStore<string>("playground-audio-model", ""); const selectedModelStore = persistentStore<string>("playground-audio-model", "");
@@ -150,14 +152,14 @@
<!-- Empty state for no models configured --> <!-- Empty state for no models configured -->
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="flex-1 flex items-center justify-center text-muted-foreground">
<p>No models configured. Add models to your configuration to transcribe audio.</p> <p>No models configured. Add models to your configuration to transcribe audio.</p>
</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-surface border border-gray-200 dark:border-white/10 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-txtsecondary"> <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>
<p>Transcribing audio...</p> <p>Transcribing audio...</p>
</div> </div>
@@ -169,29 +171,26 @@
{:else if transcriptionResult} {:else if transcriptionResult}
<div class="w-full h-full flex flex-col p-4"> <div class="w-full h-full flex flex-col p-4">
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<h3 class="font-medium">Transcription Result</h3> <h3 class="pb-0 font-medium">Transcription Result</h3>
<button <Button
class="btn btn-sm" variant="outline"
size="icon-sm"
onclick={copyToClipboard} onclick={copyToClipboard}
title={copied ? 'Copied!' : 'Copy to clipboard'} title={copied ? 'Copied!' : 'Copy to clipboard'}
> >
{#if copied} {#if copied}
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Check class="text-success" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{:else} {:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Copy />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
{/if} {/if}
</button> </Button>
</div> </div>
<div class="flex-1 overflow-auto p-3 rounded border border-gray-200 dark:border-white/10 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>
{:else if selectedFile} {:else if selectedFile}
<div class="text-center text-txtsecondary p-4"> <div class="text-center text-muted-foreground p-4">
<p class="font-medium mb-2">File Selected</p> <p class="font-medium mb-2">File Selected</p>
<p class="text-sm">{selectedFile.name}</p> <p class="text-sm">{selectedFile.name}</p>
<p class="text-xs mt-1">{formatFileSize(selectedFile.size)}</p> <p class="text-xs mt-1">{formatFileSize(selectedFile.size)}</p>
@@ -200,7 +199,7 @@
<div <div
role="region" role="region"
aria-label="Audio file drop zone" aria-label="Audio file drop zone"
class="w-full h-full flex items-center justify-center text-center text-txtsecondary p-8 {isDragging ? 'bg-primary/10' : ''}" class="w-full h-full flex items-center justify-center text-center text-muted-foreground p-8 {isDragging ? 'bg-primary/10' : ''}"
ondragover={handleDragOver} ondragover={handleDragOver}
ondragleave={handleDragLeave} ondragleave={handleDragLeave}
ondrop={handleDrop} ondrop={handleDrop}
@@ -223,33 +222,21 @@
onchange={handleFileSelect} onchange={handleFileSelect}
bind:this={fileInput} bind:this={fileInput}
/> />
<button <Button variant="outline" onclick={() => fileInput?.click()} disabled={isTranscribing}>
class="btn"
onclick={() => fileInput?.click()}
disabled={isTranscribing}
>
Browse Files Browse Files
</button> </Button>
<div class="flex-1"></div> <div class="flex-1"></div>
{#if isTranscribing} {#if isTranscribing}
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelTranscription}> <Button variant="destructive" onclick={cancelTranscription}>Cancel</Button>
Cancel
</button>
{:else} {:else}
<button <Button onclick={transcribe} disabled={!canTranscribe}>Transcribe</Button>
class="btn bg-primary text-btn-primary-text hover:opacity-90" <Button
onclick={transcribe} variant="outline"
disabled={!canTranscribe}
>
Transcribe
</button>
<button
class="btn"
onclick={clearAll} onclick={clearAll}
disabled={!selectedFile && !transcriptionResult && !error} disabled={!selectedFile && !transcriptionResult && !error}
> >
Clear Clear
</button> </Button>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -7,6 +7,14 @@
import ChatMessageComponent from "./ChatMessage.svelte"; import ChatMessageComponent from "./ChatMessage.svelte";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte"; import ExpandableTextarea from "./ExpandableTextarea.svelte";
import { Settings, Paperclip } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as Dialog from "$lib/components/ui/dialog/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", "");
@@ -30,6 +38,7 @@
let reasoningStartTime = $state<number>(0); let reasoningStartTime = $state<number>(0);
let abortController = $state<AbortController | null>(null); let abortController = $state<AbortController | null>(null);
let messagesContainer: HTMLDivElement | undefined = $state(); let messagesContainer: HTMLDivElement | undefined = $state();
let inputRef: HTMLTextAreaElement | null = $state(null);
let showSettings = $state(false); let showSettings = $state(false);
let attachedImages = $state<string[]>([]); let attachedImages = $state<string[]>([]);
let fileInput = $state<HTMLInputElement | null>(null); let fileInput = $state<HTMLInputElement | null>(null);
@@ -42,6 +51,14 @@
playgroundStores.chatStreaming.set(isStreaming); playgroundStores.chatStreaming.set(isStreaming);
}); });
let wasStreaming = $state(false);
$effect(() => {
if (wasStreaming && !isStreaming) {
inputRef?.focus();
}
wasStreaming = isStreaming;
});
function handleMessagesScroll() { function handleMessagesScroll() {
if (!messagesContainer) return; if (!messagesContainer) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainer; const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
@@ -303,96 +320,95 @@
<div class="shrink-0 flex flex-wrap gap-2 mb-4"> <div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a model..." disabled={isStreaming} /> <ModelSelector bind:value={$selectedModelStore} placeholder="Select a model..." disabled={isStreaming} />
<div class="flex gap-2"> <div class="flex gap-2">
<button <Button variant="outline" size="icon" onclick={() => (showSettings = true)} title="Settings">
class="btn" <Settings />
onclick={() => (showSettings = !showSettings)} </Button>
title="Settings" <Button variant="outline" onclick={newChat} disabled={messages.length === 0 && !isStreaming}>
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M8.34 1.804A1 1 0 0 1 9.32 1h1.36a1 1 0 0 1 .98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 0 1 1.262.125l.962.962a1 1 0 0 1 .125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.295a1 1 0 0 1 .804.98v1.36a1 1 0 0 1-.804.98l-1.473.295a6.95 6.95 0 0 1-.587 1.416l.834 1.25a1 1 0 0 1-.125 1.262l-.962.962a1 1 0 0 1-1.262.125l-1.25-.834a6.953 6.953 0 0 1-1.416.587l-.295 1.473a1 1 0 0 1-.98.804H9.32a1 1 0 0 1-.98-.804l-.295-1.473a6.957 6.957 0 0 1-1.416-.587l-1.25.834a1 1 0 0 1-1.262-.125l-.962-.962a1 1 0 0 1-.125-1.262l.834-1.25a6.957 6.957 0 0 1-.587-1.416l-1.473-.295A1 1 0 0 1 1 10.68V9.32a1 1 0 0 1 .804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 0 1 .125-1.262l.962-.962A1 1 0 0 1 5.38 3.03l1.25.834a6.957 6.957 0 0 1 1.416-.587l.294-1.473ZM13 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" clip-rule="evenodd" />
</svg>
</button>
<button class="btn" onclick={newChat} disabled={messages.length === 0 && !isStreaming}>
New Chat New Chat
</button> </Button>
</div> </div>
</div> </div>
<!-- Settings panel --> <!-- Settings dialog -->
{#if showSettings} <Dialog.Root bind:open={showSettings}>
<div class="shrink-0 mb-4 p-4 bg-surface border border-gray-200 dark:border-white/10 rounded"> <Dialog.Content class="max-w-xl">
<div class="mb-4"> <Dialog.Header>
<label class="block text-sm font-medium mb-1" for="endpoint">Endpoint</label> <Dialog.Title>Chat Settings</Dialog.Title>
<select </Dialog.Header>
id="endpoint"
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary" <div class="space-y-4">
bind:value={$endpointStore} <div>
disabled={isStreaming} <Label class="mb-1" for="endpoint">Endpoint</Label>
<Select.Root
type="single"
value={$endpointStore}
onValueChange={(v) => v && endpointStore.set(v as Endpoint)}
> >
<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>
<label class="block text-sm font-medium mb-1" for="system-prompt">System Prompt</label> <Label class="mb-1" for="system-prompt">System Prompt</Label>
<textarea <Textarea
id="system-prompt" id="system-prompt"
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none" class="resize-none"
placeholder="You are a helpful assistant..." placeholder="You are a helpful assistant..."
rows="3" rows={3}
bind:value={$systemPromptStore} bind:value={$systemPromptStore}
disabled={isStreaming} disabled={isStreaming}
></textarea> />
</div> </div>
<div class="mb-4"> <div>
<label class="block text-sm font-medium mb-1" for="temperature"> <Label class="mb-1" for="temperature">
Temperature: {$temperatureStore.toFixed(2)} Temperature: {$temperatureStore.toFixed(2)}
</label> </Label>
<input <input
id="temperature" id="temperature"
type="range" type="range"
min="0" min="0"
max="2" max="2"
step="0.05" step="0.05"
class="w-full" class="accent-primary w-full"
bind:value={$temperatureStore} bind:value={$temperatureStore}
disabled={isStreaming} disabled={isStreaming}
/> />
<div class="flex justify-between text-xs text-txtsecondary mt-1"> <div class="text-muted-foreground mt-1 flex justify-between text-xs">
<span>Precise (0)</span> <span>Precise (0)</span>
<span>Creative (2)</span> <span>Creative (2)</span>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1" for="max-tokens">Max Tokens</label> <Label class="mb-1" for="max-tokens">Max Tokens</Label>
<input <Input id="max-tokens" type="number" min="1" bind:value={$maxTokensStore} disabled={isStreaming} />
id="max-tokens" <p class="text-muted-foreground mt-1 text-xs">Required for /v1/messages.</p>
type="number"
min="1"
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$maxTokensStore}
disabled={isStreaming}
/>
<p class="text-xs text-txtsecondary mt-1">Required for /v1/messages.</p>
</div> </div>
</div> </div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => (showSettings = false)}>Done</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Empty state for no models configured --> <!-- Empty state for no models configured -->
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="text-muted-foreground flex flex-1 items-center justify-center">
<p>No models configured. Add models to your configuration to start chatting.</p> <p>No models configured. Add models to your configuration to start chatting.</p>
</div> </div>
{:else} {:else}
<!-- Messages area --> <!-- Messages area -->
<div <div
class="flex-1 overflow-y-auto mb-4 px-2" class="mb-4 flex-1 overflow-y-auto px-2"
bind:this={messagesContainer} bind:this={messagesContainer}
onscroll={handleMessagesScroll} onscroll={handleMessagesScroll}
> >
{#if messages.length === 0} {#if messages.length === 0}
<div class="h-full flex items-center justify-center text-txtsecondary"> <div class="text-muted-foreground flex h-full items-center justify-center">
<p>Start a conversation by typing a message below.</p> <p>Start a conversation by typing a message below.</p>
</div> </div>
{:else} {:else}
@@ -419,19 +435,21 @@
{#if attachedImages.length > 0} {#if attachedImages.length > 0}
<div class="mb-2 flex flex-wrap gap-2"> <div class="mb-2 flex flex-wrap gap-2">
{#each attachedImages as imageUrl, idx (idx)} {#each attachedImages as imageUrl, idx (idx)}
<div class="relative group"> <div class="group relative">
<img <img
src={imageUrl} src={imageUrl}
alt="Attached image {idx + 1}" alt="Attached image {idx + 1}"
class="w-20 h-20 object-cover rounded border border-gray-200 dark:border-white/10" class="h-20 w-20 rounded-md border object-cover"
/> />
<button <Button
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" 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>
@@ -439,7 +457,7 @@
<!-- Error message --> <!-- Error message -->
{#if imageError} {#if imageError}
<div class="mb-2 p-2 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded text-sm"> <div class="bg-destructive/10 text-destructive mb-2 rounded-md p-2 text-sm">
{imageError} {imageError}
</div> </div>
{/if} {/if}
@@ -456,6 +474,7 @@
/> />
<ExpandableTextarea <ExpandableTextarea
bind:ref={inputRef}
bind:value={userInput} bind:value={userInput}
placeholder="Type a message..." placeholder="Type a message..."
rows={3} rows={3}
@@ -464,27 +483,23 @@
/> />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if isStreaming} {#if isStreaming}
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelStreaming}> <Button variant="destructive" onclick={cancelStreaming}>Cancel</Button>
Cancel
</button>
{:else} {:else}
<button <Button
class="btn" variant="outline"
size="icon"
onclick={() => fileInput?.click()} onclick={() => fileInput?.click()}
disabled={isStreaming || !$selectedModelStore} disabled={isStreaming || !$selectedModelStore}
title="Attach image" title="Attach image"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> <Paperclip />
<path fill-rule="evenodd" d="M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.219a.75.75 0 0 0-1.06 0l-1.91 1.909.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.091a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" clip-rule="evenodd" /> </Button>
</svg> <Button
</button>
<button
class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={sendMessage} onclick={sendMessage}
disabled={(!userInput.trim() && attachedImages.length === 0) || !$selectedModelStore} disabled={(!userInput.trim() && attachedImages.length === 0) || !$selectedModelStore}
> >
Send Send
</button> </Button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { renderMarkdown, escapeHtml, renderStreamingMarkdown, createStreamingCache } from "../../lib/markdown"; import { renderMarkdown, escapeHtml, renderStreamingMarkdown, createStreamingCache } from "../../lib/markdown";
import type { RenderedBlock } from "../../lib/markdown"; import type { RenderedBlock } from "../../lib/markdown";
import { Copy, Check, Pencil, X, Save, RefreshCw, ChevronDown, ChevronRight, Brain, Code } from "lucide-svelte"; import { Copy, Check, Pencil, X, Save, RefreshCw, ChevronDown, ChevronRight, Brain, Code } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { getTextContent, getImageUrls } from "../../lib/types"; import { getTextContent, getImageUrls } from "../../lib/types";
import type { ContentPart } from "../../lib/types"; import type { ContentPart } from "../../lib/types";
@@ -161,37 +163,37 @@
<div class="flex {role === 'user' ? 'justify-end' : 'justify-start'} mb-4"> <div class="flex {role === 'user' ? 'justify-end' : 'justify-start'} mb-4">
<div <div
class="relative group rounded-lg px-4 py-2 {role === 'user' class="group relative rounded-lg px-4 py-2 {role === 'user'
? 'max-w-[85%] bg-primary text-btn-primary-text' ? 'bg-primary text-primary-foreground max-w-[85%]'
: 'w-full sm:w-4/5 bg-surface border border-gray-200 dark:border-white/10'}" : 'bg-card w-full border sm:w-4/5'}"
> >
{#if role === "assistant"} {#if role === "assistant"}
{#if reasoning_content || isReasoning} {#if reasoning_content || isReasoning}
<div class="mb-3 border border-gray-200 dark:border-white/10 rounded overflow-hidden"> <div class="mb-3 overflow-hidden rounded-md border">
<button <button
class="w-full flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-white/5 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors text-sm" 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}
> >
{#if showReasoning} {#if showReasoning}
<ChevronDown class="w-4 h-4" /> <ChevronDown class="size-4" />
{:else} {:else}
<ChevronRight class="w-4 h-4" /> <ChevronRight class="size-4" />
{/if} {/if}
<Brain class="w-4 h-4" /> <Brain class="size-4" />
<span class="font-medium">Reasoning</span> <span class="font-medium">Reasoning</span>
<span class="text-txtsecondary ml-2"> <span class="text-muted-foreground ml-2">
({reasoning_content.length} chars{#if !isReasoning && reasoningTimeMs > 0}, {formatDuration(reasoningTimeMs)}{/if}) ({reasoning_content.length} chars{#if !isReasoning && reasoningTimeMs > 0}, {formatDuration(reasoningTimeMs)}{/if})
</span> </span>
{#if isReasoning} {#if isReasoning}
<span class="ml-auto flex items-center gap-1 text-txtsecondary"> <span class="text-muted-foreground ml-auto flex items-center gap-1">
<span class="w-1.5 h-1.5 bg-primary rounded-full animate-pulse"></span> <span class="bg-primary h-1.5 w-1.5 animate-pulse rounded-full"></span>
reasoning... reasoning...
</span> </span>
{/if} {/if}
</button> </button>
{#if showReasoning} {#if showReasoning}
<div class="px-3 py-2 bg-gray-50/50 dark:bg-white/[0.02] text-sm text-txtsecondary whitespace-pre-wrap font-mono"> <div class="bg-muted/30 text-muted-foreground whitespace-pre-wrap px-3 py-2 font-mono text-sm">
{reasoning_content}{#if isReasoning}<span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5"></span>{/if} {reasoning_content}{#if isReasoning}<span class="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-current"></span>{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -201,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 border-gray-200 dark:border-white/10 hover:opacity-80 transition-opacity" 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}
@@ -226,60 +228,47 @@
</div> </div>
{/if} {/if}
{#if !isStreaming} {#if !isStreaming}
<div class="flex gap-1 mt-2 pt-1 border-t border-gray-200 dark:border-white/10"> <div class="mt-2 flex gap-1 border-t pt-1">
{#if onRegenerate} {#if onRegenerate}
<button <Button variant="ghost" size="icon-xs" class="text-muted-foreground" onclick={onRegenerate} title="Regenerate response">
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary" <RefreshCw />
onclick={onRegenerate} </Button>
title="Regenerate response"
>
<RefreshCw class="w-4 h-4" />
</button>
{/if} {/if}
<button <Button
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary" variant="ghost"
size="icon-xs"
class="text-muted-foreground"
onclick={copyToClipboard} onclick={copyToClipboard}
title={copied ? "Copied!" : "Copy to clipboard"} title={copied ? "Copied!" : "Copy to clipboard"}
> >
{#if copied} {#if copied}
<Check class="w-4 h-4 text-green-500" /> <Check class="text-success" />
{:else} {:else}
<Copy class="w-4 h-4" /> <Copy />
{/if} {/if}
</button> </Button>
<button <Button
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 {showRaw ? 'text-primary' : 'text-txtsecondary'}" variant="ghost"
size="icon-xs"
class={showRaw ? "text-primary" : "text-muted-foreground"}
onclick={() => showRaw = !showRaw} onclick={() => showRaw = !showRaw}
title={showRaw ? "Show rendered" : "Show raw"} title={showRaw ? "Show rendered" : "Show raw"}
> >
<Code class="w-4 h-4" /> <Code />
</button> </Button>
</div> </div>
{/if} {/if}
{:else} {:else}
{#if isEditing} {#if isEditing}
<div class="flex flex-col gap-2 min-w-[300px]"> <div class="flex min-w-[300px] flex-col gap-2">
<textarea <Textarea class="resize-none" rows={3} bind:value={editContent} onkeydown={handleKeyDown} />
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface text-txtmain focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows="3"
bind:value={editContent}
onkeydown={handleKeyDown}
></textarea>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <Button variant="ghost" size="icon-sm" onclick={cancelEdit} title="Cancel">
class="p-1.5 rounded hover:bg-white/20" <X />
onclick={cancelEdit} </Button>
title="Cancel" <Button variant="ghost" size="icon-sm" onclick={saveEdit} title="Save">
> <Save />
<X class="w-4 h-4" /> </Button>
</button>
<button
class="p-1.5 rounded hover:bg-white/20"
onclick={saveEdit}
title="Save"
>
<Save class="w-4 h-4" />
</button>
</div> </div>
</div> </div>
{:else} {:else}
@@ -288,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 hover:opacity-80 transition-opacity" 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}
@@ -302,11 +291,11 @@
<div class="whitespace-pre-wrap pr-8">{textContent}</div> <div class="whitespace-pre-wrap pr-8">{textContent}</div>
{#if canEdit} {#if canEdit}
<button <button
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity bg-white/20 hover:bg-white/30 shadow-sm" class="absolute right-2 top-2 rounded-lg bg-white/20 p-1.5 opacity-0 shadow-sm transition-opacity hover:bg-white/30 group-hover:opacity-100"
onclick={startEdit} onclick={startEdit}
title="Edit message" title="Edit message"
> >
<Pencil class="w-4 h-4" /> <Pencil class="size-4" />
</button> </button>
{/if} {/if}
{/if} {/if}
@@ -324,16 +313,16 @@
tabindex="-1" tabindex="-1"
> >
<button <button
class="absolute top-4 right-4 p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors" class="absolute right-4 top-4 rounded-lg bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
onclick={() => closeModal()} onclick={() => closeModal()}
title="Close" title="Close"
> >
<X class="w-6 h-6" /> <X class="size-6" />
</button> </button>
<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}
@@ -341,8 +330,8 @@
<style> <style>
.prose :global(pre) { .prose :global(pre) {
position: relative; position: relative;
background-color: var(--color-surface); background-color: var(--muted);
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2)); border: 1px solid var(--border);
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 0.75rem; padding: 0.75rem;
padding-right: 2.5rem; padding-right: 2.5rem;
@@ -359,20 +348,20 @@
justify-content: center; justify-content: center;
padding: 0.25rem; padding: 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
border: 1px solid var(--color-border); border: 1px solid var(--border);
background: var(--color-surface); background: var(--muted);
color: var(--color-txtsecondary); color: var(--muted-foreground);
cursor: pointer; cursor: pointer;
transition: background-color 0.15s; transition: background-color 0.15s;
line-height: 0; line-height: 0;
} }
.prose :global(.code-copy-btn:hover) { .prose :global(.code-copy-btn:hover) {
background: var(--color-secondary); background: var(--accent);
} }
.prose :global(.code-copy-btn.copied) { .prose :global(.code-copy-btn.copied) {
color: var(--color-success); color: var(--success);
opacity: 1; opacity: 1;
} }
@@ -387,10 +376,10 @@
} }
.prose :global(code:not(pre code)) { .prose :global(code:not(pre code)) {
background-color: var(--color-surface); background-color: var(--muted);
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2)); border: 1px solid var(--border);
} }
.prose :global(p) { .prose :global(p) {
@@ -431,14 +420,14 @@
} }
.prose :global(blockquote) { .prose :global(blockquote) {
border-left: 3px solid var(--color-primary); border-left: 3px solid var(--primary);
padding-left: 1rem; padding-left: 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
font-style: italic; font-style: italic;
} }
.prose :global(a) { .prose :global(a) {
color: var(--color-primary); color: var(--primary);
text-decoration: underline; text-decoration: underline;
} }
@@ -450,13 +439,13 @@
.prose :global(th), .prose :global(th),
.prose :global(td) { .prose :global(td) {
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2)); border: 1px solid var(--border);
padding: 0.5rem; padding: 0.5rem;
text-align: left; text-align: left;
} }
.prose :global(th) { .prose :global(th) {
background-color: var(--color-surface); background-color: var(--muted);
font-weight: 600; font-weight: 600;
} }
@@ -2,6 +2,10 @@
import { models } from "../../stores/api"; import { models } from "../../stores/api";
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 { 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";
@@ -366,38 +370,37 @@
<!-- Run controls --> <!-- Run controls -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if isRunning} {#if isRunning}
<button class="btn bg-red-500 hover:bg-red-600 text-white border-red-500" onclick={stop}> <Button variant="destructive" onclick={stop}>
<span class="inline-block w-3 h-3 bg-white align-middle mr-2"></span>Stop <span class="mr-1 inline-block h-3 w-3 bg-current align-middle"></span>Stop
</button> </Button>
{:else} {:else}
<button <Button
class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={run} onclick={run}
disabled={!canRun} disabled={!canRun}
title={$testListStore.length === 0 ? "Add models from the list below" : "Run concurrent requests"} title={$testListStore.length === 0 ? "Add models from the list below" : "Run concurrent requests"}
> >
<span class="inline-block align-middle mr-2" aria-hidden="true"></span>Go <span class="mr-1 inline-block align-middle" aria-hidden="true"></span>Go
</button> </Button>
{/if} {/if}
<button class="btn btn--sm" onclick={clearAll} disabled={isRunning || $testListStore.length === 0}> <Button variant="outline" size="sm" onclick={clearAll} disabled={isRunning || $testListStore.length === 0}>
Clear ({$testListStore.length}) Clear ({$testListStore.length})
</button> </Button>
</div> </div>
<!-- Available models --> <!-- Available models -->
<div class="flex flex-col min-h-0 flex-1"> <div class="flex flex-col min-h-0 flex-1">
<div class="text-xs font-medium text-txtsecondary 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-gray-200 dark:border-white/10 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-txtsecondary 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"> <div class="divide-y divide-gray-100 dark:divide-white/5">
{#each availableModels as m (m.id)} {#each availableModels as m (m.id)}
<li>
<button <button
class="w-full text-left px-2 py-1.5 text-sm hover:bg-secondary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" type="button"
class="hover:bg-accent hover:text-foreground flex w-full items-center gap-1.5 px-2 py-1.5 text-left text-sm font-normal transition-colors disabled:pointer-events-none disabled:opacity-50"
onclick={() => addModel(m.id)} onclick={() => addModel(m.id)}
disabled={isRunning} disabled={isRunning}
title="Add {m.id}" title="Add {m.id}"
@@ -405,38 +408,39 @@
<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>
{/each} {/each}
</ul> </div>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Settings --> <!-- Settings -->
<div class="flex flex-col gap-2 border-t border-gray-200 dark:border-white/10 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-txtsecondary">Prompt</label> <label for="concurrency-prompt" class="text-xs font-medium text-muted-foreground">Prompt</label>
<button <Button
class="text-[10px] text-txtsecondary hover:text-txtmain 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-gray-200 dark:border-white/10 bg-surface 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-txtsecondary">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-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" class="h-8 text-sm"
bind:value={$maxTokensStore} bind:value={$maxTokensStore}
disabled={isRunning} disabled={isRunning}
/> />
@@ -447,8 +451,8 @@
<div class="flex-1 min-w-0 min-h-0 overflow-y-auto"> <div class="flex-1 min-w-0 min-h-0 overflow-y-auto">
{#if $testListStore.length === 0} {#if $testListStore.length === 0}
<div class="h-full flex items-center justify-center px-6"> <div class="h-full flex items-center justify-center px-6">
<div class="max-w-md text-sm text-txtsecondary space-y-4"> <div class="max-w-md text-sm text-muted-foreground space-y-4">
<h4 class="text-base font-semibold text-txtmain pb-0">Load Test</h4> <h4 class="text-base font-semibold text-foreground pb-0">Load Test</h4>
<p> <p>
Fire several streaming chat completions at llama-swap at the same time to see how it handles parallel Fire several streaming chat completions at llama-swap at the same time to see how it handles parallel
loading and concurrent inference. Each request streams into its own panel with a live timer and status. loading and concurrent inference. Each request streams into its own panel with a live timer and status.
@@ -456,16 +460,16 @@
<ol class="list-decimal list-inside space-y-1"> <ol class="list-decimal list-inside space-y-1">
<li>Click models on the left to queue them — repeat a model to hit it with parallel requests.</li> <li>Click models on the left to queue them — repeat a model to hit it with parallel requests.</li>
<li>Tweak the prompt and <code>max_tokens</code> if you want.</li> <li>Tweak the prompt and <code>max_tokens</code> if you want.</li>
<li>Press <span class="font-semibold text-txtmain">Go</span> to launch them concurrently.</li> <li>Press <span class="font-semibold text-foreground">Go</span> to launch them concurrently.</li>
</ol> </ol>
<p class="text-xs">Tip: drag a result card's header to reorder, or hit × to drop it.</p> <p class="text-xs">Tip: drag a result card's header to reorder, or hit × to drop it.</p>
</div> </div>
</div> </div>
{:else} {:else}
<!-- Gantt-style timeline --> <!-- Gantt-style timeline -->
<div class="mb-3 border border-gray-200 dark:border-white/10 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-txtsecondary hover:bg-secondary-hover transition-colors {$timelineCollapsedStore ? 'rounded' : 'rounded-t border-b border-gray-200 dark:border-white/10'}" 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}
> >
@@ -480,7 +484,7 @@
</svg> </svg>
<span>Timeline</span> <span>Timeline</span>
{#if !$timelineCollapsedStore} {#if !$timelineCollapsedStore}
<span class="flex items-center gap-3 text-[10px] text-txtsecondary font-normal ml-3" aria-hidden="true"> <span class="flex items-center gap-3 text-[10px] text-muted-foreground font-normal ml-3" aria-hidden="true">
<span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-slate-200 dark:bg-white/10 border border-gray-300 dark:border-white/10"></span>waiting</span> <span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-slate-200 dark:bg-white/10 border border-gray-300 dark:border-white/10"></span>waiting</span>
<span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-slate-400 dark:bg-slate-500"></span>loading</span> <span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-slate-400 dark:bg-slate-500"></span>loading</span>
<span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-purple-500"></span>reasoning</span> <span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-purple-500"></span>reasoning</span>
@@ -489,7 +493,7 @@
<span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-red-500"></span>error</span> <span class="flex items-center gap-1"><span class="inline-block w-2.5 h-2.5 rounded-sm bg-red-500"></span>error</span>
</span> </span>
{/if} {/if}
<span class="ml-auto tabular-nums text-txtsecondary"> <span class="ml-auto tabular-nums text-muted-foreground">
max {formatElapsed(timelineMaxMs)} · {$testListStore.length} request{$testListStore.length === 1 ? "" : "s"} max {formatElapsed(timelineMaxMs)} · {$testListStore.length} request{$testListStore.length === 1 ? "" : "s"}
</span> </span>
</button> </button>
@@ -498,13 +502,13 @@
<!-- X axis ticks --> <!-- X axis ticks -->
<div class="flex" aria-hidden="true"> <div class="flex" aria-hidden="true">
<div class="w-40 shrink-0"></div> <div class="w-40 shrink-0"></div>
<div class="relative flex-1 h-4 border-b border-gray-200 dark:border-white/10"> <div class="relative flex-1 h-4 border-b border-border">
{#each timelineTicks as t (t)} {#each timelineTicks as t (t)}
<div <div
class="absolute top-0 bottom-0 border-l border-gray-200 dark:border-white/10" class="absolute top-0 bottom-0 border-l border-border"
style="left: {(t / timelineMaxMs) * 100}%;" style="left: {(t / timelineMaxMs) * 100}%;"
> >
<span class="absolute -top-0.5 left-1 text-[10px] text-txtsecondary tabular-nums">{formatTickMs(t)}</span> <span class="absolute -top-0.5 left-1 text-[10px] text-muted-foreground tabular-nums">{formatTickMs(t)}</span>
</div> </div>
{/each} {/each}
</div> </div>
@@ -519,14 +523,14 @@
{@const reasoningPct = run ? (run.reasoningMs / timelineMaxMs) * 100 : 0} {@const reasoningPct = run ? (run.reasoningMs / timelineMaxMs) * 100 : 0}
{@const contentPct = run ? (run.contentMs / timelineMaxMs) * 100 : 0} {@const contentPct = run ? (run.contentMs / timelineMaxMs) * 100 : 0}
<div class="flex items-center text-xs"> <div class="flex items-center text-xs">
<div class="w-40 shrink-0 flex items-center gap-1 pr-2 text-txtsecondary"> <div class="w-40 shrink-0 flex items-center gap-1 pr-2 text-muted-foreground">
<span class="tabular-nums w-5 text-right">{i + 1}.</span> <span class="tabular-nums w-5 text-right">{i + 1}.</span>
<span class="truncate" title={entry.model}>{entry.model}</span> <span class="truncate" title={entry.model}>{entry.model}</span>
</div> </div>
<div class="relative flex-1 h-4"> <div class="relative flex-1 h-4">
{#each timelineTicks as t (t)} {#each timelineTicks as t (t)}
<div <div
class="absolute top-0 bottom-0 border-l border-gray-100 dark:border-white/5" class="absolute top-0 bottom-0 border-l border-border"
style="left: {(t / timelineMaxMs) * 100}%;" style="left: {(t / timelineMaxMs) * 100}%;"
aria-hidden="true" aria-hidden="true"
></div> ></div>
@@ -560,7 +564,7 @@
></div> ></div>
{/if} {/if}
</div> </div>
<div class="w-16 shrink-0 pl-2 tabular-nums text-txtsecondary text-right"> <div class="w-16 shrink-0 pl-2 tabular-nums text-muted-foreground text-right">
{run ? formatElapsed(run.elapsedMs) : "—"} {run ? formatElapsed(run.elapsedMs) : "—"}
</div> </div>
</div> </div>
@@ -574,16 +578,16 @@
{@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-gray-200 dark:border-white/10'} {dragIndex === i ? 'opacity-40' : ''}" : 'border-border'} {dragIndex === i ? 'opacity-40' : ''}"
style="height: 280px;" style="height: 280px;"
role="listitem" role="listitem"
ondragover={(e) => onDragOver(i, e)} ondragover={(e) => onDragOver(i, e)}
ondrop={(e) => onDrop(i, e)} ondrop={(e) => onDrop(i, e)}
> >
<div <div
class="shrink-0 flex items-center gap-2 px-2 py-1.5 border-b border-gray-200 dark:border-white/10 bg-secondary/40 rounded-t" class="shrink-0 flex items-center gap-2 px-2 py-1.5 border-b border-border bg-secondary/40 rounded-t"
draggable={!isRunning} draggable={!isRunning}
role="button" role="button"
tabindex="-1" tabindex="-1"
@@ -593,26 +597,28 @@
class:cursor-grab={!isRunning} class:cursor-grab={!isRunning}
title={isRunning ? "" : "Drag to reorder"} title={isRunning ? "" : "Drag to reorder"}
> >
<span class="text-txtsecondary select-none" aria-hidden="true">⋮⋮</span> <span class="text-muted-foreground select-none" aria-hidden="true">⋮⋮</span>
<span class="text-txtsecondary tabular-nums text-xs w-5 text-right">{i + 1}.</span> <span class="text-muted-foreground tabular-nums text-xs w-5 text-right">{i + 1}.</span>
<span class="flex-1 truncate text-sm font-medium" title={entry.model}>{entry.model}</span> <span class="flex-1 truncate text-sm font-medium" title={entry.model}>{entry.model}</span>
<span class="text-xs tabular-nums text-txtsecondary"> <span class="text-xs tabular-nums text-muted-foreground">
{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-txtsecondary 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-txtsecondary 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>
@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { Maximize2, X } from "lucide-svelte"; import { Maximize2, X } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
interface Props { interface Props {
value: string; value: string;
ref?: HTMLTextAreaElement | null;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
disabled?: boolean; disabled?: boolean;
@@ -12,6 +15,7 @@
let { let {
value = $bindable(), value = $bindable(),
ref = $bindable(null),
placeholder = "", placeholder = "",
rows = 3, rows = 3,
disabled = false, disabled = false,
@@ -52,69 +56,55 @@
}); });
</script> </script>
<div class="flex-1 relative group flex items-stretch min-h-0"> <div class="group relative flex min-h-0 flex-1 items-stretch">
<textarea <Textarea
class="w-full px-3 py-2 pr-10 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary resize-none" class="resize-none pr-10"
bind:ref
{placeholder} {placeholder}
{rows} {rows}
bind:value bind:value
{onkeydown} {onkeydown}
{disabled} {disabled}
></textarea> />
<button <Button
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-60 md:opacity-0 group-hover:opacity-100 transition-opacity bg-surface/90 hover:bg-surface border border-gray-200 dark:border-white/10 shadow-sm" variant="outline"
size="icon-sm"
class="absolute right-2 top-2 opacity-60 transition-opacity group-hover:opacity-100 md:opacity-0"
onclick={openExpanded} onclick={openExpanded}
title="Expand to edit" title="Expand to edit"
type="button" type="button"
{disabled} {disabled}
> >
<Maximize2 class="w-4 h-4" /> <Maximize2 />
</button> </Button>
</div> </div>
{#if isExpanded} {#if isExpanded}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-4xl h-[80vh] flex flex-col bg-surface rounded-lg shadow-xl border border-gray-200 dark:border-white/10"> <div class="bg-card flex h-[80vh] w-full max-w-4xl flex-col rounded-lg border shadow-xl">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-white/10"> <div class="flex items-center justify-between border-b p-4">
<h3 class="font-medium">Edit Text</h3> <h3 class="pb-0 font-medium">Edit Text</h3>
<button <Button variant="ghost" size="icon-sm" onclick={closeExpanded} title="Close" type="button">
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10" <X />
onclick={closeExpanded} </Button>
title="Close"
type="button"
>
<X class="w-5 h-5" />
</button>
</div> </div>
<!-- Textarea --> <!-- Textarea -->
<div class="flex-1 p-4"> <div class="flex-1 p-4">
<textarea <Textarea
bind:this={expandedTextarea} bind:ref={expandedTextarea}
class="w-full h-full px-4 py-3 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none" class="h-full resize-none"
placeholder={placeholder} {placeholder}
bind:value={expandedValue} bind:value={expandedValue}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
></textarea> />
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-white/10"> <div class="flex justify-end gap-2 border-t p-4">
<button <Button variant="outline" onclick={closeExpanded} type="button">Cancel</Button>
class="btn" <Button onclick={saveExpanded} type="button">Done</Button>
onclick={closeExpanded}
type="button"
>
Cancel
</button>
<button
class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={saveExpanded}
type="button"
>
Done
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -7,6 +7,11 @@
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
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 { 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");
@@ -61,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);
} }
@@ -195,65 +188,73 @@
<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-gray-200 dark:border-white/10 bg-surface 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-gray-200 dark:border-white/10 bg-surface 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 <Button variant="outline" onclick={() => showSettings = !showSettings}>
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface hover:bg-secondary-hover transition-colors"
onclick={() => showSettings = !showSettings}
>
{showSettings ? "Hide Settings" : "Settings"} {showSettings ? "Hide Settings" : "Settings"}
</button> </Button>
{/if} {/if}
</div> </div>
<!-- SDAPI Settings Panel --> <!-- SDAPI Settings Panel -->
{#if isSdapi && showSettings} {#if isSdapi && showSettings}
<div class="shrink-0 mb-4 p-4 rounded border border-gray-200 dark:border-white/10 bg-surface"> <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-txtsecondary">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-gray-200 dark:border-white/10 bg-surface 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"
/> />
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">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-gray-200 dark:border-white/10 bg-surface 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"
@@ -261,121 +262,141 @@
/> />
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">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-gray-200 dark:border-white/10 bg-surface 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-txtsecondary">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-gray-200 dark:border-white/10 bg-surface 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"
/> />
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Sampler</span> <span class="text-xs text-muted-foreground">Sampler</span>
<select <Select.Root
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface 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-txtsecondary">Scheduler</span> <span class="text-xs text-muted-foreground">Scheduler</span>
<select <Select.Root
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface 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-txtsecondary">Negative Prompt</span> <span class="text-xs text-muted-foreground">Negative Prompt</span>
<textarea <Textarea
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface 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 -->
<div> <div>
<span class="text-xs text-txtsecondary block mb-1">LoRAs</span> <span class="text-xs text-muted-foreground block mb-1">LoRAs</span>
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<button <Button
class="px-3 py-1.5 text-sm rounded border border-gray-200 dark:border-white/10 bg-surface hover:bg-secondary-hover transition-colors disabled:opacity-50" variant="outline"
size="sm"
onclick={loadLoras} onclick={loadLoras}
disabled={!$selectedModelStore || isLoadingLoras} disabled={!$selectedModelStore || isLoadingLoras}
> >
{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-gray-200 dark:border-white/10 bg-surface 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)}
<Select.Item value={lora.path}>{lora.name}</Select.Item>
{/each} {/each}
</select> </Select.Content>
</Select.Root>
{/if} {/if}
</div> </div>
{#if loraError} {#if loraError}
<p class="text-xs text-red-500 mb-1">{loraError}</p> <p class="text-xs text-red-500 mb-1">{loraError}</p>
{/if} {/if}
{#if lorasLoaded && availableLoras.length === 0} {#if lorasLoaded && availableLoras.length === 0}
<p class="text-xs text-txtsecondary">No LoRAs available</p> <p class="text-xs text-muted-foreground">No LoRAs available</p>
{/if} {/if}
{#if selectedLoras.length > 0} {#if selectedLoras.length > 0}
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
{#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-gray-200 dark:border-white/10 bg-surface 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-gray-200 dark:border-white/10 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>
@@ -386,14 +407,14 @@
<!-- Empty state for no models configured --> <!-- Empty state for no models configured -->
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="flex-1 flex items-center justify-center text-muted-foreground">
<p>No models configured. Add models to your configuration to generate images.</p> <p>No models configured. Add models to your configuration to generate images.</p>
</div> </div>
{:else} {:else}
<!-- Image display area --> <!-- Image display area -->
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-surface border border-gray-200 dark:border-white/10 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-txtsecondary"> <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>
<p>Generating image...</p> <p>Generating image...</p>
</div> </div>
@@ -403,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">
@@ -418,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>
@@ -443,18 +463,18 @@
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-txtsecondary"> <div class="text-center text-muted-foreground">
<p>Enter a prompt below to generate an image</p> <p>Enter a prompt below to generate an image</p>
</div> </div>
{/if} {/if}
@@ -471,24 +491,25 @@
/> />
<div class="flex flex-row md:flex-col gap-2"> <div class="flex flex-row md:flex-col gap-2">
{#if isGenerating} {#if isGenerating}
<button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}> <Button variant="destructive" class="flex-1 md:flex-none" onclick={cancelGeneration}>
Cancel Cancel
</button> </Button>
{:else} {:else}
<button <Button
class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none" class="flex-1 md:flex-none"
onclick={generate} onclick={generate}
disabled={!prompt.trim() || !$selectedModelStore} disabled={!prompt.trim() || !$selectedModelStore}
> >
Generate Generate
</button> </Button>
<button <Button
class="btn flex-1 md:flex-none" variant="outline"
class="flex-1 md:flex-none"
onclick={clearImage} onclick={clearImage}
disabled={generatedImages.length === 0 && !error && !prompt.trim()} disabled={generatedImages.length === 0 && !error && !prompt.trim()}
> >
Clear Clear
</button> </Button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -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="min-w-0 flex-1 basis-48 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" 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>
<Select.Content>
<Select.Item value="">{placeholder}</Select.Item>
{#if hasMatching} {#if hasMatching}
<optgroup label="Matching Capabilities"> <Select.Group>
<Select.Label>Matching Capabilities</Select.Label>
{#each grouped.localMatching as model (model.id)} {#each grouped.localMatching as model (model.id)}
<option value={model.id}>{model.id}</option> <Select.Item value={model.id}>{model.id}</Select.Item>
{#if model.aliases} {#if model.aliases}
{#each model.aliases as alias (alias)} {#each model.aliases as alias (alias)}
<option value={alias}> {alias}</option> <Select.Item value={alias}> {alias}</Select.Item>
{/each} {/each}
{/if} {/if}
{/each} {/each}
</optgroup> </Select.Group>
<Select.Separator />
{/if} {/if}
{#if grouped.local.length > 0} {#if grouped.local.length > 0}
<optgroup label="Local"> <Select.Group>
<Select.Label>Local</Select.Label>
{#each grouped.local as model (model.id)} {#each grouped.local as model (model.id)}
<option value={model.id}>{model.id}</option> <Select.Item value={model.id}>{model.id}</Select.Item>
{#if model.aliases} {#if model.aliases}
{#each model.aliases as alias (alias)} {#each model.aliases as alias (alias)}
<option value={alias}> {alias}</option> <Select.Item value={alias}> {alias}</Select.Item>
{/each} {/each}
{/if} {/if}
{/each} {/each}
</optgroup> </Select.Group>
<Select.Separator />
{/if} {/if}
{#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)} {#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
<optgroup label="Peer: {peerId}"> <Select.Group>
<Select.Label>Peer: {peerId}</Select.Label>
{#each peerModels as model (model.id)} {#each peerModels as model (model.id)}
<option value={model.id}>{model.id}</option> <Select.Item value={model.id}>{model.id}</Select.Item>
{/each} {/each}
</optgroup> </Select.Group>
{/each} {/each}
</select> </Select.Content>
</Select.Root>
{/if} {/if}
@@ -7,7 +7,7 @@
</script> </script>
<div class="flex items-center justify-center h-full"> <div class="flex items-center justify-center h-full">
<div class="text-center text-txtsecondary"> <div class="text-muted-foreground text-center">
<p class="text-lg">{featureName}</p> <p class="text-lg">{featureName}</p>
<p class="text-sm mt-2">To be implemented</p> <p class="text-sm mt-2">To be implemented</p>
</div> </div>
@@ -4,6 +4,10 @@
import { rerank } from "../../lib/rerankApi"; import { rerank } from "../../lib/rerankApi";
import { playgroundStores } from "../../stores/playgroundActivity"; import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import * as ToggleGroup from "$lib/components/ui/toggle-group/index.js";
type RerankRow = { doc: string; score: number | null }; type RerankRow = { doc: string; score: number | null };
type SortOrder = "none" | "asc" | "desc"; type SortOrder = "none" | "asc" | "desc";
@@ -234,9 +238,9 @@
} }
function scoreColor(score: number | null): string { function scoreColor(score: number | null): string {
if (score === null) return "text-txtsecondary"; if (score === null) return "text-muted-foreground";
if (score > 0) return "text-green-600 dark:text-green-400"; if (score > 0) return "text-green-600 dark:text-green-400";
return "text-red-500 dark:text-red-400"; return "text-destructive";
} }
function formatScore(score: number | null): string { function formatScore(score: number | null): string {
@@ -266,9 +270,9 @@
<div class="shrink-0 flex flex-wrap gap-2 mb-4"> <div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a rerank model..." disabled={isLoading} capabilities={["reranker"]} /> <ModelSelector bind:value={$selectedModelStore} placeholder="Select a rerank model..." disabled={isLoading} capabilities={["reranker"]} />
{#if editorMode === "table"} {#if editorMode === "table"}
<input <Input
type="text" type="text"
class="min-w-0 flex-1 basis-48 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary" class="min-w-0 flex-1 basis-48"
placeholder="Query..." placeholder="Query..."
bind:value={query} bind:value={query}
disabled={isLoading} disabled={isLoading}
@@ -276,60 +280,50 @@
/> />
{/if} {/if}
<!-- Table / JSON toggle --> <!-- Table / JSON toggle -->
<div class="flex rounded border border-gray-200 dark:border-white/10 overflow-hidden shrink-0"> <ToggleGroup.Root
<button type="single"
class="px-3 py-1.5 text-sm transition-colors {editorMode === 'table' variant="outline"
? 'bg-primary text-btn-primary-text' value={editorMode}
: 'bg-surface hover:bg-secondary-hover'}" onValueChange={(v) => v && (v === "table" ? switchToTable() : switchToJson())}
onclick={switchToTable} class="shrink-0"
disabled={isLoading}
> >
Table <ToggleGroup.Item value="table" disabled={isLoading}>Table</ToggleGroup.Item>
</button> <ToggleGroup.Item value="json" disabled={isLoading}>JSON</ToggleGroup.Item>
<button </ToggleGroup.Root>
class="px-3 py-1.5 text-sm border-l border-gray-200 dark:border-white/10 transition-colors {editorMode === 'json'
? 'bg-primary text-btn-primary-text'
: 'bg-surface hover:bg-secondary-hover'}"
onclick={switchToJson}
disabled={isLoading}
>
JSON
</button>
</div>
</div> </div>
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="text-muted-foreground flex flex-1 items-center justify-center">
<p>No models configured. Add models to your configuration to use reranking.</p> <p>No models configured. Add models to your configuration to use reranking.</p>
</div> </div>
{:else if editorMode === "json"} {:else if editorMode === "json"}
<!-- JSON editor --> <!-- JSON editor -->
<div class="flex-1 flex flex-col min-h-0 mb-4"> <div class="mb-4 flex min-h-0 flex-1 flex-col">
<textarea <Textarea
class="flex-1 w-full font-mono text-sm px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary resize-none" class="w-full flex-1 resize-none font-mono text-sm"
bind:value={jsonText} bind:value={jsonText}
disabled={isLoading} disabled={isLoading}
placeholder={'{\n "query": "your search query",\n "documents": [\n "document one",\n "document two"\n ]\n}'} placeholder={'{\n "query": "your search query",\n "documents": [\n "document one",\n "document two"\n ]\n}'}
spellcheck={false} spellcheck={false}
></textarea> />
{#if jsonError} {#if jsonError}
<p class="mt-1 text-sm text-red-500">{jsonError}</p> <p class="text-destructive mt-1 text-sm">{jsonError}</p>
{/if} {/if}
</div> </div>
{:else} {:else}
<!-- Document table --> <!-- Document table -->
<div class="flex-1 overflow-y-auto mb-4 border border-gray-200 dark:border-white/10 rounded"> <div class="mb-4 flex-1 overflow-y-auto rounded-lg border">
<table class="w-full border-collapse table-fixed"> <table class="w-full table-fixed border-collapse">
<colgroup> <colgroup>
<col class="w-auto" /> <col class="w-auto" />
<col style="width: 120px" /> <col style="width: 120px" />
<col style="width: 40px" /> <col style="width: 40px" />
</colgroup> </colgroup>
<thead class="sticky top-0 bg-surface border-b border-gray-200 dark:border-white/10"> <thead class="bg-card sticky top-0 border-b">
<tr> <tr>
<th class="px-3 py-2 text-left text-sm font-medium text-txtsecondary">Document</th> <th class="text-muted-foreground px-3 py-2 text-left text-sm font-medium">Document</th>
<th <th
class="px-3 py-2 text-right text-sm font-medium text-txtsecondary cursor-pointer select-none hover:text-txtprimary transition-colors" class="text-muted-foreground hover:text-foreground cursor-pointer select-none px-3 py-2 text-right text-sm font-medium transition-colors"
onclick={cycleSortOrder} onclick={cycleSortOrder}
> >
Score{sortIndicator()} Score{sortIndicator()}
@@ -339,11 +333,11 @@
</thead> </thead>
<tbody> <tbody>
{#each displayRows as { row, i } (i)} {#each displayRows as { row, i } (i)}
<tr class="border-b border-gray-100 dark:border-white/5 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="w-full bg-transparent focus:outline-none focus:ring-1 focus:ring-primary rounded px-1 py-0.5" 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,21 +347,23 @@
</td> </td>
<td class="px-3 py-1.5 text-right font-mono text-sm {scoreColor(row.score)}"> <td class="px-3 py-1.5 text-right font-mono text-sm {scoreColor(row.score)}">
{#if isLoading && row.score === null && row.doc.trim() !== ""} {#if isLoading && row.score === null && row.doc.trim() !== ""}
<span class="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin align-middle"></span> <span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent align-middle"></span>
{:else} {:else}
{formatScore(row.score)} {formatScore(row.score)}
{/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="w-7 h-7 flex items-center justify-center text-txtsecondary hover:text-red-500 transition-colors rounded disabled:opacity-30 disabled:cursor-not-allowed" 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}
@@ -378,28 +374,18 @@
<!-- Bottom toolbar --> <!-- Bottom toolbar -->
{#if hasModels} {#if hasModels}
<div class="shrink-0 flex flex-wrap items-center gap-2"> <div class="flex shrink-0 flex-wrap items-center gap-2">
{#if isLoading} {#if isLoading}
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancel}> <Button variant="destructive" onclick={cancel}>Cancel</Button>
Cancel
</button>
{:else} {:else}
<button <Button onclick={submit} disabled={!canSubmit}>Rerank</Button>
class="btn bg-primary text-btn-primary-text hover:opacity-90" <Button variant="outline" onclick={clear} disabled={isCleared}>Clear</Button>
onclick={submit}
disabled={!canSubmit}
>
Rerank
</button>
<button class="btn" onclick={clear} disabled={isCleared}>
Clear
</button>
{/if} {/if}
{#if error} {#if error}
<span class="text-sm text-red-500 ml-2">{error}</span> <span class="text-destructive ml-2 text-sm">{error}</span>
{:else if usage} {:else if usage}
<span class="text-sm text-txtsecondary ml-2">{usage.total_tokens} tokens</span> <span class="text-muted-foreground ml-2 text-sm">{usage.total_tokens} tokens</span>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -5,6 +5,9 @@
import { playgroundStores } from "../../stores/playgroundActivity"; import { playgroundStores } from "../../stores/playgroundActivity";
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 * as Select from "$lib/components/ui/select/index.js";
import { RefreshCw, Download } from "@lucide/svelte";
const selectedModelStore = persistentStore<string>("playground-speech-model", ""); const selectedModelStore = persistentStore<string>("playground-speech-model", "");
const selectedVoiceStore = persistentStore<string>("playground-speech-voice", "coral"); const selectedVoiceStore = persistentStore<string>("playground-speech-voice", "coral");
@@ -106,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 {
@@ -208,49 +210,44 @@
<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-gray-200 dark:border-white/10 bg-surface 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}
> >
<Select.Trigger class="h-9 w-40">{$selectedVoiceStore}</Select.Trigger>
<Select.Content>
{#each availableVoices as voice (voice)} {#each availableVoices as voice (voice)}
<option value={voice}>{voice}</option> <Select.Item value={voice}>{voice}</Select.Item>
{/each} {/each}
<option value="(refresh)">(refresh)</option> <Select.Item value="(refresh)">(refresh)</Select.Item>
</select> </Select.Content>
</Select.Root>
{#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]} {#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]}
<button <Button
class="btn shrink-0" variant="outline"
size="icon"
class="shrink-0"
onclick={refreshVoices} onclick={refreshVoices}
disabled={isLoadingVoices} disabled={isLoadingVoices}
title={isLoadingVoices ? "Loading voices..." : "Load voices for this model"} title={isLoadingVoices ? "Loading voices..." : "Load voices for this model"}
> >
{#if isLoadingVoices} <RefreshCw class={isLoadingVoices ? "animate-spin" : ""} />
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"> </Button>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{:else}
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
{/if}
</button>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Empty state for no models configured --> <!-- Empty state for no models configured -->
{#if !hasModels} {#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary"> <div class="flex-1 flex items-center justify-center text-muted-foreground">
<p>No models configured. Add models to your configuration to generate speech.</p> <p>No models configured. Add models to your configuration to generate speech.</p>
</div> </div>
{:else} {:else}
<!-- Audio display area --> <!-- Audio display area -->
<div class="shrink-0 mb-4 bg-surface border border-gray-200 dark:border-white/10 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-txtsecondary py-8"> <div class="flex items-center justify-center text-muted-foreground py-8">
<div class="text-center"> <div class="text-center">
<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>
<p>Generating speech...</p> <p>Generating speech...</p>
@@ -267,7 +264,7 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<!-- Header with metadata and download --> <!-- Header with metadata and download -->
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="flex flex-wrap gap-3 text-sm text-txtsecondary"> <div class="flex flex-wrap gap-3 text-sm text-muted-foreground">
{#if generatedVoice} {#if generatedVoice}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -285,15 +282,9 @@
</span> </span>
{/if} {/if}
</div> </div>
<button <Button variant="outline" size="icon" class="shrink-0" onclick={downloadAudio} title="Download audio file">
class="btn shrink-0" <Download />
onclick={downloadAudio} </Button>
title="Download audio file"
>
<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>
</div> </div>
<!-- Audio player with larger controls --> <!-- Audio player with larger controls -->
@@ -305,7 +296,7 @@
</div> </div>
</div> </div>
{:else} {:else}
<div class="flex items-center justify-center text-txtsecondary py-8"> <div class="flex items-center justify-center text-muted-foreground py-8">
<div class="text-center"> <div class="text-center">
<svg class="w-12 h-12 md:w-16 md:h-16 mx-auto mb-2 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-12 h-12 md:w-16 md:h-16 mx-auto mb-2 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
@@ -327,24 +318,25 @@
/> />
<div class="shrink-0 flex md:flex-col gap-2"> <div class="shrink-0 flex md:flex-col gap-2">
{#if isGenerating} {#if isGenerating}
<button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}> <Button variant="destructive" class="flex-1 md:flex-none" onclick={cancelGeneration}>
Cancel Cancel
</button> </Button>
{:else} {:else}
<button <Button
class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none" class="flex-1 md:flex-none"
onclick={generate} onclick={generate}
disabled={!inputText.trim() || !$selectedModelStore} disabled={!inputText.trim() || !$selectedModelStore}
> >
Generate Generate
</button> </Button>
<button <Button
class="btn flex-1 md:flex-none" variant="outline"
class="flex-1 md:flex-none"
onclick={clearInput} onclick={clearInput}
disabled={!inputText.trim()} disabled={!inputText.trim()}
> >
Clear Clear
</button> </Button>
<label class="flex items-center justify-center gap-2 text-sm cursor-pointer"> <label class="flex items-center justify-center gap-2 text-sm cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -1,359 +0,0 @@
<script lang="ts">
import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent";
import { generateVideoSync } from "../../lib/videoApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte";
const selectedModelStore = persistentStore<string>("playground-video-model", "");
const selectedSizeStore = persistentStore<string>("playground-video-size", "");
const negativePromptStore = persistentStore<string>("playground-video-negative-prompt", "");
const numFramesStore = persistentStore<number>("playground-video-num-frames", 0);
const fpsStore = persistentStore<number>("playground-video-fps", 0);
const stepsStore = persistentStore<number>("playground-video-steps", 0);
const guidanceStore = persistentStore<number>("playground-video-guidance", 0);
const seedStore = persistentStore<number>("playground-video-seed", -1);
let prompt = $state("");
let isGenerating = $state(false);
let videoUrl = $state<string | null>(null);
let videoMime = $state("video/mp4");
let videoBytes = $state(0);
let elapsedSeconds = $state(0);
let error = $state<string | null>(null);
let abortController = $state<AbortController | null>(null);
let showSettings = $state(false);
let initImage = $state<File | null>(null);
let initImagePreview = $state<string | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
let hasModels = $derived($models.some((m) => !m.unlisted));
$effect(() => {
playgroundStores.videoGenerating.set(isGenerating);
});
function onInitImageChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0] ?? null;
setInitImage(file);
}
function setInitImage(file: File | null) {
if (initImagePreview) URL.revokeObjectURL(initImagePreview);
initImage = file;
initImagePreview = file ? URL.createObjectURL(file) : null;
}
function clearInitImage() {
setInitImage(null);
if (fileInput) fileInput.value = "";
}
async function generate() {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
isGenerating = true;
error = null;
elapsedSeconds = 0;
abortController = new AbortController();
timer = setInterval(() => (elapsedSeconds += 1), 1000);
try {
const result = await generateVideoSync(
$selectedModelStore,
trimmedPrompt,
{
negativePrompt: $negativePromptStore || undefined,
size: $selectedSizeStore || undefined,
numFrames: $numFramesStore,
fps: $fpsStore,
steps: $stepsStore,
guidanceScale: $guidanceStore,
seed: $seedStore,
initImage,
},
abortController.signal
);
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = result.url;
videoMime = result.mime;
videoBytes = result.sizeBytes;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// User cancelled
} else {
error = err instanceof Error ? err.message : "An error occurred";
}
} finally {
isGenerating = false;
abortController = null;
if (timer) clearInterval(timer);
timer = null;
}
}
function cancelGeneration() {
abortController?.abort();
}
function clearVideo() {
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = null;
error = null;
prompt = "";
}
function downloadVideo() {
if (!videoUrl) return;
const ext = videoMime.includes("webm") ? "webm" : "mp4";
const link = document.createElement("a");
link.href = videoUrl;
link.download = `generated-video-${Date.now()}.${ext}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function formatBytes(n: number): string {
if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
if (n > 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${n} B`;
}
function formatElapsed(s: number): string {
const m = Math.floor(s / 60);
return m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
generate();
}
}
</script>
<div class="flex flex-col h-full">
<!-- Model selector and options -->
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector
bind:value={$selectedModelStore}
placeholder="Select a video model..."
disabled={isGenerating}
capabilities={["video_generation", "image_to_video"]}
matchAny={true}
/>
<select
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$selectedSizeStore}
disabled={isGenerating}
>
<option value="">Model default size</option>
<optgroup label="Landscape">
<option value="1280x704">1280x704</option>
<option value="832x480">832x480</option>
</optgroup>
<optgroup label="Portrait">
<option value="704x1280">704x1280</option>
<option value="480x832">480x832</option>
</optgroup>
<optgroup label="Square">
<option value="704x704">704x704</option>
</optgroup>
</select>
<button
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface hover:bg-secondary-hover transition-colors"
onclick={() => (showSettings = !showSettings)}
>
{showSettings ? "Hide Settings" : "Settings"}
</button>
</div>
<!-- Settings panel -->
{#if showSettings}
<div class="shrink-0 mb-4 p-4 rounded border border-gray-200 dark:border-white/10 bg-surface">
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Frames (0 = default)</span>
<input
type="number"
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$numFramesStore}
min="0"
max="1000"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">FPS (0 = default)</span>
<input
type="number"
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$fpsStore}
min="0"
max="60"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Steps (0 = default)</span>
<input
type="number"
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$stepsStore}
min="0"
max="150"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Guidance (0 = default)</span>
<input
type="number"
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$guidanceStore}
min="0"
max="30"
step="0.5"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Seed (-1 = random)</span>
<input
type="number"
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={$seedStore}
min="-1"
/>
</label>
</div>
<label class="flex flex-col gap-1">
<span class="text-xs text-txtsecondary">Negative Prompt</span>
<textarea
class="px-2 py-1 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary resize-y text-sm"
bind:value={$negativePromptStore}
rows="2"
placeholder="Elements to avoid..."
></textarea>
</label>
</div>
{/if}
<!-- Empty state for no models configured -->
{#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary">
<p>No models configured. Add models to your configuration to generate videos.</p>
</div>
{:else}
<!-- Video display area -->
<div
class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-surface border border-gray-200 dark:border-white/10 rounded"
>
{#if isGenerating}
<div class="text-center text-txtsecondary">
<div
class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"
></div>
<p>Generating video… {formatElapsed(elapsedSeconds)}</p>
<p class="text-xs mt-1">
Video generation takes minutes — a cold model load takes longer still.
</p>
</div>
{:else if error}
<div class="text-center text-red-500 p-4">
<p class="font-medium">Error</p>
<p class="text-sm mt-1 break-all">{error}</p>
</div>
{:else if videoUrl}
<div class="relative max-w-full max-h-full flex items-center justify-center">
<!-- svelte-ignore a11y_media_has_caption -->
<video src={videoUrl} class="max-w-full max-h-full object-contain" controls autoplay loop></video>
<button
class="absolute bottom-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
onclick={downloadVideo}
aria-label="Download video"
title="Download ({formatBytes(videoBytes)})"
>
<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>
</div>
{:else}
<div class="text-center text-txtsecondary">
<p>Enter a prompt below to generate a short video clip</p>
<p class="text-xs mt-1">Attach an image to animate it (image-to-video)</p>
</div>
{/if}
</div>
<!-- Init image chip -->
{#if initImagePreview}
<div class="shrink-0 flex items-center gap-2 mb-2">
<img src={initImagePreview} alt="Conditioning frame" class="h-12 rounded border border-gray-200 dark:border-white/10" />
<span class="text-xs text-txtsecondary">Animating this image (image-to-video)</span>
<button
class="px-1.5 py-0.5 text-xs rounded border border-gray-200 dark:border-white/10 hover:bg-red-500 hover:text-white hover:border-red-500 transition-colors"
onclick={clearInitImage}
disabled={isGenerating}
>
Remove
</button>
</div>
{/if}
<!-- Prompt input area -->
<div class="shrink-0 flex flex-col md:flex-row gap-2">
<ExpandableTextarea
bind:value={prompt}
placeholder="Describe the video you want to generate..."
rows={3}
onkeydown={handleKeyDown}
disabled={isGenerating || !$selectedModelStore}
/>
<div class="flex flex-row md:flex-col gap-2">
{#if isGenerating}
<button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}>
Cancel
</button>
{:else}
<button
class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none"
onclick={generate}
disabled={!prompt.trim() || !$selectedModelStore}
>
Generate
</button>
<label
class="btn flex-1 md:flex-none text-center cursor-pointer {isGenerating ? 'opacity-50 pointer-events-none' : ''}"
>
Image…
<input
type="file"
accept="image/*"
class="hidden"
bind:this={fileInput}
onchange={onInitImageChange}
/>
</label>
<button
class="btn flex-1 md:flex-none"
onclick={clearVideo}
disabled={!videoUrl && !error && !prompt.trim()}
>
Clear
</button>
{/if}
</div>
</div>
{/if}
</div>
+151 -133
View File
@@ -1,178 +1,196 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "katex/dist/katex.min.css"; @import "katex/dist/katex.min.css";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme { @custom-variant dark (&:is(.dark *));
--color-background: rgba(252, 252, 249, 1);
--color-surface: rgba(255, 255, 253, 1);
/* text colors */ :root {
--color-txtmain: rgba(19, 52, 59, 1); --radius: 0;
--color-txtsecondary: rgba(98, 108, 113, 1);
--color-navlink-active: rgba(245, 245, 245, 1);
--color-primary: rgba(50, 184, 198, 1); /* shadcn base palette (zinc) */
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--color-primary-hover: rgba(29, 116, 128, 1); /* brand accent: llama-swap teal */
--color-primary-active: rgba(26, 104, 115, 1); --primary: rgb(50 184 198);
--color-secondary: rgba(94, 82, 64, 0.12); --primary-foreground: oklch(0.985 0 0);
--color-secondary-hover: rgba(94, 82, 64, 0.2);
--color-secondary-active: rgba(94, 82, 64, 0.25); --secondary: oklch(0.967 0.001 286.375);
--color-border: rgba(94, 82, 64, 0.3); --secondary-foreground: oklch(0.21 0.006 285.885);
--color-btn-primary-text: rgba(252, 252, 249, 1); --muted: oklch(0.967 0.001 286.375);
--color-card-border: rgba(94, 82, 64, 0.12); --muted-foreground: oklch(0.552 0.016 285.938);
--color-card-border-inner: rgba(94, 82, 64, 0.12); --accent: oklch(0.967 0.001 286.375);
--color-error: rgba(192, 21, 47, 1); --accent-foreground: oklch(0.21 0.006 285.885);
--color-success: rgba(33, 128, 141, 1); --destructive: oklch(0.577 0.245 27.325);
--color-warning: rgb(244, 155, 0); --border: oklch(0.92 0.004 286.32);
--color-info: rgba(98, 108, 113, 1); --input: oklch(0.92 0.004 286.32);
--color-focus-ring: rgba(33, 128, 141, 0.4); --ring: rgb(50 184 198);
--color-select-caret: rgba(19, 52, 59, 0.8);
--color-btn-border: rgba(94, 82, 64, 0.7); --chart-1: rgb(50 184 198);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: rgb(50 184 198);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: rgb(50 184 198);
/* semantic status colors (shared light/dark-aware below) */
--success: oklch(0.6 0.118 184.704);
--warning: oklch(0.769 0.17 70.08);
--info: oklch(0.552 0.016 285.938);
} }
@layer theme { .dark {
/* over ride theme for dark mode */ --background: oklch(0.141 0.005 285.823);
[data-theme="dark"] { --foreground: oklch(0.985 0 0);
--color-background: rgba(31, 33, 33, 1); --card: oklch(0.21 0.006 285.885);
--color-surface: rgba(38, 40, 40, 1); --card-foreground: oklch(0.985 0 0);
/* text colors */ --popover: oklch(0.21 0.006 285.885);
--color-txtmain: rgba(245, 245, 245, 1); --popover-foreground: oklch(0.985 0 0);
--color-txtsecondary: rgba(167, 169, 169, 0.7);
--color-navlink-active: rgba(245, 245, 245, 1); /* brand accent: deeper teal for dark surfaces */
--primary: rgb(45 166 178);
--primary-foreground: oklch(0.141 0.005 285.823);
--color-primary: rgba(33, 128, 141, 1); --secondary: oklch(0.274 0.006 286.033);
--color-primary-hover: rgba(45, 166, 178, 1); --secondary-foreground: oklch(0.985 0 0);
--color-primary-active: rgba(41, 150, 161, 1); --muted: oklch(0.274 0.006 286.033);
--color-secondary: rgba(119, 124, 124, 0.15); --muted-foreground: oklch(0.705 0.015 286.067);
--color-secondary-hover: rgba(119, 124, 124, 0.25); --accent: oklch(0.274 0.006 286.033);
--color-secondary-active: rgba(119, 124, 124, 0.3); --accent-foreground: oklch(0.985 0 0);
--color-border: rgba(119, 124, 124, 0.3); --destructive: oklch(0.704 0.191 22.216);
--color-error: rgba(255, 84, 89, 1); --border: oklch(1 0 0 / 10%);
--color-success: rgba(50, 184, 198, 1); --input: oklch(1 0 0 / 15%);
--color-warning: rgb(244, 155, 0); --ring: rgb(50 184 198);
--color-info: rgba(167, 169, 169, 1);
--color-focus-ring: rgba(50, 184, 198, 0.4); --chart-1: rgb(50 184 198);
--color-btn-primary-text: rgba(19, 52, 59, 1); --chart-2: oklch(0.696 0.17 162.48);
--color-card-border: rgba(119, 124, 124, 0.2); --chart-3: oklch(0.769 0.188 70.08);
--color-card-border-inner: rgba(119, 124, 124, 0.15); --chart-4: oklch(0.627 0.265 303.9);
--shadow-inset-sm: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 0 rgba(0, 0, 0, 0.15); --chart-5: oklch(0.645 0.246 16.439);
--button-border-secondary: rgba(119, 124, 124, 0.2);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: rgb(50 184 198);
--sidebar-primary-foreground: oklch(0.141 0.005 285.823);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: rgb(50 184 198);
--success: oklch(0.696 0.17 162.48);
--warning: oklch(0.769 0.17 70.08);
--info: oklch(0.705 0.015 286.067);
} }
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-error: var(--destructive);
} }
@layer base { @layer base {
* {
@apply border-border outline-ring/50;
}
body { body {
/* example of how colors using theme colors*/ @apply bg-background text-foreground;
@apply bg-background text-txtmain;
} }
h1 { h1 {
@apply text-4xl text-txtmain font-bold pb-4; @apply text-3xl font-bold tracking-tight pb-4;
} }
h2 { h2 {
@apply text-3xl text-txtmain font-bold pb-4; @apply text-2xl font-bold tracking-tight pb-4;
} }
h3 { h3 {
@apply text-2xl text-txtmain font-bold pb-4; @apply text-xl font-semibold tracking-tight pb-4;
} }
h4 { h4 {
@apply text-xl text-txtmain font-bold pb-4; @apply text-lg font-semibold pb-4;
} }
h5 { h5 {
@apply text-lg text-txtmain font-bold pb-4; @apply text-base font-semibold pb-4;
} }
h6 { h6 {
@apply text-base text-txtmain font-bold pb-4; @apply text-sm font-semibold pb-4;
} }
} }
/* define CSS classes here for specific types of components */
@layer components { @layer components {
.container { /* default padding for ad-hoc tables (header/detail views) */
@apply px-4;
}
/* Tables */
table th { table th {
@apply p-2 font-semibold; @apply p-2 font-semibold;
} }
table td { table td {
@apply p-2; @apply p-2;
} }
/* Navigation Header */
.navlink {
@apply text-txtsecondary hover:bg-secondary hover:text-txtmain rounded-lg p-2;
}
.navlink.active {
@apply bg-primary text-navlink-active;
} }
/* Card component */ @utility activity-link {
.card { background: linear-gradient(90deg, #6366f1, #8b5cf6, #a855f7, #8b5cf6, #6366f1);
@apply bg-surface rounded-lg border border-card-border shadow-sm overflow-hidden p-4; background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 2s linear infinite;
} }
.card:hover { @keyframes gradient-shift {
@apply shadow-md; 0% {
background-position: 0% 50%;
} }
100% {
.card__body { background-position: 200% 50%;
@apply p-4;
}
.card__header,
.card__footer {
@apply p-4 border-b border-card-border-inner;
}
/* Status Badges */
.status {
@apply inline-block px-2 py-1 text-xs font-medium rounded-lg;
}
.status--ready {
@apply bg-success/10 text-success;
}
.status--starting,
.status--stopping,
.status--queued {
@apply bg-warning/10 text-warning;
}
.status--stopped {
@apply bg-error/10 text-error;
}
/* Buttons */
.btn {
@apply bg-surface py-2 px-4 text-sm rounded-md border transition-colors duration-200 border-btn-border;
}
.btn:hover {
cursor: pointer;
}
.btn--sm {
@apply px-2 py-0.5 text-xs;
}
.btn:disabled {
@apply opacity-50 cursor-not-allowed;
}
}
@layer utilities {
.ml-2 {
margin-left: 0.5rem;
}
.my-8 {
margin-top: 2rem;
margin-bottom: 2rem;
} }
} }
@@ -0,0 +1,49 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
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",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>
@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn(
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-content"
class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
</script>
<div
bind:this={ref}
data-slot="card"
data-size={size}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};
@@ -0,0 +1,19 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content
bind:ref
data-slot="collapsible-content"
class={className}
{...restProps}
>
{@render children?.()}
</CollapsiblePrimitive.Content>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger
bind:ref
data-slot="collapsible-trigger"
class={className}
{...restProps}
>
{@render children?.()}
</CollapsiblePrimitive.Trigger>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root
bind:ref
data-slot="collapsible"
class={className}
{...restProps}
>
{@render children?.()}
</CollapsiblePrimitive.Root>
@@ -0,0 +1,13 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
export {
Root,
Trigger,
Content,
//
Root as Collapsible,
Trigger as CollapsibleTrigger,
Content as CollapsibleContent,
};
@@ -0,0 +1,142 @@
import {
type RowData,
type TableOptions,
type TableOptionsResolved,
type TableState,
type Updater,
createTable,
} from "@tanstack/table-core";
/**
* Creates a reactive TanStack table object for Svelte.
* @param options Table options to create the table with.
* @returns A reactive table object.
* @example
* ```svelte
* <script>
* const table = createSvelteTable({ ... })
* </script>
*
* <table>
* <thead>
* {#each table.getHeaderGroups() as headerGroup}
* <tr>
* {#each headerGroup.headers as header}
* <th colspan={header.colSpan}>
* <FlexRender content={header.column.columnDef.header} context={header.getContext()} />
* </th>
* {/each}
* </tr>
* {/each}
* </thead>
* <!-- ... -->
* </table>
* ```
*/
export function createSvelteTable<TData extends RowData>(options: TableOptions<TData>) {
const resolvedOptions: TableOptionsResolved<TData> = mergeObjects(
{
state: {},
onStateChange() {},
renderFallbackValue: null,
mergeOptions: (
defaultOptions: TableOptions<TData>,
options: Partial<TableOptions<TData>>
) => {
return mergeObjects(defaultOptions, options);
},
},
options
);
const table = createTable(resolvedOptions);
let state = $state<TableState>(table.initialState);
function updateOptions() {
table.setOptions(() => {
return mergeObjects(resolvedOptions, options, {
state: mergeObjects(state, options.state || {}),
onStateChange: (updater: Updater<TableState>) => {
if (updater instanceof Function) state = updater(state);
else state = mergeObjects(state, updater);
options.onStateChange?.(updater);
},
});
});
}
updateOptions();
$effect.pre(() => {
updateOptions();
});
return table;
}
type MaybeThunk<T extends object> = T | (() => T | null | undefined);
type Intersection<T extends readonly unknown[]> = (T extends [infer H, ...infer R]
? H & Intersection<R>
: unknown) & {};
/**
* Lazily merges several objects (or thunks) while preserving
* getter semantics from every source.
*
* Proxy-based to avoid known WebKit recursion issue.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mergeObjects<Sources extends readonly MaybeThunk<any>[]>(
...sources: Sources
): Intersection<{ [K in keyof Sources]: Sources[K] }> {
const resolve = <T extends object>(src: MaybeThunk<T>): T | undefined =>
typeof src === "function" ? (src() ?? undefined) : src;
const findSourceWithKey = (key: PropertyKey) => {
for (let i = sources.length - 1; i >= 0; i--) {
const obj = resolve(sources[i]);
if (obj && key in obj) return obj;
}
return undefined;
};
return new Proxy(Object.create(null), {
get(_, key) {
const src = findSourceWithKey(key);
return src?.[key as never];
},
has(_, key) {
return !!findSourceWithKey(key);
},
ownKeys(): (string | symbol)[] {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const all = new Set<string | symbol>();
for (const s of sources) {
const obj = resolve(s);
if (obj) {
for (const k of Reflect.ownKeys(obj) as (string | symbol)[]) {
all.add(k);
}
}
}
return [...all];
},
getOwnPropertyDescriptor(_, key) {
const src = findSourceWithKey(key);
if (!src) return undefined;
return {
configurable: true,
enumerable: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: (src as any)[key],
writable: true,
};
},
}) as Intersection<{ [K in keyof Sources]: Sources[K] }>;
}
@@ -0,0 +1,40 @@
<script
lang="ts"
generics="TData, TValue, TContext extends HeaderContext<TData, TValue> | CellContext<TData, TValue>"
>
import type { CellContext, ColumnDefTemplate, HeaderContext } from "@tanstack/table-core";
import { RenderComponentConfig, RenderSnippetConfig } from "./render-helpers.js";
import type { Attachment } from "svelte/attachments";
type Props = {
/** The cell or header field of the current cell's column definition. */
content?: TContext extends HeaderContext<TData, TValue>
? ColumnDefTemplate<HeaderContext<TData, TValue>>
: TContext extends CellContext<TData, TValue>
? ColumnDefTemplate<CellContext<TData, TValue>>
: never;
/** The result of the `getContext()` function of the header or cell */
context: TContext;
/** Used to pass attachments that can't be gotten through context */
attach?: Attachment;
};
let { content, context, attach }: Props = $props();
</script>
{#if typeof content === "string"}
{content}
{:else if content instanceof Function}
<!-- It's unlikely that a CellContext will be passed to a Header -->
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
{@const result = content(context as any)}
{#if result instanceof RenderComponentConfig}
{@const { component: Component, props } = result}
<Component {...props} {attach} />
{:else if result instanceof RenderSnippetConfig}
{@const { snippet, params } = result}
{@render snippet({ ...params, attach })}
{:else}
{result}
{/if}
{/if}
@@ -0,0 +1,3 @@
export { default as FlexRender } from "./flex-render.svelte";
export { renderComponent, renderSnippet } from "./render-helpers.js";
export { createSvelteTable } from "./data-table.svelte.js";
@@ -0,0 +1,111 @@
import type { Component, ComponentProps, Snippet } from "svelte";
/**
* A helper class to make it easy to identify Svelte components in
* `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderComponentConfig}
* {@const { component: Component, props } = result}
* <Component {...props} />
* {/if}
* ```
*/
export class RenderComponentConfig<TComponent extends Component> {
component: TComponent;
props: ComponentProps<TComponent> | Record<string, never>;
constructor(
component: TComponent,
props: ComponentProps<TComponent> | Record<string, never> = {}
) {
this.component = component;
this.props = props;
}
}
/**
* A helper class to make it easy to identify Svelte Snippets in `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderSnippetConfig}
* {@const { snippet, params } = result}
* {@render snippet(params)}
* {/if}
* ```
*/
export class RenderSnippetConfig<TProps> {
snippet: Snippet<[TProps]>;
params: TProps;
constructor(snippet: Snippet<[TProps]>, params: TProps) {
this.snippet = snippet;
this.params = params;
}
}
/**
* A helper function to help create cells from Svelte components through ColumnDef's `cell` and `header` properties.
*
* This is only to be used with Svelte Components - use `renderSnippet` for Svelte Snippets.
*
* @param component A Svelte component
* @param props The props to pass to `component`
* @returns A `RenderComponentConfig` object that helps svelte-table know how to render the header/cell component.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* header: header => renderComponent(SortHeader, { label: 'Name', header }),
* }),
* columnHelper.accessor('state', {
* header: header => renderComponent(SortHeader, { label: 'State', header }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderComponent<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends Component<any>,
Props extends ComponentProps<T>,
>(component: T, props: Props = {} as Props) {
return new RenderComponentConfig(component, props);
}
/**
* A helper function to help create cells from Svelte Snippets through ColumnDef's `cell` and `header` properties.
*
* The snippet must only take one parameter.
*
* This is only to be used with Snippets - use `renderComponent` for Svelte Components.
*
* @param snippet
* @param params
* @returns - A `RenderSnippetConfig` object that helps svelte-table know how to render the header/cell snippet.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* cell: cell => renderSnippet(nameSnippet, { name: cell.row.name }),
* }),
* columnHelper.accessor('state', {
* cell: cell => renderSnippet(stateSnippet, { state: cell.row.state }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderSnippet<TProps>(snippet: Snippet<[TProps]>, params: TProps = {} as TProps) {
return new RenderSnippetConfig(snippet, params);
}
@@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />
@@ -0,0 +1,48 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Button } from "$lib/components/ui/button/index.js";
import XIcon from '@lucide/svelte/icons/x';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close data-slot="dialog-close">
{#snippet child({ props })}
<Button variant="ghost" class="absolute top-2 right-2" size="icon-sm" {...props}>
<XIcon />
<span class="sr-only">Close</span>
</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { Dialog as DialogPrimitive } from "bits-ui";
import { Button } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
children,
showCloseButton = false,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
showCloseButton?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Close</Button>
{/snippet}
</DialogPrimitive.Close>
{/if}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("gap-2 flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-base leading-none font-medium", className)}
{...restProps}
/>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let {
ref = $bindable(null),
type = "button",
...restProps
}: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {type} {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>
@@ -0,0 +1,44 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import MinusIcon from '@lucide/svelte/icons/minus';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
{#if indeterminate}
<MinusIcon />
{:else if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,31 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
sideOffset = 4,
align = "start",
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{align}
class={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-(--bits-dropdown-menu-anchor-width) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden",
className
)}
{...restProps}
/>
</DropdownMenuPortal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
/>

Some files were not shown because too many files have changed in this diff Show More