Compare commits

..

11 Commits

Author SHA1 Message Date
steve 2f0bb2556e ci: push immutable sha- and moving main-cuda- tags alongside the version label
Build CUDA image (fork) / build (push) Successful in 1m9s
Push-triggered builds always defaulted to the v230 label, silently
overwriting it on every main merge — a stack pinned to any other
version label (v231) kept pulling stale images. Every build now also
pushes sha-<short> (immutable pin) and main-cuda-<llamacpp> (explicit
latest-main), so deployments can choose mutable-tracking or exact-pin
deliberately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXQxVhXBw8PwFAtsVrXSmj
2026-07-12 11:25:51 -04:00
steve fdd89d6580 Merge pull request 'feat(ui): Video playground tab + video capability plumbing' (#2) from feat/video-ui into main
Build CUDA image (fork) / build (push) Successful in 2m57s
Reviewed-on: #2
2026-07-12 14:56:35 +00:00
steve 1934efb0b5 feat(ui): Video playground tab + video capability plumbing
New Video tab in the Playground: model selector filtered to
video_generation/image_to_video capabilities, prompt + negative prompt,
size/frames/fps/steps/guidance/seed knobs (0 = model default), optional
conditioning-image upload (image-to-video), elapsed-time spinner for
the minutes-long blocking call, inline <video> playback + download.
Calls POST /v1/videos/sync (multipart); resolution rides as both
width/height and size so either upstream convention honors it.

Config side: "video" joins the valid capability modalities, and
/v1/models maps text->video to video_generation and image->video to
image_to_video, mirroring the image mappings. Declare e.g.:

  videogen-wan22-5b:
    capabilities:
      in: [text, image]
      out: [video]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXQxVhXBw8PwFAtsVrXSmj
2026-07-12 10:53:23 -04:00
steve b83edeb9ef ci: rebuild fork image on main source pushes, not just build-definition edits
Build CUDA image (fork) / build (push) Successful in 4m49s
The merge of the /v1/videos/sync routes produced no image because the
push trigger only watched the workflow file and Containerfile. Now any
Go/UI/deps change on main rebuilds; docs/config-example edits still
don't. (The video-routes image itself was built via manual dispatch,
tag prefix v231.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXQxVhXBw8PwFAtsVrXSmj
2026-07-12 10:24:00 -04:00
steve 7886758053 Merge pull request 'feat(server): dispatch /v1/videos/sync by model' (#1) from feat/video-routes into main
Reviewed-on: #1
2026-07-12 14:13:05 +00:00
steve d297852b9e fix: scope video dispatch to /v1/videos/sync + capture mask
Review findings: the async POST /v1/videos leg is deliberately NOT
dispatched — its poll/download companions carry no model field to route
by, so registering creation alone would start unretrievable upstream
jobs (a stray OpenAI-videos client now gets a clean 404). And
/v1/videos/sync joins captureFieldsByPath (headers only, both
directions) so enabling captures doesn't buffer conditioning frames or
whole clips through cbor+zstd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXQxVhXBw8PwFAtsVrXSmj
2026-07-12 09:53:33 -04:00
steve cecb89880e feat(server): dispatch /v1/videos and /v1/videos/sync by model
Registers the OpenAI/vLLM-Omni video generation endpoints as
model-dispatched routes so llama-swap can swap in video upstreams
(e.g. vllm serve <model> --omni, or a ComfyUI shim exposing the same
shape). Extraction is content-type driven, so both multipart form and
JSON bodies resolve the model field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXQxVhXBw8PwFAtsVrXSmj
2026-07-12 09:25:55 -04:00
steve 0292c90ca1 ci: copy ui-svelte/.npmrc before npm ci in fork-cuda build
Build CUDA image (fork) / build (push) Successful in 12m49s
npm ci ran without .npmrc (legacy-peer-deps=true), failing on the
tailwind/vite peer dependency conflict. Copy .npmrc with the manifest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:56:21 -04:00
steve 617c7dc6b9 ci: add Gitea workflow to build fork CUDA image
Build CUDA image (fork) / build (push) Failing after 2m23s
Add a Gitea Actions workflow and multi-stage Containerfile that build
this fork's llama-swap (serial scheduler + embedded Svelte UI) from
source and layer it on a pinned llama.cpp CUDA server base, then push to
the Gitea container registry as v230-cuda-b9821.

- docker/fork-cuda.Containerfile: node UI -> go build -> cuda runtime,
  runs as root to match the upstream non-suffixed image
- .gitea/workflows/build-cuda-image.yml: workflow_dispatch (version +
  llama.cpp build inputs) and push-on-build-files; logs in with
  REGISTRY_USER/REGISTRY_PASSWORD

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:48:48 -04:00
steve 542b79dacf internal/router/scheduler: add serial scheduler, default on this fork
Validate JSON Schema / validate-schema (push) Successful in 9m53s
Linux CI / run-tests (push) Failing after 15m57s
Windows CI / run-tests (push) Has been cancelled
Add a strict one-model-at-a-time scheduler. Requests run in exact
arrival order; at most one runs at a time; switching to a different
model evicts every other running model first so a single model occupies
memory at a time. Unlike fifo it never reorders or batches same-model
requests, and it ignores group/matrix co-residency entirely, making the
single-model guarantee a property of the scheduler rather than the config.

- new Serial scheduler implementing the Scheduler interface
- register "serial" in scheduler.New; default routing.scheduler.use to
  "serial" at config load (fifo still selectable for upstream behavior)
- update config schema, example config, and config defaults tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:17:32 -04:00
Benson Wong 0a25b3bd31 AGENTS.md: small tweaks 2026-06-25 20:31:48 -07:00
212 changed files with 3372 additions and 6464 deletions
+89
View File
@@ -0,0 +1,89 @@
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"
+4 -6
View File
@@ -5,16 +5,14 @@ llama-swap is a light weight, transparent proxy server that provides automatic m
## Tech stack ## Tech stack
- golang - golang
- typescript, vite and svelt5 for UI (located in ui/) - typescript, vite and svelte 5 for UI (located in ui-svelte/)
## 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
- never include a test plan - skip the 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
@@ -30,7 +28,7 @@ llama-swap is a light weight, transparent proxy server that provides automatic m
### Commit message example format: ### Commit message example format:
``` ```
proxy: add new feature internal/server: add new feature
Add new feature that implements functionality X and Y. Add new feature that implements functionality X and Y.
+3 -2
View File
@@ -601,10 +601,11 @@
"use": { "use": {
"type": "string", "type": "string",
"enum": [ "enum": [
"serial",
"fifo" "fifo"
], ],
"default": "fifo", "default": "serial",
"description": "Scheduler to use. Only 'fifo' is currently supported." "description": "Scheduler to use. 'serial' (default on this fork): strict one-model-at-a-time, requests run in exact arrival order, switching models evicts every other model first. 'fifo': throughput-oriented, batches same-model requests and allows parallel/co-resident models."
}, },
"settings": { "settings": {
"type": "object", "type": "object",
+13 -3
View File
@@ -556,11 +556,21 @@ routing:
# expands to: [L] # expands to: [L]
full: "L" full: "L"
# scheduler: how queued requests are ordered. # scheduler: how queued requests are ordered and run.
# The default and only valid scheduler is "fifo" # - optional, default on this fork: "serial"
# - valid values:
# - "serial": strict one-model-at-a-time. Requests run in exact arrival
# order; 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 (priority) 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.
scheduler: scheduler:
use: fifo use: serial
settings: settings:
# 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
+74
View File
@@ -0,0 +1,74 @@
# 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" ]
+1 -1
View File
@@ -277,7 +277,7 @@ groups:
}, },
}, },
Scheduler: SchedulerConfig{ Scheduler: SchedulerConfig{
Use: "fifo", Use: "serial",
}, },
}, },
} }
+2 -2
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, "fifo", cfg.Routing.Scheduler.Use) assert.Equal(t, "serial", 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, "fifo", cfg.Routing.Scheduler.Use) assert.Equal(t, "serial", cfg.Routing.Scheduler.Use)
} }
func TestConfig_Routing_LegacyAndRoutingConflict(t *testing.T) { func TestConfig_Routing_LegacyAndRoutingConflict(t *testing.T) {
+1 -1
View File
@@ -266,7 +266,7 @@ groups:
}, },
}, },
Scheduler: SchedulerConfig{ Scheduler: SchedulerConfig{
Use: "fifo", Use: "serial",
}, },
}, },
} }
+8 -3
View File
@@ -358,11 +358,16 @@ 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 = "fifo" config.Routing.Scheduler.Use = "serial"
} }
if config.Routing.Scheduler.Use != "fifo" { switch config.Routing.Scheduler.Use {
return Config{}, fmt.Errorf("routing.scheduler.use: unknown scheduler %q (valid: fifo)", config.Routing.Scheduler.Use) case "fifo", "serial":
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 {
+3 -2
View File
@@ -14,6 +14,7 @@ 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.
@@ -37,12 +38,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", m) return fmt.Errorf("capabilities.in: invalid modality %q, must be one of: text, audio, image, video", 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", m) return fmt.Errorf("capabilities.out: invalid modality %q, must be one of: text, audio, image, video", m)
} }
} }
if c.Context < 0 { if c.Context < 0 {
+11 -6
View File
@@ -296,20 +296,25 @@ 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{"video"}} caps := ModelCapConfig{In: []string{"smell"}}
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(), "video") assert.Contains(t, err.Error(), "smell")
}) })
t.Run("invalid_out_modality", func(t *testing.T) { t.Run("invalid_out_modality", func(t *testing.T) {
caps := ModelCapConfig{Out: []string{"video"}} caps := ModelCapConfig{Out: []string{"smell"}}
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(), "video") assert.Contains(t, err.Error(), "smell")
}) })
t.Run("negative_context", func(t *testing.T) { t.Run("negative_context", func(t *testing.T) {
@@ -327,10 +332,10 @@ models:
capabilities: capabilities:
in: in:
- text - text
- video - smell
` `
_, err := LoadConfigFromReader(strings.NewReader(content)) _, err := LoadConfigFromReader(strings.NewReader(content))
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "video") assert.Contains(t, err.Error(), "smell")
}) })
} }
+11 -3
View File
@@ -92,9 +92,14 @@ 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 // New returns a Scheduler selected by conf.Routing.Scheduler.Use, configured from
// from conf and bound to the given planner and effects. Currently only "fifo" // conf and bound to the given planner and effects. Supported values are "fifo"
// (the default) is supported. // (throughput-oriented, batches same-model requests) and "serial" (strict
// one-model-at-a-time, exact arrival order).
//
// 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 == "" {
@@ -103,6 +108,9 @@ 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, eff), nil
default: default:
return nil, fmt.Errorf("unsupported scheduler type: %q", use) return nil, fmt.Errorf("unsupported scheduler type: %q", use)
} }
+253
View File
@@ -0,0 +1,253 @@
package scheduler
import (
"fmt"
"sort"
"time"
"github.com/mostlygeek/llama-swap/internal/logmon"
"github.com/mostlygeek/llama-swap/internal/process"
)
// Serial is a strict one-model-at-a-time scheduler. Unlike FIFO it never reorders
// or batches: requests run in exact arrival order and at most one request runs at
// any instant. 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,
// so a single model occupies memory at a time — at the cost of throughput.
//
// Example: A B C A is served as A B C A. The final A reloads its model even
// though it ran first, because B and C displaced it in between. (FIFO, by
// contrast, would batch the two A requests: A A B C.)
//
// 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.
//
// Like FIFO, every method runs on the router's single run-loop goroutine, so no
// internal locking is needed.
type Serial struct {
name string
logger *logmon.Monitor
effects Effects
// queued holds requests in strict arrival order. It is never reordered.
queued []HandlerReq
// 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
}
// 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, eff Effects) *Serial {
return &Serial{
name: name,
logger: logger,
effects: eff,
}
}
// OnRequest validates the model and appends the request to the tail of the queue,
// 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, req)
broadcastQueuePositions(s.queued)
s.startNext()
}
// startNext begins processing the head of the queue 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 {
req := s.queued[0]
s.queued = s.queued[1:]
broadcastQueuePositions(s.queued)
state, ok := s.effects.ModelState(req.Model)
if !ok {
s.effects.GrantError(req, ErrModelNotFound)
continue
}
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)", s.name, req.Model)
if s.serve() {
return
}
continue // caller gone; pick the next request
}
s.logger.Debugf("%s: swapping to model %s, evicting %v", s.name, req.Model, evict)
s.phase = phaseSwapping
s.effects.StartSwap(req.Model, evict)
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.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)
broadcastQueuePositions(s.queued)
}
}
// 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.Model] {
s.effects.GrantError(q, unloadErr)
continue
}
kept = append(kept, q)
}
s.queued = kept
broadcastQueuePositions(s.queued)
}
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, err)
}
s.queued = nil
}
// 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
}
+391
View File
@@ -0,0 +1,391 @@
package scheduler
import (
"errors"
"io"
"testing"
"time"
"github.com/mostlygeek/llama-swap/internal/logmon"
"github.com/mostlygeek/llama-swap/internal/process"
)
// 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.
func newSerial(eff Effects) *Serial {
return NewSerial("test", logmon.NewWriter(io.Discard), eff)
}
// 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 is the core guarantee: 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.
func TestSerial_StrictArrivalOrder(t *testing.T) {
eff := newFakeEffects()
for _, m := range []string{"qwen36", "qwen35", "sdxl"} {
eff.states[m] = process.StateStopped
}
s := newSerial(eff)
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
}
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)
}
}
+6
View File
@@ -88,6 +88,12 @@ 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 {
+5 -1
View File
@@ -37,7 +37,8 @@ const (
) )
// captureFieldsByPath overrides the default capture mask for routes carrying // captureFieldsByPath overrides the default capture mask for routes carrying
// large binary payloads (audio/image) where storing the full body is wasteful. // large binary payloads (audio/image/video) where storing the full body is
// 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,
@@ -46,6 +47,9 @@ 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
+9
View File
@@ -80,6 +80,15 @@ 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
+55
View File
@@ -1,9 +1,11 @@
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"
@@ -339,3 +341,56 @@ 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)
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"$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"
}
+120 -421
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,22 +23,14 @@
"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",
@@ -46,21 +38,21 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.2", "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.1", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -69,9 +61,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -79,44 +71,6 @@
"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",
@@ -173,25 +127,15 @@
"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.6", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@tybys/wasm-util": "^0.10.3" "@tybys/wasm-util": "^0.10.1"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -203,9 +147,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.137.0", "version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -213,9 +157,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -230,9 +174,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -247,9 +191,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -264,9 +208,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -281,9 +225,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -298,16 +242,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -318,16 +259,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -338,16 +276,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -358,16 +293,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -378,16 +310,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -398,16 +327,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -418,9 +344,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -435,9 +361,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@@ -445,18 +371,18 @@
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "1.11.1", "@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.11.1", "@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.6" "@napi-rs/wasm-runtime": "^1.1.3"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -471,9 +397,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -488,9 +414,9 @@
} }
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.1", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -554,16 +480,6 @@
"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",
@@ -836,19 +752,6 @@
"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",
@@ -857,9 +760,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1150,31 +1053,6 @@
"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",
@@ -1677,13 +1555,6 @@
"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",
@@ -2010,14 +1881,13 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/lz-string": { "node_modules/lucide-svelte": {
"version": "1.5.0", "version": "0.563.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.563.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==",
"dev": true, "license": "ISC",
"license": "MIT", "peerDependencies": {
"bin": { "svelte": "^3 || ^4 || ^5.0.0-next.42"
"lz-string": "bin/bin.js"
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
@@ -2868,9 +2738,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2897,74 +2767,6 @@
], ],
"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",
@@ -3005,9 +2807,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3025,7 +2827,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -3183,14 +2985,14 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.3", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.137.0", "@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "1.0.0-rc.15"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@@ -3199,46 +3001,21 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.1.3" "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
}
},
"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": {
@@ -3309,16 +3086,6 @@
"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",
@@ -3383,65 +3150,6 @@
"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",
@@ -3488,14 +3196,14 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.4" "picomatch": "^4.0.3"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -3539,17 +3247,8 @@
"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",
@@ -3730,17 +3429,17 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.0", "version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"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.15", "postcss": "^8.5.8",
"rolldown": "~1.1.2", "rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
@@ -3756,7 +3455,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0", "@vitejs/devtools": "^0.1.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",
+2 -10
View File
@@ -12,38 +12,30 @@
"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"
+17 -63
View File
@@ -1,68 +1,33 @@
<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 AppSidebar from "./components/AppSidebar.svelte"; import Header from "./components/Header.svelte";
import LogViewer from "./routes/LogViewer.svelte"; import LogViewer from "./routes/LogViewer.svelte";
import ModelDetail from "./routes/ModelDetail.svelte"; import Models from "./routes/Models.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": ModelsDash, "/models": Models,
"/models/:id": ModelDetail,
"/logs": LogViewer, "/logs": LogViewer,
"/activity": Activity, "/activity": Activity,
"/settings": Settings,
"/performance": Performance, "/performance": Performance,
"*": PlaygroundStub, "*": PlaygroundStub,
}; };
const routeTitles: Record<string, string> = { function handleRouteLoaded(event: { detail: { route: string | RegExp } }) {
"/": "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;
// Prefer the actual URL path so parameterised routes (e.g. /models/:id) currentRoute.set(typeof route === "string" ? route : "/");
// are reflected accurately in currentRoute for sidebar highlighting.
const loc = event.detail.location;
currentRoute.set(loc ?? (typeof route === "string" ? route : "/"));
} }
$effect(() => { $effect(() => {
document.documentElement.classList.toggle("dark", $isDarkMode); document.documentElement.setAttribute("data-theme", $isDarkMode ? "dark" : "light");
}); });
$effect(() => { $effect(() => {
@@ -84,26 +49,15 @@
}); });
</script> </script>
<Tooltip.Provider> <div class="flex flex-col h-screen">
<Sidebar.Provider> <Header />
<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="min-h-0 flex-1 overflow-auto p-4"> <main class="flex-1 overflow-auto p-4">
<div class="h-full" class:hidden={$currentRoute !== "/"}> <div class="h-full" class:hidden={$currentRoute !== "/"}>
<Playground /> <Playground />
</div> </div>
<div class="h-full" class:hidden={$currentRoute === "/"}> <div class="h-full" class:hidden={$currentRoute === "/"}>
<Router {routes} on:routeLoaded={handleRouteLoaded} /> <Router {routes} on:routeLoaded={handleRouteLoaded} />
</div> </div>
</main> </main>
</Sidebar.Inset> </div>
</Sidebar.Provider>
</Tooltip.Provider>
+26 -27
View File
@@ -3,9 +3,6 @@
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);
@@ -38,24 +35,26 @@
}); });
</script> </script>
<Card.Root class="relative p-3"> <div class="card relative p-3">
<Button <button
variant="ghost" 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"
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}
<ChevronDown /> <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.5 6l3.5 4 3.5-4H4.5z" />
</svg>
{:else} {:else}
<X /> <svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<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="mb-3 flex flex-col gap-6 sm:flex-row"> <div class="flex flex-col sm:flex-row gap-6 mb-3">
<div class="w-full min-w-0 sm:w-1/2"> <div class="w-full sm:w-1/2 min-w-0">
<div class="text-muted-foreground mb-1 text-sm font-medium">Prompt Processing</div> <div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prompt Processing</div>
{#if stats.promptHistogramData} {#if stats.promptHistogramData}
<TokenHistogram <TokenHistogram
data={stats.promptHistogramData} data={stats.promptHistogramData}
@@ -63,36 +62,36 @@
colorClass="text-amber-500 dark:text-amber-400" colorClass="text-amber-500 dark:text-amber-400"
/> />
{:else} {:else}
<div class="text-muted-foreground py-6 text-center text-sm">No prompt speed data yet</div> <div class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">No prompt speed data yet</div>
{/if} {/if}
</div> </div>
<div class="w-full min-w-0 sm:w-1/2"> <div class="w-full sm:w-1/2 min-w-0">
<div class="text-muted-foreground mb-1 text-sm font-medium">Token Generation</div> <div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">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="text-muted-foreground py-6 text-center text-sm">No generation speed data yet</div> <div class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">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-muted-foreground text-xs uppercase tracking-wider">Requests</div> <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">Cached</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">Processed</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">Generated</div> <div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Generated</div>
<div class="text-sm"> <div class="text-sm text-gray-700 dark:text-gray-300">
<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"> <div class="text-sm text-gray-700 dark:text-gray-300">
<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"> <div class="text-sm text-gray-700 dark:text-gray-300">
<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"> <div class="text-sm text-gray-700 dark:text-gray-300">
<span class="font-semibold">{nf.format(stats.totalOutputTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalOutputTokens)}</span> tokens
</div> </div>
</div> </div>
</Card.Root> </div>
@@ -1,449 +0,0 @@
<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
@@ -1,266 +0,0 @@
<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>
+70 -54
View File
@@ -1,7 +1,5 @@
<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;
@@ -11,12 +9,22 @@
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) {
@@ -31,6 +39,10 @@
} }
}); });
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 {
@@ -178,36 +190,40 @@
}); });
</script> </script>
<Dialog.Root <dialog
{open} bind:this={dialogEl}
onOpenChange={(v) => { onclose={handleDialogClose}
if (!v) onclose(); 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"
}}
> >
<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="min-h-0 flex-1 overflow-y-auto space-y-4 p-4"> <div class="overflow-y-auto flex-1 p-4 space-y-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-muted-foreground hover:text-foreground" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain"
> >
Request Headers Request Headers
</summary> </summary>
<div <div
class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-48" class="mt-2 bg-background rounded border border-card-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-border last:border-0"> <tr class="border-b border-card-border-inner 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
> >
@@ -222,7 +238,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-muted-foreground hover:text-foreground" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain"
> >
Request Body Request Body
</summary> </summary>
@@ -255,14 +271,14 @@
</button> </button>
</div> </div>
<div <div
class="mt-1 bg-background rounded-md border border-border overflow-auto max-h-96" class="mt-1 bg-background rounded border border-card-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-md border border-border overflow-auto max-h-96" class="mt-2 bg-background rounded border border-card-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
@@ -274,17 +290,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-muted-foreground hover:text-foreground" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain"
> >
Response Headers Response Headers
</summary> </summary>
<div <div
class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-48" class="mt-2 bg-background rounded border border-card-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-border last:border-0"> <tr class="border-b border-card-border-inner 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
> >
@@ -299,13 +315,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-muted-foreground hover:text-foreground" class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain"
> >
Response Body Response Body
</summary> </summary>
{#if isResponseImage && capture.resp_body} {#if isResponseImage && capture.resp_body}
<div <div
class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-96" class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-96"
> >
<div class="p-3 flex justify-center"> <div class="p-3 flex justify-center">
<img <img
@@ -352,26 +368,26 @@
</button> </button>
</div> </div>
<div <div
class="mt-1 bg-background rounded-md border border-border overflow-auto max-h-96" class="mt-1 bg-background rounded border border-card-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-muted-foreground mb-1" class="text-xs font-semibold uppercase tracking-wider text-txtsecondary mb-1"
> >
Reasoning Reasoning
</div> </div>
<pre <pre
class="font-mono whitespace-pre-wrap break-all text-muted-foreground">{sseChat.reasoning}</pre> class="font-mono whitespace-pre-wrap break-all text-txtsecondary">{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-muted-foreground mb-1" class="text-xs font-semibold uppercase tracking-wider text-txtsecondary mb-1"
> >
Response Response
</div> </div>
@@ -391,15 +407,15 @@
</div> </div>
{:else if responseBodyRaw} {:else if responseBodyRaw}
<div <div
class="mt-2 bg-background rounded-md border border-border overflow-auto max-h-96" class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-96"
> >
<div class="p-3 text-sm text-muted-foreground italic"> <div class="p-3 text-sm text-txtsecondary 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-md border border-border overflow-auto max-h-96" class="mt-2 bg-background rounded border border-card-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>
@@ -407,39 +423,39 @@
</details> </details>
</div> </div>
<Dialog.Footer class="border-t border-border px-4 py-3 sm:justify-end"> <div class="p-4 border-t border-card-border flex justify-end">
<Button variant="outline" onclick={onclose}>Close</Button> <button onclick={() => dialogEl?.close()} class="btn"> Close </button>
</Dialog.Footer>
{:else}
<div class="flex flex-col items-center justify-center p-12">
<p class="text-lg text-muted-foreground">Capture not found</p>
<p class="text-sm text-muted-foreground mt-1">The capture may have expired or was never recorded.</p>
<div class="mt-4">
<Button variant="outline" onclick={onclose}>Close</Button>
</div>
</div> </div>
{/if} </div>
</Dialog.Content> {:else}
</Dialog.Root> <div class="flex flex-col items-center justify-center p-12">
<p class="text-lg text-txtsecondary">Capture not found</p>
<p class="text-sm text-txtsecondary mt-1">The capture may have expired or was never recorded.</p>
<div class="mt-4">
<button onclick={() => dialogEl?.close()} class="btn">Close</button>
</div>
</div>
{/if}
</dialog>
<style> <style>
.tab-btn { .tab-btn {
padding: 2px 10px; padding: 2px 10px;
font-size: 0.75rem; font-size: 0.75rem;
border-radius: 0; border-radius: 4px;
color: var(--muted-foreground); color: var(--color-txtsecondary);
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(--foreground); color: var(--color-txtmain);
background: var(--accent); background: var(--color-secondary);
} }
.tab-btn-active { .tab-btn-active {
color: var(--primary); color: var(--color-primary);
background: color-mix(in srgb, var(--primary) 12%, transparent); background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: color-mix(in srgb, var(--primary) 25%, transparent); border-color: color-mix(in srgb, var(--color-primary) 25%, transparent);
} }
</style> </style>
@@ -1,5 +1,6 @@
<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) {
@@ -13,7 +14,9 @@
} }
}); });
let tooltipText = $derived(`Event Stream: ${$connectionState ?? "unknown"}`); let tooltipText = $derived(
`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
@@ -0,0 +1,144 @@
<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>
+51 -28
View File
@@ -1,9 +1,5 @@
<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;
@@ -85,32 +81,59 @@
}); });
</script> </script>
<Card.Root class="bg-muted/30 h-full w-full gap-0 overflow-hidden py-0"> <div class="rounded-lg overflow-hidden flex flex-col bg-gray-950/5 dark:bg-white/10 h-full w-full p-1">
<Card.Header class="border-b px-4 py-2"> <div class="p-4">
<Card.Title class="text-sm font-semibold">{title}</Card.Title> <div class="flex items-center justify-between">
<Card.Action> <h3 class="m-0 text-lg p-0">{title}</h3>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" onclick={toggleFontSize} title="Change font size"> <div class="flex gap-2 items-center">
<Type /> <button class="btn border-0" onclick={toggleFontSize} title="Change font size">
</Button> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<Button variant="ghost" size="icon-sm" onclick={toggleWrapText} title="Toggle text wrap"> <path d="M2 4v3h5v12h3V7h5V4H2zm19 5h-9v3h3v7h3v-7h3V9z"/>
<WrapText class={$wrapTextStore ? "text-primary" : ""} /> </svg>
</Button> </button>
<Button variant="ghost" size="icon-sm" onclick={toggleFilter} title="Toggle filter"> <button class="btn border-0" onclick={toggleWrapText} title="Toggle text wrap">
{#if $showFilterStore}<SearchX />{:else}<Search />{/if} {#if $wrapTextStore}
</Button> <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-.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>
{: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}
<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="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" />
</svg>
{:else}
<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">
<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>
</Card.Action> </div>
{#if $showFilterStore} {#if $showFilterStore}
<div class="flex w-full items-center gap-2 pt-2"> <div class="mt-2 flex gap-2 items-center w-full">
<Input type="text" class="h-8" placeholder="Filter logs (regex)..." bind:value={filterRegex} /> <input
<Button variant="ghost" size="icon-sm" onclick={() => (filterRegex = "")} aria-label="Clear filter"> type="text"
<CircleX /> class="w-full text-sm border border-gray-950/10 dark:border-white/5 p-2 rounded outline-none"
</Button> 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}
</Card.Header> </div>
<Card.Content class="bg-background min-h-0 flex-1 p-0 font-mono text-sm"> <div class="rounded-lg bg-background font-mono text-sm flex-1 overflow-hidden">
<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>
</Card.Content> </div>
</Card.Root> </div>
+70 -22
View File
@@ -1,6 +1,5 @@
<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;
@@ -10,28 +9,77 @@
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>
{#if entries.length > 0} <span
<Tooltip.Provider> bind:this={triggerEl}
<Tooltip.Root> onmouseenter={onEnter}
<Tooltip.Trigger> onmouseleave={onLeave}
{@render children()} onfocus={onEnter}
</Tooltip.Trigger> onblur={onLeave}
<Tooltip.Content class="min-w-[12rem] max-w-[24rem] normal-case"> class="inline-flex"
<table class="w-full text-left"> role="button"
<tbody> tabindex="0"
{#each entries as [key, value]} aria-label="Show metadata"
<tr class="border-b border-white/10 last:border-0"> >
<td class="py-1 pr-3 font-medium whitespace-nowrap text-primary">{key}</td>
<td class="py-1 break-all">{value}</td>
</tr>
{/each}
</tbody>
</table>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{:else}
{@render children()} {@render children()}
</span>
{#if show && entries.length > 0}
<div
bind:this={tooltipEl}
style={tooltipStyle}
class="fixed px-3 py-2 bg-gray-900 text-white text-sm rounded-md z-50 normal-case min-w-[12rem] max-w-[24rem] shadow-lg whitespace-normal"
>
<table class="w-full text-left">
<tbody>
{#each entries as [key, value]}
<tr class="border-b border-white/10 last:border-0">
<td class="py-1 pr-3 font-medium whitespace-nowrap text-primary">{key}</td>
<td class="py-1 break-all">{value}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if} {/if}
+238
View File
@@ -0,0 +1,238 @@
<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,7 +2,6 @@
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);
@@ -144,8 +143,6 @@
}); });
</script> </script>
<Card.Root class="h-[300px] py-0"> <div class="card p-4 h-[300px]">
<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-md flex-shrink-0" class="{handleClass} bg-primary hover:bg-success transition-colors rounded flex-shrink-0"
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
ontouchstart={handleTouchStart} ontouchstart={handleTouchStart}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
+12 -6
View File
@@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props { interface Props {
content: string; content: string;
} }
@@ -8,7 +6,15 @@
let { content }: Props = $props(); let { content }: Props = $props();
</script> </script>
<Tooltip.Root> <div class="relative group inline-block">
<Tooltip.Trigger class="cursor-help align-middle normal-case">&#9432;</Tooltip.Trigger> <span class="cursor-help">&#9432;</span>
<Tooltip.Content>{content}</Tooltip.Content> <div
</Tooltip.Root> class="absolute top-full left-1/2 transform -translate-x-1/2 mt-2
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>
@@ -1,21 +0,0 @@
<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}
@@ -1,33 +0,0 @@
<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}
@@ -1,19 +0,0 @@
<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}
@@ -1,24 +0,0 @@
<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"
/>
@@ -1,44 +0,0 @@
<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>
@@ -1,26 +0,0 @@
<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,8 +4,6 @@
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", "");
@@ -152,14 +150,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-muted-foreground"> <div class="flex-1 flex items-center justify-center text-txtsecondary">
<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-background border border-border rounded-md"> <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 isTranscribing} {#if isTranscribing}
<div class="text-center text-muted-foreground"> <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> <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>
@@ -171,26 +169,29 @@
{: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="pb-0 font-medium">Transcription Result</h3> <h3 class="font-medium">Transcription Result</h3>
<Button <button
variant="outline" class="btn btn-sm"
size="icon-sm"
onclick={copyToClipboard} onclick={copyToClipboard}
title={copied ? 'Copied!' : 'Copy to clipboard'} title={copied ? 'Copied!' : 'Copy to clipboard'}
> >
{#if copied} {#if copied}
<Check class="text-success" /> <svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{:else} {:else}
<Copy /> <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="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-md border border-border bg-background whitespace-pre-wrap"> <div class="flex-1 overflow-auto p-3 rounded border border-gray-200 dark:border-white/10 bg-background whitespace-pre-wrap">
{transcriptionResult} {transcriptionResult}
</div> </div>
</div> </div>
{:else if selectedFile} {:else if selectedFile}
<div class="text-center text-muted-foreground p-4"> <div class="text-center text-txtsecondary 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>
@@ -199,7 +200,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-muted-foreground p-8 {isDragging ? 'bg-primary/10' : ''}" class="w-full h-full flex items-center justify-center text-center text-txtsecondary p-8 {isDragging ? 'bg-primary/10' : ''}"
ondragover={handleDragOver} ondragover={handleDragOver}
ondragleave={handleDragLeave} ondragleave={handleDragLeave}
ondrop={handleDrop} ondrop={handleDrop}
@@ -222,21 +223,33 @@
onchange={handleFileSelect} onchange={handleFileSelect}
bind:this={fileInput} bind:this={fileInput}
/> />
<Button variant="outline" onclick={() => fileInput?.click()} disabled={isTranscribing}> <button
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 variant="destructive" onclick={cancelTranscription}>Cancel</Button> <button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelTranscription}>
Cancel
</button>
{:else} {:else}
<Button onclick={transcribe} disabled={!canTranscribe}>Transcribe</Button> <button
<Button class="btn bg-primary text-btn-primary-text hover:opacity-90"
variant="outline" onclick={transcribe}
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,14 +7,6 @@
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", "");
@@ -38,7 +30,6 @@
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);
@@ -51,14 +42,6 @@
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;
@@ -320,95 +303,96 @@
<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 variant="outline" size="icon" onclick={() => (showSettings = true)} title="Settings"> <button
<Settings /> class="btn"
</Button> onclick={() => (showSettings = !showSettings)}
<Button variant="outline" onclick={newChat} disabled={messages.length === 0 && !isStreaming}> title="Settings"
>
<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 dialog --> <!-- Settings panel -->
<Dialog.Root bind:open={showSettings}> {#if showSettings}
<Dialog.Content class="max-w-xl"> <div class="shrink-0 mb-4 p-4 bg-surface border border-gray-200 dark:border-white/10 rounded">
<Dialog.Header> <div class="mb-4">
<Dialog.Title>Chat Settings</Dialog.Title> <label class="block text-sm font-medium mb-1" for="endpoint">Endpoint</label>
</Dialog.Header> <select
id="endpoint"
<div class="space-y-4"> 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> bind:value={$endpointStore}
<Label class="mb-1" for="endpoint">Endpoint</Label> disabled={isStreaming}
<Select.Root >
type="single" <option value="v1/chat/completions">/v1/chat/completions</option>
value={$endpointStore} <option value="v1/messages">/v1/messages</option>
onValueChange={(v) => v && endpointStore.set(v as Endpoint)} <option value="v1/responses">/v1/responses</option>
> </select>
<Select.Trigger class="w-full">/{$endpointStore}</Select.Trigger> </div>
<Select.Content> <div class="mb-4">
<Select.Item value="v1/chat/completions">/v1/chat/completions</Select.Item> <label class="block text-sm font-medium mb-1" for="system-prompt">System Prompt</label>
<Select.Item value="v1/messages">/v1/messages</Select.Item> <textarea
<Select.Item value="v1/responses">/v1/responses</Select.Item> id="system-prompt"
</Select.Content> 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"
</Select.Root> placeholder="You are a helpful assistant..."
</div> rows="3"
<div> bind:value={$systemPromptStore}
<Label class="mb-1" for="system-prompt">System Prompt</Label> disabled={isStreaming}
<Textarea ></textarea>
id="system-prompt" </div>
class="resize-none" <div class="mb-4">
placeholder="You are a helpful assistant..." <label class="block text-sm font-medium mb-1" for="temperature">
rows={3} Temperature: {$temperatureStore.toFixed(2)}
bind:value={$systemPromptStore} </label>
disabled={isStreaming} <input
/> id="temperature"
</div> type="range"
<div> min="0"
<Label class="mb-1" for="temperature"> max="2"
Temperature: {$temperatureStore.toFixed(2)} step="0.05"
</Label> class="w-full"
<input bind:value={$temperatureStore}
id="temperature" disabled={isStreaming}
type="range" />
min="0" <div class="flex justify-between text-xs text-txtsecondary mt-1">
max="2" <span>Precise (0)</span>
step="0.05" <span>Creative (2)</span>
class="accent-primary w-full"
bind:value={$temperatureStore}
disabled={isStreaming}
/>
<div class="text-muted-foreground mt-1 flex justify-between text-xs">
<span>Precise (0)</span>
<span>Creative (2)</span>
</div>
</div>
<div>
<Label class="mb-1" for="max-tokens">Max Tokens</Label>
<Input id="max-tokens" type="number" min="1" bind:value={$maxTokensStore} disabled={isStreaming} />
<p class="text-muted-foreground mt-1 text-xs">Required for /v1/messages.</p>
</div> </div>
</div> </div>
<div>
<Dialog.Footer> <label class="block text-sm font-medium mb-1" for="max-tokens">Max Tokens</label>
<Button variant="outline" onclick={() => (showSettings = false)}>Done</Button> <input
</Dialog.Footer> id="max-tokens"
</Dialog.Content> type="number"
</Dialog.Root> 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>
{/if}
<!-- Empty state for no models configured --> <!-- Empty state for no models configured -->
{#if !hasModels} {#if !hasModels}
<div class="text-muted-foreground flex flex-1 items-center justify-center"> <div class="flex-1 flex items-center justify-center text-txtsecondary">
<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="mb-4 flex-1 overflow-y-auto px-2" class="flex-1 overflow-y-auto mb-4 px-2"
bind:this={messagesContainer} bind:this={messagesContainer}
onscroll={handleMessagesScroll} onscroll={handleMessagesScroll}
> >
{#if messages.length === 0} {#if messages.length === 0}
<div class="text-muted-foreground flex h-full items-center justify-center"> <div class="h-full flex items-center justify-center text-txtsecondary">
<p>Start a conversation by typing a message below.</p> <p>Start a conversation by typing a message below.</p>
</div> </div>
{:else} {:else}
@@ -435,21 +419,19 @@
{#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="group relative"> <div class="relative group">
<img <img
src={imageUrl} src={imageUrl}
alt="Attached image {idx + 1}" alt="Attached image {idx + 1}"
class="h-20 w-20 rounded-md border object-cover" class="w-20 h-20 object-cover rounded border border-gray-200 dark:border-white/10"
/> />
<Button <button
variant="destructive" 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"
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>
@@ -457,7 +439,7 @@
<!-- Error message --> <!-- Error message -->
{#if imageError} {#if imageError}
<div class="bg-destructive/10 text-destructive mb-2 rounded-md p-2 text-sm"> <div class="mb-2 p-2 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded text-sm">
{imageError} {imageError}
</div> </div>
{/if} {/if}
@@ -474,7 +456,6 @@
/> />
<ExpandableTextarea <ExpandableTextarea
bind:ref={inputRef}
bind:value={userInput} bind:value={userInput}
placeholder="Type a message..." placeholder="Type a message..."
rows={3} rows={3}
@@ -483,23 +464,27 @@
/> />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if isStreaming} {#if isStreaming}
<Button variant="destructive" onclick={cancelStreaming}>Cancel</Button> <button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelStreaming}>
Cancel
</button>
{:else} {:else}
<Button <button
variant="outline" class="btn"
size="icon"
onclick={() => fileInput?.click()} onclick={() => fileInput?.click()}
disabled={isStreaming || !$selectedModelStore} disabled={isStreaming || !$selectedModelStore}
title="Attach image" title="Attach image"
> >
<Paperclip /> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
</Button> <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
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,9 +1,7 @@
<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";
@@ -163,37 +161,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="group relative rounded-lg px-4 py-2 {role === 'user' class="relative group rounded-lg px-4 py-2 {role === 'user'
? 'bg-primary text-primary-foreground max-w-[85%]' ? 'max-w-[85%] bg-primary text-btn-primary-text'
: 'bg-card w-full border sm:w-4/5'}" : 'w-full sm:w-4/5 bg-surface border border-gray-200 dark:border-white/10'}"
> >
{#if role === "assistant"} {#if role === "assistant"}
{#if reasoning_content || isReasoning} {#if reasoning_content || isReasoning}
<div class="mb-3 overflow-hidden rounded-md border"> <div class="mb-3 border border-gray-200 dark:border-white/10 rounded overflow-hidden">
<button <button
class="bg-muted/50 hover:bg-muted flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors" class="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"
onclick={() => showReasoning = !showReasoning} onclick={() => showReasoning = !showReasoning}
> >
{#if showReasoning} {#if showReasoning}
<ChevronDown class="size-4" /> <ChevronDown class="w-4 h-4" />
{:else} {:else}
<ChevronRight class="size-4" /> <ChevronRight class="w-4 h-4" />
{/if} {/if}
<Brain class="size-4" /> <Brain class="w-4 h-4" />
<span class="font-medium">Reasoning</span> <span class="font-medium">Reasoning</span>
<span class="text-muted-foreground ml-2"> <span class="text-txtsecondary 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="text-muted-foreground ml-auto flex items-center gap-1"> <span class="ml-auto flex items-center gap-1 text-txtsecondary">
<span class="bg-primary h-1.5 w-1.5 animate-pulse rounded-full"></span> <span class="w-1.5 h-1.5 bg-primary rounded-full animate-pulse"></span>
reasoning... reasoning...
</span> </span>
{/if} {/if}
</button> </button>
{#if showReasoning} {#if showReasoning}
<div class="bg-muted/30 text-muted-foreground whitespace-pre-wrap px-3 py-2 font-mono text-sm"> <div class="px-3 py-2 bg-gray-50/50 dark:bg-white/[0.02] text-sm text-txtsecondary whitespace-pre-wrap font-mono">
{reasoning_content}{#if isReasoning}<span class="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-current"></span>{/if} {reasoning_content}{#if isReasoning}<span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5"></span>{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -203,12 +201,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-md border transition-opacity hover:opacity-80" class="cursor-pointer rounded border border-gray-200 dark:border-white/10 hover:opacity-80 transition-opacity"
> >
<img <img
src={imageUrl} src={imageUrl}
alt="Image {idx + 1}" alt="Image {idx + 1}"
class="max-h-64 rounded-md" class="max-h-64 rounded"
/> />
</button> </button>
{/each} {/each}
@@ -228,47 +226,60 @@
</div> </div>
{/if} {/if}
{#if !isStreaming} {#if !isStreaming}
<div class="mt-2 flex gap-1 border-t pt-1"> <div class="flex gap-1 mt-2 pt-1 border-t border-gray-200 dark:border-white/10">
{#if onRegenerate} {#if onRegenerate}
<Button variant="ghost" size="icon-xs" class="text-muted-foreground" onclick={onRegenerate} title="Regenerate response"> <button
<RefreshCw /> class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary"
</Button> onclick={onRegenerate}
title="Regenerate response"
>
<RefreshCw class="w-4 h-4" />
</button>
{/if} {/if}
<Button <button
variant="ghost" class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary"
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="text-success" /> <Check class="w-4 h-4 text-green-500" />
{:else} {:else}
<Copy /> <Copy class="w-4 h-4" />
{/if} {/if}
</Button> </button>
<Button <button
variant="ghost" class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 {showRaw ? 'text-primary' : 'text-txtsecondary'}"
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 /> <Code class="w-4 h-4" />
</Button> </button>
</div> </div>
{/if} {/if}
{:else} {:else}
{#if isEditing} {#if isEditing}
<div class="flex min-w-[300px] flex-col gap-2"> <div class="flex flex-col gap-2 min-w-[300px]">
<Textarea class="resize-none" rows={3} bind:value={editContent} onkeydown={handleKeyDown} /> <textarea
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 variant="ghost" size="icon-sm" onclick={cancelEdit} title="Cancel"> <button
<X /> class="p-1.5 rounded hover:bg-white/20"
</Button> onclick={cancelEdit}
<Button variant="ghost" size="icon-sm" onclick={saveEdit} title="Save"> title="Cancel"
<Save /> >
</Button> <X class="w-4 h-4" />
</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}
@@ -277,12 +288,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-md border border-white/20 transition-opacity hover:opacity-80" class="cursor-pointer rounded border border-white/20 hover:opacity-80 transition-opacity"
> >
<img <img
src={imageUrl} src={imageUrl}
alt="Image {idx + 1}" alt="Image {idx + 1}"
class="max-w-[200px] rounded-md" class="max-w-[200px] rounded"
/> />
</button> </button>
{/each} {/each}
@@ -291,11 +302,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 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" 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"
onclick={startEdit} onclick={startEdit}
title="Edit message" title="Edit message"
> >
<Pencil class="size-4" /> <Pencil class="w-4 h-4" />
</button> </button>
{/if} {/if}
{/if} {/if}
@@ -313,16 +324,16 @@
tabindex="-1" tabindex="-1"
> >
<button <button
class="absolute right-4 top-4 rounded-lg bg-white/10 p-2 text-white transition-colors hover:bg-white/20" class="absolute top-4 right-4 p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
onclick={() => closeModal()} onclick={() => closeModal()}
title="Close" title="Close"
> >
<X class="size-6" /> <X class="w-6 h-6" />
</button> </button>
<img <img
src={modalImageUrl} src={modalImageUrl}
alt="" alt=""
class="max-w-full max-h-full rounded-md pointer-events-none" class="max-w-full max-h-full rounded pointer-events-none"
/> />
</div> </div>
{/if} {/if}
@@ -330,8 +341,8 @@
<style> <style>
.prose :global(pre) { .prose :global(pre) {
position: relative; position: relative;
background-color: var(--muted); background-color: var(--color-surface);
border: 1px solid var(--border); border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 0.75rem; padding: 0.75rem;
padding-right: 2.5rem; padding-right: 2.5rem;
@@ -348,20 +359,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(--border); border: 1px solid var(--color-border);
background: var(--muted); background: var(--color-surface);
color: var(--muted-foreground); color: var(--color-txtsecondary);
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(--accent); background: var(--color-secondary);
} }
.prose :global(.code-copy-btn.copied) { .prose :global(.code-copy-btn.copied) {
color: var(--success); color: var(--color-success);
opacity: 1; opacity: 1;
} }
@@ -376,10 +387,10 @@
} }
.prose :global(code:not(pre code)) { .prose :global(code:not(pre code)) {
background-color: var(--muted); background-color: var(--color-surface);
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
border: 1px solid var(--border); border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
} }
.prose :global(p) { .prose :global(p) {
@@ -420,14 +431,14 @@
} }
.prose :global(blockquote) { .prose :global(blockquote) {
border-left: 3px solid var(--primary); border-left: 3px solid var(--color-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(--primary); color: var(--color-primary);
text-decoration: underline; text-decoration: underline;
} }
@@ -439,13 +450,13 @@
.prose :global(th), .prose :global(th),
.prose :global(td) { .prose :global(td) {
border: 1px solid var(--border); border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
padding: 0.5rem; padding: 0.5rem;
text-align: left; text-align: left;
} }
.prose :global(th) { .prose :global(th) {
background-color: var(--muted); background-color: var(--color-surface);
font-weight: 600; font-weight: 600;
} }
@@ -2,10 +2,6 @@
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";
@@ -370,77 +366,77 @@
<!-- Run controls --> <!-- Run controls -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if isRunning} {#if isRunning}
<Button variant="destructive" onclick={stop}> <button class="btn bg-red-500 hover:bg-red-600 text-white border-red-500" onclick={stop}>
<span class="mr-1 inline-block h-3 w-3 bg-current align-middle"></span>Stop <span class="inline-block w-3 h-3 bg-white align-middle mr-2"></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="mr-1 inline-block align-middle" aria-hidden="true"></span>Go <span class="inline-block align-middle mr-2" aria-hidden="true"></span>Go
</Button> </button>
{/if} {/if}
<Button variant="outline" size="sm" onclick={clearAll} disabled={isRunning || $testListStore.length === 0}> <button class="btn btn--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-muted-foreground mb-1"> <div class="text-xs font-medium text-txtsecondary mb-1">
Models <span class="text-[10px] font-normal">— click to queue (add the same model more than once to test parallel requests)</span> Models <span class="text-[10px] font-normal">— click to queue (add the same model more than once to test parallel requests)</span>
</div> </div>
<div class="flex-1 border border-border rounded-md overflow-y-auto min-h-0"> <div class="flex-1 border border-gray-200 dark:border-white/10 rounded overflow-y-auto min-h-0">
{#if !hasModels} {#if !hasModels}
<div class="p-3 text-sm text-muted-foreground text-center">No models configured.</div> <div class="p-3 text-sm text-txtsecondary text-center">No models configured.</div>
{:else} {:else}
<div class="divide-y divide-gray-100 dark:divide-white/5"> <ul class="divide-y divide-gray-100 dark:divide-white/5">
{#each availableModels as m (m.id)} {#each availableModels as m (m.id)}
<button <li>
type="button" <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" 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"
onclick={() => addModel(m.id)} onclick={() => addModel(m.id)}
disabled={isRunning} disabled={isRunning}
title="Add {m.id}" title="Add {m.id}"
> >
<span class="text-primary" aria-hidden="true">+</span> <span class="text-primary" aria-hidden="true">+</span>
<span class="truncate flex-1">{m.id}</span> <span class="truncate flex-1">{m.id}</span>
</button> </button>
</li>
{/each} {/each}
</div> </ul>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Settings --> <!-- Settings -->
<div class="flex flex-col gap-2 border-t border-border pt-3"> <div class="flex flex-col gap-2 border-t border-gray-200 dark:border-white/10 pt-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label for="concurrency-prompt" class="text-xs font-medium text-muted-foreground">Prompt</label> <label for="concurrency-prompt" class="text-xs font-medium text-txtsecondary">Prompt</label>
<Button <button
variant="link" class="text-[10px] text-txtsecondary hover:text-txtmain underline"
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="resize-none text-sm" 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"
rows={3} rows="3"
bind:value={$promptStore} bind:value={$promptStore}
disabled={isRunning} disabled={isRunning}
></Textarea> ></textarea>
<label for="concurrency-max-tokens" class="text-xs font-medium text-muted-foreground">max_tokens</label> <label for="concurrency-max-tokens" class="text-xs font-medium text-txtsecondary">max_tokens</label>
<Input <input
id="concurrency-max-tokens" id="concurrency-max-tokens"
type="number" type="number"
min="1" min="1"
class="h-8 text-sm" 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"
bind:value={$maxTokensStore} bind:value={$maxTokensStore}
disabled={isRunning} disabled={isRunning}
/> />
@@ -451,8 +447,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-muted-foreground space-y-4"> <div class="max-w-md text-sm text-txtsecondary space-y-4">
<h4 class="text-base font-semibold text-foreground pb-0">Load Test</h4> <h4 class="text-base font-semibold text-txtmain 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.
@@ -460,16 +456,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-foreground">Go</span> to launch them concurrently.</li> <li>Press <span class="font-semibold text-txtmain">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-border rounded-md"> <div class="mb-3 border border-gray-200 dark:border-white/10 rounded">
<button <button
class="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent transition-colors {$timelineCollapsedStore ? 'rounded-md' : 'rounded-t border-b border-border'}" 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'}"
onclick={() => timelineCollapsedStore.update((v) => !v)} onclick={() => timelineCollapsedStore.update((v) => !v)}
aria-expanded={!$timelineCollapsedStore} aria-expanded={!$timelineCollapsedStore}
> >
@@ -484,7 +480,7 @@
</svg> </svg>
<span>Timeline</span> <span>Timeline</span>
{#if !$timelineCollapsedStore} {#if !$timelineCollapsedStore}
<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-3 text-[10px] text-txtsecondary 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>
@@ -493,7 +489,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-muted-foreground"> <span class="ml-auto tabular-nums text-txtsecondary">
max {formatElapsed(timelineMaxMs)} · {$testListStore.length} request{$testListStore.length === 1 ? "" : "s"} max {formatElapsed(timelineMaxMs)} · {$testListStore.length} request{$testListStore.length === 1 ? "" : "s"}
</span> </span>
</button> </button>
@@ -502,13 +498,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-border"> <div class="relative flex-1 h-4 border-b border-gray-200 dark:border-white/10">
{#each timelineTicks as t (t)} {#each timelineTicks as t (t)}
<div <div
class="absolute top-0 bottom-0 border-l border-border" class="absolute top-0 bottom-0 border-l border-gray-200 dark:border-white/10"
style="left: {(t / timelineMaxMs) * 100}%;" style="left: {(t / timelineMaxMs) * 100}%;"
> >
<span class="absolute -top-0.5 left-1 text-[10px] text-muted-foreground tabular-nums">{formatTickMs(t)}</span> <span class="absolute -top-0.5 left-1 text-[10px] text-txtsecondary tabular-nums">{formatTickMs(t)}</span>
</div> </div>
{/each} {/each}
</div> </div>
@@ -523,14 +519,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-muted-foreground"> <div class="w-40 shrink-0 flex items-center gap-1 pr-2 text-txtsecondary">
<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-border" class="absolute top-0 bottom-0 border-l border-gray-100 dark:border-white/5"
style="left: {(t / timelineMaxMs) * 100}%;" style="left: {(t / timelineMaxMs) * 100}%;"
aria-hidden="true" aria-hidden="true"
></div> ></div>
@@ -564,7 +560,7 @@
></div> ></div>
{/if} {/if}
</div> </div>
<div class="w-16 shrink-0 pl-2 tabular-nums text-muted-foreground text-right"> <div class="w-16 shrink-0 pl-2 tabular-nums text-txtsecondary text-right">
{run ? formatElapsed(run.elapsedMs) : "—"} {run ? formatElapsed(run.elapsedMs) : "—"}
</div> </div>
</div> </div>
@@ -578,16 +574,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-md flex flex-col min-h-0 transition-colors {dragOverIndex === i && dragIndex !== i class="border rounded flex flex-col min-h-0 transition-colors {dragOverIndex === i && dragIndex !== i
? 'border-primary ring-2 ring-primary/40' ? 'border-primary ring-2 ring-primary/40'
: 'border-border'} {dragIndex === i ? 'opacity-40' : ''}" : 'border-gray-200 dark:border-white/10'} {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-border bg-secondary/40 rounded-t" 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"
draggable={!isRunning} draggable={!isRunning}
role="button" role="button"
tabindex="-1" tabindex="-1"
@@ -597,28 +593,26 @@
class:cursor-grab={!isRunning} class:cursor-grab={!isRunning}
title={isRunning ? "" : "Drag to reorder"} title={isRunning ? "" : "Drag to reorder"}
> >
<span class="text-muted-foreground select-none" aria-hidden="true">⋮⋮</span> <span class="text-txtsecondary select-none" aria-hidden="true">⋮⋮</span>
<span class="text-muted-foreground tabular-nums text-xs w-5 text-right">{i + 1}.</span> <span class="text-txtsecondary 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-muted-foreground"> <span class="text-xs tabular-nums text-txtsecondary">
{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
variant="ghost" 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"
size="icon-sm"
class="h-5 w-5 text-muted-foreground hover:text-red-500"
onclick={() => removeEntry(entry.id)} onclick={() => removeEntry(entry.id)}
disabled={isRunning} disabled={isRunning}
aria-label="Remove" aria-label="Remove"
tabindex={-1} tabindex="-1"
> >
<X class="size-3" /> ×
</Button> </button>
</div> </div>
<div class="flex-1 min-h-0 overflow-y-auto font-mono text-xs px-2 py-1.5"> <div class="flex-1 min-h-0 overflow-y-auto font-mono text-xs px-2 py-1.5">
{#if run?.loadingText} {#if run?.loadingText}
<div class="bg-secondary/40 dark:bg-white/5 text-muted-foreground rounded-md px-2 py-1 mb-2 whitespace-pre-wrap">{run.loadingText.trim()}</div> <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>
{/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,12 +1,9 @@
<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;
@@ -15,7 +12,6 @@
let { let {
value = $bindable(), value = $bindable(),
ref = $bindable(null),
placeholder = "", placeholder = "",
rows = 3, rows = 3,
disabled = false, disabled = false,
@@ -56,55 +52,69 @@
}); });
</script> </script>
<div class="group relative flex min-h-0 flex-1 items-stretch"> <div class="flex-1 relative group flex items-stretch min-h-0">
<Textarea <textarea
class="resize-none pr-10" 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"
bind:ref
{placeholder} {placeholder}
{rows} {rows}
bind:value bind:value
{onkeydown} {onkeydown}
{disabled} {disabled}
/> ></textarea>
<Button <button
variant="outline" 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"
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 /> <Maximize2 class="w-4 h-4" />
</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="bg-card flex h-[80vh] w-full max-w-4xl flex-col rounded-lg border shadow-xl"> <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">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between border-b p-4"> <div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-white/10">
<h3 class="pb-0 font-medium">Edit Text</h3> <h3 class="font-medium">Edit Text</h3>
<Button variant="ghost" size="icon-sm" onclick={closeExpanded} title="Close" type="button"> <button
<X /> class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10"
</Button> onclick={closeExpanded}
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:ref={expandedTextarea} bind:this={expandedTextarea}
class="h-full resize-none" 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"
{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 border-t p-4"> <div class="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-white/10">
<Button variant="outline" onclick={closeExpanded} type="button">Cancel</Button> <button
<Button onclick={saveExpanded} type="button">Done</Button> class="btn"
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,11 +7,6 @@
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");
@@ -66,6 +61,18 @@
} }
} }
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);
} }
@@ -188,73 +195,65 @@
<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.Root <select
type="single" 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"
value={$apiModeStore} bind:value={$apiModeStore}
onValueChange={(v) => v && apiModeStore.set(v as ImageApiMode)} disabled={isGenerating}
> >
<Select.Trigger class="h-9 w-32">{$apiModeStore}</Select.Trigger> <option value="openai">OpenAI</option>
<Select.Content> <option value="sdapi">SDAPI</option>
<Select.Item value="openai">OpenAI</Select.Item> </select>
<Select.Item value="sdapi">SDAPI</Select.Item>
</Select.Content>
</Select.Root>
<Select.Root <select
type="single" 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"
value={$selectedSizeStore} bind:value={$selectedSizeStore}
onValueChange={(v) => v && selectedSizeStore.set(v)} disabled={isGenerating}
> >
<Select.Trigger class="h-9 w-40">{$selectedSizeStore}</Select.Trigger> <optgroup label="Square">
<Select.Content> <option value="512x512">512x512</option>
<Select.Group> <option value="1024x1024">1024x1024</option>
<Select.Label>Square</Select.Label> </optgroup>
<Select.Item value="512x512">512x512</Select.Item> <optgroup label="Landscape">
<Select.Item value="1024x1024">1024x1024</Select.Item> <option value="1024x768">1024x768 (4:3)</option>
</Select.Group> <option value="1280x720">1280x720 (16:9)</option>
<Select.Separator /> <option value="1792x1024">1792x1024 (SDXL)</option>
<Select.Group> </optgroup>
<Select.Label>Landscape</Select.Label> <optgroup label="Portrait">
<Select.Item value="1024x768">1024x768 (4:3)</Select.Item> <option value="768x1024">768x1024 (3:4)</option>
<Select.Item value="1280x720">1280x720 (16:9)</Select.Item> <option value="720x1280">720x1280 (9:16)</option>
<Select.Item value="1792x1024">1792x1024 (SDXL)</Select.Item> <option value="1024x1792">1024x1792 (SDXL)</option>
</Select.Group> </optgroup>
<Select.Separator /> </select>
<Select.Group>
<Select.Label>Portrait</Select.Label>
<Select.Item value="768x1024">768x1024 (3:4)</Select.Item>
<Select.Item value="720x1280">720x1280 (9:16)</Select.Item>
<Select.Item value="1024x1792">1024x1792 (SDXL)</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
{#if isSdapi} {#if isSdapi}
<Button variant="outline" onclick={() => showSettings = !showSettings}> <button
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-md border border-border bg-background"> <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-4 gap-3 mb-3"> <div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">Steps</span> <span class="text-xs text-txtsecondary">Steps</span>
<Input <input
type="number" type="number"
class="h-8" 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={$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-muted-foreground">CFG Scale</span> <span class="text-xs text-txtsecondary">CFG Scale</span>
<Input <input
type="number" type="number"
class="h-8" 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={$sdCfgScaleStore} bind:value={$sdCfgScaleStore}
min="1" min="1"
max="30" max="30"
@@ -262,141 +261,121 @@
/> />
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">Seed (-1 = random)</span> <span class="text-xs text-txtsecondary">Seed (-1 = random)</span>
<Input <input
type="number" type="number"
class="h-8" 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={$sdSeedStore} bind:value={$sdSeedStore}
min="-1" min="-1"
/> />
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">Batch Size</span> <span class="text-xs text-txtsecondary">Batch Size</span>
<Input <input
type="number" type="number"
class="h-8" 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={$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-muted-foreground">Sampler</span> <span class="text-xs text-txtsecondary">Sampler</span>
<Select.Root <select
type="single" 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"
value={$sdSamplerStore} bind:value={$sdSamplerStore}
onValueChange={(v) => sdSamplerStore.set(v ?? "")}
> >
<Select.Trigger class="h-8">{$sdSamplerStore || "Default"}</Select.Trigger> <option value="">Default</option>
<Select.Content> <option value="euler_a">euler_a</option>
<Select.Item value="">Default</Select.Item> <option value="euler">euler</option>
<Select.Item value="euler_a">euler_a</Select.Item> <option value="heun">heun</option>
<Select.Item value="euler">euler</Select.Item> <option value="dpm2">dpm2</option>
<Select.Item value="heun">heun</Select.Item> <option value="dpmpp2s_a">dpmpp2s_a</option>
<Select.Item value="dpm2">dpm2</Select.Item> <option value="dpmpp2m">dpmpp2m</option>
<Select.Item value="dpmpp2s_a">dpmpp2s_a</Select.Item> <option value="dpmpp2mv2">dpmpp2mv2</option>
<Select.Item value="dpmpp2m">dpmpp2m</Select.Item> <option value="ipndm">ipndm</option>
<Select.Item value="dpmpp2mv2">dpmpp2mv2</Select.Item> <option value="ipndm_v">ipndm_v</option>
<Select.Item value="ipndm">ipndm</Select.Item> <option value="lcm">lcm</option>
<Select.Item value="ipndm_v">ipndm_v</Select.Item> <option value="ddim_trailing">ddim_trailing</option>
<Select.Item value="lcm">lcm</Select.Item> <option value="tcd">tcd</option>
<Select.Item value="ddim_trailing">ddim_trailing</Select.Item> </select>
<Select.Item value="tcd">tcd</Select.Item>
</Select.Content>
</Select.Root>
</label> </label>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">Scheduler</span> <span class="text-xs text-txtsecondary">Scheduler</span>
<Select.Root <select
type="single" 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"
value={$sdSchedulerStore} bind:value={$sdSchedulerStore}
onValueChange={(v) => sdSchedulerStore.set(v ?? "")}
> >
<Select.Trigger class="h-8">{$sdSchedulerStore || "Auto for model"}</Select.Trigger> <option value="">Auto for model</option>
<Select.Content> <option value="discrete">discrete</option>
<Select.Item value="">Auto for model</Select.Item> <option value="karras">karras</option>
<Select.Item value="discrete">discrete</Select.Item> <option value="exponential">exponential</option>
<Select.Item value="karras">karras</Select.Item> <option value="ays">ays</option>
<Select.Item value="exponential">exponential</Select.Item> <option value="gits">gits</option>
<Select.Item value="ays">ays</Select.Item> </select>
<Select.Item value="gits">gits</Select.Item>
</Select.Content>
</Select.Root>
</label> </label>
</div> </div>
<label class="flex flex-col gap-1 mb-3"> <label class="flex flex-col gap-1 mb-3">
<span class="text-xs text-muted-foreground">Negative Prompt</span> <span class="text-xs text-txtsecondary">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-muted-foreground block mb-1">LoRAs</span> <span class="text-xs text-txtsecondary 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
variant="outline" 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"
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.Root <select
type="single" 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"
value="" onchange={addLora}
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 }];
}
}
}}
> >
<Select.Trigger class="h-8 flex-1">Add a LoRA...</Select.Trigger> <option value="">Add a LoRA...</option>
<Select.Content> {#each availableLoras.filter((l) => !selectedLoras.some((s) => s.path === l.path)) as lora}
{#each availableLoras.filter((l) => !selectedLoras.some((s) => s.path === l.path)) as lora (lora.path)} <option value={lora.path}>{lora.name}</option>
<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-muted-foreground">No LoRAs available</p> <p class="text-xs text-txtsecondary">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="h-7 w-20 text-xs" 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"
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
variant="outline" 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"
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 class="size-3" /> x
</Button> </button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -407,14 +386,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-muted-foreground"> <div class="flex-1 flex items-center justify-center text-txtsecondary">
<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-background border border-border rounded-md"> <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} {#if isGenerating}
<div class="text-center text-muted-foreground"> <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> <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>
@@ -424,6 +403,7 @@
<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">
@@ -438,15 +418,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
variant="secondary" class="absolute bottom-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
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"
> >
<Download class="size-4" /> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</Button> <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>
{/each} {/each}
</div> </div>
@@ -463,18 +443,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
variant="secondary" class="absolute bottom-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
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"
> >
<Download class="size-5" /> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</Button> <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>
{:else} {:else}
<div class="text-center text-muted-foreground"> <div class="text-center text-txtsecondary">
<p>Enter a prompt below to generate an image</p> <p>Enter a prompt below to generate an image</p>
</div> </div>
{/if} {/if}
@@ -491,25 +471,24 @@
/> />
<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 variant="destructive" class="flex-1 md:flex-none" onclick={cancelGeneration}> <button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}>
Cancel Cancel
</Button> </button>
{:else} {:else}
<Button <button
class="flex-1 md:flex-none" class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none"
onclick={generate} onclick={generate}
disabled={!prompt.trim() || !$selectedModelStore} disabled={!prompt.trim() || !$selectedModelStore}
> >
Generate Generate
</Button> </button>
<Button <button
variant="outline" class="btn flex-1 md:flex-none"
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>
@@ -526,15 +505,13 @@
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
> >
<Button <button
variant="secondary" 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"
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,7 +1,6 @@
<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;
@@ -19,51 +18,42 @@
</script> </script>
{#if hasModels} {#if hasModels}
<Select.Root <select
type="single" 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"
{value} bind:value
onValueChange={(v) => v !== undefined && (value = v)}
{disabled} {disabled}
> >
<Select.Trigger class="min-w-0 flex-1 basis-48">{value || placeholder}</Select.Trigger> <option value="">{placeholder}</option>
<Select.Content> {#if hasMatching}
<Select.Item value="">{placeholder}</Select.Item> <optgroup label="Matching Capabilities">
{#if hasMatching} {#each grouped.localMatching as model (model.id)}
<Select.Group> <option value={model.id}>{model.id}</option>
<Select.Label>Matching Capabilities</Select.Label> {#if model.aliases}
{#each grouped.localMatching as model (model.id)} {#each model.aliases as alias (alias)}
<Select.Item value={model.id}>{model.id}</Select.Item> <option value={alias}> {alias}</option>
{#if model.aliases} {/each}
{#each model.aliases as alias (alias)} {/if}
<Select.Item value={alias}> {alias}</Select.Item> {/each}
{/each} </optgroup>
{/if} {/if}
{/each} {#if grouped.local.length > 0}
</Select.Group> <optgroup label="Local">
<Select.Separator /> {#each grouped.local as model (model.id)}
{/if} <option value={model.id}>{model.id}</option>
{#if grouped.local.length > 0} {#if model.aliases}
<Select.Group> {#each model.aliases as alias (alias)}
<Select.Label>Local</Select.Label> <option value={alias}> {alias}</option>
{#each grouped.local as model (model.id)} {/each}
<Select.Item value={model.id}>{model.id}</Select.Item> {/if}
{#if model.aliases} {/each}
{#each model.aliases as alias (alias)} </optgroup>
<Select.Item value={alias}> {alias}</Select.Item> {/if}
{/each} {#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
{/if} <optgroup label="Peer: {peerId}">
{/each} {#each peerModels as model (model.id)}
</Select.Group> <option value={model.id}>{model.id}</option>
<Select.Separator /> {/each}
{/if} </optgroup>
{#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)} {/each}
<Select.Group> </select>
<Select.Label>Peer: {peerId}</Select.Label>
{#each peerModels as model (model.id)}
<Select.Item value={model.id}>{model.id}</Select.Item>
{/each}
</Select.Group>
{/each}
</Select.Content>
</Select.Root>
{/if} {/if}
@@ -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-muted-foreground text-center"> <div class="text-center text-txtsecondary">
<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,10 +4,6 @@
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";
@@ -238,9 +234,9 @@
} }
function scoreColor(score: number | null): string { function scoreColor(score: number | null): string {
if (score === null) return "text-muted-foreground"; if (score === null) return "text-txtsecondary";
if (score > 0) return "text-green-600 dark:text-green-400"; if (score > 0) return "text-green-600 dark:text-green-400";
return "text-destructive"; return "text-red-500 dark:text-red-400";
} }
function formatScore(score: number | null): string { function formatScore(score: number | null): string {
@@ -270,9 +266,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" 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"
placeholder="Query..." placeholder="Query..."
bind:value={query} bind:value={query}
disabled={isLoading} disabled={isLoading}
@@ -280,50 +276,60 @@
/> />
{/if} {/if}
<!-- Table / JSON toggle --> <!-- Table / JSON toggle -->
<ToggleGroup.Root <div class="flex rounded border border-gray-200 dark:border-white/10 overflow-hidden shrink-0">
type="single" <button
variant="outline" class="px-3 py-1.5 text-sm transition-colors {editorMode === 'table'
value={editorMode} ? 'bg-primary text-btn-primary-text'
onValueChange={(v) => v && (v === "table" ? switchToTable() : switchToJson())} : 'bg-surface hover:bg-secondary-hover'}"
class="shrink-0" onclick={switchToTable}
> disabled={isLoading}
<ToggleGroup.Item value="table" disabled={isLoading}>Table</ToggleGroup.Item> >
<ToggleGroup.Item value="json" disabled={isLoading}>JSON</ToggleGroup.Item> Table
</ToggleGroup.Root> </button>
<button
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="text-muted-foreground flex flex-1 items-center justify-center"> <div class="flex-1 flex items-center justify-center text-txtsecondary">
<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="mb-4 flex min-h-0 flex-1 flex-col"> <div class="flex-1 flex flex-col min-h-0 mb-4">
<Textarea <textarea
class="w-full flex-1 resize-none font-mono text-sm" 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"
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="text-destructive mt-1 text-sm">{jsonError}</p> <p class="mt-1 text-sm text-red-500">{jsonError}</p>
{/if} {/if}
</div> </div>
{:else} {:else}
<!-- Document table --> <!-- Document table -->
<div class="mb-4 flex-1 overflow-y-auto rounded-lg border"> <div class="flex-1 overflow-y-auto mb-4 border border-gray-200 dark:border-white/10 rounded">
<table class="w-full table-fixed border-collapse"> <table class="w-full border-collapse table-fixed">
<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="bg-card sticky top-0 border-b"> <thead class="sticky top-0 bg-surface border-b border-gray-200 dark:border-white/10">
<tr> <tr>
<th class="text-muted-foreground px-3 py-2 text-left text-sm font-medium">Document</th> <th class="px-3 py-2 text-left text-sm font-medium text-txtsecondary">Document</th>
<th <th
class="text-muted-foreground hover:text-foreground cursor-pointer select-none px-3 py-2 text-right text-sm font-medium transition-colors" class="px-3 py-2 text-right text-sm font-medium text-txtsecondary cursor-pointer select-none hover:text-txtprimary transition-colors"
onclick={cycleSortOrder} onclick={cycleSortOrder}
> >
Score{sortIndicator()} Score{sortIndicator()}
@@ -333,11 +339,11 @@
</thead> </thead>
<tbody> <tbody>
{#each displayRows as { row, i } (i)} {#each displayRows as { row, i } (i)}
<tr class="border-b last:border-0"> <tr class="border-b border-gray-100 dark:border-white/5 last:border-0">
<td class="px-3 py-1.5"> <td class="px-3 py-1.5">
<Input <input
type="text" type="text"
class="border-0 focus-visible:ring-1 h-7 px-1 py-0.5 bg-transparent" class="w-full bg-transparent focus:outline-none focus:ring-1 focus:ring-primary rounded px-1 py-0.5"
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)}
@@ -347,23 +353,21 @@
</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 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent align-middle"></span> <span class="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin 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
variant="ghost" 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"
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}
@@ -374,18 +378,28 @@
<!-- Bottom toolbar --> <!-- Bottom toolbar -->
{#if hasModels} {#if hasModels}
<div class="flex shrink-0 flex-wrap items-center gap-2"> <div class="shrink-0 flex flex-wrap items-center gap-2">
{#if isLoading} {#if isLoading}
<Button variant="destructive" onclick={cancel}>Cancel</Button> <button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancel}>
Cancel
</button>
{:else} {:else}
<Button onclick={submit} disabled={!canSubmit}>Rerank</Button> <button
<Button variant="outline" onclick={clear} disabled={isCleared}>Clear</Button> class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={submit}
disabled={!canSubmit}
>
Rerank
</button>
<button class="btn" onclick={clear} disabled={isCleared}>
Clear
</button>
{/if} {/if}
{#if error} {#if error}
<span class="text-destructive ml-2 text-sm">{error}</span> <span class="text-sm text-red-500 ml-2">{error}</span>
{:else if usage} {:else if usage}
<span class="text-muted-foreground ml-2 text-sm">{usage.total_tokens} tokens</span> <span class="text-sm text-txtsecondary ml-2">{usage.total_tokens} tokens</span>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -5,9 +5,6 @@
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");
@@ -109,7 +106,8 @@
} }
} }
function handleVoiceChange(value: string) { function handleVoiceChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
if (value === "(refresh)") { if (value === "(refresh)") {
refreshVoices(); refreshVoices();
} else { } else {
@@ -210,44 +208,49 @@
<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.Root <select
type="single" 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"
value={$selectedVoiceStore} value={$selectedVoiceStore}
onValueChange={(v) => v && handleVoiceChange(v)} onchange={handleVoiceChange}
disabled={isGenerating || isLoadingVoices || !$selectedModelStore}
> >
<Select.Trigger class="h-9 w-40">{$selectedVoiceStore}</Select.Trigger> {#each availableVoices as voice (voice)}
<Select.Content> <option value={voice}>{voice}</option>
{#each availableVoices as voice (voice)} {/each}
<Select.Item value={voice}>{voice}</Select.Item> <option value="(refresh)">(refresh)</option>
{/each} </select>
<Select.Item value="(refresh)">(refresh)</Select.Item>
</Select.Content>
</Select.Root>
{#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]} {#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]}
<Button <button
variant="outline" class="btn shrink-0"
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"}
> >
<RefreshCw class={isLoadingVoices ? "animate-spin" : ""} /> {#if isLoadingVoices}
</Button> <svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<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-muted-foreground"> <div class="flex-1 flex items-center justify-center text-txtsecondary">
<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-background border border-border rounded-md p-4 md:p-6"> <div class="shrink-0 mb-4 bg-surface border border-gray-200 dark:border-white/10 rounded p-4 md:p-6">
{#if isGenerating} {#if isGenerating}
<div class="flex items-center justify-center text-muted-foreground py-8"> <div class="flex items-center justify-center text-txtsecondary 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>
@@ -264,7 +267,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-muted-foreground"> <div class="flex flex-wrap gap-3 text-sm text-txtsecondary">
{#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">
@@ -282,9 +285,15 @@
</span> </span>
{/if} {/if}
</div> </div>
<Button variant="outline" size="icon" class="shrink-0" onclick={downloadAudio} title="Download audio file"> <button
<Download /> class="btn shrink-0"
</Button> onclick={downloadAudio}
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 -->
@@ -296,7 +305,7 @@
</div> </div>
</div> </div>
{:else} {:else}
<div class="flex items-center justify-center text-muted-foreground py-8"> <div class="flex items-center justify-center text-txtsecondary 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>
@@ -318,25 +327,24 @@
/> />
<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 variant="destructive" class="flex-1 md:flex-none" onclick={cancelGeneration}> <button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}>
Cancel Cancel
</Button> </button>
{:else} {:else}
<Button <button
class="flex-1 md:flex-none" class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none"
onclick={generate} onclick={generate}
disabled={!inputText.trim() || !$selectedModelStore} disabled={!inputText.trim() || !$selectedModelStore}
> >
Generate Generate
</Button> </button>
<Button <button
variant="outline" class="btn flex-1 md:flex-none"
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"
@@ -0,0 +1,359 @@
<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>
+136 -154
View File
@@ -1,196 +1,178 @@
@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] *));
@custom-variant dark (&:is(.dark *)); @theme {
--color-background: rgba(252, 252, 249, 1);
--color-surface: rgba(255, 255, 253, 1);
:root { /* text colors */
--radius: 0; --color-txtmain: rgba(19, 52, 59, 1);
--color-txtsecondary: rgba(98, 108, 113, 1);
--color-navlink-active: rgba(245, 245, 245, 1);
/* shadcn base palette (zinc) */ --color-primary: rgba(50, 184, 198, 1);
--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);
/* brand accent: llama-swap teal */ --color-primary-hover: rgba(29, 116, 128, 1);
--primary: rgb(50 184 198); --color-primary-active: rgba(26, 104, 115, 1);
--primary-foreground: oklch(0.985 0 0); --color-secondary: rgba(94, 82, 64, 0.12);
--color-secondary-hover: rgba(94, 82, 64, 0.2);
--secondary: oklch(0.967 0.001 286.375); --color-secondary-active: rgba(94, 82, 64, 0.25);
--secondary-foreground: oklch(0.21 0.006 285.885); --color-border: rgba(94, 82, 64, 0.3);
--muted: oklch(0.967 0.001 286.375); --color-btn-primary-text: rgba(252, 252, 249, 1);
--muted-foreground: oklch(0.552 0.016 285.938); --color-card-border: rgba(94, 82, 64, 0.12);
--accent: oklch(0.967 0.001 286.375); --color-card-border-inner: rgba(94, 82, 64, 0.12);
--accent-foreground: oklch(0.21 0.006 285.885); --color-error: rgba(192, 21, 47, 1);
--destructive: oklch(0.577 0.245 27.325); --color-success: rgba(33, 128, 141, 1);
--border: oklch(0.92 0.004 286.32); --color-warning: rgb(244, 155, 0);
--input: oklch(0.92 0.004 286.32); --color-info: rgba(98, 108, 113, 1);
--ring: rgb(50 184 198); --color-focus-ring: rgba(33, 128, 141, 0.4);
--color-select-caret: rgba(19, 52, 59, 0.8);
--chart-1: rgb(50 184 198); --color-btn-border: rgba(94, 82, 64, 0.7);
--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);
} }
.dark { @layer theme {
--background: oklch(0.141 0.005 285.823); /* over ride theme for dark mode */
--foreground: oklch(0.985 0 0); [data-theme="dark"] {
--card: oklch(0.21 0.006 285.885); --color-background: rgba(31, 33, 33, 1);
--card-foreground: oklch(0.985 0 0); --color-surface: rgba(38, 40, 40, 1);
--popover: oklch(0.21 0.006 285.885); /* text colors */
--popover-foreground: oklch(0.985 0 0); --color-txtmain: rgba(245, 245, 245, 1);
--color-txtsecondary: rgba(167, 169, 169, 0.7);
/* brand accent: deeper teal for dark surfaces */ --color-navlink-active: rgba(245, 245, 245, 1);
--primary: rgb(45 166 178);
--primary-foreground: oklch(0.141 0.005 285.823);
--secondary: oklch(0.274 0.006 286.033); --color-primary: rgba(33, 128, 141, 1);
--secondary-foreground: oklch(0.985 0 0); --color-primary-hover: rgba(45, 166, 178, 1);
--muted: oklch(0.274 0.006 286.033); --color-primary-active: rgba(41, 150, 161, 1);
--muted-foreground: oklch(0.705 0.015 286.067); --color-secondary: rgba(119, 124, 124, 0.15);
--accent: oklch(0.274 0.006 286.033); --color-secondary-hover: rgba(119, 124, 124, 0.25);
--accent-foreground: oklch(0.985 0 0); --color-secondary-active: rgba(119, 124, 124, 0.3);
--destructive: oklch(0.704 0.191 22.216); --color-border: rgba(119, 124, 124, 0.3);
--border: oklch(1 0 0 / 10%); --color-error: rgba(255, 84, 89, 1);
--input: oklch(1 0 0 / 15%); --color-success: rgba(50, 184, 198, 1);
--ring: rgb(50 184 198); --color-warning: rgb(244, 155, 0);
--color-info: rgba(167, 169, 169, 1);
--chart-1: rgb(50 184 198); --color-focus-ring: rgba(50, 184, 198, 0.4);
--chart-2: oklch(0.696 0.17 162.48); --color-btn-primary-text: rgba(19, 52, 59, 1);
--chart-3: oklch(0.769 0.188 70.08); --color-card-border: rgba(119, 124, 124, 0.2);
--chart-4: oklch(0.627 0.265 303.9); --color-card-border-inner: rgba(119, 124, 124, 0.15);
--chart-5: oklch(0.645 0.246 16.439); --shadow-inset-sm: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 0 rgba(0, 0, 0, 0.15);
--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 {
@apply bg-background text-foreground; /* example of how colors using theme colors*/
@apply bg-background text-txtmain;
} }
h1 { h1 {
@apply text-3xl font-bold tracking-tight pb-4; @apply text-4xl text-txtmain font-bold pb-4;
} }
h2 { h2 {
@apply text-2xl font-bold tracking-tight pb-4; @apply text-3xl text-txtmain font-bold pb-4;
} }
h3 { h3 {
@apply text-xl font-semibold tracking-tight pb-4; @apply text-2xl text-txtmain font-bold pb-4;
} }
h4 { h4 {
@apply text-lg font-semibold pb-4; @apply text-xl text-txtmain font-bold pb-4;
} }
h5 { h5 {
@apply text-base font-semibold pb-4; @apply text-lg text-txtmain font-bold pb-4;
} }
h6 { h6 {
@apply text-sm font-semibold pb-4; @apply text-base text-txtmain font-bold pb-4;
} }
} }
/* define CSS classes here for specific types of components */
@layer components { @layer components {
/* default padding for ad-hoc tables (header/detail views) */ .container {
@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;
} }
}
@utility activity-link { /* Navigation Header */
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 { .navlink {
0% { @apply text-txtsecondary hover:bg-secondary hover:text-txtmain rounded-lg p-2;
background-position: 0% 50%;
} }
100% { .navlink.active {
background-position: 200% 50%; @apply bg-primary text-navlink-active;
}
/* Card component */
.card {
@apply bg-surface rounded-lg border border-card-border shadow-sm overflow-hidden p-4;
}
.card:hover {
@apply shadow-md;
}
.card__body {
@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;
} }
} }
@@ -1,49 +0,0 @@
<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>
@@ -1,2 +0,0 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -1,82 +0,0 @@
<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}
@@ -1,17 +0,0 @@
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,
};
@@ -1,23 +0,0 @@
<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>
@@ -1,20 +0,0 @@
<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>
@@ -1,20 +0,0 @@
<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>
@@ -1,20 +0,0 @@
<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>
@@ -1,23 +0,0 @@
<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>
@@ -1,20 +0,0 @@
<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>
@@ -1,22 +0,0 @@
<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>
@@ -1,25 +0,0 @@
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,
};
@@ -1,19 +0,0 @@
<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>
@@ -1,19 +0,0 @@
<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>
@@ -1,19 +0,0 @@
<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>
@@ -1,13 +0,0 @@
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,
};
@@ -1,142 +0,0 @@
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] }>;
}
@@ -1,40 +0,0 @@
<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}
@@ -1,3 +0,0 @@
export { default as FlexRender } from "./flex-render.svelte";
export { renderComponent, renderSnippet } from "./render-helpers.js";
export { createSvelteTable } from "./data-table.svelte.js";
@@ -1,111 +0,0 @@
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);
}
@@ -1,11 +0,0 @@
<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} />
@@ -1,48 +0,0 @@
<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>
@@ -1,17 +0,0 @@
<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}
/>
@@ -1,32 +0,0 @@
<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>
@@ -1,20 +0,0 @@
<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>
@@ -1,17 +0,0 @@
<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}
/>
@@ -1,7 +0,0 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />
@@ -1,17 +0,0 @@
<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}
/>
@@ -1,11 +0,0 @@
<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} />
@@ -1,7 +0,0 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />
@@ -1,34 +0,0 @@
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,
};
@@ -1,16 +0,0 @@
<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}
/>
@@ -1,44 +0,0 @@
<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>
@@ -1,31 +0,0 @@
<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>
@@ -1,22 +0,0 @@
<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}
/>
@@ -1,7 +0,0 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -1,27 +0,0 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -1,24 +0,0 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7 data-[inset]:pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -1,7 +0,0 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />
@@ -1,16 +0,0 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -1,34 +0,0 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-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 })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -1,17 +0,0 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn("bg-border -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -1,20 +0,0 @@
<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<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -1,17 +0,0 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
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-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 w-auto", className)}
{...restProps}
/>
@@ -1,29 +0,0 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
@@ -1,7 +0,0 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
</script>
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
@@ -1,7 +0,0 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
@@ -1,7 +0,0 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root bind:open {...restProps} />

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