Compare commits

...

17 Commits

Author SHA1 Message Date
Benson Wong 64e4c79fc3 ui: add Rerank tab to playground (#536)
Add a new Rerank tab to the playground that lets users test /v1/rerank
endpoints. Supports a visual table editor and a JSON editor mode that
stay in sync when toggling between them.

- add rerankApi.ts with typed wrapper for /v1/rerank
- add RerankInterface.svelte with query input, sortable document table,
color-coded scores, auto-add row, cancel/clear, and token usage
- add rerankLoading store to playgroundActivity derived store
- register Rerank tab in Playground.svelte

Updates #481
2026-02-21 21:59:14 -08:00
Benson Wong 19fb5f35e9 proxy: implement setParamsByID filter (#535)
Add setParamsByID filter that applies different request parameters based
on the requested model ID, enabling per-alias behaviour for a single
loaded model.

- add SetParamsByID field to Filters struct and SanitizedSetParamsByID
method
- substitute ${MODEL_ID} and other macros in setParamsByID keys and
values
- validate no unknown macros remain in keys or values after substitution
- apply setParamsByID in proxyInferenceHandler after setParams (can
override it)
- update config-schema.json with setParamsByID definition
- update UI to show aliases and make them selectable in the Playground

closes #534
2026-02-19 22:21:10 -08:00
Benson Wong b45102bde8 ui: smart auto-scroll in LogPanel (#530)
Pause auto-scroll when the user scrolls up to review logs, and resume
when they scroll back to the bottom.

- add `userScrolledUp` state variable
- add `handleScroll` to detect scroll position with 40px threshold
- guard the auto-scroll effect with `!userScrolledUp`

closes #529
2026-02-18 19:47:37 -08:00
Brian Mendonca 1688bdd1e9 proxy, ui: add pending requests count to the main dashboard (#516)
add a real time counter of pending (inflight) requests to the UI.
2026-02-16 09:41:15 -08:00
Benson Wong d33d51fa75 .coderabbit.yaml,AGENTS.md: small tweaks 2026-02-15 21:31:30 -08:00
Benson Wong e3bf065574 ui: persist playground state across route navigation (#525)
- Keep Playground component mounted when navigating away, preserving
streaming/generating state
- Add animated gradient effect on Playground nav link when activity is
in progress
2026-02-15 21:30:52 -08:00
Benson Wong 3e52144058 ui-svelte: incremental rendering of chat messages in the Playground (#520)
add incremental rendering to Playground > Chat
2026-02-15 11:00:44 -08:00
Benson Wong d5e52d7d00 build: disable provenance attestations in container builds (#523)
## Summary
- Add `--provenance=false` to docker build commands in
`build-container.sh`
- BuildKit attestation manifests are stored as untagged images in GHCR,
and the `delete-untagged-containers` cleanup job deletes them, breaking
the manifest list and causing `manifest unknown` errors on pull
- ref: https://github.com/actions/delete-package-versions/issues/162
2026-02-14 10:23:08 -08:00
Benson Wong 17e5263a76 .github/workflows: fix expired token in publishing images (#522)
Fixes: #517
2026-02-14 10:06:05 -08:00
Benson Wong 8d6d949ec3 proxy: support timings for /infill from llama-server (#510)
fixes: #463
2026-02-07 17:16:27 -08:00
Benson Wong b5fde8eb6d proxy,ui-svelte: add request/response capturing (#508)
Add saving request and response headers and bodies that go through
llama-swap in memory.

- captureBuffer added to configuration. Captures are enabled by default.
- 5MB of memory is allocated for req/response captures in a ring buffer.
Setting captureBuffer to 0 will disable captures.
- UI elements to view captured data added to Activity page. Includes
some
QOL features like json formatting and recombining SSE chat streams
- capture saving is done at the byte level and has minimal impact on
llama-swap performance

Fixes #464 
Ref #503
2026-02-07 15:40:01 -08:00
Nuno 7eef5defb8 docs: add stable-diffusion.cpp references (#506)
Signed-off-by: rare-magma <rare-magma@posteo.eu>
2026-02-04 20:20:39 -08:00
Benson Wong bc01e6f539 build: add stable-diffusion server to musa and vulkan container images (#504)
Add sd-server from stable-diffusion.cpp docker image for 
vulkan and musa containers.

closes #450
2026-02-01 16:17:26 -08:00
Benson Wong 0462e3dc3f Reorganize UI controls and improve form interactions (#500)
Reorganizes control placement in the playground interfaces and
improves form interactions for better UX, particularly on mobile
devices.

## Key Changes

- **AudioInterface & ImageInterface**: Moved "Clear" buttons from the
top control bar into the action button group below the form inputs for
better visual hierarchy and logical grouping
- **ImageInterface**: 
- Added prompt clearing to the `clearImage()` function so the input
field is reset when clearing generated images
- Updated Clear button disabled state to also check if prompt is empty,
allowing users to clear an empty prompt
- Added responsive flex styling (`flex-1 md:flex-none`) to the Clear
button for better mobile layout
- **ExpandableTextarea**: 
- Imported `untrack` from Svelte to properly handle reactive
dependencies
- Wrapped `expandedValue.length` in `untrack()` to prevent unnecessary
reactivity when setting cursor position
- Improved button visibility on mobile by changing opacity from
`opacity-0` to `opacity-60` with `md:opacity-0` breakpoint, making the
expand button more discoverable on touch devices

## Implementation Details
The `untrack()` usage in ExpandableTextarea ensures that reading the
text length doesn't create a reactive dependency, preventing potential
infinite loops while still allowing the effect to run when `isExpanded`
changes.
2026-02-01 15:18:22 -08:00
Benson Wong 7b20fc011b Add path filters to CI workflows and create UI test workflow (#501)
* .github/workflows: add UI tests and path-filter Go CI

Add ui-tests.yml workflow to run svelte type checking and vitest
on push/PR to main when ui-svelte/ files change.

- Add path filters to go-ci.yml and go-ci-windows.yml to skip
  Go tests when only non-backend files change
- Filter on **/*.go, go.mod, go.sum, and Makefile

https://claude.ai/code/session_01E6acq54D8JjuE7pczxPGT7

* ui-svelte: remove unused declarations in SpeechInterface

Remove unused `generatedText` state and `clearAudio` function
that caused svelte-check errors.

https://claude.ai/code/session_01E6acq54D8JjuE7pczxPGT7

* .github/workflows: update Node.js to v24

Node 23 is end-of-life; bump to 24 in ui-tests.yml and release.yml.

https://claude.ai/code/session_01E6acq54D8JjuE7pczxPGT7

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-01 15:11:49 -08:00
Benson Wong 20738f3623 proxy,ui-svelte: replace old UI with svelte+playground
Replace the legacy React UI with the new Svelte-based one. Introduce a Playground in the UI to quickly test out text, image, text to speech and speech to text models behind llama-swap. 

Key Changes

New Svelte UI (ui-svelte/)

  - Multi-tab Playground with Chat, Image Generation, Audio Transcription, and Speech interfaces
  - Chat: message editing/regeneration, markdown rendering with LaTeX math support, image attachments, code syntax highlighting
  - Image: size selector, download/fullscreen viewing
  - Audio: transcription with peer support
  - Speech: voice caching with manual refresh, download button
  - Responsive mobile layout with collapsible navigation
  - XSS fixes and accessibility improvements

Proxy Improvements

  - Add gzip/brotli compression for UI static assets (proxy/ui_compress.go)
  - Add GET /v1/audio/voices?model={model} endpoint for voice listing
  - Add peer support for /v1/audio/transcriptions
2026-01-31 22:49:13 -08:00
Benson Wong cdea7d16bd proxy/config: skip env macros in YAML comment lines (#496)
Fix a bug where ${env.macro_not_exist} in comments would trigger a non-substituted macro error. 

fixes #495
2026-01-30 20:10:29 -08:00
100 changed files with 7877 additions and 6234 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ early_access: false
reviews:
profile: "chill"
request_changes_workflow: false
high_level_summary: true
high_level_summary: false
poem: false
review_status: true
collapse_walkthrough: false
+8
View File
@@ -15,6 +15,14 @@ on:
paths:
- '.github/workflows/containers.yml'
- 'docker/build-container.sh'
- 'docker/*.Containerfile'
# grant permissions on GITHUB_TOKEN to publish packages
# ref: https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#publishing-a-package-using-an-action
permissions:
contents: read
packages: write
id-token: write
jobs:
build-and-push:
+18 -2
View File
@@ -3,9 +3,25 @@ name: Windows CI
on:
push:
branches: [ "main" ]
# only run when backend source changes
# cmd/ is excluded because it contains utilities without tests
paths:
- '**/*.go'
- '!cmd/**'
- 'go.mod'
- 'go.sum'
- 'Makefile'
- '.github/workflows/go-ci-windows.yml'
pull_request:
branches: [ "main" ]
paths:
- '**/*.go'
- '!cmd/**'
- 'go.mod'
- 'go.sum'
- 'Makefile'
- '.github/workflows/go-ci-windows.yml'
# Allows manual triggering of the workflow
workflow_dispatch:
@@ -28,7 +44,7 @@ jobs:
uses: actions/cache/restore@v4
with:
path: ./build
key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }}
key: ${{ runner.os }}-simple-responder-${{ hashFiles('cmd/simple-responder/simple-responder.go') }}
# necessary for testing proxy/Process swapping
- name: Create simple-responder
@@ -43,7 +59,7 @@ jobs:
uses: actions/cache/save@v4
with:
path: ./build
key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }}
key: ${{ runner.os }}-simple-responder-${{ hashFiles('cmd/simple-responder/simple-responder.go') }}
- name: Test all
shell: bash
+16
View File
@@ -3,9 +3,25 @@ name: Linux CI
on:
push:
branches: [ "main" ]
# only run when backend source changes
# cmd/ is excluded because it contains utilities without tests
paths:
- '**/*.go'
- '!cmd/**'
- 'go.mod'
- 'go.sum'
- 'Makefile'
- '.github/workflows/go-ci.yml'
pull_request:
branches: [ "main" ]
paths:
- '**/*.go'
- '!cmd/**'
- 'go.mod'
- 'go.sum'
- 'Makefile'
- '.github/workflows/go-ci.yml'
# Allows manual triggering of the workflow
workflow_dispatch:
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "23"
node-version: "24"
- name: Install dependencies and build UI
run: |
cd ui-svelte
+42
View File
@@ -0,0 +1,42 @@
name: UI Tests
on:
push:
branches: [ "main" ]
paths:
- 'ui-svelte/**'
- '.github/workflows/ui-tests.yml'
pull_request:
branches: [ "main" ]
paths:
- 'ui-svelte/**'
- '.github/workflows/ui-tests.yml'
workflow_dispatch:
jobs:
run-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui-svelte
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: ui-svelte/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run check
- name: Run tests
run: npm test
+50
View File
@@ -0,0 +1,50 @@
## Project Description:
llama-swap is a light weight, transparent proxy server that provides automatic model swapping to llama.cpp's server.
## Tech stack
- golang
- typescript, vite and svelt5 for UI (located in ui/)
## Workflow Tasks
- 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:
- keep them short and focused on changes.
- never include a test plan
- write the summary using the same style rules as commit message
## Testing
- Follow test naming conventions like `TestProxyManager_<test name>`, `TestProcessGroup_<test name>`, etc.
- Use `go test -v -run <name pattern for new tests>` to run any new tests you've written.
- Use `make test-dev` after running new tests for a quick over all test run. This runs `go test` and `staticcheck`. Fix any static checking errors. Use this only when changes are made to any code under the `proxy/` directory
- Use `make test-all` before completing work. This includes long running concurrency tests.
### Commit message example format:
```
proxy: add new feature
Add new feature that implements functionality X and Y.
- key change 1
- key change 2
- key change 3
fixes #123
```
## Code Reviews
- use three levels High, Medium, Low severity
- label each discovered issue with a label like H1, M2, L3 respectively
- High severity are must fix issues (security, race conditions, critical bugs)
- Medium severity are recommended improvements (coding style, missing functionality, inconsistencies)
- Low severity are nice to have changes and nits
- Include a suggestion with each discovered item
- Limit your code review to three items with the highest priority first
- Double check your discovered items and recommended remediations
+1 -49
View File
@@ -1,49 +1 @@
## Project Description:
llama-swap is a light weight, transparent proxy server that provides automatic model swapping to llama.cpp's server.
## Tech stack
- golang
- typescript, vite and react for UI (located in ui/)
## Workflow Tasks
- when summarizing changes only include details that require further action
- just say "Done." when there is no further action
- use `gh` to create PRs and load issues
- do include Co-Authored-By or created by when committing changes or creating PRs
- keep PR descriptions short and focused on changes.
- never include a test plan
## Testing
- Follow test naming conventions like `TestProxyManager_<test name>`, `TestProcessGroup_<test name>`, etc.
- Use `go test -v -run <name pattern for new tests>` to run any new tests you've written.
- Use `make test-dev` after running new tests for a quick over all test run. This runs `go test` and `staticcheck`. Fix any static checking errors. Use this only when changes are made to any code under the `proxy/` directory
- Use `make test-all` before completing work. This includes long running concurrency tests.
### Commit message example format:
```
proxy: add new feature
Add new feature that implements functionality X and Y.
- key change 1
- key change 2
- key change 3
fixes #123
```
## Code Reviews
- use three levels High, Medium, Low severity
- label each discovered issue with a label like H1, M2, L3 respectively
- High severity are must fix issues (security, race conditions, critical bugs)
- Medium severity are recommended improvements (coding style, missing functionality, inconsistencies)
- Low severity are nice to have changes and nits
- Include a suggestion with each discovered item
- Limit your code review to three items with the highest priority first
- Double check your discovered items and recommended remediations
@AGENTS.md
+2 -1
View File
@@ -13,7 +13,7 @@ Built in Go for performance and simplicity, llama-swap has zero dependencies and
- ✅ Easy to deploy and configure: one binary, one configuration file. no external dependencies
- ✅ On-demand model switching
- ✅ Use any local OpenAI compatible server (llama.cpp, vllm, tabbyAPI, etc.)
- ✅ Use any local OpenAI compatible server (llama.cpp, vllm, tabbyAPI, stable-diffusion.cpp, etc.)
- future proof, upgrade your inference servers at any time.
- ✅ OpenAI API supported endpoints:
- `v1/completions`
@@ -69,6 +69,7 @@ llama-swap can be installed in multiple ways
### Docker Install ([download images](https://github.com/mostlygeek/llama-swap/pkgs/container/llama-swap))
Nightly container images with llama-swap and llama-server are built for multiple platforms (cuda, vulkan, intel, etc.) including [non-root variants with improved security](docs/container-security.md).
The stable-diffusion.cpp server is also included for the musa and vulkan platforms.
```shell
$ docker pull ghcr.io/mostlygeek/llama-swap:cuda
+5
View File
@@ -210,6 +210,11 @@ func main() {
})
})
r.GET("/v1/audio/voices", func(c *gin.Context) {
model := c.Query("model")
c.JSON(http.StatusOK, gin.H{"voices": []string{"voice1"}, "model": model})
})
r.GET("/slow-respond", func(c *gin.Context) {
echo := c.Query("echo")
delay := c.Query("delay")
+16 -1
View File
@@ -87,6 +87,12 @@
"default": 1000,
"description": "Maximum number of metrics to keep in memory. Controls how many metrics are stored before older ones are discarded."
},
"captureBuffer": {
"type": "integer",
"minimum": 0,
"default": 5,
"description": "Size in megabytes of the buffer for storing request/response captures. Set to 0 to disable captures."
},
"startPort": {
"type": "integer",
"default": 5800,
@@ -194,11 +200,20 @@
"additionalProperties": true,
"default": {},
"description": "Dictionary of parameters to set/override in requests. Useful for enforcing specific parameter values. Protected params like 'model' cannot be overridden. Values can be strings, numbers, booleans, arrays, or objects."
},
"setParamsByID": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true
},
"default": {},
"description": "Dictionary mapping requested model IDs (or aliases) to parameters to set/override in requests. Applied after setParams and can override those values. Useful with aliases to vary behaviour depending on which alias the client used (e.g. different reasoning_effort per alias). Keys support ${MODEL_ID} macro substitution. Protected params like 'model' cannot be overridden."
}
},
"additionalProperties": false,
"default": {},
"description": "Dictionary of filter settings. Supports stripParams and setParams."
"description": "Dictionary of filter settings. Supports stripParams, setParams, and setParamsByID."
},
"metadata": {
"type": "object",
+37 -13
View File
@@ -50,6 +50,11 @@ logToStdout: "proxy"
# - useful for limiting memory usage when processing large volumes of metrics
metricsMaxInMemory: 1000
# captureBuffer: how many MBs to allocate for storing request/response captures
# - optional, default: 10
# - set to 0 to disable
captureBuffer: 15
# startPort: sets the starting port number for the automatic ${PORT} macro.
# - optional, default: 5800
# - the ${PORT} macro can be used in model.cmd and model.proxy settings
@@ -121,7 +126,7 @@ apiKeys:
# - below are examples of the all the settings a model can have
models:
# keys are the model names used in API requests
"llama":
"gpt-oss-120b":
# macros: a dictionary of string substitutions specific to this model
# - optional, default: empty dictionary
# - macros defined here override macros defined in the global macros section
@@ -138,7 +143,7 @@ models:
cmd: |
# ${latest-llama} is a macro that is defined above
${latest-llama}
--model path/to/llama-8B-Q4_K_M.gguf
--model path/to/gpt-oss-120B.gguf
--ctx-size ${default_ctx}
--temperature ${temp}
@@ -146,13 +151,13 @@ models:
# - optional, default: empty string
# - if set, it will be used in the v1/models API response
# - if not set, it will be omitted in the JSON model record
name: "llama 3.1 8B"
name: "gpt-oss 120B"
# description: a description for the model
# - optional, default: empty string
# - if set, it will be used in the v1/models API response
# - if not set, it will be omitted in the JSON model record
description: "A small but capable model used for quick testing"
description: "A thinking model from OpenAI"
# env: define an array of environment variables to inject into cmd's environment
# - optional, default: empty array
@@ -167,14 +172,6 @@ models:
# - if you use a custom port in cmd this *must* be set
proxy: http://127.0.0.1:8999
# aliases: alternative model names that this model configuration is used for
# - optional, default: empty array
# - aliases must be unique globally
# - useful for impersonating a specific model
aliases:
- "gpt-4o-mini"
- "gpt-3.5-turbo"
# checkEndpoint: URL path to check if the server is ready
# - optional, default: /health
# - endpoint is expected to return an HTTP 200 response
@@ -192,7 +189,7 @@ models:
# - optional, default: ""
# - useful for when the upstream server expects a specific model name that
# is different from the model's ID
useModelName: "qwen:qwq"
useModelName: "openai/gpt-oss-120B"
# filters: a dictionary of filter settings
# - optional, default: empty dictionary
@@ -211,11 +208,38 @@ models:
# - useful for enforcing specific parameter values
# - protected params like "model" cannot be overridden
# - values can be strings, numbers, booleans, arrays, or objects
# - always runs for the model
setParams:
# Example: enforce specific sampling parameters
temperature: 0.7
top_p: 0.9
# setParamsByID: a dictionary of parameters to set based the model ID
# - optional, default: empty dictionary
# - combine with aliases to create variant behaviour without reloading the model
# - parameters are set in the request body JSON
# - run after setParams so it will override any settings
# - protected params like "model" cannot be overridden
# - values can be strings, numbers, booleans, arrays, or objects
# - model aliases will be automatically created for each key
setParamsByID:
"${MODEL_ID}":
chat_template_kwargs:
reasoning_effort: medium
"${MODEL_ID}:high":
chat_template_kwargs:
reasoning_effort: high
"${MODEL_ID}:low":
chat_template_kwargs:
reasoning_effort: low
# aliases: alternative model names that this model configuration is used for
# - optional, default: empty array
# - aliases must be unique globally
# - useful for impersonating a specific model
aliases:
- "gpt-4o-mini"
# metadata: a dictionary of arbitrary values that are included in /v1/models
# - optional, default: empty dictionary
# - while metadata can contains complex types it is recommended to keep it simple
+22 -2
View File
@@ -1,5 +1,7 @@
#!/bin/bash
set -euo pipefail
cd $(dirname "$0")
# use this to test locally, example:
@@ -8,6 +10,9 @@ cd $(dirname "$0")
# the scopes: gist, read:org, repo, write:packages
# then: gh auth login (and copy/paste the new token)
LOG_DEBUG=${LOG_DEBUG:-0}
DEBUG_ABORT_BUILD=${DEBUG_ABORT_BUILD:-}
log_debug() {
if [ "$LOG_DEBUG" = "1" ]; then
echo "[DEBUG] $*"
@@ -31,7 +36,7 @@ if [[ ! " ${ALLOWED_ARCHS[@]} " =~ " ${ARCH} " ]]; then
fi
# Check if GITHUB_TOKEN is set and not empty
if [[ -z "$GITHUB_TOKEN" ]]; then
if [[ -z "${GITHUB_TOKEN:-}" ]]; then
log_info "Error: GITHUB_TOKEN is not set or is empty."
exit 1
fi
@@ -39,6 +44,7 @@ fi
# Set llama.cpp base image, customizable using the BASE_LLAMACPP_IMAGE environment
# variable, this permits testing with forked llama.cpp repositories
BASE_IMAGE=${BASE_LLAMACPP_IMAGE:-ghcr.io/ggml-org/llama.cpp}
SD_IMAGE=${BASE_SDCPP_IMAGE:-ghcr.io/leejet/stable-diffusion.cpp}
# Set llama-swap repository, automatically uses GITHUB_REPOSITORY variable
# to enable easy container builds on forked repos
@@ -105,6 +111,8 @@ else
BASE_TAG=server-${ARCH}-${LCPP_TAG}
fi
SD_TAG=master-${ARCH}
# Abort if LCPP_TAG is empty.
if [[ -z "$LCPP_TAG" ]]; then
log_info "Abort: Could not find llama-server container for arch: $ARCH"
@@ -134,9 +142,21 @@ for CONTAINER_TYPE in non-root root; do
fi
log_info "Building $CONTAINER_TYPE $CONTAINER_TAG $LS_VER"
docker build -f llama-swap.Containerfile --build-arg BASE_TAG=${BASE_TAG} --build-arg LS_VER=${LS_VER} --build-arg UID=${USER_UID} \
docker build --provenance=false -f llama-swap.Containerfile --build-arg BASE_TAG=${BASE_TAG} --build-arg LS_VER=${LS_VER} --build-arg UID=${USER_UID} \
--build-arg LS_REPO=${LS_REPO} --build-arg GID=${USER_GID} --build-arg USER_HOME=${USER_HOME} -t ${CONTAINER_TAG} -t ${CONTAINER_LATEST} \
--build-arg BASE_IMAGE=${BASE_IMAGE} .
# For architectures with stable-diffusion.cpp support, layer sd-server on top
case "$ARCH" in
"musa" | "vulkan")
log_info "Adding sd-server to $CONTAINER_TAG"
docker build --provenance=false -f llama-swap-sd.Containerfile \
--build-arg BASE=${CONTAINER_TAG} \
--build-arg SD_IMAGE=${SD_IMAGE} --build-arg SD_TAG=${SD_TAG} \
--build-arg UID=${USER_UID} --build-arg GID=${USER_GID} \
-t ${CONTAINER_TAG} -t ${CONTAINER_LATEST} . ;;
esac
if [ "$PUSH_IMAGES" == "true" ]; then
docker push ${CONTAINER_TAG}
docker push ${CONTAINER_LATEST}
+16 -1
View File
@@ -15,4 +15,19 @@ models:
cmd: >
/app/llama-server
-hf bartowski/SmolLM2-135M-Instruct-GGUF:Q4_K_M
--port 9999
--port 9999
z-image:
checkEndpoint: /
cmd: |
/app/sd-server
--listen-port 9999
--diffusion-fa
--diffusion-model /models/z_image_turbo-Q8_0.gguf
--vae /models/ae.safetensors
--llm /models/qwen3-4b-instruct-2507-q8_0.gguf
--offload-to-cpu
--cfg-scale 1.0
--height 512 --width 512
--steps 8
aliases: [gpt-image-1,dall-e-2,dall-e-3,gpt-image-1-mini,gpt-image-1.5]
+11
View File
@@ -0,0 +1,11 @@
ARG SD_IMAGE=ghcr.io/leejet/stable-diffusion.cpp
ARG SD_TAG=master-vulkan
ARG BASE=llama-swap:latest
FROM ${SD_IMAGE}:${SD_TAG} AS sd-source
FROM ${BASE}
ARG UID=10001
ARG GID=10001
COPY --from=sd-source --chown=${UID}:${GID} /sd-server /app/sd-server
+73 -4
View File
@@ -123,6 +123,7 @@ type Config struct {
LogTimeFormat string `yaml:"logTimeFormat"`
LogToStdout string `yaml:"logToStdout"`
MetricsMaxInMemory int `yaml:"metricsMaxInMemory"`
CaptureBuffer int `yaml:"captureBuffer"`
Models map[string]ModelConfig `yaml:"models"` /* key is model ID */
Profiles map[string][]string `yaml:"profiles"`
Groups map[string]GroupConfig `yaml:"groups"` /* key is group ID */
@@ -201,6 +202,7 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
LogTimeFormat: "",
LogToStdout: LogToStdoutProxy,
MetricsMaxInMemory: 1000,
CaptureBuffer: 5,
}
if err = yaml.Unmarshal([]byte(yamlStr), &config); err != nil {
return Config{}, err
@@ -292,6 +294,24 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
modelConfig.CheckEndpoint = strings.ReplaceAll(modelConfig.CheckEndpoint, macroSlug, macroStr)
modelConfig.Filters.StripParams = strings.ReplaceAll(modelConfig.Filters.StripParams, macroSlug, macroStr)
// Substitute macros in SetParamsByID keys and values
if len(modelConfig.Filters.SetParamsByID) > 0 {
newSetParamsByID := make(map[string]map[string]any, len(modelConfig.Filters.SetParamsByID))
for key, paramMap := range modelConfig.Filters.SetParamsByID {
newKey := strings.ReplaceAll(key, macroSlug, macroStr)
newValAny, err := substituteMacroInValue(any(paramMap), entry.Name, entry.Value)
if err != nil {
return Config{}, fmt.Errorf("model %s filters.setParamsByID: %s", modelId, err.Error())
}
newParamMap, ok := newValAny.(map[string]any)
if !ok {
return Config{}, fmt.Errorf("model %s filters.setParamsByID: unexpected type after macro substitution", modelId)
}
newSetParamsByID[newKey] = newParamMap
}
modelConfig.Filters.SetParamsByID = newSetParamsByID
}
// Substitute in metadata (type-preserving)
if len(modelConfig.Metadata) > 0 {
result, err := substituteMacroInValue(modelConfig.Metadata, entry.Name, entry.Value)
@@ -357,6 +377,34 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
}
}
// Validate SetParamsByID keys and values
for key, paramMap := range modelConfig.Filters.SetParamsByID {
if matches := macroPatternRegex.FindAllStringSubmatch(key, -1); len(matches) > 0 {
return Config{}, fmt.Errorf("unknown macro '${%s}' found in model %s filters.setParamsByID key", matches[0][1], modelId)
}
if err := validateNestedForUnknownMacros(any(paramMap), fmt.Sprintf("model %s filters.setParamsByID[%s]", modelId, key)); err != nil {
return Config{}, err
}
}
// Auto-register setParamsByID keys as aliases (skip the model's own ID)
for key := range modelConfig.Filters.SetParamsByID {
if key == modelId {
continue
}
if _, exists := config.Models[key]; exists {
return Config{}, fmt.Errorf("model %s filters.setParamsByID: key '%s' conflicts with an existing model ID", modelId, key)
}
if existingModel, exists := config.aliases[key]; exists {
if existingModel != modelId {
return Config{}, fmt.Errorf("duplicate alias '%s' in model %s filters.setParamsByID, already used by model %s", key, modelId, existingModel)
}
continue // already registered as explicit alias for this model
}
config.aliases[key] = modelId
modelConfig.Aliases = append(modelConfig.Aliases, key)
}
if _, err := url.Parse(modelConfig.Proxy); err != nil {
return Config{}, fmt.Errorf("model %s: invalid proxy URL: %w", modelId, err)
}
@@ -667,11 +715,32 @@ func substituteMacroInValue(value any, macroName string, macroValue any) (any, e
}
}
// substituteEnvMacros replaces ${env.VAR_NAME} with environment variable values
// Returns error if any env var is not set or contains invalid characters
// substituteEnvMacros replaces ${env.VAR_NAME} with environment variable values.
// Returns error if any referenced env var is not set or contains invalid characters.
// Env macros inside YAML comments are ignored by unmarshalling the YAML first
// (which strips comments) and only checking the comment-free version for macros.
func substituteEnvMacros(s string) (string, error) {
result := s
matches := envMacroRegex.FindAllStringSubmatch(s, -1)
// Unmarshal and remarshal to strip YAML comments
var raw any
if err := yaml.Unmarshal([]byte(s), &raw); err != nil {
// If YAML is invalid, fall back to scanning the original string
// so the user gets the env var error rather than a confusing YAML parse error
return substituteEnvMacrosInString(s, s)
}
clean, err := yaml.Marshal(raw)
if err != nil {
return substituteEnvMacrosInString(s, s)
}
return substituteEnvMacrosInString(s, string(clean))
}
// substituteEnvMacrosInString finds ${env.VAR} macros in scanStr and substitutes
// them in target. This separation allows scanning comment-free YAML while
// substituting in the original string.
func substituteEnvMacrosInString(target, scanStr string) (string, error) {
result := target
matches := envMacroRegex.FindAllStringSubmatch(scanStr, -1)
for _, match := range matches {
fullMatch := match[0] // ${env.VAR_NAME}
varName := match[1] // VAR_NAME
+1
View File
@@ -215,6 +215,7 @@ groups:
},
HealthCheckTimeout: 15,
MetricsMaxInMemory: 1000,
CaptureBuffer: 5,
Profiles: map[string][]string{
"test": {"model1", "model2"},
},
+64
View File
@@ -1308,4 +1308,68 @@ peers:
assert.Contains(t, err.Error(), "peers.openrouter.filters.setParams")
assert.Contains(t, err.Error(), "unknown macro")
})
t.Run("env macros in comments are ignored", func(t *testing.T) {
content := `
# apiKeys:
# - "${env.COMMENTED_OUT_KEY_1}"
# - "${env.COMMENTED_OUT_KEY_2}"
models:
test:
cmd: "server"
proxy: "http://localhost:8080"
`
// These env vars are NOT set, but should not cause an error
// because they only appear in comment lines
config, err := LoadConfigFromReader(strings.NewReader(content))
assert.NoError(t, err)
assert.Empty(t, config.RequiredAPIKeys)
})
t.Run("env macros in comments ignored while active ones resolve", func(t *testing.T) {
t.Setenv("TEST_ACTIVE_KEY", "active-key-value")
content := `
# apiKeys: ["${env.COMMENTED_OUT_KEY}"]
apiKeys: ["${env.TEST_ACTIVE_KEY}"]
models:
test:
cmd: "server"
proxy: "http://localhost:8080"
`
config, err := LoadConfigFromReader(strings.NewReader(content))
assert.NoError(t, err)
assert.Equal(t, []string{"active-key-value"}, config.RequiredAPIKeys)
})
t.Run("env macros in indented comments are ignored", func(t *testing.T) {
content := `
models:
test:
cmd: |
server
--port 8080
proxy: "http://localhost:8080"
# metadata:
# api_key: "${env.SOME_UNSET_KEY}"
`
_, err := LoadConfigFromReader(strings.NewReader(content))
assert.NoError(t, err)
})
t.Run("env macros in inline comments are ignored", func(t *testing.T) {
t.Setenv("TEST_INLINE_KEY", "real-value")
content := `
apiKeys: ["${env.TEST_INLINE_KEY}"] # TODO: add ${env.FUTURE_KEY} later
models:
test:
cmd: "server"
proxy: "http://localhost:8080"
`
config, err := LoadConfigFromReader(strings.NewReader(content))
assert.NoError(t, err)
assert.Equal(t, []string{"real-value"}, config.RequiredAPIKeys)
})
}
+1
View File
@@ -204,6 +204,7 @@ groups:
},
HealthCheckTimeout: 15,
MetricsMaxInMemory: 1000,
CaptureBuffer: 5,
Profiles: map[string][]string{
"test": {"model1", "model2"},
},
+33
View File
@@ -20,6 +20,12 @@ type Filters struct {
// SetParams is a dictionary of parameters to set/override in requests
// Protected params (like "model") cannot be set
SetParams map[string]any `yaml:"setParams"`
// SetParamsByID maps requested model IDs to parameters to set/override in requests.
// Useful with aliases: a single loaded model can behave differently depending on
// which alias the client used. Applied after SetParams, so it can override those values.
// Protected params (like "model") cannot be set.
SetParamsByID map[string]map[string]any `yaml:"setParamsByID"`
}
// SanitizedStripParams returns a sorted list of parameters to strip,
@@ -51,6 +57,33 @@ func (f Filters) SanitizedStripParams() []string {
return cleaned
}
// SanitizedSetParamsByID returns the params to set for the given requestedModelID,
// with protected params removed and keys sorted for consistent iteration order.
// Returns nil if the ID has no entry or all its params are protected.
func (f Filters) SanitizedSetParamsByID(requestedModelID string) (map[string]any, []string) {
if len(f.SetParamsByID) == 0 {
return nil, nil
}
params, found := f.SetParamsByID[requestedModelID]
if !found || len(params) == 0 {
return nil, nil
}
result := make(map[string]any, len(params))
keys := make([]string, 0, len(params))
for key, value := range params {
if slices.Contains(ProtectedParams, key) {
continue
}
result[key] = value
keys = append(keys, key)
}
sort.Strings(keys)
if len(result) == 0 {
return nil, nil
}
return result, keys
}
// SanitizedSetParams returns a copy of SetParams with protected params removed
// and keys sorted for consistent iteration order
func (f Filters) SanitizedSetParams() (map[string]any, []string) {
+117
View File
@@ -162,6 +162,123 @@ func TestFilters_SanitizedSetParams(t *testing.T) {
}
}
func TestFilters_SanitizedSetParamsByID(t *testing.T) {
tests := []struct {
name string
setParamsByID map[string]map[string]any
requestedModelID string
wantParams map[string]any
wantKeys []string
}{
{
name: "empty SetParamsByID returns nil",
setParamsByID: nil,
requestedModelID: "model1",
wantParams: nil,
wantKeys: nil,
},
{
name: "empty map returns nil",
setParamsByID: map[string]map[string]any{},
requestedModelID: "model1",
wantParams: nil,
wantKeys: nil,
},
{
name: "non-matching model ID returns nil",
setParamsByID: map[string]map[string]any{
"model2": {"temperature": 0.9},
},
requestedModelID: "model1",
wantParams: nil,
wantKeys: nil,
},
{
name: "matching model ID returns correct params",
setParamsByID: map[string]map[string]any{
"model1": {"temperature": 0.7, "top_p": 0.9},
"model2": {"temperature": 0.5},
},
requestedModelID: "model1",
wantParams: map[string]any{
"temperature": 0.7,
"top_p": 0.9,
},
wantKeys: []string{"temperature", "top_p"},
},
{
name: "protected param model is filtered out",
setParamsByID: map[string]map[string]any{
"model1": {
"model": "should-be-filtered",
"temperature": 0.7,
},
},
requestedModelID: "model1",
wantParams: map[string]any{
"temperature": 0.7,
},
wantKeys: []string{"temperature"},
},
{
name: "only protected param returns nil",
setParamsByID: map[string]map[string]any{
"model1": {
"model": "should-be-filtered",
},
},
requestedModelID: "model1",
wantParams: nil,
wantKeys: nil,
},
{
name: "keys are sorted",
setParamsByID: map[string]map[string]any{
"model1": {
"z_param": "z",
"a_param": "a",
"m_param": "m",
},
},
requestedModelID: "model1",
wantParams: map[string]any{
"z_param": "z",
"a_param": "a",
"m_param": "m",
},
wantKeys: []string{"a_param", "m_param", "z_param"},
},
{
name: "alias style key lookup",
setParamsByID: map[string]map[string]any{
"model1:high": {"reasoning_effort": "high"},
"model1:low": {"reasoning_effort": "low"},
},
requestedModelID: "model1:high",
wantParams: map[string]any{
"reasoning_effort": "high",
},
wantKeys: []string{"reasoning_effort"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := Filters{SetParamsByID: tt.setParamsByID}
gotParams, gotKeys := f.SanitizedSetParamsByID(tt.requestedModelID)
if tt.wantParams == nil {
assert.Nil(t, gotParams)
assert.Nil(t, gotKeys)
return
}
assert.Equal(t, tt.wantKeys, gotKeys)
assert.Equal(t, tt.wantParams, gotParams)
})
}
}
func TestProtectedParams(t *testing.T) {
// Verify that "model" is protected
assert.Contains(t, ProtectedParams, "model")
+66
View File
@@ -73,6 +73,72 @@ models:
}
}
func TestConfig_SetParamsByIDAutoAlias(t *testing.T) {
content := `
models:
model1:
cmd: path/to/cmd --port ${PORT}
filters:
setParamsByID:
"${MODEL_ID}:high":
reasoning_effort: high
"${MODEL_ID}:low":
reasoning_effort: low
`
cfg, err := LoadConfigFromReader(strings.NewReader(content))
assert.NoError(t, err)
// Keys (other than the model's own ID) should be registered as aliases
realName, found := cfg.RealModelName("model1:high")
assert.True(t, found, "model1:high should be an auto-registered alias")
assert.Equal(t, "model1", realName)
realName, found = cfg.RealModelName("model1:low")
assert.True(t, found, "model1:low should be an auto-registered alias")
assert.Equal(t, "model1", realName)
// Auto-aliases should also appear in modelConfig.Aliases
aliases := cfg.Models["model1"].Aliases
assert.Contains(t, aliases, "model1:high")
assert.Contains(t, aliases, "model1:low")
}
func TestConfig_SetParamsByIDAutoAliasConflictWithModelID(t *testing.T) {
content := `
models:
model1:
cmd: path/to/cmd --port ${PORT}
filters:
setParamsByID:
model2:
reasoning_effort: high
model2:
cmd: path/to/cmd --port ${PORT}
`
_, err := LoadConfigFromReader(strings.NewReader(content))
assert.ErrorContains(t, err, "conflicts with an existing model ID")
}
func TestConfig_SetParamsByIDAutoAliasConflictWithOtherModel(t *testing.T) {
content := `
models:
model1:
cmd: path/to/cmd --port ${PORT}
filters:
setParamsByID:
"shared-alias":
reasoning_effort: high
model2:
cmd: path/to/cmd --port ${PORT}
filters:
setParamsByID:
"shared-alias":
reasoning_effort: low
`
_, err := LoadConfigFromReader(strings.NewReader(content))
assert.ErrorContains(t, err, "duplicate alias")
}
func TestConfig_ModelFiltersWithSetParams(t *testing.T) {
content := `
models:
+9
View File
@@ -8,6 +8,7 @@ const ConfigFileChangedEventID = 0x03
const LogDataEventID = 0x04
const TokenMetricsEventID = 0x05
const ModelPreloadedEventID = 0x06
const InFlightRequestsEventID = 0x07
type ProcessStateChangeEvent struct {
ProcessName string
@@ -58,3 +59,11 @@ type ModelPreloadedEvent struct {
func (e ModelPreloadedEvent) Type() uint32 {
return ModelPreloadedEventID
}
type InFlightRequestsEvent struct {
Total int
}
func (e InFlightRequestsEvent) Type() uint32 {
return InFlightRequestsEventID
}
+5 -1
View File
@@ -71,11 +71,15 @@ func getTestSimpleResponderConfig(expectedMessage string) config.ModelConfig {
}
func getTestSimpleResponderConfigPort(expectedMessage string, port int) config.ModelConfig {
// Convert path to forward slashes for cross-platform compatibility
// Windows handles forward slashes in paths correctly
cmdPath := filepath.ToSlash(simpleResponderPath)
// Create a YAML string with just the values we want to set
yamlStr := fmt.Sprintf(`
cmd: '%s --port %d --silent --respond %s'
proxy: "http://127.0.0.1:%d"
`, simpleResponderPath, port, expectedMessage, port)
`, cmdPath, port, expectedMessage, port)
var cfg config.ModelConfig
if err := yaml.Unmarshal([]byte(yamlStr), &cfg); err != nil {
+166 -10
View File
@@ -28,6 +28,28 @@ type TokenMetrics struct {
PromptPerSecond float64 `json:"prompt_per_second"`
TokensPerSecond float64 `json:"tokens_per_second"`
DurationMs int `json:"duration_ms"`
HasCapture bool `json:"has_capture"`
}
type ReqRespCapture struct {
ID int `json:"id"`
ReqPath string `json:"req_path"`
ReqHeaders map[string]string `json:"req_headers"`
ReqBody []byte `json:"req_body"`
RespHeaders map[string]string `json:"resp_headers"`
RespBody []byte `json:"resp_body"`
}
// Size returns the approximate memory usage of this capture in bytes
func (c *ReqRespCapture) Size() int {
size := len(c.ReqPath) + len(c.ReqBody) + len(c.RespBody)
for k, v := range c.ReqHeaders {
size += len(k) + len(v)
}
for k, v := range c.RespHeaders {
size += len(k) + len(v)
}
return size
}
// TokenMetricsEvent represents a token metrics event
@@ -46,19 +68,32 @@ type metricsMonitor struct {
maxMetrics int
nextID int
logger *LogMonitor
// capture fields
enableCaptures bool
captures map[int]ReqRespCapture // map for O(1) lookup by ID
captureOrder []int // track insertion order for FIFO eviction
captureSize int // current total size in bytes
maxCaptureSize int // max bytes for captures
}
func newMetricsMonitor(logger *LogMonitor, maxMetrics int) *metricsMonitor {
mp := &metricsMonitor{
logger: logger,
maxMetrics: maxMetrics,
// newMetricsMonitor creates a new metricsMonitor. captureBufferMB is the
// capture buffer size in megabytes; 0 disables captures.
func newMetricsMonitor(logger *LogMonitor, maxMetrics int, captureBufferMB int) *metricsMonitor {
return &metricsMonitor{
logger: logger,
maxMetrics: maxMetrics,
enableCaptures: captureBufferMB > 0,
captures: make(map[int]ReqRespCapture),
captureOrder: make([]int, 0),
captureSize: 0,
maxCaptureSize: captureBufferMB * 1024 * 1024,
}
return mp
}
// addMetrics adds a new metric to the collection and publishes an event
func (mp *metricsMonitor) addMetrics(metric TokenMetrics) {
// addMetrics adds a new metric to the collection and publishes an event.
// Returns the assigned metric ID.
func (mp *metricsMonitor) addMetrics(metric TokenMetrics) int {
mp.mu.Lock()
defer mp.mu.Unlock()
@@ -69,6 +104,49 @@ func (mp *metricsMonitor) addMetrics(metric TokenMetrics) {
mp.metrics = mp.metrics[len(mp.metrics)-mp.maxMetrics:]
}
event.Emit(TokenMetricsEvent{Metrics: metric})
return metric.ID
}
// addCapture adds a new capture to the buffer with size-based eviction.
// Captures are skipped if enableCaptures is false or if capture exceeds maxCaptureSize.
func (mp *metricsMonitor) addCapture(capture ReqRespCapture) {
if !mp.enableCaptures {
return
}
mp.mu.Lock()
defer mp.mu.Unlock()
captureSize := capture.Size()
if captureSize > mp.maxCaptureSize {
mp.logger.Warnf("capture size %d exceeds max %d, skipping", captureSize, mp.maxCaptureSize)
return
}
// Evict oldest (FIFO) until room available
for mp.captureSize+captureSize > mp.maxCaptureSize && len(mp.captureOrder) > 0 {
oldestID := mp.captureOrder[0]
mp.captureOrder = mp.captureOrder[1:]
if evicted, exists := mp.captures[oldestID]; exists {
mp.captureSize -= evicted.Size()
delete(mp.captures, oldestID)
}
}
mp.captures[capture.ID] = capture
mp.captureOrder = append(mp.captureOrder, capture.ID)
mp.captureSize += captureSize
}
// getCaptureByID returns a capture by its ID, or nil if not found.
func (mp *metricsMonitor) getCaptureByID(id int) *ReqRespCapture {
mp.mu.RLock()
defer mp.mu.RUnlock()
if capture, exists := mp.captures[id]; exists {
return &capture
}
return nil
}
// getMetrics returns a copy of the current metrics
@@ -97,6 +175,28 @@ func (mp *metricsMonitor) wrapHandler(
request *http.Request,
next func(modelID string, w http.ResponseWriter, r *http.Request) error,
) error {
// Capture request body and headers if captures enabled
var reqBody []byte
var reqHeaders map[string]string
if mp.enableCaptures {
if request.Body != nil {
var err error
reqBody, err = io.ReadAll(request.Body)
if err != nil {
return fmt.Errorf("failed to read request body for capture: %w", err)
}
request.Body.Close()
request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
reqHeaders = make(map[string]string)
for key, values := range request.Header {
if len(values) > 0 {
reqHeaders[key] = values[0]
}
}
redactHeaders(reqHeaders)
}
recorder := newBodyCopier(writer)
// Filter Accept-Encoding to only include encodings we can decompress for metrics
@@ -140,7 +240,6 @@ func (mp *metricsMonitor) wrapHandler(
return nil
}
}
if strings.Contains(recorder.Header().Get("Content-Type"), "text/event-stream") {
if parsed, err := processStreamingResponse(modelID, recorder.StartTime(), body); err != nil {
mp.logger.Warnf("error processing streaming response: %v, path=%s, recording minimal metrics", err, request.URL.Path)
@@ -153,6 +252,14 @@ func (mp *metricsMonitor) wrapHandler(
usage := parsed.Get("usage")
timings := parsed.Get("timings")
// extract timings for infill - response is an array, timings are in the last element
// see #463
if strings.HasPrefix(request.URL.Path, "/infill") {
if arr := parsed.Array(); len(arr) > 0 {
timings = arr[len(arr)-1].Get("timings")
}
}
if usage.Exists() || timings.Exists() {
if parsedMetrics, err := parseMetrics(modelID, recorder.StartTime(), usage, timings); err != nil {
mp.logger.Warnf("error parsing metrics: %v, path=%s, recording minimal metrics", err, request.URL.Path)
@@ -165,7 +272,38 @@ func (mp *metricsMonitor) wrapHandler(
}
}
mp.addMetrics(tm)
// Build capture if enabled and determine if it will be stored
var capture *ReqRespCapture
if mp.enableCaptures {
respHeaders := make(map[string]string)
for key, values := range recorder.Header() {
if len(values) > 0 {
respHeaders[key] = values[0]
}
}
redactHeaders(respHeaders)
delete(respHeaders, "Content-Encoding")
capture = &ReqRespCapture{
ReqPath: request.URL.Path,
ReqHeaders: reqHeaders,
ReqBody: reqBody,
RespHeaders: respHeaders,
RespBody: body,
}
// Only set HasCapture if the capture will actually be stored (not too large)
if capture.Size() <= mp.maxCaptureSize {
tm.HasCapture = true
}
}
metricID := mp.addMetrics(tm)
// Store capture if enabled
if capture != nil {
capture.ID = metricID
mp.addCapture(*capture)
}
return nil
}
@@ -336,6 +474,24 @@ func (w *responseBodyCopier) StartTime() time.Time {
return w.start
}
// sensitiveHeaders lists headers that should be redacted in captures
var sensitiveHeaders = map[string]bool{
"authorization": true,
"proxy-authorization": true,
"cookie": true,
"set-cookie": true,
"x-api-key": true,
}
// redactHeaders replaces sensitive header values in-place with "[REDACTED]"
func redactHeaders(headers map[string]string) {
for key := range headers {
if sensitiveHeaders[strings.ToLower(key)] {
headers[key] = "[REDACTED]"
}
}
}
// filterAcceptEncoding filters the Accept-Encoding header to only include
// encodings we can decompress (gzip, deflate). This respects the client's
// preferences while ensuring we can parse response bodies for metrics.
+323 -29
View File
@@ -18,7 +18,7 @@ import (
func TestMetricsMonitor_AddMetrics(t *testing.T) {
t.Run("adds metrics and assigns ID", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
metric := TokenMetrics{
Model: "test-model",
@@ -37,7 +37,7 @@ func TestMetricsMonitor_AddMetrics(t *testing.T) {
})
t.Run("increments ID for each metric", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
for i := 0; i < 5; i++ {
mm.addMetrics(TokenMetrics{Model: "model"})
@@ -51,7 +51,7 @@ func TestMetricsMonitor_AddMetrics(t *testing.T) {
})
t.Run("respects max metrics limit", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 3)
mm := newMetricsMonitor(testLogger, 3, 0)
// Add 5 metrics
for i := 0; i < 5; i++ {
@@ -71,7 +71,7 @@ func TestMetricsMonitor_AddMetrics(t *testing.T) {
})
t.Run("emits TokenMetricsEvent", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
receivedEvent := make(chan TokenMetricsEvent, 1)
cancel := event.On(func(e TokenMetricsEvent) {
@@ -101,14 +101,14 @@ func TestMetricsMonitor_AddMetrics(t *testing.T) {
func TestMetricsMonitor_GetMetrics(t *testing.T) {
t.Run("returns empty slice when no metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
metrics := mm.getMetrics()
assert.NotNil(t, metrics)
assert.Equal(t, 0, len(metrics))
})
t.Run("returns copy of metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
mm.addMetrics(TokenMetrics{Model: "model1"})
mm.addMetrics(TokenMetrics{Model: "model2"})
@@ -128,7 +128,7 @@ func TestMetricsMonitor_GetMetrics(t *testing.T) {
func TestMetricsMonitor_GetMetricsJSON(t *testing.T) {
t.Run("returns valid JSON for empty metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
jsonData, err := mm.getMetricsJSON()
assert.NoError(t, err)
assert.NotNil(t, jsonData)
@@ -140,7 +140,7 @@ func TestMetricsMonitor_GetMetricsJSON(t *testing.T) {
})
t.Run("returns valid JSON with metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
mm.addMetrics(TokenMetrics{
Model: "model1",
InputTokens: 100,
@@ -168,7 +168,7 @@ func TestMetricsMonitor_GetMetricsJSON(t *testing.T) {
func TestMetricsMonitor_WrapHandler(t *testing.T) {
t.Run("successful non-streaming request with usage data", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
responseBody := `{
"usage": {
@@ -199,7 +199,7 @@ func TestMetricsMonitor_WrapHandler(t *testing.T) {
})
t.Run("successful request with timings data", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
responseBody := `{
"timings": {
@@ -239,7 +239,7 @@ func TestMetricsMonitor_WrapHandler(t *testing.T) {
})
t.Run("streaming request with SSE format", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
// Note: SSE format requires proper line breaks - each data line followed by blank line
responseBody := `data: {"choices":[{"text":"Hello"}]}
@@ -275,7 +275,7 @@ data: [DONE]
})
t.Run("non-OK status code does not record metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusBadRequest)
@@ -295,7 +295,7 @@ data: [DONE]
})
t.Run("empty response body records minimal metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusOK)
@@ -317,7 +317,7 @@ data: [DONE]
})
t.Run("invalid JSON records minimal metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", "application/json")
@@ -341,7 +341,7 @@ data: [DONE]
})
t.Run("next handler error is propagated", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
expectedErr := assert.AnError
nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error {
@@ -360,7 +360,7 @@ data: [DONE]
})
t.Run("response without usage or timings records minimal metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
responseBody := `{"result": "ok"}`
@@ -384,6 +384,75 @@ data: [DONE]
assert.Equal(t, 0, metrics[0].InputTokens)
assert.Equal(t, 0, metrics[0].OutputTokens)
})
t.Run("infill request extracts timings from last array element", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, 0)
// Infill response is an array with timings in the last element
responseBody := `[
{"content": "first chunk"},
{"content": "second chunk"},
{"content": "final", "timings": {
"prompt_n": 150,
"predicted_n": 75,
"prompt_per_second": 200.5,
"predicted_per_second": 35.5,
"prompt_ms": 600.0,
"predicted_ms": 1800.0,
"cache_n": 30
}}
]`
nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(responseBody))
return nil
}
req := httptest.NewRequest("POST", "/infill", nil)
rec := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(rec)
err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler)
assert.NoError(t, err)
metrics := mm.getMetrics()
assert.Equal(t, 1, len(metrics))
assert.Equal(t, "test-model", metrics[0].Model)
assert.Equal(t, 150, metrics[0].InputTokens)
assert.Equal(t, 75, metrics[0].OutputTokens)
assert.Equal(t, 30, metrics[0].CachedTokens)
assert.Equal(t, 200.5, metrics[0].PromptPerSecond)
assert.Equal(t, 35.5, metrics[0].TokensPerSecond)
assert.Equal(t, 2400, metrics[0].DurationMs) // 600 + 1800
})
t.Run("infill request with empty array records minimal metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, 0)
responseBody := `[]`
nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(responseBody))
return nil
}
req := httptest.NewRequest("POST", "/infill", nil)
rec := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(rec)
err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler)
assert.NoError(t, err)
metrics := mm.getMetrics()
assert.Equal(t, 1, len(metrics))
assert.Equal(t, "test-model", metrics[0].Model)
assert.Equal(t, 0, metrics[0].InputTokens)
assert.Equal(t, 0, metrics[0].OutputTokens)
})
}
func TestMetricsMonitor_ResponseBodyCopier(t *testing.T) {
@@ -437,7 +506,7 @@ func TestMetricsMonitor_ResponseBodyCopier(t *testing.T) {
func TestMetricsMonitor_Concurrent(t *testing.T) {
t.Run("concurrent addMetrics is safe", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 1000)
mm := newMetricsMonitor(testLogger, 1000, 0)
var wg sync.WaitGroup
numGoroutines := 10
@@ -464,7 +533,7 @@ func TestMetricsMonitor_Concurrent(t *testing.T) {
})
t.Run("concurrent reads and writes are safe", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 100)
mm := newMetricsMonitor(testLogger, 100, 0)
done := make(chan bool)
@@ -502,7 +571,7 @@ func TestMetricsMonitor_Concurrent(t *testing.T) {
func TestMetricsMonitor_ParseMetrics(t *testing.T) {
t.Run("prefers timings over usage data", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
// Timings should take precedence over usage
responseBody := `{
@@ -542,7 +611,7 @@ func TestMetricsMonitor_ParseMetrics(t *testing.T) {
})
t.Run("handles missing cache_n in timings", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
responseBody := `{
"timings": {
@@ -577,7 +646,7 @@ func TestMetricsMonitor_ParseMetrics(t *testing.T) {
func TestMetricsMonitor_StreamingResponse(t *testing.T) {
t.Run("finds metrics in last valid SSE data", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
// Metrics should be found in the last data line before [DONE]
responseBody := `data: {"choices":[{"text":"First"}]}
@@ -611,7 +680,7 @@ data: [DONE]
})
t.Run("handles streaming with no valid JSON records minimal metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
responseBody := `data: not json
@@ -641,7 +710,7 @@ data: [DONE]
})
t.Run("handles empty streaming response records minimal metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
responseBody := ``
@@ -669,7 +738,7 @@ data: [DONE]
// Benchmark tests
func BenchmarkMetricsMonitor_AddMetrics(b *testing.B) {
mm := newMetricsMonitor(testLogger, 1000)
mm := newMetricsMonitor(testLogger, 1000, 0)
metric := TokenMetrics{
Model: "test-model",
@@ -690,7 +759,7 @@ func BenchmarkMetricsMonitor_AddMetrics(b *testing.B) {
func BenchmarkMetricsMonitor_AddMetrics_SmallBuffer(b *testing.B) {
// Test performance with a smaller buffer where wrapping occurs more frequently
mm := newMetricsMonitor(testLogger, 100)
mm := newMetricsMonitor(testLogger, 100, 0)
metric := TokenMetrics{
Model: "test-model",
@@ -711,7 +780,7 @@ func BenchmarkMetricsMonitor_AddMetrics_SmallBuffer(b *testing.B) {
func TestMetricsMonitor_WrapHandler_Compression(t *testing.T) {
t.Run("gzip encoded response", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
responseBody := `{"usage": {"prompt_tokens": 100, "completion_tokens": 50}}`
@@ -745,7 +814,7 @@ func TestMetricsMonitor_WrapHandler_Compression(t *testing.T) {
})
t.Run("deflate encoded response", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
responseBody := `{"usage": {"prompt_tokens": 200, "completion_tokens": 75}}`
@@ -779,7 +848,7 @@ func TestMetricsMonitor_WrapHandler_Compression(t *testing.T) {
})
t.Run("invalid gzip data records minimal metrics", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
// Invalid compressed data
invalidData := []byte("this is not gzip data")
@@ -807,7 +876,7 @@ func TestMetricsMonitor_WrapHandler_Compression(t *testing.T) {
})
t.Run("unknown encoding treated as uncompressed", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10)
mm := newMetricsMonitor(testLogger, 10, 0)
responseBody := `{"usage": {"prompt_tokens": 300, "completion_tokens": 100}}`
@@ -832,3 +901,228 @@ func TestMetricsMonitor_WrapHandler_Compression(t *testing.T) {
assert.Equal(t, 100, metrics[0].OutputTokens)
})
}
func TestReqRespCapture_Size(t *testing.T) {
t.Run("calculates size correctly", func(t *testing.T) {
capture := ReqRespCapture{
ID: 1,
ReqPath: "/v1/chat/completions", // 20 bytes
ReqHeaders: map[string]string{
"Content-Type": "application/json", // 12 + 16 = 28
},
ReqBody: []byte("request body"), // 12 bytes
RespHeaders: map[string]string{
"X-Test": "value", // 6 + 5 = 11
},
RespBody: []byte("response body"), // 13 bytes
}
// Expected: 20 + 12 + 13 + 28 + 11 = 84
assert.Equal(t, 84, capture.Size())
})
t.Run("handles empty capture", func(t *testing.T) {
capture := ReqRespCapture{}
assert.Equal(t, 0, capture.Size())
})
}
func TestMetricsMonitor_AddCapture(t *testing.T) {
t.Run("does nothing when captures disabled", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, 0)
capture := ReqRespCapture{
ID: 0,
ReqBody: []byte("test"),
}
mm.addCapture(capture)
// Should not store capture
assert.Nil(t, mm.getCaptureByID(0))
})
t.Run("adds capture when enabled", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, 5)
capture := ReqRespCapture{
ID: 0,
ReqBody: []byte("test request"),
RespBody: []byte("test response"),
}
mm.addCapture(capture)
retrieved := mm.getCaptureByID(0)
assert.NotNil(t, retrieved)
assert.Equal(t, 0, retrieved.ID)
assert.Equal(t, []byte("test request"), retrieved.ReqBody)
assert.Equal(t, []byte("test response"), retrieved.RespBody)
})
t.Run("evicts oldest when exceeding max size", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, 5)
mm.maxCaptureSize = 100 // Set small limit for test
// Add captures that will exceed the limit
capture1 := ReqRespCapture{ID: 0, ReqBody: make([]byte, 40)}
capture2 := ReqRespCapture{ID: 1, ReqBody: make([]byte, 40)}
capture3 := ReqRespCapture{ID: 2, ReqBody: make([]byte, 40)}
mm.addCapture(capture1)
mm.addCapture(capture2)
// Adding capture3 should evict capture1
mm.addCapture(capture3)
assert.Nil(t, mm.getCaptureByID(0), "capture 0 should be evicted")
assert.NotNil(t, mm.getCaptureByID(1), "capture 1 should exist")
assert.NotNil(t, mm.getCaptureByID(2), "capture 2 should exist")
})
t.Run("skips capture larger than max size", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, 5)
mm.maxCaptureSize = 100
// Add a capture larger than max
largeCapture := ReqRespCapture{ID: 0, ReqBody: make([]byte, 200)}
mm.addCapture(largeCapture)
assert.Nil(t, mm.getCaptureByID(0), "oversized capture should not be stored")
})
}
func TestMetricsMonitor_GetCaptureByID(t *testing.T) {
t.Run("returns nil for non-existent ID", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, 5)
assert.Nil(t, mm.getCaptureByID(999))
})
t.Run("returns capture by ID", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, 5)
capture := ReqRespCapture{
ID: 42,
ReqBody: []byte("test"),
}
mm.addCapture(capture)
retrieved := mm.getCaptureByID(42)
assert.NotNil(t, retrieved)
assert.Equal(t, 42, retrieved.ID)
})
}
func TestRedactHeaders(t *testing.T) {
t.Run("redacts sensitive headers", func(t *testing.T) {
headers := map[string]string{
"Authorization": "Bearer secret-token",
"Proxy-Authorization": "Basic creds",
"Cookie": "session=abc123",
"Set-Cookie": "session=xyz789",
"X-Api-Key": "sk-12345",
"Content-Type": "application/json",
"X-Custom": "safe-value",
}
redactHeaders(headers)
assert.Equal(t, "[REDACTED]", headers["Authorization"])
assert.Equal(t, "[REDACTED]", headers["Proxy-Authorization"])
assert.Equal(t, "[REDACTED]", headers["Cookie"])
assert.Equal(t, "[REDACTED]", headers["Set-Cookie"])
assert.Equal(t, "[REDACTED]", headers["X-Api-Key"])
assert.Equal(t, "application/json", headers["Content-Type"])
assert.Equal(t, "safe-value", headers["X-Custom"])
})
t.Run("handles mixed case header names", func(t *testing.T) {
headers := map[string]string{
"authorization": "Bearer token",
"COOKIE": "session=abc",
"x-api-key": "key123",
}
redactHeaders(headers)
assert.Equal(t, "[REDACTED]", headers["authorization"])
assert.Equal(t, "[REDACTED]", headers["COOKIE"])
assert.Equal(t, "[REDACTED]", headers["x-api-key"])
})
t.Run("handles empty headers", func(t *testing.T) {
headers := map[string]string{}
redactHeaders(headers)
assert.Empty(t, headers)
})
}
func TestMetricsMonitor_WrapHandler_Capture(t *testing.T) {
t.Run("captures request and response when enabled", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, 5)
requestBody := `{"model": "test", "prompt": "hello"}`
responseBody := `{"usage": {"prompt_tokens": 100, "completion_tokens": 50}}`
nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Custom", "header-value")
w.WriteHeader(http.StatusOK)
w.Write([]byte(responseBody))
return nil
}
req := httptest.NewRequest("POST", "/test", bytes.NewBufferString(requestBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(rec)
err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler)
assert.NoError(t, err)
// Check metric was recorded
metrics := mm.getMetrics()
assert.Equal(t, 1, len(metrics))
metricID := metrics[0].ID
// Check capture was stored with same ID
capture := mm.getCaptureByID(metricID)
assert.NotNil(t, capture)
assert.Equal(t, metricID, capture.ID)
assert.Equal(t, []byte(requestBody), capture.ReqBody)
assert.Equal(t, []byte(responseBody), capture.RespBody)
assert.Equal(t, "/test", capture.ReqPath)
assert.Equal(t, "application/json", capture.ReqHeaders["Content-Type"])
assert.Equal(t, "[REDACTED]", capture.ReqHeaders["Authorization"])
assert.Equal(t, "application/json", capture.RespHeaders["Content-Type"])
assert.Equal(t, "header-value", capture.RespHeaders["X-Custom"])
})
t.Run("does not capture when disabled", func(t *testing.T) {
mm := newMetricsMonitor(testLogger, 10, 0)
requestBody := `{"model": "test"}`
responseBody := `{"usage": {"prompt_tokens": 100, "completion_tokens": 50}}`
nextHandler := func(modelID string, w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(responseBody))
return nil
}
req := httptest.NewRequest("POST", "/test", bytes.NewBufferString(requestBody))
rec := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(rec)
err := mm.wrapHandler("test-model", ginCtx.Writer, req, nextHandler)
assert.NoError(t, err)
// Metrics should still be recorded
metrics := mm.getMetrics()
assert.Equal(t, 1, len(metrics))
// But no capture
capture := mm.getCaptureByID(metrics[0].ID)
assert.Nil(t, capture)
})
}
+154 -37
View File
@@ -28,6 +28,40 @@ const (
type proxyCtxKey string
type InflightCounter struct {
mu sync.Mutex
total int
}
func newInflightCounter() *InflightCounter {
return &InflightCounter{}
}
func (ic *InflightCounter) Current() int {
ic.mu.Lock()
total := ic.total
ic.mu.Unlock()
return total
}
func (ic *InflightCounter) Increment() int {
ic.mu.Lock()
ic.total++
total := ic.total
ic.mu.Unlock()
return total
}
func (ic *InflightCounter) Decrement() int {
ic.mu.Lock()
if ic.total > 0 {
ic.total--
}
total := ic.total
ic.mu.Unlock()
return total
}
type ProxyManager struct {
sync.Mutex
@@ -43,6 +77,8 @@ type ProxyManager struct {
processGroups map[string]*ProcessGroup
inFlightCounter *InflightCounter
// shutdown signaling
shutdownCtx context.Context
shutdownCancel context.CancelFunc
@@ -151,10 +187,12 @@ func New(proxyConfig config.Config) *ProxyManager {
muxLogger: muxLogger,
upstreamLogger: upstreamLogger,
metricsMonitor: newMetricsMonitor(proxyLogger, maxMetrics),
metricsMonitor: newMetricsMonitor(proxyLogger, maxMetrics, proxyConfig.CaptureBuffer),
processGroups: make(map[string]*ProcessGroup),
inFlightCounter: newInflightCounter(),
shutdownCtx: shutdownCtx,
shutdownCancel: shutdownCancel,
@@ -276,36 +314,37 @@ func (pm *ProxyManager) setupGinEngine() {
// Set up routes using the Gin engine
// Protected routes use pm.apiKeyAuth() middleware
pm.ginEngine.POST("/v1/chat/completions", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/responses", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/chat/completions", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/responses", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
// Support legacy /v1/completions api, see issue #12
pm.ginEngine.POST("/v1/completions", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/completions", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
// Support anthropic /v1/messages (added https://github.com/ggml-org/llama.cpp/pull/17570)
pm.ginEngine.POST("/v1/messages", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/messages", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
// Support anthropic count_tokens API (Also added in the above PR)
pm.ginEngine.POST("/v1/messages/count_tokens", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/messages/count_tokens", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
// Support embeddings and reranking
pm.ginEngine.POST("/v1/embeddings", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/embeddings", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
// llama-server's /reranking endpoint + aliases
pm.ginEngine.POST("/reranking", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/rerank", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/rerank", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/reranking", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/reranking", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/rerank", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/rerank", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/reranking", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
// llama-server's /infill endpoint for code infilling
pm.ginEngine.POST("/infill", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/infill", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
// llama-server's /completion endpoint
pm.ginEngine.POST("/completion", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/completion", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
// Support audio/speech endpoint
pm.ginEngine.POST("/v1/audio/speech", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/audio/voices", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/audio/transcriptions", pm.apiKeyAuth(), pm.proxyOAIPostFormHandler)
pm.ginEngine.POST("/v1/images/generations", pm.apiKeyAuth(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/images/edits", pm.apiKeyAuth(), pm.proxyOAIPostFormHandler)
pm.ginEngine.POST("/v1/audio/speech", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/audio/voices", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
pm.ginEngine.GET("/v1/audio/voices", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyGETModelHandler)
pm.ginEngine.POST("/v1/audio/transcriptions", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyOAIPostFormHandler)
pm.ginEngine.POST("/v1/images/generations", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
pm.ginEngine.POST("/v1/images/edits", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyOAIPostFormHandler)
pm.ginEngine.GET("/v1/models", pm.apiKeyAuth(), pm.listModelsHandler)
@@ -324,7 +363,7 @@ func (pm *ProxyManager) setupGinEngine() {
pm.ginEngine.GET("/upstream", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/ui/models")
})
pm.ginEngine.Any("/upstream/*upstreamPath", pm.apiKeyAuth(), pm.proxyToUpstream)
pm.ginEngine.Any("/upstream/*upstreamPath", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyToUpstream)
pm.ginEngine.GET("/unload", pm.apiKeyAuth(), pm.unloadAllModelsHandler)
pm.ginEngine.GET("/running", pm.apiKeyAuth(), pm.listRunningProcessesHandler)
pm.ginEngine.GET("/health", func(c *gin.Context) {
@@ -348,25 +387,35 @@ func (pm *ProxyManager) setupGinEngine() {
if err != nil {
pm.proxyLogger.Errorf("Failed to load React filesystem: %v", err)
} else {
// Serve files with compression support under /ui/*
// This handler checks for pre-compressed .br and .gz files
pm.ginEngine.GET("/ui/*filepath", func(c *gin.Context) {
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
// Default to index.html for directory-like paths
if filepath == "" {
filepath = "index.html"
}
// serve files that exist under /ui/*
pm.ginEngine.StaticFS("/ui", reactFS)
ServeCompressedFile(reactFS, c.Writer, c.Request, filepath)
})
// server SPA for UI under /ui/*
// Serve SPA for UI under /ui/* - fallback to index.html for client-side routing
pm.ginEngine.NoRoute(func(c *gin.Context) {
if !strings.HasPrefix(c.Request.URL.Path, "/ui") {
c.AbortWithStatus(http.StatusNotFound)
return
}
file, err := reactFS.Open("index.html")
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
// Check if this looks like a file request (has extension)
path := c.Request.URL.Path
if strings.Contains(path, ".") && !strings.HasSuffix(path, "/") {
// This was likely a file request that wasn't found
c.AbortWithStatus(http.StatusNotFound)
return
}
defer file.Close()
http.ServeContent(c.Writer, c.Request, "index.html", time.Now(), file)
// Serve index.html for SPA routing
ServeCompressedFile(reactFS, c.Writer, c.Request, "index.html")
})
}
@@ -378,6 +427,14 @@ func (pm *ProxyManager) setupGinEngine() {
gin.DisableConsoleColor()
}
func (pm *ProxyManager) trackInflight() gin.HandlerFunc {
return func(c *gin.Context) {
event.Emit(InFlightRequestsEvent{Total: pm.inFlightCounter.Increment()})
defer event.Emit(InFlightRequestsEvent{Total: pm.inFlightCounter.Decrement()})
c.Next()
}
}
// ServeHTTP implements http.Handler interface
func (pm *ProxyManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
pm.ginEngine.ServeHTTP(w, r)
@@ -663,6 +720,17 @@ func (pm *ProxyManager) proxyInferenceHandler(c *gin.Context) {
}
}
// setParamsByID: set params based on the requested model ID (runs after setParams, can override it)
setParamsByIDParams, setParamsByIDKeys := pm.config.Models[modelID].Filters.SanitizedSetParamsByID(requestedModel)
for _, key := range setParamsByIDKeys {
pm.proxyLogger.Debugf("<%s> setting param by id: %s", requestedModel, key)
bodyBytes, err = sjson.SetBytes(bodyBytes, key, setParamsByIDParams[key])
if err != nil {
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error setting parameter %s in request", key))
return
}
}
pm.proxyLogger.Debugf("ProxyManager using local Process for model: %s", requestedModel)
nextHandler = processGroup.ProxyRequest
} else if pm.peerProxy != nil && pm.peerProxy.HasPeerModel(requestedModel) {
@@ -744,15 +812,29 @@ func (pm *ProxyManager) proxyOAIPostFormHandler(c *gin.Context) {
return
}
// Look for a matching local model first, then check peers
var nextHandler func(modelID string, w http.ResponseWriter, r *http.Request) error
var useModelName string
modelID, found := pm.config.RealModelName(requestedModel)
if !found {
pm.sendErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("could not find real modelID for %s", requestedModel))
return
if found {
processGroup, err := pm.swapProcessGroup(modelID)
if err != nil {
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error()))
return
}
useModelName = pm.config.Models[modelID].UseModelName
pm.proxyLogger.Debugf("ProxyManager using local Process for model: %s", requestedModel)
nextHandler = processGroup.ProxyRequest
} else if pm.peerProxy != nil && pm.peerProxy.HasPeerModel(requestedModel) {
pm.proxyLogger.Debugf("ProxyManager using ProxyPeer for model: %s", requestedModel)
modelID = requestedModel
nextHandler = pm.peerProxy.ProxyRequest
}
processGroup, err := pm.swapProcessGroup(modelID)
if err != nil {
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error()))
if nextHandler == nil {
pm.sendErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("could not find suitable handler for %s", requestedModel))
return
}
@@ -768,8 +850,6 @@ func (pm *ProxyManager) proxyOAIPostFormHandler(c *gin.Context) {
// If this is the model field and we have a profile, use just the model name
if key == "model" {
// # issue #69 allow custom model names to be sent to upstream
useModelName := pm.config.Models[modelID].UseModelName
if useModelName != "" {
fieldValue = useModelName
} else {
@@ -839,9 +919,46 @@ func (pm *ProxyManager) proxyOAIPostFormHandler(c *gin.Context) {
modifiedReq.ContentLength = int64(requestBuffer.Len())
// Use the modified request for proxying
if err := processGroup.ProxyRequest(modelID, c.Writer, modifiedReq); err != nil {
if err := nextHandler(modelID, c.Writer, modifiedReq); err != nil {
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error()))
pm.proxyLogger.Errorf("Error Proxying Request for processGroup %s and model %s", processGroup.id, modelID)
pm.proxyLogger.Errorf("Error Proxying Request for model %s", modelID)
return
}
}
func (pm *ProxyManager) proxyGETModelHandler(c *gin.Context) {
requestedModel := c.Query("model")
if requestedModel == "" {
pm.sendErrorResponse(c, http.StatusBadRequest, "missing required 'model' query parameter")
return
}
var nextHandler func(modelID string, w http.ResponseWriter, r *http.Request) error
var modelID string
if realModelID, found := pm.config.RealModelName(requestedModel); found {
processGroup, err := pm.swapProcessGroup(realModelID)
if err != nil {
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error()))
return
}
modelID = realModelID
pm.proxyLogger.Debugf("ProxyManager using local Process for model: %s", requestedModel)
nextHandler = processGroup.ProxyRequest
} else if pm.peerProxy != nil && pm.peerProxy.HasPeerModel(requestedModel) {
modelID = requestedModel
pm.proxyLogger.Debugf("ProxyManager using ProxyPeer for model: %s", requestedModel)
nextHandler = pm.peerProxy.ProxyRequest
}
if nextHandler == nil {
pm.sendErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("could not find suitable handler for %s", requestedModel))
return
}
if err := nextHandler(modelID, c.Writer, c.Request); err != nil {
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error()))
pm.proxyLogger.Errorf("Error Proxying GET Request for model %s", modelID)
return
}
}
+48 -6
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -13,12 +14,13 @@ import (
)
type Model struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
State string `json:"state"`
Unlisted bool `json:"unlisted"`
PeerID string `json:"peerID"`
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
State string `json:"state"`
Unlisted bool `json:"unlisted"`
PeerID string `json:"peerID"`
Aliases []string `json:"aliases,omitempty"`
}
func addApiHandlers(pm *ProxyManager) {
@@ -31,6 +33,7 @@ func addApiHandlers(pm *ProxyManager) {
apiGroup.GET("/events", pm.apiSendEvents)
apiGroup.GET("/metrics", pm.apiGetMetrics)
apiGroup.GET("/version", pm.apiGetVersion)
apiGroup.GET("/captures/:id", pm.apiGetCapture)
}
}
@@ -81,6 +84,7 @@ func (pm *ProxyManager) getModelStatus() []Model {
Description: pm.config.Models[modelID].Description,
State: state,
Unlisted: pm.config.Models[modelID].Unlisted,
Aliases: pm.config.Models[modelID].Aliases,
})
}
@@ -105,6 +109,7 @@ const (
msgTypeModelStatus messageType = "modelStatus"
msgTypeLogData messageType = "logData"
msgTypeMetrics messageType = "metrics"
msgTypeInFlight messageType = "inflight"
)
type messageEnvelope struct {
@@ -164,6 +169,18 @@ func (pm *ProxyManager) apiSendEvents(c *gin.Context) {
}
}
sendInFlight := func(total int) {
jsonData, err := json.Marshal(gin.H{"total": total})
if err == nil {
select {
case sendBuffer <- messageEnvelope{Type: msgTypeInFlight, Data: string(jsonData)}:
case <-ctx.Done():
return
default:
}
}
}
/**
* Send updated models list
*/
@@ -191,11 +208,19 @@ func (pm *ProxyManager) apiSendEvents(c *gin.Context) {
sendMetrics([]TokenMetrics{e.Metrics})
})()
/**
* Send in-flight request stats related to token stats "Waiting: N" count.
*/
defer event.On(func(e InFlightRequestsEvent) {
sendInFlight(e.Total)
})()
// send initial batch of data
sendLogData("proxy", pm.proxyLogger.GetHistory())
sendLogData("upstream", pm.upstreamLogger.GetHistory())
sendModels()
sendMetrics(pm.metricsMonitor.getMetrics())
sendInFlight(pm.inFlightCounter.Current())
for {
select {
@@ -250,3 +275,20 @@ func (pm *ProxyManager) apiGetVersion(c *gin.Context) {
"build_date": pm.buildDate,
})
}
func (pm *ProxyManager) apiGetCapture(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid capture ID"})
return
}
capture := pm.metricsMonitor.getCaptureByID(id)
if capture == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "capture not found"})
return
}
c.JSON(http.StatusOK, capture)
}
+92
View File
@@ -850,6 +850,43 @@ func TestProxyManager_UseModelName(t *testing.T) {
})
}
func TestProxyManager_AudioVoicesGETHandler(t *testing.T) {
conf := config.AddDefaultGroupToConfig(config.Config{
HealthCheckTimeout: 15,
Models: map[string]config.ModelConfig{
"model1": getTestSimpleResponderConfig("model1"),
},
LogLevel: "error",
})
proxy := New(conf)
defer proxy.StopProcesses(StopWaitForInflightRequest)
t.Run("successful GET with model query param", func(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/audio/voices?model=model1", nil)
w := CreateTestResponseRecorder()
proxy.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "voice1")
})
t.Run("missing model query param returns 400", func(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/audio/voices", nil)
w := CreateTestResponseRecorder()
proxy.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "missing required 'model' query parameter")
})
t.Run("unknown model returns 400", func(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/audio/voices?model=nonexistent", nil)
w := CreateTestResponseRecorder()
proxy.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "could not find suitable handler")
})
}
func TestProxyManager_CORSOptionsHandler(t *testing.T) {
config := config.AddDefaultGroupToConfig(config.Config{
HealthCheckTimeout: 15,
@@ -1009,6 +1046,61 @@ func TestProxyManager_FiltersStripParams(t *testing.T) {
// t.Logf("%v", response)
}
func TestProxyManager_FiltersSetParamsByID(t *testing.T) {
// no explicit aliases — setParamsByID keys are auto-registered as aliases
configStr := strings.Replace(`
logLevel: error
models:
model1:
cmd: 'SRPATH --port ${PORT} --silent --respond model1'
proxy: "http://127.0.0.1:${PORT}"
filters:
setParams:
reasoning_effort: medium
setParamsByID:
"${MODEL_ID}:high":
reasoning_effort: high
"${MODEL_ID}:low":
reasoning_effort: low
`, "SRPATH", simpleResponderPath, -1)
cfg, err := config.LoadConfigFromReader(strings.NewReader(configStr))
if !assert.NoError(t, err, "invalid test configuration") {
return
}
proxy := New(cfg)
defer proxy.StopProcesses(StopWaitForInflightRequest)
tests := []struct {
requestedModel string
wantEffort string
}{
// setParams applies, no setParamsByID match
{requestedModel: "model1", wantEffort: "medium"},
// setParamsByID overrides setParams
{requestedModel: "model1:high", wantEffort: "high"},
{requestedModel: "model1:low", wantEffort: "low"},
}
for _, tt := range tests {
t.Run(tt.requestedModel, func(t *testing.T) {
reqBody := fmt.Sprintf(`{"model":%q}`, tt.requestedModel)
req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(reqBody))
w := CreateTestResponseRecorder()
proxy.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
requestBody, _ := response["request_body"].(string)
gotEffort := gjson.Get(requestBody, "reasoning_effort").String()
assert.Equal(t, tt.wantEffort, gotEffort, "reasoning_effort mismatch for model %s", tt.requestedModel)
})
}
}
func TestProxyManager_HealthEndpoint(t *testing.T) {
config := config.AddDefaultGroupToConfig(config.Config{
HealthCheckTimeout: 15,
+81
View File
@@ -0,0 +1,81 @@
package proxy
import (
"net/http"
"strings"
)
// selectEncoding chooses the best encoding based on Accept-Encoding header
// Returns the encoding ("br", "gzip", or "") and the corresponding file extension
func selectEncoding(acceptEncoding string) (encoding, ext string) {
if acceptEncoding == "" {
return "", ""
}
for _, part := range strings.Split(acceptEncoding, ",") {
enc := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
if enc == "br" {
return "br", ".br"
}
}
for _, part := range strings.Split(acceptEncoding, ",") {
enc := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
if enc == "gzip" {
return "gzip", ".gz"
}
}
return "", ""
}
// ServeCompressedFile serves a file with compression support.
// It checks for pre-compressed versions and serves them with proper headers.
func ServeCompressedFile(fs http.FileSystem, w http.ResponseWriter, r *http.Request, name string) {
encoding, ext := selectEncoding(r.Header.Get("Accept-Encoding"))
// Try to serve compressed version if client supports it
if encoding != "" {
if cf, err := fs.Open(name + ext); err == nil {
defer cf.Close()
// Verify it's a regular file (not a directory)
if stat, err := cf.Stat(); err == nil && !stat.IsDir() {
// Set the content encoding header
w.Header().Set("Content-Encoding", encoding)
w.Header().Add("Vary", "Accept-Encoding")
// Get original file info for content type detection
origFile, err := fs.Open(name)
if err == nil {
origFile.Close()
}
// Serve the compressed file
http.ServeContent(w, r, name, stat.ModTime(), cf)
return
}
}
}
// Fall back to serving the uncompressed file
file, err := fs.Open(name)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if stat.IsDir() {
http.Error(w, "is a directory", http.StatusForbidden)
return
}
http.ServeContent(w, r, name, stat.ModTime(), file)
}
+283
View File
@@ -0,0 +1,283 @@
package proxy
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
)
func TestServeCompressedFile_Brotli(t *testing.T) {
// Create test content
content := []byte("This is test content that should be compressed with brotli")
brContent := []byte("fake-brotli-compressed-data")
// Create a test filesystem
mapFS := fstest.MapFS{
"test.js": {Data: content, ModTime: time.Now()},
"test.js.br": {Data: brContent, ModTime: time.Now()},
"test.js.gz": {Data: []byte("fake-gzip-data"), ModTime: time.Now()},
}
fs := http.FS(mapFS)
req := httptest.NewRequest(http.MethodGet, "/test.js", nil)
req.Header.Set("Accept-Encoding", "br, gzip")
w := httptest.NewRecorder()
ServeCompressedFile(fs, w, req, "test.js")
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
// Check that brotli is used (preferred over gzip)
if encoding := resp.Header.Get("Content-Encoding"); encoding != "br" {
t.Errorf("Expected Content-Encoding 'br', got '%s'", encoding)
}
if vary := resp.Header.Get("Vary"); vary != "Accept-Encoding" {
t.Errorf("Expected Vary 'Accept-Encoding', got '%s'", vary)
}
if !bytes.Equal(body, brContent) {
t.Errorf("Expected brotli content, got %s", string(body))
}
}
func TestServeCompressedFile_Gzip(t *testing.T) {
// Create test content
content := []byte("This is test content that should be compressed with gzip")
gzContent := []byte("fake-gzip-compressed-data")
// Create a test filesystem without brotli
mapFS := fstest.MapFS{
"test.js": {Data: content, ModTime: time.Now()},
"test.js.gz": {Data: gzContent, ModTime: time.Now()},
}
fs := http.FS(mapFS)
req := httptest.NewRequest(http.MethodGet, "/test.js", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
ServeCompressedFile(fs, w, req, "test.js")
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
if encoding := resp.Header.Get("Content-Encoding"); encoding != "gzip" {
t.Errorf("Expected Content-Encoding 'gzip', got '%s'", encoding)
}
if !bytes.Equal(body, gzContent) {
t.Errorf("Expected gzip content, got %s", string(body))
}
}
func TestServeCompressedFile_UncompressedFallback(t *testing.T) {
// Create test content
content := []byte("This is uncompressed test content")
// Create a test filesystem without compressed versions
mapFS := fstest.MapFS{
"test.js": {Data: content, ModTime: time.Now()},
}
fs := http.FS(mapFS)
req := httptest.NewRequest(http.MethodGet, "/test.js", nil)
req.Header.Set("Accept-Encoding", "br, gzip")
w := httptest.NewRecorder()
ServeCompressedFile(fs, w, req, "test.js")
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
// Should not have Content-Encoding header since we're serving uncompressed
if encoding := resp.Header.Get("Content-Encoding"); encoding != "" {
t.Errorf("Expected no Content-Encoding, got '%s'", encoding)
}
if !bytes.Equal(body, content) {
t.Errorf("Expected original content, got %s", string(body))
}
}
func TestServeCompressedFile_NoAcceptEncoding(t *testing.T) {
// Create test content
content := []byte("This is test content")
// Create a test filesystem with compressed versions
mapFS := fstest.MapFS{
"test.js": {Data: content, ModTime: time.Now()},
"test.js.br": {Data: []byte("brotli"), ModTime: time.Now()},
"test.js.gz": {Data: []byte("gzip"), ModTime: time.Now()},
}
fs := http.FS(mapFS)
req := httptest.NewRequest(http.MethodGet, "/test.js", nil)
// No Accept-Encoding header
w := httptest.NewRecorder()
ServeCompressedFile(fs, w, req, "test.js")
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
// Should serve uncompressed content
if encoding := resp.Header.Get("Content-Encoding"); encoding != "" {
t.Errorf("Expected no Content-Encoding, got '%s'", encoding)
}
if !bytes.Equal(body, content) {
t.Errorf("Expected original content, got %s", string(body))
}
}
func TestServeCompressedFile_NotFound(t *testing.T) {
mapFS := fstest.MapFS{}
fs := http.FS(mapFS)
req := httptest.NewRequest(http.MethodGet, "/nonexistent.js", nil)
w := httptest.NewRecorder()
ServeCompressedFile(fs, w, req, "nonexistent.js")
resp := w.Result()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", resp.StatusCode)
}
}
func TestSelectEncoding(t *testing.T) {
tests := []struct {
acceptEncoding string
wantEncoding string
wantExt string
}{
{"br, gzip", "br", ".br"},
{"gzip, deflate", "gzip", ".gz"},
{"gzip", "gzip", ".gz"},
{"br", "br", ".br"},
{"", "", ""},
{"deflate", "", ""},
{"br;q=1.0, gzip;q=0.5", "br", ".br"},
{"gzip;q=1.0, br;q=0.5", "br", ".br"},
{"browser", "", ""},
{"compress, deflate", "", ""},
}
for _, tt := range tests {
gotEncoding, gotExt := selectEncoding(tt.acceptEncoding)
if gotEncoding != tt.wantEncoding || gotExt != tt.wantExt {
t.Errorf("selectEncoding(%q) = (%q, %q), want (%q, %q)",
tt.acceptEncoding, gotEncoding, gotExt, tt.wantEncoding, tt.wantExt)
}
}
}
// Test with actual pre-compressed files from ui_dist
func TestServeCompressedFile_RealFiles(t *testing.T) {
// Check if ui_dist exists
if _, err := os.Stat("./ui_dist"); os.IsNotExist(err) {
t.Skip("ui_dist not found, skipping real file test")
}
// Find a .js or .css file that has compressed versions
entries, err := os.ReadDir("./ui_dist/assets")
if err != nil {
t.Skipf("Could not read ui_dist/assets: %v", err)
}
var testFile string
for _, entry := range entries {
name := entry.Name()
if strings.HasSuffix(name, ".js") && !strings.HasSuffix(name, ".js.gz") && !strings.HasSuffix(name, ".js.br") {
// Check if compressed versions exist
base := strings.TrimSuffix(name, ".js")
if _, err := os.Stat(filepath.Join("./ui_dist/assets", base+".js.gz")); err == nil {
testFile = "assets/" + name
break
}
}
}
if testFile == "" {
t.Skip("No suitable test file found with compressed versions")
}
fs := http.FS(os.DirFS("./ui_dist"))
// Test brotli
t.Run("brotli", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/"+testFile, nil)
req.Header.Set("Accept-Encoding", "br")
w := httptest.NewRecorder()
ServeCompressedFile(fs, w, req, testFile)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
if encoding := resp.Header.Get("Content-Encoding"); encoding != "br" {
t.Errorf("Expected Content-Encoding 'br', got '%s'", encoding)
}
})
// Test gzip
t.Run("gzip", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/"+testFile, nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
ServeCompressedFile(fs, w, req, testFile)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
}
if encoding := resp.Header.Get("Content-Encoding"); encoding != "gzip" {
t.Errorf("Expected Content-Encoding 'gzip', got '%s'", encoding)
}
// Verify it's valid gzip
reader, err := gzip.NewReader(resp.Body)
if err != nil {
t.Errorf("Expected valid gzip content: %v", err)
return
}
defer reader.Close()
// Just read to verify it's valid
_, err = io.Copy(io.Discard, reader)
if err != nil {
t.Errorf("Failed to decompress gzip: %v", err)
}
})
}
+2023 -24
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -7,19 +7,36 @@
"start": "vite",
"build": "vite build --emptyOutDir",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.8",
"@tsconfig/svelte": "^5.0.4",
"@types/hast": "^3.0.4",
"@types/node": "^25.1.0",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.8",
"typescript": "~5.8.3",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vite-plugin-compression2": "^2.4.0",
"vitest": "^4.0.18"
},
"dependencies": {
"svelte-spa-router": "^4.0.1"
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"lucide-svelte": "^0.563.0",
"rehype-katex": "^7.0.1",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"svelte-spa-router": "^4.0.1",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0"
}
}
+17 -5
View File
@@ -5,22 +5,29 @@
import LogViewer from "./routes/LogViewer.svelte";
import Models from "./routes/Models.svelte";
import Activity from "./routes/Activity.svelte";
import Playground from "./routes/Playground.svelte";
import PlaygroundStub from "./routes/PlaygroundStub.svelte";
import { enableAPIEvents } from "./stores/api";
import { initScreenWidth, isDarkMode, appTitle, connectionState } from "./stores/theme";
import { currentRoute } from "./stores/route";
const routes = {
"/": Models,
"/": PlaygroundStub,
"/models": Models,
"/logs": LogViewer,
"/activity": Activity,
"*": Models,
"*": PlaygroundStub,
};
// Sync theme to document attribute
function handleRouteLoaded(event: { detail: { route: string | RegExp } }) {
const route = event.detail.route;
currentRoute.set(typeof route === "string" ? route : "/");
}
$effect(() => {
document.documentElement.setAttribute("data-theme", $isDarkMode ? "dark" : "light");
});
// Sync title to document
$effect(() => {
const icon = $connectionState === "connecting" ? "\u{1F7E1}" : $connectionState === "connected" ? "\u{1F7E2}" : "\u{1F534}";
document.title = `${icon} ${$appTitle}`;
@@ -41,6 +48,11 @@
<Header />
<main class="flex-1 overflow-auto p-4">
<Router {routes} />
<div class="h-full" class:hidden={$currentRoute !== "/"}>
<Playground />
</div>
<div class="h-full" class:hidden={$currentRoute === "/"}>
<Router {routes} on:routeLoaded={handleRouteLoaded} />
</div>
</main>
</div>
@@ -0,0 +1,452 @@
<script lang="ts">
import type { ReqRespCapture } from "../lib/types";
interface Props {
capture: ReqRespCapture | null;
open: boolean;
onclose: () => void;
}
let { capture, open, onclose }: Props = $props();
let dialogEl: HTMLDialogElement | undefined = $state();
type BodyTab = "raw" | "pretty" | "chat";
let reqBodyTab: BodyTab = $state("pretty");
let respBodyTab: BodyTab = $state("pretty");
let copiedReq = $state(false);
let copiedResp = $state(false);
$effect(() => {
if (open && dialogEl) {
dialogEl.showModal();
} else if (!open && dialogEl) {
dialogEl.close();
}
});
// Reset tabs when capture changes
$effect(() => {
if (capture) {
const reqCt = getContentType(capture.req_headers);
const respCt = getContentType(capture.resp_headers);
reqBodyTab = reqCt.includes("json") ? "pretty" : "raw";
respBodyTab = respCt.includes("text/event-stream")
? "chat"
: respCt.includes("json")
? "pretty"
: "raw";
}
});
function handleDialogClose() {
onclose();
}
function decodeBody(body: string | null | undefined): string {
if (!body) return "";
try {
const binary = atob(body);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
} catch {
return body;
}
}
function formatJson(str: string): string {
try {
const parsed = JSON.parse(str);
return JSON.stringify(parsed, null, 2);
} catch {
return str;
}
}
function getContentType(
headers: Record<string, string> | null | undefined,
): string {
if (!headers) return "";
const ct = headers["Content-Type"] || headers["content-type"] || "";
return ct.toLowerCase();
}
function isImageContentType(contentType: string): boolean {
return contentType.startsWith("image/");
}
function isTextContentType(contentType: string): boolean {
return (
contentType.startsWith("text/") ||
contentType.includes("application/json") ||
contentType.includes("application/xml") ||
contentType.includes("application/javascript")
);
}
function getImageDataUrl(body: string, contentType: string): string {
const mimeType = contentType.split(";")[0].trim();
return `data:${mimeType};base64,${body}`;
}
interface SSEChat {
reasoning: string;
content: string;
}
function parseSSEChat(text: string): SSEChat {
const result: SSEChat = { reasoning: "", content: "" };
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data: ")) continue;
const data = trimmed.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta;
if (delta?.content) result.content += delta.content;
if (delta?.reasoning_content) result.reasoning += delta.reasoning_content;
} catch {
// skip unparseable lines
}
}
return result;
}
async function copyToClipboard(text: string, type: "req" | "resp") {
try {
await navigator.clipboard.writeText(text);
if (type === "req") {
copiedReq = true;
setTimeout(() => (copiedReq = false), 1500);
} else {
copiedResp = true;
setTimeout(() => (copiedResp = false), 1500);
}
} catch {
// ignore
}
}
function getCopyText(): string {
if (respBodyTab === "chat") {
let text = "";
if (sseChat.reasoning) text += sseChat.reasoning + "\n\n";
text += sseChat.content;
return text;
}
return displayedResponseBody;
}
// Request body derivations
let requestContentType = $derived(
capture ? getContentType(capture.req_headers) : "",
);
let isRequestJson = $derived(requestContentType.includes("json"));
let requestBodyRaw = $derived.by(() => {
if (!capture) return "";
return decodeBody(capture.req_body);
});
let requestBodyPretty = $derived.by(() => {
if (!isRequestJson) return requestBodyRaw;
return formatJson(requestBodyRaw);
});
let displayedRequestBody = $derived(
reqBodyTab === "pretty" ? requestBodyPretty : requestBodyRaw,
);
// Response body derivations
let responseContentType = $derived(
capture ? getContentType(capture.resp_headers) : "",
);
let isResponseImage = $derived(isImageContentType(responseContentType));
let isResponseText = $derived(isTextContentType(responseContentType));
let isResponseJson = $derived(responseContentType.includes("json"));
let isSSE = $derived(responseContentType.includes("text/event-stream"));
let responseBodyRaw = $derived.by(() => {
if (!capture) return "";
return decodeBody(capture.resp_body);
});
let responseBodyPretty = $derived.by(() => {
if (!isResponseJson) return responseBodyRaw;
return formatJson(responseBodyRaw);
});
let sseChat = $derived.by(() => {
if (!isSSE || !responseBodyRaw)
return { reasoning: "", content: "" } as SSEChat;
return parseSSEChat(responseBodyRaw);
});
let displayedResponseBody = $derived.by(() => {
if (respBodyTab === "pretty") return responseBodyPretty;
return responseBodyRaw;
});
</script>
<dialog
bind:this={dialogEl}
onclose={handleDialogClose}
class="bg-surface text-txtmain rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] p-0 backdrop:bg-black/50 m-auto"
>
{#if capture}
<div class="flex flex-col max-h-[90vh]">
<div
class="flex justify-between items-center p-4 border-b border-card-border"
>
<h2 class="text-xl font-bold pb-0">Capture #{capture.id + 1}{#if capture.req_path} <span class="text-base font-mono font-normal text-txtsecondary">{capture.req_path}</span>{/if}</h2>
<button
onclick={() => dialogEl?.close()}
class="text-txtsecondary hover:text-txtmain text-2xl leading-none"
>
&times;
</button>
</div>
<div class="overflow-y-auto flex-1 p-4 space-y-4">
<!-- Request Headers -->
<details class="group" open>
<summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain"
>
Request Headers
</summary>
<div
class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-48"
>
<table class="w-full text-sm">
<tbody>
{#each Object.entries(capture.req_headers || {}) as [key, value]}
<tr class="border-b border-card-border-inner last:border-0">
<td class="px-3 py-1 font-mono text-primary whitespace-nowrap"
>{key}</td
>
<td class="px-3 py-1 font-mono break-all">{value}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</details>
<!-- Request Body -->
<details class="group" open>
<summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain"
>
Request Body
</summary>
{#if requestBodyRaw}
<div class="mt-2 flex items-center justify-between">
<div class="flex gap-1">
{#if isRequestJson}
<button
class="tab-btn"
class:tab-btn-active={reqBodyTab === "pretty"}
onclick={() => (reqBodyTab = "pretty")}>Pretty</button
>
<button
class="tab-btn"
class:tab-btn-active={reqBodyTab === "raw"}
onclick={() => (reqBodyTab = "raw")}>Raw</button
>
{/if}
</div>
<button
class="tab-btn"
onclick={() =>
copyToClipboard(displayedRequestBody, "req")}
>
{#if copiedReq}
Copied!
{:else}
Copy
{/if}
</button>
</div>
<div
class="mt-1 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">{displayedRequestBody}</pre>
</div>
{:else}
<div
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"
>(empty)</pre
>
</div>
{/if}
</details>
<!-- Response Headers -->
<details class="group" open>
<summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain"
>
Response Headers
</summary>
<div
class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-48"
>
<table class="w-full text-sm">
<tbody>
{#each Object.entries(capture.resp_headers || {}) as [key, value]}
<tr class="border-b border-card-border-inner last:border-0">
<td class="px-3 py-1 font-mono text-primary whitespace-nowrap"
>{key}</td
>
<td class="px-3 py-1 font-mono break-all">{value}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</details>
<!-- Response Body -->
<details class="group" open>
<summary
class="cursor-pointer font-semibold text-sm uppercase tracking-wider text-txtsecondary hover:text-txtmain"
>
Response Body
</summary>
{#if isResponseImage && capture.resp_body}
<div
class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-96"
>
<div class="p-3 flex justify-center">
<img
src={getImageDataUrl(capture.resp_body, responseContentType)}
alt="Response"
class="max-w-full h-auto"
/>
</div>
</div>
{:else if isSSE || isResponseText}
<div class="mt-2 flex items-center justify-between">
<div class="flex gap-1">
{#if isSSE}
<button
class="tab-btn"
class:tab-btn-active={respBodyTab === "chat"}
onclick={() => (respBodyTab = "chat")}>Chat</button
>
{/if}
{#if isResponseJson}
<button
class="tab-btn"
class:tab-btn-active={respBodyTab === "pretty"}
onclick={() => (respBodyTab = "pretty")}>Pretty</button
>
{/if}
{#if isSSE || isResponseJson}
<button
class="tab-btn"
class:tab-btn-active={respBodyTab === "raw"}
onclick={() => (respBodyTab = "raw")}>Raw</button
>
{/if}
</div>
<button
class="tab-btn"
onclick={() => copyToClipboard(getCopyText(), "resp")}
>
{#if copiedResp}
Copied!
{:else}
Copy
{/if}
</button>
</div>
<div
class="mt-1 bg-background rounded border border-card-border overflow-auto max-h-96"
>
{#if respBodyTab === "chat"}
<div class="p-3 text-sm space-y-3">
{#if sseChat.reasoning}
<div>
<div
class="text-xs font-semibold uppercase tracking-wider text-txtsecondary mb-1"
>
Reasoning
</div>
<pre
class="font-mono whitespace-pre-wrap break-all text-txtsecondary">{sseChat.reasoning}</pre>
</div>
{/if}
{#if sseChat.content}
<div>
{#if sseChat.reasoning}
<div
class="text-xs font-semibold uppercase tracking-wider text-txtsecondary mb-1"
>
Response
</div>
{/if}
<pre
class="font-mono whitespace-pre-wrap break-all">{sseChat.content}</pre>
</div>
{/if}
{#if !sseChat.reasoning && !sseChat.content}
<pre class="font-mono">(empty)</pre>
{/if}
</div>
{:else}
<pre
class="p-3 text-sm font-mono whitespace-pre-wrap break-all">{displayedResponseBody || "(empty)"}</pre>
{/if}
</div>
{:else if responseBodyRaw}
<div
class="mt-2 bg-background rounded border border-card-border overflow-auto max-h-96"
>
<div class="p-3 text-sm text-txtsecondary italic">
(binary data - {responseContentType || "unknown content type"})
</div>
</div>
{:else}
<div
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>
</div>
{/if}
</details>
</div>
<div class="p-4 border-t border-card-border flex justify-end">
<button onclick={() => dialogEl?.close()} class="btn"> Close </button>
</div>
</div>
{/if}
</dialog>
<style>
.tab-btn {
padding: 2px 10px;
font-size: 0.75rem;
border-radius: 4px;
color: var(--color-txtsecondary);
cursor: pointer;
border: 1px solid transparent;
background: transparent;
transition: all 0.15s;
}
.tab-btn:hover {
color: var(--color-txtmain);
background: var(--color-secondary);
}
.tab-btn-active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 25%, transparent);
}
</style>
+40 -10
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { link, location } from "svelte-spa-router";
import { link } from "svelte-spa-router";
import { screenWidth, toggleTheme, isDarkMode, appTitle, isNarrow } from "../stores/theme";
import { currentRoute } from "../stores/route";
import { playgroundActivity } from "../stores/playgroundActivity";
import ConnectionStatus from "./ConnectionStatus.svelte";
function handleTitleChange(newTitle: string): void {
@@ -22,9 +24,10 @@
handleTitleChange(target.textContent || "(set title)");
}
function isActive(path: string, currentLocation: string): boolean {
return path === "/" ? currentLocation === "/" : currentLocation.startsWith(path);
function isActive(path: string, current: string): boolean {
return path === "/" ? current === "/" : current.startsWith(path);
}
</script>
<header
@@ -43,28 +46,35 @@
</h1>
{/if}
<menu class="flex items-center gap-4">
<menu class="flex items-center gap-4 overflow-x-auto">
<a
href="/"
use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1"
class:font-semibold={isActive("/", $location)}
class="p-1 whitespace-nowrap {isActive('/', $currentRoute) ? 'font-semibold' : ''} {$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)}
>
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"
class:font-semibold={isActive("/activity", $location)}
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)}
>
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"
class:font-semibold={isActive("/logs", $location)}
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)}
>
Logs
</a>
@@ -88,3 +98,23 @@
<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>
+10 -3
View File
@@ -65,10 +65,17 @@
});
let preElement: HTMLPreElement;
let userScrolledUp = $state(false);
// Auto scroll to bottom when logs change
function handleScroll() {
if (!preElement) return;
const { scrollTop, scrollHeight, clientHeight } = preElement;
userScrolledUp = scrollHeight - scrollTop - clientHeight > 40;
}
// Auto scroll to bottom when logs change, unless user has scrolled up
$effect(() => {
if (preElement && filteredLogs) {
if (preElement && filteredLogs && !userScrolledUp) {
preElement.scrollTop = preElement.scrollHeight;
}
});
@@ -127,6 +134,6 @@
{/if}
</div>
<div class="rounded-lg bg-background font-mono text-sm flex-1 overflow-hidden">
<pre bind:this={preElement} class="{textWrapClass} {fontSizeClass} h-full overflow-auto p-4">{filteredLogs}</pre>
<pre bind:this={preElement} onscroll={handleScroll} class="{textWrapClass} {fontSizeClass} h-full overflow-auto p-4">{filteredLogs}</pre>
</div>
</div>
@@ -165,6 +165,9 @@
{#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"}
+24 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { metrics } from "../stores/api";
import { inFlightRequests, metrics } from "../stores/api";
import TokenHistogram from "./TokenHistogram.svelte";
interface HistogramData {
@@ -15,7 +15,14 @@
let stats = $derived.by(() => {
const totalRequests = $metrics.length;
if (totalRequests === 0) {
return { totalRequests: 0, totalInputTokens: 0, totalOutputTokens: 0, tokenStats: { p99: "0", p95: "0", p50: "0" }, histogramData: null };
return {
totalRequests: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
inFlightRequests: $inFlightRequests,
tokenStats: { p99: "0", p95: "0", p50: "0" },
histogramData: null,
};
}
const totalInputTokens = $metrics.reduce((sum, m) => sum + m.input_tokens, 0);
@@ -24,7 +31,14 @@
// Calculate token statistics using output_tokens and duration_ms
const validMetrics = $metrics.filter((m) => m.duration_ms > 0 && m.output_tokens > 0);
if (validMetrics.length === 0) {
return { totalRequests, totalInputTokens, totalOutputTokens, tokenStats: { p99: "0", p95: "0", p50: "0" }, histogramData: null };
return {
totalRequests,
totalInputTokens,
totalOutputTokens,
inFlightRequests: $inFlightRequests,
tokenStats: { p99: "0", p95: "0", p50: "0" },
histogramData: null,
};
}
// Calculate tokens/second for each valid metric
@@ -63,6 +77,7 @@
totalRequests,
totalInputTokens,
totalOutputTokens,
inFlightRequests: $inFlightRequests,
tokenStats: {
p99: p99.toFixed(2),
p95: p95.toFixed(2),
@@ -95,7 +110,12 @@
<tbody class="bg-surface divide-y divide-card-border-inner">
<tr class="hover:bg-secondary">
<td class="px-4 py-4 text-sm font-semibold text-gray-900 dark:text-white">{stats.totalRequests}</td>
<td class="px-4 py-4 text-sm font-semibold text-gray-900 dark:text-white">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Completed: {nf.format(stats.totalRequests)}</span>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Waiting: {nf.format(stats.inFlightRequests)}</span>
</div>
</td>
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300 border-l border-gray-200 dark:border-white/10">
<div class="flex items-center gap-2">
@@ -0,0 +1,256 @@
<script lang="ts">
import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent";
import { transcribeAudio } from "../../lib/audioApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte";
const selectedModelStore = persistentStore<string>("playground-audio-model", "");
let selectedFile = $state<File | null>(null);
let isTranscribing = $state(false);
let transcriptionResult = $state<string | null>(null);
let error = $state<string | null>(null);
let abortController = $state<AbortController | null>(null);
let isDragging = $state(false);
let fileInput = $state<HTMLInputElement | null>(null);
let copied = $state(false);
const ACCEPTED_FORMATS = ['.mp3', '.wav'];
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB
let hasModels = $derived($models.some((m) => !m.unlisted));
let canTranscribe = $derived(selectedFile !== null && $selectedModelStore !== "" && !isTranscribing);
$effect(() => {
playgroundStores.audioTranscribing.set(isTranscribing);
});
function validateFile(file: File): { valid: boolean; error?: string } {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!ACCEPTED_FORMATS.includes(ext)) {
return { valid: false, error: 'Invalid file type. Accepted: MP3, WAV' };
}
if (file.size > MAX_FILE_SIZE) {
return { valid: false, error: 'File too large. Maximum: 25MB' };
}
return { valid: true };
}
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
const validation = validateFile(file);
if (validation.valid) {
selectedFile = file;
error = null;
transcriptionResult = null;
} else {
error = validation.error || "Invalid file";
selectedFile = null;
}
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
isDragging = true;
}
function handleDragLeave() {
isDragging = false;
}
function handleDrop(event: DragEvent) {
event.preventDefault();
isDragging = false;
const file = event.dataTransfer?.files[0];
if (file) {
const validation = validateFile(file);
if (validation.valid) {
selectedFile = file;
error = null;
transcriptionResult = null;
} else {
error = validation.error || "Invalid file";
selectedFile = null;
}
}
}
async function transcribe() {
if (!selectedFile || !$selectedModelStore || isTranscribing) return;
isTranscribing = true;
error = null;
transcriptionResult = null;
abortController = new AbortController();
try {
const response = await transcribeAudio(
$selectedModelStore,
selectedFile,
abortController.signal
);
transcriptionResult = response.text;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// User cancelled
} else {
error = err instanceof Error ? err.message : "An error occurred";
}
} finally {
isTranscribing = false;
abortController = null;
}
}
function cancelTranscription() {
abortController?.abort();
}
function clearAll() {
selectedFile = null;
transcriptionResult = null;
error = null;
if (fileInput) {
fileInput.value = '';
}
}
function copyToClipboard() {
if (transcriptionResult) {
navigator.clipboard.writeText(transcriptionResult);
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
}
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
</script>
<div class="flex flex-col h-full">
<!-- Model selector -->
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an audio model..." disabled={isTranscribing} />
</div>
<!-- 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 transcribe audio.</p>
</div>
{:else}
<!-- File upload / Result display area -->
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-surface border border-gray-200 dark:border-white/10 rounded">
{#if isTranscribing}
<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>Transcribing audio...</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">{error}</p>
</div>
{:else if transcriptionResult}
<div class="w-full h-full flex flex-col p-4">
<div class="flex justify-between items-center mb-2">
<h3 class="font-medium">Transcription Result</h3>
<button
class="btn btn-sm"
onclick={copyToClipboard}
title={copied ? 'Copied!' : 'Copy to clipboard'}
>
{#if copied}
<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}
<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}
</button>
</div>
<div class="flex-1 overflow-auto p-3 rounded border border-gray-200 dark:border-white/10 bg-background whitespace-pre-wrap">
{transcriptionResult}
</div>
</div>
{:else if selectedFile}
<div class="text-center text-txtsecondary p-4">
<p class="font-medium mb-2">File Selected</p>
<p class="text-sm">{selectedFile.name}</p>
<p class="text-xs mt-1">{formatFileSize(selectedFile.size)}</p>
</div>
{:else}
<div
role="region"
aria-label="Audio file drop zone"
class="w-full h-full flex items-center justify-center text-center text-txtsecondary p-8 {isDragging ? 'bg-primary/10' : ''}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
<div>
<p class="mb-2">Drag and drop an audio file here</p>
<p class="text-sm">or use the Browse button below</p>
<p class="text-xs mt-4">Accepted formats: MP3, WAV (max 25MB)</p>
</div>
</div>
{/if}
</div>
<!-- File input and transcribe button -->
<div class="shrink-0 flex gap-2">
<input
type="file"
accept=".mp3,.wav"
class="hidden"
onchange={handleFileSelect}
bind:this={fileInput}
/>
<button
class="btn"
onclick={() => fileInput?.click()}
disabled={isTranscribing}
>
Browse Files
</button>
<div class="flex-1"></div>
{#if isTranscribing}
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelTranscription}>
Cancel
</button>
{:else}
<button
class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={transcribe}
disabled={!canTranscribe}
>
Transcribe
</button>
<button
class="btn"
onclick={clearAll}
disabled={!selectedFile && !transcriptionResult && !error}
>
Clear
</button>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,466 @@
<script lang="ts">
import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent";
import { streamChatCompletion } from "../../lib/chatApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import type { ChatMessage, ContentPart } from "../../lib/types";
import ChatMessageComponent from "./ChatMessage.svelte";
import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte";
const selectedModelStore = persistentStore<string>("playground-selected-model", "");
const systemPromptStore = persistentStore<string>("playground-system-prompt", "");
const temperatureStore = persistentStore<number>("playground-temperature", 0.7);
function loadMessages(): ChatMessage[] {
try {
const saved = localStorage.getItem("playground-messages");
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}
let messages = $state<ChatMessage[]>(loadMessages());
let userInput = $state("");
let isStreaming = $state(false);
let isReasoning = $state(false);
let reasoningStartTime = $state<number>(0);
let abortController = $state<AbortController | null>(null);
let messagesContainer: HTMLDivElement | undefined = $state();
let showSettings = $state(false);
let attachedImages = $state<string[]>([]);
let fileInput = $state<HTMLInputElement | null>(null);
let imageError = $state<string | null>(null);
let hasModels = $derived($models.some((m) => !m.unlisted));
let userScrolledUp = $state(false);
$effect(() => {
playgroundStores.chatStreaming.set(isStreaming);
});
function handleMessagesScroll() {
if (!messagesContainer) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
// Consider "at bottom" if within 40px of the bottom
userScrolledUp = scrollHeight - scrollTop - clientHeight > 40;
}
// Auto-scroll when messages change — skip if user scrolled up
$effect(() => {
if (messages.length > 0 && messagesContainer && !userScrolledUp) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: isStreaming ? "instant" : "smooth",
});
}
});
// Persist messages to localStorage (throttled to once per 2s)
let lastSaveTime = 0;
$effect(() => {
const json = JSON.stringify(messages);
const elapsed = Date.now() - lastSaveTime;
const save = () => {
try { localStorage.setItem("playground-messages", json); } catch {}
lastSaveTime = Date.now();
};
if (elapsed >= 2000) {
save();
return;
}
const timer = setTimeout(save, 2000 - elapsed);
return () => clearTimeout(timer);
});
async function sendMessage() {
const trimmedInput = userInput.trim();
if ((!trimmedInput && attachedImages.length === 0) || !$selectedModelStore || isStreaming) return;
userScrolledUp = false;
// Build message content (multimodal if images attached)
let content: string | ContentPart[];
if (attachedImages.length > 0) {
const parts: ContentPart[] = [];
if (trimmedInput) {
parts.push({ type: "text", text: trimmedInput });
}
for (const url of attachedImages) {
parts.push({ type: "image_url", image_url: { url } });
}
content = parts;
} else {
content = trimmedInput;
}
// Add user message
messages = [...messages, { role: "user", content }];
userInput = "";
attachedImages = [];
imageError = null;
// Generate response from the new user message
await regenerateFromIndex(messages.length - 1);
}
function cancelStreaming() {
abortController?.abort();
}
function newChat() {
if (isStreaming) {
cancelStreaming();
}
messages = [];
isReasoning = false;
reasoningStartTime = 0;
}
async function regenerateFromIndex(idx: number) {
// Remove all messages after the edited user message
messages = messages.slice(0, idx + 1);
// Add empty assistant message for the new response
messages = [...messages, { role: "assistant", content: "" }];
isStreaming = true;
isReasoning = false;
reasoningStartTime = 0;
abortController = new AbortController();
try {
// Build messages array with optional system prompt
const apiMessages: ChatMessage[] = [];
if ($systemPromptStore.trim()) {
apiMessages.push({ role: "system", content: $systemPromptStore.trim() });
}
apiMessages.push(...messages.slice(0, -1)); // Add all messages except the empty assistant one
const stream = streamChatCompletion(
$selectedModelStore,
apiMessages,
abortController.signal,
{ temperature: $temperatureStore }
);
for await (const chunk of stream) {
if (chunk.done) break;
// Handle reasoning content
if (chunk.reasoning_content) {
// Start timing on first reasoning content
if (!isReasoning) {
isReasoning = true;
reasoningStartTime = Date.now();
}
// Update the last message with reasoning content
messages = messages.map((msg, i) =>
i === messages.length - 1
? { ...msg, reasoning_content: (msg.reasoning_content || "") + chunk.reasoning_content }
: msg
);
}
// Handle regular content - end reasoning phase when we get content
if (chunk.content) {
if (isReasoning) {
// Calculate reasoning time
const reasoningTimeMs = Date.now() - reasoningStartTime;
isReasoning = false;
// Update message with reasoning time
messages = messages.map((msg, i) =>
i === messages.length - 1
? { ...msg, reasoningTimeMs }
: msg
);
}
// Update the last message (assistant) with new content
messages = messages.map((msg, i) =>
i === messages.length - 1
? { ...msg, content: msg.content + chunk.content }
: msg
);
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// User cancelled, keep partial response
// If we were still reasoning, record the time
if (isReasoning && reasoningStartTime > 0) {
const reasoningTimeMs = Date.now() - reasoningStartTime;
messages = messages.map((msg, i) =>
i === messages.length - 1
? { ...msg, reasoningTimeMs }
: msg
);
}
} else {
// Show error in the assistant message
const errorMessage = error instanceof Error ? error.message : "An error occurred";
messages = messages.map((msg, i) =>
i === messages.length - 1
? { ...msg, content: msg.content + `\n\n**Error:** ${errorMessage}` }
: msg
);
}
} finally {
isStreaming = false;
isReasoning = false;
abortController = null;
}
}
async function editMessage(idx: number, newContent: string) {
if (isStreaming || !$selectedModelStore) return;
// Update the user message at the specified index
messages = messages.map((msg, i) =>
i === idx ? { ...msg, content: newContent } : msg
);
// Trigger a new chat request with the updated messages
await regenerateFromIndex(idx);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
const ACCEPTED_IMAGE_FORMATS = ["image/jpeg", "image/png", "image/gif", "image/webp"];
const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB
const MAX_IMAGES_PER_MESSAGE = 5;
function validateImageFile(file: File): string | null {
if (!ACCEPTED_IMAGE_FORMATS.includes(file.type)) {
return `Invalid file type: ${file.type}. Accepted formats: JPG, PNG, GIF, WEBP`;
}
if (file.size > MAX_IMAGE_SIZE) {
return `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum size: 20MB`;
}
return null;
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}
async function processImageFiles(files: File[]): Promise<void> {
imageError = null;
if (attachedImages.length + files.length > MAX_IMAGES_PER_MESSAGE) {
imageError = `Maximum ${MAX_IMAGES_PER_MESSAGE} images per message`;
return;
}
for (const file of files) {
const error = validateImageFile(file);
if (error) {
imageError = error;
return;
}
}
try {
const dataUrls = await Promise.all(files.map(fileToDataUrl));
attachedImages = [...attachedImages, ...dataUrls];
} catch (error) {
imageError = error instanceof Error ? error.message : "Failed to process images";
}
}
function handleImageSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
processImageFiles(Array.from(input.files));
}
// Reset the input so the same file can be selected again
input.value = "";
}
function removeImage(idx: number) {
attachedImages = attachedImages.filter((_, i) => i !== idx);
imageError = null;
}
</script>
<div class="flex flex-col h-full">
<!-- Model selector and controls -->
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a model..." disabled={isStreaming} />
<div class="flex gap-2">
<button
class="btn"
onclick={() => (showSettings = !showSettings)}
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
</button>
</div>
</div>
<!-- Settings panel -->
{#if showSettings}
<div class="shrink-0 mb-4 p-4 bg-surface border border-gray-200 dark:border-white/10 rounded">
<div class="mb-4">
<label class="block text-sm font-medium mb-1" for="system-prompt">System Prompt</label>
<textarea
id="system-prompt"
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder="You are a helpful assistant..."
rows="3"
bind:value={$systemPromptStore}
disabled={isStreaming}
></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1" for="temperature">
Temperature: {$temperatureStore.toFixed(2)}
</label>
<input
id="temperature"
type="range"
min="0"
max="2"
step="0.05"
class="w-full"
bind:value={$temperatureStore}
disabled={isStreaming}
/>
<div class="flex justify-between text-xs text-txtsecondary mt-1">
<span>Precise (0)</span>
<span>Creative (2)</span>
</div>
</div>
</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 start chatting.</p>
</div>
{:else}
<!-- Messages area -->
<div
class="flex-1 overflow-y-auto mb-4 px-2"
bind:this={messagesContainer}
onscroll={handleMessagesScroll}
>
{#if messages.length === 0}
<div class="h-full flex items-center justify-center text-txtsecondary">
<p>Start a conversation by typing a message below.</p>
</div>
{:else}
{#each messages as message, idx (idx)}
<ChatMessageComponent
role={message.role}
content={message.content}
reasoning_content={message.reasoning_content}
reasoningTimeMs={message.reasoningTimeMs}
isStreaming={isStreaming && idx === messages.length - 1 && message.role === "assistant"}
isReasoning={isReasoning && idx === messages.length - 1 && message.role === "assistant"}
onEdit={message.role === "user" ? (newContent) => editMessage(idx, newContent) : undefined}
onRegenerate={message.role === "assistant" && idx > 0 && messages[idx - 1].role === "user"
? () => regenerateFromIndex(idx - 1)
: undefined}
/>
{/each}
{/if}
</div>
<!-- Input area -->
<div class="shrink-0">
<!-- Image preview strip -->
{#if attachedImages.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each attachedImages as imageUrl, idx (idx)}
<div class="relative group">
<img
src={imageUrl}
alt="Attached image {idx + 1}"
class="w-20 h-20 object-cover rounded border border-gray-200 dark:border-white/10"
/>
<button
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
onclick={() => removeImage(idx)}
title="Remove image"
>
×
</button>
</div>
{/each}
</div>
{/if}
<!-- Error message -->
{#if imageError}
<div class="mb-2 p-2 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded text-sm">
{imageError}
</div>
{/if}
<div class="flex gap-2">
<!-- Hidden file input -->
<input
type="file"
accept=".jpg,.jpeg,.png,.gif,.webp"
multiple
class="hidden"
bind:this={fileInput}
onchange={handleImageSelect}
/>
<ExpandableTextarea
bind:value={userInput}
placeholder="Type a message..."
rows={3}
onkeydown={handleKeyDown}
disabled={isStreaming || !$selectedModelStore}
/>
<div class="flex flex-col gap-2">
{#if isStreaming}
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelStreaming}>
Cancel
</button>
{:else}
<button
class="btn"
onclick={() => fileInput?.click()}
disabled={isStreaming || !$selectedModelStore}
title="Attach image"
>
<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="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" />
</svg>
</button>
<button
class="btn bg-primary text-btn-primary-text hover:opacity-90"
onclick={sendMessage}
disabled={(!userInput.trim() && attachedImages.length === 0) || !$selectedModelStore}
>
Send
</button>
{/if}
</div>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,398 @@
<script lang="ts">
import { renderMarkdown, escapeHtml, renderStreamingMarkdown, createStreamingCache } from "../../lib/markdown";
import type { RenderedBlock } from "../../lib/markdown";
import { Copy, Check, Pencil, X, Save, RefreshCw, ChevronDown, ChevronRight, Brain, Code } from "lucide-svelte";
import { getTextContent, getImageUrls } from "../../lib/types";
import type { ContentPart } from "../../lib/types";
interface Props {
role: "user" | "assistant" | "system";
content: string | ContentPart[];
reasoning_content?: string;
reasoningTimeMs?: number;
isStreaming?: boolean;
isReasoning?: boolean;
onEdit?: (newContent: string) => void;
onRegenerate?: () => void;
}
let { role, content, reasoning_content = "", reasoningTimeMs = 0, isStreaming = false, isReasoning = false, onEdit, onRegenerate }: Props = $props();
let textContent = $derived(getTextContent(content));
let imageUrls = $derived(getImageUrls(content));
let hasImages = $derived(imageUrls.length > 0);
let canEdit = $derived(onEdit !== undefined && !hasImages);
let streamingCache = createStreamingCache();
let renderedParts = $derived.by(() => {
if (role !== "assistant") {
return { blocks: [{ id: -1, html: escapeHtml(textContent).replace(/\n/g, '<br>') }] as RenderedBlock[], pendingHtml: "" };
}
if (!isStreaming) {
streamingCache = createStreamingCache();
return { blocks: [{ id: -1, html: renderMarkdown(textContent) }] as RenderedBlock[], pendingHtml: "" };
}
return renderStreamingMarkdown(textContent, streamingCache);
});
let copied = $state(false);
let showRaw = $state(false);
let isEditing = $state(false);
let editContent = $state("");
let showReasoning = $state(false);
let modalImageUrl = $state<string | null>(null);
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms.toFixed(0)}ms`;
}
return `${(ms / 1000).toFixed(1)}s`;
}
async function copyToClipboard() {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(textContent);
} else {
// Fallback for non-secure contexts (HTTP)
const textarea = document.createElement("textarea");
textarea.value = textContent;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
function startEdit() {
editContent = textContent;
isEditing = true;
}
function cancelEdit() {
isEditing = false;
editContent = "";
}
function saveEdit() {
if (onEdit && editContent.trim() !== textContent) {
onEdit(editContent.trim());
}
isEditing = false;
editContent = "";
}
function openModal(imageUrl: string) {
modalImageUrl = imageUrl;
document.body.style.overflow = "hidden";
}
function closeModal(event?: MouseEvent) {
// Only close if clicking the background, not the image
if (event && event.target !== event.currentTarget) {
return;
}
modalImageUrl = null;
document.body.style.overflow = "";
}
function handleModalKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
closeModal();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
saveEdit();
} else if (event.key === "Escape") {
cancelEdit();
}
}
</script>
<div class="flex {role === 'user' ? 'justify-end' : 'justify-start'} mb-4">
<div
class="relative group rounded-lg px-4 py-2 {role === 'user'
? 'max-w-[85%] bg-primary text-btn-primary-text'
: 'w-full sm:w-4/5 bg-surface border border-gray-200 dark:border-white/10'}"
>
{#if role === "assistant"}
{#if reasoning_content || isReasoning}
<div class="mb-3 border border-gray-200 dark:border-white/10 rounded overflow-hidden">
<button
class="w-full flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-white/5 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors text-sm"
onclick={() => showReasoning = !showReasoning}
>
{#if showReasoning}
<ChevronDown class="w-4 h-4" />
{:else}
<ChevronRight class="w-4 h-4" />
{/if}
<Brain class="w-4 h-4" />
<span class="font-medium">Reasoning</span>
<span class="text-txtsecondary ml-2">
({reasoning_content.length} chars{#if !isReasoning && reasoningTimeMs > 0}, {formatDuration(reasoningTimeMs)}{/if})
</span>
{#if isReasoning}
<span class="ml-auto flex items-center gap-1 text-txtsecondary">
<span class="w-1.5 h-1.5 bg-primary rounded-full animate-pulse"></span>
reasoning...
</span>
{/if}
</button>
{#if showReasoning}
<div class="px-3 py-2 bg-gray-50/50 dark:bg-white/[0.02] text-sm text-txtsecondary whitespace-pre-wrap font-mono">
{reasoning_content}{#if isReasoning}<span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5"></span>{/if}
</div>
{/if}
</div>
{/if}
{#if hasImages}
<div class="mb-3 flex flex-wrap gap-2">
{#each imageUrls as imageUrl, idx (idx)}
<button
onclick={() => openModal(imageUrl)}
class="cursor-pointer rounded border border-gray-200 dark:border-white/10 hover:opacity-80 transition-opacity"
>
<img
src={imageUrl}
alt="Image {idx + 1}"
class="max-h-64 rounded"
/>
</button>
{/each}
</div>
{/if}
{#if showRaw}
<div class="whitespace-pre-wrap font-mono text-sm">{textContent}</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
{#each renderedParts.blocks as block (block.id)}
{@html block.html}
{/each}
{@html renderedParts.pendingHtml}
{#if isStreaming && !isReasoning}
<span class="inline-block w-2 h-4 bg-current animate-pulse ml-0.5"></span>
{/if}
</div>
{/if}
{#if !isStreaming}
<div class="flex gap-1 mt-2 pt-1 border-t border-gray-200 dark:border-white/10">
{#if onRegenerate}
<button
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary"
onclick={onRegenerate}
title="Regenerate response"
>
<RefreshCw class="w-4 h-4" />
</button>
{/if}
<button
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary"
onclick={copyToClipboard}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{#if copied}
<Check class="w-4 h-4 text-green-500" />
{:else}
<Copy class="w-4 h-4" />
{/if}
</button>
<button
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 {showRaw ? 'text-primary' : 'text-txtsecondary'}"
onclick={() => showRaw = !showRaw}
title={showRaw ? "Show rendered" : "Show raw"}
>
<Code class="w-4 h-4" />
</button>
</div>
{/if}
{:else}
{#if isEditing}
<div class="flex flex-col gap-2 min-w-[300px]">
<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">
<button
class="p-1.5 rounded hover:bg-white/20"
onclick={cancelEdit}
title="Cancel"
>
<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>
{:else}
{#if hasImages}
<div class="mb-2 flex flex-wrap gap-2">
{#each imageUrls as imageUrl, idx (idx)}
<button
onclick={() => openModal(imageUrl)}
class="cursor-pointer rounded border border-white/20 hover:opacity-80 transition-opacity"
>
<img
src={imageUrl}
alt="Image {idx + 1}"
class="max-w-[200px] rounded"
/>
</button>
{/each}
</div>
{/if}
<div class="whitespace-pre-wrap pr-8">{textContent}</div>
{#if canEdit}
<button
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity bg-white/20 hover:bg-white/30 shadow-sm"
onclick={startEdit}
title="Edit message"
>
<Pencil class="w-4 h-4" />
</button>
{/if}
{/if}
{/if}
</div>
</div>
<!-- Full-size image modal -->
{#if modalImageUrl}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onclick={(e) => closeModal(e)}
onkeydown={handleModalKeyDown}
role="button"
tabindex="-1"
>
<button
class="absolute top-4 right-4 p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
onclick={() => closeModal()}
title="Close"
>
<X class="w-6 h-6" />
</button>
<img
src={modalImageUrl}
alt=""
class="max-w-full max-h-full rounded pointer-events-none"
/>
</div>
{/if}
<style>
.prose :global(pre) {
background-color: var(--color-surface);
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
border-radius: 0.375rem;
padding: 0.75rem;
overflow-x: auto;
margin: 0.5rem 0;
}
.prose :global(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875em;
}
.prose :global(pre code) {
background: none;
padding: 0;
}
.prose :global(code:not(pre code)) {
background-color: var(--color-surface);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
}
.prose :global(p) {
margin: 0.5rem 0;
}
.prose :global(p:first-child) {
margin-top: 0;
}
.prose :global(p:last-child) {
margin-bottom: 0;
}
.prose :global(ul),
.prose :global(ol) {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.prose :global(li) {
margin: 0.25rem 0;
}
.prose :global(h1),
.prose :global(h2),
.prose :global(h3),
.prose :global(h4) {
margin: 1rem 0 0.5rem 0;
font-weight: 600;
}
.prose :global(h1:first-child),
.prose :global(h2:first-child),
.prose :global(h3:first-child),
.prose :global(h4:first-child) {
margin-top: 0;
}
.prose :global(blockquote) {
border-left: 3px solid var(--color-primary);
padding-left: 1rem;
margin: 0.5rem 0;
font-style: italic;
}
.prose :global(a) {
color: var(--color-primary);
text-decoration: underline;
}
.prose :global(table) {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
}
.prose :global(th),
.prose :global(td) {
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
padding: 0.5rem;
text-align: left;
}
.prose :global(th) {
background-color: var(--color-surface);
font-weight: 600;
}
/* Highlight.js theme overrides for dark mode */
:global(.dark) .prose :global(.hljs) {
background: transparent;
}
</style>
@@ -0,0 +1,121 @@
<script lang="ts">
import { untrack } from "svelte";
import { Maximize2, X } from "lucide-svelte";
interface Props {
value: string;
placeholder?: string;
rows?: number;
disabled?: boolean;
onkeydown?: (event: KeyboardEvent) => void;
}
let {
value = $bindable(),
placeholder = "",
rows = 3,
disabled = false,
onkeydown,
}: Props = $props();
let isExpanded = $state(false);
let expandedValue = $state("");
let expandedTextarea: HTMLTextAreaElement | undefined = $state();
function openExpanded() {
expandedValue = value;
isExpanded = true;
}
function closeExpanded() {
isExpanded = false;
}
function saveExpanded() {
value = expandedValue;
isExpanded = false;
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
closeExpanded();
}
}
// Focus the textarea when expanded view opens
$effect(() => {
if (isExpanded && expandedTextarea) {
expandedTextarea.focus();
const len = untrack(() => expandedValue.length);
expandedTextarea.setSelectionRange(len, len);
}
});
</script>
<div class="flex-1 relative group flex items-stretch min-h-0">
<textarea
class="w-full px-3 py-2 pr-10 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary resize-none"
{placeholder}
{rows}
bind:value
{onkeydown}
{disabled}
></textarea>
<button
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-60 md:opacity-0 group-hover:opacity-100 transition-opacity bg-surface/90 hover:bg-surface border border-gray-200 dark:border-white/10 shadow-sm"
onclick={openExpanded}
title="Expand to edit"
type="button"
{disabled}
>
<Maximize2 class="w-4 h-4" />
</button>
</div>
{#if isExpanded}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-4xl h-[80vh] flex flex-col bg-surface rounded-lg shadow-xl border border-gray-200 dark:border-white/10">
<!-- Header -->
<div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-white/10">
<h3 class="font-medium">Edit Text</h3>
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10"
onclick={closeExpanded}
title="Close"
type="button"
>
<X class="w-5 h-5" />
</button>
</div>
<!-- Textarea -->
<div class="flex-1 p-4">
<textarea
bind:this={expandedTextarea}
class="w-full h-full px-4 py-3 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder={placeholder}
bind:value={expandedValue}
onkeydown={handleKeyDown}
></textarea>
</div>
<!-- Footer -->
<div class="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-white/10">
<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>
{/if}
@@ -0,0 +1,234 @@
<script lang="ts">
import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent";
import { generateImage } from "../../lib/imageApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte";
const selectedModelStore = persistentStore<string>("playground-image-model", "");
const selectedSizeStore = persistentStore<string>("playground-image-size", "1024x1024");
let prompt = $state("");
let isGenerating = $state(false);
let generatedImage = $state<string | null>(null);
let error = $state<string | null>(null);
let abortController = $state<AbortController | null>(null);
let showFullscreen = $state(false);
let hasModels = $derived($models.some((m) => !m.unlisted));
$effect(() => {
playgroundStores.imageGenerating.set(isGenerating);
});
async function generate() {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
isGenerating = true;
error = null;
abortController = new AbortController();
try {
const response = await generateImage(
$selectedModelStore,
trimmedPrompt,
$selectedSizeStore,
abortController.signal
);
if (response.data && response.data.length > 0) {
const imageData = response.data[0];
if (imageData.b64_json) {
generatedImage = `data:image/png;base64,${imageData.b64_json}`;
} else if (imageData.url) {
generatedImage = imageData.url;
}
}
} 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;
}
}
function cancelGeneration() {
abortController?.abort();
}
function clearImage() {
generatedImage = null;
error = null;
prompt = "";
}
function downloadImage() {
if (!generatedImage) return;
const link = document.createElement("a");
link.href = generatedImage;
link.download = `generated-image-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function openFullscreen() {
showFullscreen = true;
}
function closeFullscreen(event?: MouseEvent) {
// Only close if clicking the background, not the image
if (event && event.target !== event.currentTarget) {
return;
}
showFullscreen = false;
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
generate();
}
}
</script>
<div class="flex flex-col h-full">
<!-- Model selector -->
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an image model..." disabled={isGenerating} />
<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}
>
<optgroup label="Square">
<option value="512x512">512x512</option>
<option value="1024x1024">1024x1024</option>
</optgroup>
<optgroup label="Landscape">
<option value="1024x768">1024x768 (4:3)</option>
<option value="1280x720">1280x720 (16:9)</option>
<option value="1792x1024">1792x1024 (SDXL)</option>
</optgroup>
<optgroup label="Portrait">
<option value="768x1024">768x1024 (3:4)</option>
<option value="720x1280">720x1280 (9:16)</option>
<option value="1024x1792">1024x1792 (SDXL)</option>
</optgroup>
</select>
</div>
<!-- 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 images.</p>
</div>
{:else}
<!-- Image display area -->
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-surface border border-gray-200 dark:border-white/10 rounded">
{#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 image...</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">{error}</p>
</div>
{:else if generatedImage}
<div class="relative max-w-full max-h-full flex items-center justify-center">
<button
class="p-0 border-0 bg-transparent cursor-pointer"
onclick={openFullscreen}
aria-label="View fullscreen"
>
<img
src={generatedImage}
alt="AI generated content"
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
/>
</button>
<button
class="absolute bottom-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
onclick={(e) => { e.stopPropagation(); downloadImage(); }}
aria-label="Download image"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
</button>
</div>
{:else}
<div class="text-center text-txtsecondary">
<p>Enter a prompt below to generate an image</p>
</div>
{/if}
</div>
<!-- Prompt input area -->
<div class="shrink-0 flex flex-col md:flex-row gap-2">
<ExpandableTextarea
bind:value={prompt}
placeholder="Describe the image 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>
<button
class="btn flex-1 md:flex-none"
onclick={clearImage}
disabled={!generatedImage && !error && !prompt.trim()}
>
Clear
</button>
{/if}
</div>
</div>
{/if}
</div>
<!-- Fullscreen dialog -->
{#if showFullscreen && generatedImage}
<div
class="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
onclick={(e) => closeFullscreen(e)}
onkeydown={(e) => e.key === 'Escape' && closeFullscreen()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<button
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
onclick={() => closeFullscreen()}
aria-label="Close fullscreen"
>
×
</button>
<img
src={generatedImage}
alt="AI generated content"
class="max-w-full max-h-full object-contain pointer-events-none"
/>
</div>
{/if}
@@ -0,0 +1,44 @@
<script lang="ts">
import { models } from "../../stores/api";
import { groupModels } from "../../lib/modelUtils";
interface Props {
value: string;
placeholder?: string;
disabled?: boolean;
}
let { value = $bindable(), placeholder = "Select a model...", disabled = false }: Props = $props();
let grouped = $derived(groupModels($models));
let hasModels = $derived(grouped.local.length > 0 || Object.keys(grouped.peersByProvider).length > 0);
</script>
{#if hasModels}
<select
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"
bind:value
{disabled}
>
<option value="">{placeholder}</option>
{#if grouped.local.length > 0}
<optgroup label="Local">
{#each grouped.local as model (model.id)}
<option value={model.id}>{model.id}</option>
{#if model.aliases}
{#each model.aliases as alias (alias)}
<option value={alias}> {alias}</option>
{/each}
{/if}
{/each}
</optgroup>
{/if}
{#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
<optgroup label="Peer: {peerId}">
{#each peerModels as model (model.id)}
<option value={model.id}>{model.id}</option>
{/each}
</optgroup>
{/each}
</select>
{/if}
@@ -0,0 +1,14 @@
<script lang="ts">
interface Props {
featureName: string;
}
let { featureName }: Props = $props();
</script>
<div class="flex items-center justify-center h-full">
<div class="text-center text-txtsecondary">
<p class="text-lg">{featureName}</p>
<p class="text-sm mt-2">To be implemented</p>
</div>
</div>
@@ -0,0 +1,406 @@
<script lang="ts">
import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent";
import { rerank } from "../../lib/rerankApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte";
type RerankRow = { doc: string; score: number | null };
type SortOrder = "none" | "asc" | "desc";
type EditorMode = "table" | "json";
const selectedModelStore = persistentStore<string>("playground-rerank-model", "");
const defaultQuery = "How do LLM's work?";
const defaultDocs = [
"Large language models (LLMs) use transformer architectures to predict the next token in a sequence based on massive amounts of text data.",
"LLMs are trained on diverse internet text, learning statistical patterns of language that allow them to generate coherent responses.",
"During training, LLMs minimize a loss function that measures the difference between predicted and actual tokens across billions of examples.",
"Attention mechanisms in transformers enable LLMs to weigh the importance of different words when generating output.",
"Fine\u2011tuning allows a pre\u2011trained LLM to adapt to a specific downstream task with a smaller dataset.",
"Neural networks consist of layers of interconnected neurons that adjust their weights during back\u2011propagation.",
"The history of the Roman Empire spanned over a thousand years.",
"Soccer is the most popular sport in many countries around the world.",
"Quantum computing uses qubits to perform calculations that are intractable for classical computers.",
];
let query = $state(defaultQuery);
let rows = $state<RerankRow[]>([
...defaultDocs.map((doc) => ({ doc, score: null })),
{ doc: "", score: null },
]);
let isLoading = $state(false);
let error = $state<string | null>(null);
let usage = $state<{ prompt_tokens: number; total_tokens: number } | null>(null);
let abortController: AbortController | null = null;
let sortOrder = $state<SortOrder>("desc");
let editorMode = $state<EditorMode>("table");
let jsonText = $state("");
let jsonError = $state<string | null>(null);
let hasModels = $derived($models.some((m) => !m.unlisted));
let canSubmit = $derived((() => {
if (!$selectedModelStore || isLoading) return false;
if (editorMode === "json") {
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
return (
typeof parsed.query === "string" &&
parsed.query.trim() !== "" &&
Array.isArray(parsed.documents) &&
(parsed.documents as unknown[]).some(
(d) => typeof d === "string" && (d as string).trim() !== ""
)
);
} catch {
return false;
}
}
return query.trim() !== "" && rows.some((r) => r.doc.trim() !== "");
})());
// Display rows with sort applied (display-only transform, rows[] is never mutated by sorting)
let displayRows = $derived((() => {
const indexed = rows.map((row, i) => ({ row, i }));
if (sortOrder === "none") return indexed;
return [...indexed].sort((a, b) => {
if (a.row.score === null && b.row.score === null) return 0;
if (a.row.score === null) return 1;
if (b.row.score === null) return -1;
return sortOrder === "desc"
? b.row.score - a.row.score
: a.row.score - b.row.score;
});
})());
// Auto-add a new empty row when the last row gets content (table mode only)
$effect(() => {
if (editorMode === "table" && rows[rows.length - 1]?.doc.trim() !== "") {
rows = [...rows, { doc: "", score: null }];
}
});
// Sync loading state to activity store
$effect(() => {
playgroundStores.rerankLoading.set(isLoading);
});
function switchToJson() {
if (editorMode === "json") return;
const docs = rows.filter((r) => r.doc.trim() !== "").map((r) => r.doc);
jsonText = JSON.stringify({ query, documents: docs }, null, 2);
jsonError = null;
editorMode = "json";
}
function switchToTable() {
if (editorMode === "table") return;
if (jsonText.trim() === "") {
query = "";
rows = [{ doc: "", score: null }];
jsonError = null;
editorMode = "table";
return;
}
try {
const parsed = JSON.parse(jsonText) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("Expected a JSON object");
}
const obj = parsed as Record<string, unknown>;
if (typeof obj.query !== "string") throw new Error('"query" must be a string');
if (!Array.isArray(obj.documents)) throw new Error('"documents" must be an array');
query = obj.query;
const newRows: RerankRow[] = (obj.documents as unknown[]).map((d) => ({
doc: typeof d === "string" ? d : String(d),
score: null,
}));
if (newRows.length === 0 || newRows[newRows.length - 1].doc.trim() !== "") {
newRows.push({ doc: "", score: null });
}
rows = newRows;
jsonError = null;
editorMode = "table";
} catch (err) {
jsonError = err instanceof Error ? err.message : "Invalid JSON";
}
}
function cycleSortOrder() {
sortOrder = sortOrder === "none" ? "desc" : sortOrder === "desc" ? "asc" : "none";
}
function sortIndicator(): string {
if (sortOrder === "desc") return " ↓";
if (sortOrder === "asc") return " ↑";
return "";
}
async function submit() {
if (!canSubmit) return;
let submitQuery: string;
let nonEmptyEntries: { originalIndex: number; doc: string }[];
if (editorMode === "json") {
// Parse JSON, sync state to table, then submit
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
submitQuery = parsed.query as string;
const docs = (parsed.documents as string[]).filter((d) => d.trim() !== "");
const newRows: RerankRow[] = docs.map((d) => ({ doc: d, score: null }));
newRows.push({ doc: "", score: null });
rows = newRows;
query = submitQuery;
editorMode = "table";
} catch {
error = "Invalid JSON — fix before submitting";
return;
}
nonEmptyEntries = rows
.map((r, i) => ({ originalIndex: i, doc: r.doc }))
.filter((e) => e.doc.trim() !== "");
} else {
submitQuery = query;
nonEmptyEntries = rows
.map((r, i) => ({ originalIndex: i, doc: r.doc }))
.filter((e) => e.doc.trim() !== "");
}
isLoading = true;
error = null;
usage = null;
// Clear previous scores
rows = rows.map((r) => ({ ...r, score: null }));
abortController = new AbortController();
try {
const response = await rerank(
$selectedModelStore,
submitQuery,
nonEmptyEntries.map((e) => e.doc),
abortController.signal
);
usage = response.usage;
// Map result.index (position in submitted docs array) back to original rows[] index
const updated = rows.map((r) => ({ ...r }));
for (const result of response.results) {
const entry = nonEmptyEntries[result.index];
if (entry !== undefined) {
updated[entry.originalIndex].score = result.relevance_score;
}
}
rows = updated;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// User cancelled
} else {
error = err instanceof Error ? err.message : "An error occurred";
}
} finally {
isLoading = false;
abortController = null;
}
}
function cancel() {
abortController?.abort();
}
function clear() {
query = defaultQuery;
rows = [...defaultDocs.map((doc) => ({ doc, score: null })), { doc: "", score: null }];
error = null;
usage = null;
sortOrder = "desc";
jsonText = "";
jsonError = null;
}
function deleteRow(originalIndex: number) {
if (rows.length <= 1) return;
rows = rows.filter((_, i) => i !== originalIndex);
}
function updateDoc(originalIndex: number, value: string) {
const updated = rows.map((r) => ({ ...r }));
updated[originalIndex].doc = value;
rows = updated;
}
function scoreColor(score: number | null): string {
if (score === null) return "text-txtsecondary";
if (score > 0) return "text-green-600 dark:text-green-400";
return "text-red-500 dark:text-red-400";
}
function formatScore(score: number | null): string {
if (score === null) return "—";
return score.toFixed(3);
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}
let isCleared = $derived(
query === defaultQuery &&
rows.every((r, i) => r.score === null && r.doc === (defaultDocs[i] ?? "")) &&
rows.length === defaultDocs.length + 1 &&
!jsonText.trim() &&
!error &&
!usage
);
</script>
<div class="flex flex-col h-full">
<!-- Top bar: model selector + query input (table mode) + mode toggle -->
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a rerank model..." disabled={isLoading} />
{#if editorMode === "table"}
<input
type="text"
class="min-w-0 flex-1 basis-48 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Query..."
bind:value={query}
disabled={isLoading}
onkeydown={handleKeyDown}
/>
{/if}
<!-- Table / JSON toggle -->
<div class="flex rounded border border-gray-200 dark:border-white/10 overflow-hidden shrink-0">
<button
class="px-3 py-1.5 text-sm transition-colors {editorMode === 'table'
? 'bg-primary text-btn-primary-text'
: 'bg-surface hover:bg-secondary-hover'}"
onclick={switchToTable}
disabled={isLoading}
>
Table
</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>
{#if !hasModels}
<div class="flex-1 flex items-center justify-center text-txtsecondary">
<p>No models configured. Add models to your configuration to use reranking.</p>
</div>
{:else if editorMode === "json"}
<!-- JSON editor -->
<div class="flex-1 flex flex-col min-h-0 mb-4">
<textarea
class="flex-1 w-full font-mono text-sm px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary resize-none"
bind:value={jsonText}
disabled={isLoading}
placeholder={'{\n "query": "your search query",\n "documents": [\n "document one",\n "document two"\n ]\n}'}
spellcheck={false}
></textarea>
{#if jsonError}
<p class="mt-1 text-sm text-red-500">{jsonError}</p>
{/if}
</div>
{:else}
<!-- Document table -->
<div class="flex-1 overflow-y-auto mb-4 border border-gray-200 dark:border-white/10 rounded">
<table class="w-full border-collapse table-fixed">
<colgroup>
<col class="w-auto" />
<col style="width: 120px" />
<col style="width: 40px" />
</colgroup>
<thead class="sticky top-0 bg-surface border-b border-gray-200 dark:border-white/10">
<tr>
<th class="px-3 py-2 text-left text-sm font-medium text-txtsecondary">Document</th>
<th
class="px-3 py-2 text-right text-sm font-medium text-txtsecondary cursor-pointer select-none hover:text-txtprimary transition-colors"
onclick={cycleSortOrder}
>
Score{sortIndicator()}
</th>
<th class="px-2 py-2"></th>
</tr>
</thead>
<tbody>
{#each displayRows as { row, i } (i)}
<tr class="border-b border-gray-100 dark:border-white/5 last:border-0">
<td class="px-3 py-1.5">
<input
type="text"
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..."}
value={row.doc}
oninput={(e) => updateDoc(i, (e.target as HTMLInputElement).value)}
disabled={isLoading}
onkeydown={handleKeyDown}
/>
</td>
<td class="px-3 py-1.5 text-right font-mono text-sm {scoreColor(row.score)}">
{#if isLoading && row.score === null && row.doc.trim() !== ""}
<span class="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin align-middle"></span>
{:else}
{formatScore(row.score)}
{/if}
</td>
<td class="px-2 py-1.5 text-center">
<button
class="w-7 h-7 flex items-center justify-center text-txtsecondary hover:text-red-500 transition-colors rounded disabled:opacity-30 disabled:cursor-not-allowed"
onclick={() => deleteRow(i)}
disabled={rows.length <= 1}
tabindex="-1"
aria-label="Remove row"
>
×
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Bottom toolbar -->
{#if hasModels}
<div class="shrink-0 flex flex-wrap items-center gap-2">
{#if isLoading}
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancel}>
Cancel
</button>
{:else}
<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 error}
<span class="text-sm text-red-500 ml-2">{error}</span>
{:else if usage}
<span class="text-sm text-txtsecondary ml-2">{usage.total_tokens} tokens</span>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,360 @@
<script lang="ts">
import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent";
import { generateSpeech } from "../../lib/speechApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte";
const selectedModelStore = persistentStore<string>("playground-speech-model", "");
const selectedVoiceStore = persistentStore<string>("playground-speech-voice", "coral");
const autoPlayStore = persistentStore<boolean>("playground-speech-autoplay", false);
let inputText = $state("");
let isGenerating = $state(false);
let generatedAudioUrl = $state<string | null>(null);
let generatedVoice = $state<string | null>(null);
let generatedTimestamp = $state<Date | null>(null);
let error = $state<string | null>(null);
let abortController = $state<AbortController | null>(null);
let audioElement = $state<HTMLAudioElement | null>(null);
let availableVoices = $state<string[]>(["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
let isLoadingVoices = $state(false);
const defaultVoices = ["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"];
const CACHE_KEY = "playground-speech-voices-cache";
function getVoicesCache(): Record<string, string[]> {
if (typeof window === "undefined") return {};
try {
const saved = localStorage.getItem(CACHE_KEY);
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
}
function saveVoicesCache(cache: Record<string, string[]>) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
} catch (e) {
console.error("Error saving voices cache", e);
}
}
let hasModels = $derived($models.some((m) => !m.unlisted));
let isInitialLoad = $state(true);
$effect(() => {
playgroundStores.speechGenerating.set(isGenerating);
});
// On page load, restore cached voices for the selected model if available
$effect(() => {
const model = $selectedModelStore;
if (isInitialLoad) {
isInitialLoad = false;
// If we have cached voices for this model, use them
const cache = getVoicesCache();
if (model && cache[model]) {
availableVoices = cache[model];
}
}
});
async function refreshVoices() {
const model = $selectedModelStore;
if (!model || isLoadingVoices) return;
isLoadingVoices = true;
try {
const response = await fetch(`/v1/audio/voices?model=${encodeURIComponent(model)}`);
if (!response.ok) {
// Fall back to default voices if API call fails
availableVoices = defaultVoices;
const cache = getVoicesCache();
cache[model] = defaultVoices;
saveVoicesCache(cache);
selectedVoiceStore.set(defaultVoices[0]);
return;
}
const data = await response.json();
// Expect response to be an array of voice strings or an object with a voices array
const voices = Array.isArray(data) ? data : (data.voices || defaultVoices);
const newVoices = voices.length > 0 ? voices : defaultVoices;
availableVoices = newVoices;
const cache = getVoicesCache();
cache[model] = newVoices;
saveVoicesCache(cache);
// Reset to first available voice
selectedVoiceStore.set(newVoices[0]);
} catch {
// Fall back to default voices on error
availableVoices = defaultVoices;
const cache = getVoicesCache();
cache[model] = defaultVoices;
saveVoicesCache(cache);
selectedVoiceStore.set(defaultVoices[0]);
} finally {
isLoadingVoices = false;
}
}
function handleVoiceChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
if (value === "(refresh)") {
refreshVoices();
} else {
selectedVoiceStore.set(value);
}
}
// Auto-play effect when new audio is generated
$effect(() => {
if (generatedAudioUrl && $autoPlayStore && audioElement) {
audioElement.load();
audioElement.play().catch(() => {
// Ignore auto-play errors (e.g., browser policy blocks)
});
}
});
async function generate() {
const trimmedText = inputText.trim();
if (!trimmedText || !$selectedModelStore || isGenerating) return;
isGenerating = true;
error = null;
abortController = new AbortController();
try {
const audioBlob = await generateSpeech(
$selectedModelStore,
trimmedText,
$selectedVoiceStore,
abortController.signal
);
// Revoke previous URL to prevent memory leaks
if (generatedAudioUrl) {
URL.revokeObjectURL(generatedAudioUrl);
}
// Create object URL for the audio blob and store metadata
generatedAudioUrl = URL.createObjectURL(audioBlob);
generatedVoice = $selectedVoiceStore;
generatedTimestamp = new Date();
} 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;
}
}
function cancelGeneration() {
abortController?.abort();
}
function clearInput() {
inputText = "";
}
function downloadAudio() {
if (!generatedAudioUrl) return;
const timestamp = (generatedTimestamp || new Date()).toISOString().replace(/[:.]/g, '-').slice(0, -5);
const voice = generatedVoice || 'speech';
const filename = `${voice}-${timestamp}.mp3`;
const a = document.createElement('a');
a.href = generatedAudioUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function formatTimestamp(date: Date): string {
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
generate();
}
}
</script>
<div class="flex flex-col h-full">
<!-- Model and voice selectors -->
<div class="shrink-0 flex gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a speech model..." disabled={isGenerating} />
<div class="flex gap-2">
<select
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}
onchange={handleVoiceChange}
disabled={isGenerating || isLoadingVoices || !$selectedModelStore}
>
{#each availableVoices as voice (voice)}
<option value={voice}>{voice}</option>
{/each}
<option value="(refresh)">(refresh)</option>
</select>
{#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]}
<button
class="btn shrink-0"
onclick={refreshVoices}
disabled={isLoadingVoices}
title={isLoadingVoices ? "Loading voices..." : "Load voices for this model"}
>
{#if isLoadingVoices}
<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}
</div>
</div>
<!-- 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 speech.</p>
</div>
{:else}
<!-- Audio display area -->
<div class="shrink-0 mb-4 bg-surface border border-gray-200 dark:border-white/10 rounded p-4 md:p-6">
{#if isGenerating}
<div class="flex items-center justify-center text-txtsecondary py-8">
<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>
<p>Generating speech...</p>
</div>
</div>
{:else if error}
<div class="flex items-center justify-center py-8">
<div class="text-center text-red-500">
<p class="font-medium">Error</p>
<p class="text-sm mt-1">{error}</p>
</div>
</div>
{:else if generatedAudioUrl}
<div class="flex flex-col gap-4">
<!-- Header with metadata and download -->
<div class="flex items-center justify-between gap-4">
<div class="flex flex-wrap gap-3 text-sm text-txtsecondary">
{#if generatedVoice}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
</svg>
{generatedVoice}
</span>
{/if}
{#if generatedTimestamp}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{formatTimestamp(generatedTimestamp)}
</span>
{/if}
</div>
<button
class="btn shrink-0"
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>
<!-- Audio player with larger controls -->
<div class="w-full">
<audio bind:this={audioElement} controls class="w-full h-12 md:h-16">
<source src={generatedAudioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
</div>
{:else}
<div class="flex items-center justify-center text-txtsecondary py-8">
<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">
<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>
</svg>
<p>Enter text below to convert to speech</p>
</div>
</div>
{/if}
</div>
<!-- Text input area -->
<div class="flex-1 flex flex-col md:flex-row gap-2 min-h-0">
<ExpandableTextarea
bind:value={inputText}
placeholder="Enter text to convert to speech..."
rows={8}
onkeydown={handleKeyDown}
disabled={isGenerating || !$selectedModelStore}
/>
<div class="shrink-0 flex 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={!inputText.trim() || !$selectedModelStore}
>
Generate
</button>
<button
class="btn flex-1 md:flex-none"
onclick={clearInput}
disabled={!inputText.trim()}
>
Clear
</button>
<label class="flex items-center justify-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
bind:checked={$autoPlayStore}
class="cursor-pointer"
/>
Auto-play
</label>
{/if}
</div>
</div>
{/if}
</div>
+1
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "katex/dist/katex.min.css";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme {
+24
View File
@@ -0,0 +1,24 @@
import type { AudioTranscriptionResponse } from "./types";
export async function transcribeAudio(
model: string,
file: File,
signal?: AbortSignal
): Promise<AudioTranscriptionResponse> {
const formData = new FormData();
formData.append("file", file);
formData.append("model", model);
const response = await fetch("/v1/audio/transcriptions", {
method: "POST",
body: formData,
signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Audio API error: ${response.status} - ${errorText}`);
}
return response.json();
}
+108
View File
@@ -0,0 +1,108 @@
import type { ChatMessage, ChatCompletionRequest } from "./types";
export interface StreamChunk {
content: string;
reasoning_content?: string;
done: boolean;
}
export interface ChatOptions {
temperature?: number;
}
function parseSSELine(line: string): StreamChunk | null {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data: ")) {
return null;
}
const data = trimmed.slice(6);
if (data === "[DONE]") {
return { content: "", done: true };
}
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta;
const content = delta?.content || "";
const reasoning_content = delta?.reasoning_content || "";
if (content || reasoning_content) {
return { content, reasoning_content, done: false };
}
return null;
} catch {
return null;
}
}
export async function* streamChatCompletion(
model: string,
messages: ChatMessage[],
signal?: AbortSignal,
options?: ChatOptions
): AsyncGenerator<StreamChunk> {
const request: ChatCompletionRequest = {
model,
messages,
stream: true,
temperature: options?.temperature,
};
const response = await fetch("/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Chat API error: ${response.status} - ${errorText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const result = parseSSELine(line);
if (result?.done) {
yield result;
return;
}
if (result) {
yield result;
}
}
}
// Process any remaining buffer
const result = parseSSELine(buffer);
if (result && !result.done) {
yield result;
}
yield { content: "", done: true };
} finally {
reader.releaseLock();
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { ImageGenerationRequest, ImageGenerationResponse } from "./types";
export async function generateImage(
model: string,
prompt: string,
size: string,
signal?: AbortSignal
): Promise<ImageGenerationResponse> {
const request: ImageGenerationRequest = {
model,
prompt,
n: 1,
size,
};
const response = await fetch("/v1/images/generations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Image API error: ${response.status} - ${errorText}`);
}
return response.json();
}
+423
View File
@@ -0,0 +1,423 @@
import { describe, it, expect } from "vitest";
import { renderMarkdown, escapeHtml, splitCompleteBlocks, closePendingBlock, normalizeLatexDelimiters, renderStreamingMarkdown, createStreamingCache } from "./markdown";
describe("renderMarkdown", () => {
describe("basic markdown", () => {
it("renders plain text", () => {
const result = renderMarkdown("Hello world");
expect(result).toContain("Hello world");
});
it("renders bold text", () => {
const result = renderMarkdown("**bold**");
expect(result).toContain("<strong>bold</strong>");
});
it("renders italic text", () => {
const result = renderMarkdown("*italic*");
expect(result).toContain("<em>italic</em>");
});
it("renders code blocks", () => {
const result = renderMarkdown("```js\nconst x = 1;\n```");
expect(result).toContain("hljs");
expect(result).toContain("const");
});
it("returns empty string for empty content", () => {
const result = renderMarkdown("");
expect(result).toBe("");
});
it("returns empty string for null/undefined content", () => {
// @ts-expect-error - testing null input
expect(renderMarkdown(null)).toBe("");
// @ts-expect-error - testing undefined input
expect(renderMarkdown(undefined)).toBe("");
});
});
describe("KaTeX math rendering", () => {
it("renders inline math with $...$ syntax", () => {
const result = renderMarkdown("The equation $E = mc^2$ is famous.");
// KaTeX should convert this to HTML with katex class
expect(result).toContain("katex");
expect(result).toContain("E");
expect(result).toContain("=");
expect(result).toContain("mc");
});
it("renders display math with $$...$$ syntax", () => {
const result = renderMarkdown("$$\\int_{a}^{b} f(x) dx$$");
// Math should be rendered with KaTeX
expect(result).toContain("katex");
expect(result).toContain("∫");
expect(result).toContain("f(x)");
});
it("renders complex LaTeX expressions", () => {
const result = renderMarkdown("$$\\sum_{i=1}^{n} x_i = \\frac{1}{n}\\sum_{i=1}^{n} x_i$$");
expect(result).toContain("katex");
expect(result).toContain("∑"); // or the MathML equivalent
});
it("renders LaTeX with Greek letters", () => {
const result = renderMarkdown("$\\alpha + \\beta = \\gamma$");
expect(result).toContain("katex");
// Greek letters should be rendered
expect(result).toMatch(/[αβγ]|alpha|beta|gamma/);
});
it("renders LaTeX with fractions", () => {
const result = renderMarkdown("$\\frac{a}{b}$");
expect(result).toContain("katex");
expect(result).toContain("frac");
});
it("renders LaTeX with subscripts and superscripts", () => {
const result = renderMarkdown("$x^2 + y_3$");
expect(result).toContain("katex");
expect(result).toContain("sup"); // superscript
expect(result).toContain("sub"); // subscript
});
it("renders multiple inline math expressions in one paragraph", () => {
const result = renderMarkdown("First $x = 1$ and then $y = 2$.");
// Should contain multiple katex spans
const katexMatches = result.match(/katex/g);
expect(katexMatches?.length).toBeGreaterThanOrEqual(2);
});
it("renders math within a larger markdown document", () => {
const markdown = `# Heading
This is a paragraph with $E = mc^2$ inline math.
$$\\int_0^\\infty e^{-x} dx = 1$$
More text here.
`;
const result = renderMarkdown(markdown);
expect(result).toContain("<h1>Heading</h1>");
expect(result).toContain("katex");
// Both inline and display math should be rendered
expect(result).toContain("E = mc");
expect(result).toContain("∫");
expect(result).toContain("∞");
});
it("handles escaped dollar signs", () => {
const result = renderMarkdown("This costs \\$5 and $x = 1$.");
// Should render the escaped $5 as text and the math
expect(result).toContain("katex");
expect(result).toContain("$5");
});
it("handles empty math expressions gracefully", () => {
// Empty math should not break the renderer
const result = renderMarkdown("$$$");
expect(result).toBeTruthy();
});
it("renders LaTeX matrices", () => {
const result = renderMarkdown("$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$$");
expect(result).toContain("katex");
expect(result).toContain("pmatrix");
});
it("renders LaTeX square roots", () => {
const result = renderMarkdown("$\\sqrt{x^2 + y^2}$");
expect(result).toContain("katex");
expect(result).toContain("sqrt");
});
it("renders \\[...\\] display math", () => {
const result = renderMarkdown("\\[\nx^2 + y^2 = z^2\n\\]");
expect(result).toContain("katex");
});
it("renders \\(...\\) inline math", () => {
const result = renderMarkdown("The equation \\(E = mc^2\\) is famous.");
expect(result).toContain("katex");
});
});
describe("normalizeLatexDelimiters", () => {
it("converts \\[...\\] to $$...$$", () => {
expect(normalizeLatexDelimiters("\\[\nx^2\n\\]")).toBe("$$\nx^2\n$$");
});
it("converts \\(...\\) to $...$", () => {
expect(normalizeLatexDelimiters("\\(x^2\\)")).toBe("$x^2$");
});
it("leaves $$ and $ delimiters unchanged", () => {
const text = "$$x^2$$ and $y$";
expect(normalizeLatexDelimiters(text)).toBe(text);
});
it("handles multiple occurrences", () => {
expect(normalizeLatexDelimiters("\\(a\\) and \\(b\\)")).toBe("$a$ and $b$");
});
});
describe("escapeHtml", () => {
it("escapes HTML entities", () => {
expect(escapeHtml("<script>")).toBe("&lt;script&gt;");
expect(escapeHtml('"quoted"')).toBe("&quot;quoted&quot;");
expect(escapeHtml("'single'")).toBe("&#39;single&#39;");
expect(escapeHtml("a & b")).toBe("a &amp; b");
});
it("handles empty string", () => {
expect(escapeHtml("")).toBe("");
});
});
describe("error handling", () => {
it("does not throw on invalid LaTeX syntax", () => {
// Invalid LaTeX should not crash the renderer
expect(() => renderMarkdown("$\\invalidcommand{")).not.toThrow();
});
it("returns fallback HTML on processing errors", () => {
// Very large or malformed input should be handled
const result = renderMarkdown("$" + "a".repeat(10000) + "$");
expect(result).toBeTruthy();
});
});
});
describe("splitCompleteBlocks", () => {
it("returns everything as pending when no blank line", () => {
const result = splitCompleteBlocks("Hello world");
expect(result.complete).toBe("");
expect(result.pending).toBe("Hello world");
});
it("returns empty for empty input", () => {
const result = splitCompleteBlocks("");
expect(result.complete).toBe("");
expect(result.pending).toBe("");
});
it("splits on blank line between paragraphs", () => {
const result = splitCompleteBlocks("First paragraph.\n\nSecond paragraph");
expect(result.complete).toBe("First paragraph.\n");
expect(result.pending).toBe("Second paragraph");
});
it("splits multiple paragraphs at last blank line", () => {
const result = splitCompleteBlocks("Para 1.\n\nPara 2.\n\nPara 3");
expect(result.complete).toBe("Para 1.\n\nPara 2.\n");
expect(result.pending).toBe("Para 3");
});
it("treats closed code fence as complete boundary", () => {
const text = "```js\nconst x = 1;\n```\nMore text";
const result = splitCompleteBlocks(text);
expect(result.complete).toBe("```js\nconst x = 1;\n```");
expect(result.pending).toBe("More text");
});
it("treats unclosed code fence as pending", () => {
const text = "Done paragraph.\n\n```js\nconst x = 1;";
const result = splitCompleteBlocks(text);
expect(result.complete).toBe("Done paragraph.\n");
expect(result.pending).toBe("```js\nconst x = 1;");
});
it("does not split on blank lines inside code fences", () => {
const text = "```\nline1\n\nline2\n```";
const result = splitCompleteBlocks(text);
expect(result.complete).toBe("```\nline1\n\nline2\n```");
expect(result.pending).toBe("");
});
it("handles tilde fences", () => {
const text = "~~~py\nprint('hi')\n~~~\nAfter";
const result = splitCompleteBlocks(text);
expect(result.complete).toBe("~~~py\nprint('hi')\n~~~");
expect(result.pending).toBe("After");
});
it("does not close backtick fence with tilde fence", () => {
const text = "```\ncode\n~~~\nstill code";
const result = splitCompleteBlocks(text);
// The ~~~ should not close a backtick fence, so everything from ``` onward is pending
expect(result.complete).toBe("");
expect(result.pending).toBe("```\ncode\n~~~\nstill code");
});
it("treats closed math block as complete boundary", () => {
const text = "$$\nx^2\n$$\nAfter";
const result = splitCompleteBlocks(text);
expect(result.complete).toBe("$$\nx^2\n$$");
expect(result.pending).toBe("After");
});
it("treats unclosed math block as pending", () => {
const text = "Before.\n\n$$\nx^2";
const result = splitCompleteBlocks(text);
expect(result.complete).toBe("Before.\n");
expect(result.pending).toBe("$$\nx^2");
});
it("treats closed \\[...\\] math block as complete boundary", () => {
const text = "\\[\nx^2\n\\]\nAfter";
const result = splitCompleteBlocks(text);
expect(result.complete).toBe("\\[\nx^2\n\\]");
expect(result.pending).toBe("After");
});
it("treats unclosed \\[ math block as pending", () => {
const text = "Before.\n\n\\[\nx^2";
const result = splitCompleteBlocks(text);
expect(result.complete).toBe("Before.\n");
expect(result.pending).toBe("\\[\nx^2");
});
it("handles trailing blank line making everything complete", () => {
const text = "Hello world.\n";
const result = splitCompleteBlocks(text);
// Last line is empty string after split, which is a blank line
expect(result.complete).toBe("Hello world.\n");
expect(result.pending).toBe("");
});
});
describe("closePendingBlock", () => {
it("returns empty string for empty input", () => {
expect(closePendingBlock("")).toBe("");
});
it("returns plain text unchanged", () => {
expect(closePendingBlock("Hello world")).toBe("Hello world");
});
it("closes an open backtick code fence", () => {
const result = closePendingBlock("```python\nprint('hi')");
expect(result).toBe("```python\nprint('hi')\n```");
});
it("closes an open tilde code fence", () => {
const result = closePendingBlock("~~~js\nconst x = 1;");
expect(result).toBe("~~~js\nconst x = 1;\n~~~");
});
it("does not modify already-closed code fence", () => {
const text = "```py\ncode\n```";
expect(closePendingBlock(text)).toBe(text);
});
it("closes an open math block", () => {
const result = closePendingBlock("$$\nx^2 + y^2");
expect(result).toBe("$$\nx^2 + y^2\n$$");
});
it("does not modify already-closed math block", () => {
const text = "$$\nx^2\n$$";
expect(closePendingBlock(text)).toBe(text);
});
it("closes an open \\[ math block with \\]", () => {
const result = closePendingBlock("\\[\nx^2 + y^2");
expect(result).toBe("\\[\nx^2 + y^2\n\\]");
});
it("does not modify already-closed \\[...\\] math block", () => {
const text = "\\[\nx^2\n\\]";
expect(closePendingBlock(text)).toBe(text);
});
it("closes code fence when preceded by regular text", () => {
const result = closePendingBlock("Some text\n```\ncode");
expect(result).toBe("Some text\n```\ncode\n```");
});
it("leaves headers unchanged", () => {
expect(closePendingBlock("## Hello")).toBe("## Hello");
});
it("leaves tables unchanged", () => {
const table = "| a | b |\n| --- | --- |\n| 1 | 2 |";
expect(closePendingBlock(table)).toBe(table);
});
it("leaves lists unchanged", () => {
expect(closePendingBlock("- item 1\n- item 2")).toBe("- item 1\n- item 2");
});
});
describe("renderStreamingMarkdown", () => {
it("renders complete blocks and pending as markdown", () => {
const cache = createStreamingCache();
const text = "# Hello\n\nWorld";
const { blocks, pendingHtml } = renderStreamingMarkdown(text, cache);
expect(blocks).toHaveLength(1);
expect(blocks[0].html).toContain("<h1>Hello</h1>");
expect(pendingHtml).toContain("World");
expect(pendingHtml).toContain("<p>");
});
it("preserves existing blocks when complete portion is unchanged", () => {
const cache = createStreamingCache();
renderStreamingMarkdown("# Hello\n\nWor", cache);
const firstBlocks = cache.blocks;
const { blocks } = renderStreamingMarkdown("# Hello\n\nWorld", cache);
// Same block array reference — nothing changed in the complete section
expect(blocks).toBe(firstBlocks);
expect(cache.completeKey).toBe("# Hello\n");
});
it("appends a new block when a new section completes", () => {
const cache = createStreamingCache();
renderStreamingMarkdown("# Hello\n\nParagraph", cache);
expect(cache.blocks).toHaveLength(1);
const firstBlock = cache.blocks[0];
renderStreamingMarkdown("# Hello\n\nParagraph.\n\nMore", cache);
expect(cache.blocks).toHaveLength(2);
// First block is preserved with the same id and html
expect(cache.blocks[0].id).toBe(firstBlock.id);
expect(cache.blocks[0].html).toBe(firstBlock.html);
// Second block contains the new paragraph
expect(cache.blocks[1].html).toContain("Paragraph.");
});
it("assigns unique stable ids to each block", () => {
const cache = createStreamingCache();
renderStreamingMarkdown("A.\n\nB.\n\nC", cache);
expect(cache.blocks).toHaveLength(1);
const id0 = cache.blocks[0].id;
renderStreamingMarkdown("A.\n\nB.\n\nC.\n\nD", cache);
expect(cache.blocks).toHaveLength(2);
expect(cache.blocks[0].id).toBe(id0);
expect(cache.blocks[1].id).toBe(id0 + 1);
});
it("renders pending code block with syntax highlighting", () => {
const cache = createStreamingCache();
const text = "Done.\n\n```python\nprint('hello')";
const { pendingHtml } = renderStreamingMarkdown(text, cache);
expect(pendingHtml).toContain("<code");
expect(pendingHtml).toContain("hljs");
});
it("renders pending table as markdown", () => {
const cache = createStreamingCache();
const text = "Done.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |";
const { pendingHtml } = renderStreamingMarkdown(text, cache);
expect(pendingHtml).toContain("<table>");
expect(pendingHtml).toContain("<td>");
});
it("renders pending portion through markdown pipeline", () => {
const cache = createStreamingCache();
const text = "Done.\n\nSome **bold** text";
const { pendingHtml } = renderStreamingMarkdown(text, cache);
expect(pendingHtml).toContain("<strong>bold</strong>");
});
});
+260
View File
@@ -0,0 +1,260 @@
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkRehype from "remark-rehype";
import rehypeKatex from "rehype-katex";
import rehypeStringify from "rehype-stringify";
import hljs from "highlight.js";
import { visit } from "unist-util-visit";
import type { Element, Root } from "hast";
// Custom plugin to highlight code blocks with highlight.js
function rehypeHighlight() {
return (tree: Root) => {
visit(tree, "element", (node: Element) => {
if (node.tagName === "code" && node.properties) {
const className = node.properties.className;
const classes = Array.isArray(className)
? className.filter((c): c is string => typeof c === "string")
: [];
const lang = classes
.find((c) => c.startsWith("language-"))
?.replace("language-", "");
const text = node.children
.filter((child): child is { type: "text"; value: string } => child.type === "text")
.map((child) => child.value)
.join("");
if (text) {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
// Replace the text node with raw HTML
node.properties.className = [
"hljs",
`language-${language}`,
...classes.filter((c) => !c.startsWith("language-")),
];
// Use type assertion since we're modifying the tree structure
(node.children as unknown) = [
{ type: "raw", value: highlighted },
];
}
}
});
};
}
export function escapeHtml(text: string): string {
const htmlEntities: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return text.replace(/[&<>"']/g, (char) => htmlEntities[char]);
}
// Create the unified processor
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex)
.use(rehypeHighlight)
.use(rehypeStringify, { allowDangerousHtml: true });
export function splitCompleteBlocks(text: string): { complete: string; pending: string } {
if (!text) {
return { complete: "", pending: "" };
}
const lines = text.split("\n");
let lastCompleteBoundary = -1; // index of last line that ends a complete block
let inFence = false;
let fenceChar = "";
let inMathBlock = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trimEnd();
if (inFence) {
// Check for closing fence: same character, at least 3, no other content
if (new RegExp(`^\\s*${fenceChar.replace(/~/g, "\\~")}{3,}\\s*$`).test(trimmed)) {
inFence = false;
fenceChar = "";
lastCompleteBoundary = i;
}
continue;
}
if (inMathBlock) {
if (trimmed === "$$" || trimmed === "\\]") {
inMathBlock = false;
lastCompleteBoundary = i;
}
continue;
}
// Check for opening fence
const fenceMatch = trimmed.match(/^(\s*)(```|~~~)/);
if (fenceMatch) {
// Check if it's an opening fence (may have language info after)
// A line with just ``` or ~~~ could be opening or closing, but since we're not in a fence it's opening
fenceChar = fenceMatch[2][0]; // '`' or '~'
inFence = true;
continue;
}
// Check for opening math block
if (trimmed === "$$" || trimmed === "\\[") {
inMathBlock = true;
continue;
}
// Outside fences/math: blank line marks a complete boundary
if (trimmed === "") {
lastCompleteBoundary = i;
}
}
if (lastCompleteBoundary < 0) {
return { complete: "", pending: text };
}
const completeLines = lines.slice(0, lastCompleteBoundary + 1);
const pendingLines = lines.slice(lastCompleteBoundary + 1);
return {
complete: completeLines.join("\n"),
pending: pendingLines.join("\n"),
};
}
export function closePendingBlock(pending: string): string {
if (!pending) return "";
const lines = pending.split("\n");
let inFence = false;
let fenceStr = "";
let inMathBlock = false;
let mathClose = "";
for (const line of lines) {
const trimmed = line.trimEnd();
if (inFence) {
if (new RegExp(`^\\s*${fenceStr[0] === "~" ? "~~~" : "\\`\\`\\`"}\\s*$`).test(trimmed)) {
inFence = false;
fenceStr = "";
}
continue;
}
if (inMathBlock) {
if (trimmed === "$$" || trimmed === "\\]") {
inMathBlock = false;
mathClose = "";
}
continue;
}
const fenceMatch = trimmed.match(/^(\s*)(```|~~~)/);
if (fenceMatch) {
fenceStr = fenceMatch[2];
inFence = true;
continue;
}
if (trimmed === "$$") {
inMathBlock = true;
mathClose = "$$";
continue;
}
if (trimmed === "\\[") {
inMathBlock = true;
mathClose = "\\]";
continue;
}
}
if (inFence) return pending + "\n" + fenceStr;
if (inMathBlock) return pending + "\n" + mathClose;
return pending;
}
export interface RenderedBlock {
id: number;
html: string;
}
export interface StreamingCache {
blocks: RenderedBlock[];
nextId: number;
completeKey: string;
}
export function createStreamingCache(): StreamingCache {
return { blocks: [], nextId: 0, completeKey: "" };
}
export function renderStreamingMarkdown(
text: string,
cache: StreamingCache,
): { blocks: RenderedBlock[]; pendingHtml: string } {
const { complete, pending } = splitCompleteBlocks(text);
if (complete) {
if (cache.completeKey !== complete) {
if (complete.startsWith(cache.completeKey) && cache.completeKey.length > 0) {
// Complete section grew — render only the new part as a new block
const newPart = complete.slice(cache.completeKey.length);
cache.blocks = [...cache.blocks, { id: cache.nextId++, html: renderMarkdown(newPart) }];
} else {
// Complete section changed unexpectedly — re-render as single block
cache.blocks = [{ id: cache.nextId++, html: renderMarkdown(complete) }];
}
cache.completeKey = complete;
}
} else if (cache.blocks.length > 0) {
cache.blocks = [];
cache.completeKey = "";
}
let pendingHtml = "";
if (pending) {
const closed = closePendingBlock(pending);
pendingHtml = renderMarkdown(closed);
}
return { blocks: cache.blocks, pendingHtml };
}
// Convert \[...\] to $$...$$ and \(...\) to $...$
export function normalizeLatexDelimiters(text: string): string {
// Display math: \[...\] → $$...$$ (may span multiple lines)
text = text.replace(/\\\[([\s\S]*?)\\\]/g, (_match, inner) => `$$${inner}$$`);
// Inline math: \(...\) → $...$
text = text.replace(/\\\(([\s\S]*?)\\\)/g, (_match, inner) => `$${inner}$`);
return text;
}
export function renderMarkdown(content: string): string {
if (!content) {
return "";
}
try {
const result = processor.processSync(normalizeLatexDelimiters(content));
return String(result);
} catch {
// Fallback to escaped plain text if markdown parsing fails
return `<p>${escapeHtml(content)}</p>`;
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { Model } from "./types";
export interface GroupedModels {
local: Model[];
peersByProvider: Record<string, Model[]>;
}
export function groupModels(models: Model[]): GroupedModels {
const available = models.filter((m) => !m.unlisted);
const local = available.filter((m) => !m.peerID);
const peerModels = available.filter((m) => m.peerID);
const peersByProvider = 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 { local, peersByProvider };
}
+27
View File
@@ -0,0 +1,27 @@
export interface RerankResult {
index: number;
relevance_score: number;
}
export interface RerankResponse {
model: string;
object: string;
usage: { prompt_tokens: number; total_tokens: number };
results: RerankResult[];
}
export async function rerank(
model: string,
query: string,
documents: string[],
signal: AbortSignal
): Promise<RerankResponse> {
const response = await fetch("/v1/rerank", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, query, documents }),
signal,
});
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.json();
}
+30
View File
@@ -0,0 +1,30 @@
import type { SpeechGenerationRequest } from "./types";
export async function generateSpeech(
model: string,
input: string,
voice: string,
signal?: AbortSignal
): Promise<Blob> {
const request: SpeechGenerationRequest = {
model,
input,
voice,
};
const response = await fetch("/v1/audio/speech", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Speech API error: ${response.status} - ${errorText}`);
}
return response.blob();
}
+90 -1
View File
@@ -9,6 +9,7 @@ export interface Model {
description: string;
unlisted: boolean;
peerID: string;
aliases?: string[];
}
export interface Metrics {
@@ -21,6 +22,16 @@ export interface Metrics {
prompt_per_second: number;
tokens_per_second: number;
duration_ms: number;
has_capture: boolean;
}
export interface ReqRespCapture {
id: number;
req_path: string;
req_headers: Record<string, string>;
req_body: string; // base64 encoded bytes
resp_headers: Record<string, string>;
resp_body: string; // base64 encoded bytes
}
export interface LogData {
@@ -28,8 +39,12 @@ export interface LogData {
data: string;
}
export interface InFlightStats {
total: number;
}
export interface APIEventEnvelope {
type: "modelStatus" | "logData" | "metrics";
type: "modelStatus" | "logData" | "metrics" | "inflight";
data: string;
}
@@ -40,3 +55,77 @@ export interface VersionInfo {
}
export type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
export type TextContentPart = {
type: "text";
text: string;
};
export type ImageContentPart = {
type: "image_url";
image_url: { url: string };
};
export type ContentPart = TextContentPart | ImageContentPart;
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string | ContentPart[];
reasoning_content?: string;
reasoningTimeMs?: number;
}
export function getTextContent(content: string | ContentPart[]): string {
if (typeof content === "string") {
return content;
}
const textParts = content.filter((part): part is TextContentPart => part.type === "text");
return textParts.map((part) => part.text).join("\n");
}
export function getImageUrls(content: string | ContentPart[]): string[] {
if (typeof content === "string") {
return [];
}
return content
.filter((part): part is ImageContentPart => part.type === "image_url")
.map((part) => part.image_url.url);
}
export interface ChatCompletionRequest {
model: string;
messages: ChatMessage[];
stream: boolean;
temperature?: number;
max_tokens?: number;
}
export interface ImageGenerationRequest {
model: string;
prompt: string;
n?: number;
size?: string;
}
export interface ImageGenerationResponse {
created: number;
data: Array<{
url?: string;
b64_json?: string;
}>;
}
export interface AudioTranscriptionRequest {
file: File;
model: string;
}
export interface AudioTranscriptionResponse {
text: string;
}
export interface SpeechGenerationRequest {
model: string;
input: string;
voice: string;
}
+1
View File
@@ -1,4 +1,5 @@
import "./index.css";
import "highlight.js/styles/github-dark.css";
import App from "./App.svelte";
import { mount } from "svelte";
+38 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { metrics } from "../stores/api";
import { metrics, getCapture } from "../stores/api";
import Tooltip from "../components/Tooltip.svelte";
import CaptureDialog from "../components/CaptureDialog.svelte";
import type { ReqRespCapture } from "../lib/types";
function formatSpeed(speed: number): string {
return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s";
@@ -38,6 +40,25 @@
}
let sortedMetrics = $derived([...$metrics].sort((a, b) => b.id - a.id));
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;
if (capture) {
selectedCapture = capture;
dialogOpen = true;
}
}
function closeDialog() {
dialogOpen = false;
selectedCapture = null;
}
</script>
<div class="p-2">
@@ -65,6 +86,7 @@
<th class="px-6 py-3">Prompt Processing</th>
<th class="px-6 py-3">Generation Speed</th>
<th class="px-6 py-3">Duration</th>
<th class="px-6 py-3">Capture</th>
</tr>
</thead>
<tbody class="divide-y">
@@ -79,6 +101,19 @@
<td class="px-6 py-4">{formatSpeed(metric.prompt_per_second)}</td>
<td class="px-6 py-4">{formatSpeed(metric.tokens_per_second)}</td>
<td class="px-6 py-4">{formatDuration(metric.duration_ms)}</td>
<td class="px-6 py-4">
{#if metric.has_capture}
<button
onclick={() => viewCapture(metric.id)}
disabled={loadingCaptureId === metric.id}
class="btn btn--sm"
>
{loadingCaptureId === metric.id ? "..." : "View"}
</button>
{:else}
<span class="text-txtsecondary">-</span>
{/if}
</td>
</tr>
{/each}
</tbody>
@@ -86,3 +121,5 @@
</div>
{/if}
</div>
<CaptureDialog capture={selectedCapture} open={dialogOpen} onclose={closeDialog} />
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import { persistentStore } from "../stores/persistent";
import ChatInterface from "../components/playground/ChatInterface.svelte";
import ImageInterface from "../components/playground/ImageInterface.svelte";
import AudioInterface from "../components/playground/AudioInterface.svelte";
import SpeechInterface from "../components/playground/SpeechInterface.svelte";
import RerankInterface from "../components/playground/RerankInterface.svelte";
type Tab = "chat" | "images" | "speech" | "audio" | "rerank";
const selectedTabStore = persistentStore<Tab>("playground-selected-tab", "chat");
let mobileMenuOpen = $state(false);
const tabs: { id: Tab; label: string }[] = [
{ id: "chat", label: "Chat" },
{ id: "images", label: "Images" },
{ id: "speech", label: "Speech" },
{ id: "audio", label: "Transcription" },
{ id: "rerank", label: "Rerank" },
];
function selectTab(tab: Tab) {
selectedTabStore.set(tab);
mobileMenuOpen = false;
}
function getTabLabel(tabId: Tab): string {
return tabs.find(t => t.id === tabId)?.label || "";
}
</script>
<div class="card h-full flex flex-col">
<!-- Tab navigation -->
<div class="shrink-0 mb-4">
<!-- Mobile: Dropdown menu (hidden on md and up) -->
<div class="block md:hidden relative">
<button
class="w-full px-4 py-2 rounded font-medium transition-colors flex items-center justify-between bg-surface hover:bg-secondary-hover border border-gray-200 dark:border-white/10"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
>
<span>{getTabLabel($selectedTabStore)}</span>
<svg
class="w-5 h-5 transition-transform {mobileMenuOpen ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
{#if mobileMenuOpen}
<div class="absolute top-full left-0 right-0 mt-1 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-10">
{#each tabs as tab (tab.id)}
<button
class="w-full px-4 py-2 text-left hover:bg-secondary-hover transition-colors first:rounded-t last:rounded-b {$selectedTabStore === tab.id ? 'bg-primary/10 font-medium' : ''}"
onclick={() => selectTab(tab.id)}
>
{tab.label}
</button>
{/each}
</div>
{/if}
</div>
<!-- Desktop: Tab buttons (shown on md and up) -->
<div class="hidden md:flex flex-wrap gap-2">
{#each tabs as tab (tab.id)}
<button
class="px-4 py-2 rounded font-medium transition-colors {$selectedTabStore === tab.id
? 'bg-primary text-btn-primary-text'
: 'bg-surface hover:bg-secondary-hover border border-gray-200 dark:border-white/10'}"
onclick={() => selectTab(tab.id)}
>
{tab.label}
</button>
{/each}
</div>
</div>
<!-- Tab content -->
<div class="flex-1 overflow-hidden relative">
<div class="h-full" class:tab-hidden={$selectedTabStore !== "chat"}>
<ChatInterface />
</div>
<div class="h-full" class:tab-hidden={$selectedTabStore !== "images"}>
<ImageInterface />
</div>
<div class="h-full" class:tab-hidden={$selectedTabStore !== "speech"}>
<SpeechInterface />
</div>
<div class="h-full" class:tab-hidden={$selectedTabStore !== "audio"}>
<AudioInterface />
</div>
<div class="h-full" class:tab-hidden={$selectedTabStore !== "rerank"}>
<RerankInterface />
</div>
</div>
</div>
<style>
.tab-hidden {
display: none;
}
</style>
@@ -0,0 +1 @@
<!-- empty: real Playground is always mounted in App.svelte -->
+25 -1
View File
@@ -1,5 +1,5 @@
import { writable } from "svelte/store";
import type { Model, Metrics, VersionInfo, LogData, APIEventEnvelope } from "../lib/types";
import type { Model, Metrics, VersionInfo, LogData, APIEventEnvelope, ReqRespCapture, InFlightStats } from "../lib/types";
import { connectionState } from "./theme";
const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */
@@ -9,6 +9,7 @@ export const models = writable<Model[]>([]);
export const proxyLogs = writable<string>("");
export const upstreamLogs = writable<string>("");
export const metrics = writable<Metrics[]>([]);
export const inFlightRequests = writable<number>(0);
export const versionInfo = writable<VersionInfo>({
build_date: "unknown",
commit: "unknown",
@@ -29,6 +30,7 @@ export function enableAPIEvents(enabled: boolean): void {
apiEventSource?.close();
apiEventSource = null;
metrics.set([]);
inFlightRequests.set(0);
return;
}
@@ -46,6 +48,7 @@ export function enableAPIEvents(enabled: boolean): void {
proxyLogs.set("");
upstreamLogs.set("");
metrics.set([]);
inFlightRequests.set(0);
models.set([]);
retryCount = 0;
connectionState.set("connected");
@@ -83,6 +86,11 @@ export function enableAPIEvents(enabled: boolean): void {
metrics.update((prevMetrics) => [...newMetrics, ...prevMetrics]);
break;
}
case "inflight": {
const stats = JSON.parse(message.data) as InFlightStats;
inFlightRequests.set(stats.total ?? 0);
break;
}
}
} catch (err) {
console.error(e.data, err);
@@ -172,3 +180,19 @@ export async function loadModel(model: string): Promise<void> {
throw error;
}
}
export async function getCapture(id: number): Promise<ReqRespCapture | null> {
try {
const response = await fetch(`/api/captures/${id}`);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Failed to fetch capture: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch capture:", error);
return null;
}
}
@@ -0,0 +1,20 @@
import { writable, derived } from "svelte/store";
const chatStreaming = writable(false);
const imageGenerating = writable(false);
const speechGenerating = writable(false);
const audioTranscribing = writable(false);
const rerankLoading = writable(false);
export const playgroundActivity = derived(
[chatStreaming, imageGenerating, speechGenerating, audioTranscribing, rerankLoading],
([$chat, $image, $speech, $audio, $rerank]) => $chat || $image || $speech || $audio || $rerank
);
export const playgroundStores = {
chatStreaming,
imageGenerating,
speechGenerating,
audioTranscribing,
rerankLoading,
};
+3
View File
@@ -0,0 +1,3 @@
import { writable } from "svelte/store";
export const currentRoute = writable("/");
+17 -1
View File
@@ -1,10 +1,25 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
import { compression } from "vite-plugin-compression2";
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte(), tailwindcss()],
plugins: [
svelte(),
tailwindcss(),
compression({
algorithm: "gzip",
exclude: [/\.(br)$/, /\.(gz)$/],
threshold: 1024,
}),
compression({
algorithm: "brotliCompress",
exclude: [/\.(br)$/, /\.(gz)$/],
threshold: 1024,
filename: "[path][base].br",
}),
],
base: "/ui/",
build: {
outDir: "../proxy/ui_dist",
@@ -16,6 +31,7 @@ export default defineConfig({
"/logs": "http://localhost:8080",
"/upstream": "http://localhost:8080",
"/unload": "http://localhost:8080",
"/v1": "http://localhost:8080",
},
},
});
-25
View File
@@ -1,25 +0,0 @@
.vite
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-54
View File
@@ -1,54 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```
-28
View File
@@ -1,28 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
-17
View File
@@ -1,17 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>llama-swap</title>
</head>
<body >
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Binary file not shown.
-4188
View File
File diff suppressed because it is too large Load Diff
-34
View File
@@ -1,34 +0,0 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "vite",
"build": "tsc -b && vite build --emptyOutDir",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.4",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/vite": "^4.1.8",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"tailwindcss": "^4.1.8",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 38 KiB

-21
View File
@@ -1,21 +0,0 @@
{
"name": "llama-swap",
"short_name": "llama-swap",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

-6
View File
@@ -1,6 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
-38
View File
@@ -1,38 +0,0 @@
import { useEffect } from "react";
import { Navigate, Route, BrowserRouter as Router, Routes } from "react-router-dom";
import { Header } from "./components/Header";
import { useAPI } from "./contexts/APIProvider";
import { useTheme } from "./contexts/ThemeProvider";
import ActivityPage from "./pages/Activity";
import LogViewerPage from "./pages/LogViewer";
import ModelPage from "./pages/Models";
function App() {
const { setConnectionState } = useTheme();
const { connectionStatus } = useAPI();
// Synchronize the window.title connections state with the actual connection state
useEffect(() => {
setConnectionState(connectionStatus);
}, [connectionStatus]);
return (
<Router basename="/ui/">
<div className="flex flex-col h-screen">
<Header />
<main className="flex-1 overflow-auto p-4">
<Routes>
<Route path="/" element={<LogViewerPage />} />
<Route path="/models" element={<ModelPage />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
</Router>
);
}
export default App;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

-26
View File
@@ -1,26 +0,0 @@
import { useAPI } from "../contexts/APIProvider";
import { useMemo } from "react";
const ConnectionStatusIcon = () => {
const { connectionStatus, versionInfo } = useAPI();
const eventStatusColor = useMemo(() => {
switch (connectionStatus) {
case "connected":
return "bg-emerald-500";
case "connecting":
return "bg-amber-500";
case "disconnected":
default:
return "bg-red-500";
}
}, [connectionStatus]);
return (
<div className="flex items-center" title={`Event Stream: ${connectionStatus ?? 'unknown'}\nAPI Version: ${versionInfo?.version ?? 'unknown'}\nCommit Hash: ${versionInfo?.commit?.substring(0,7) ?? 'unknown'}\nBuild Date: ${versionInfo?.build_date ?? 'unknown'}`}>
<span className={`inline-block w-3 h-3 rounded-full ${eventStatusColor} mr-2`}></span>
</div>
);
};
export default ConnectionStatusIcon;
-56
View File
@@ -1,56 +0,0 @@
import { useCallback } from "react";
import { RiMoonFill, RiSunFill } from "react-icons/ri";
import { NavLink, type NavLinkRenderProps } from "react-router-dom";
import { useTheme } from "../contexts/ThemeProvider";
import ConnectionStatusIcon from "./ConnectionStatus";
export function Header() {
const { screenWidth, toggleTheme, isDarkMode, appTitle, setAppTitle, isNarrow } = useTheme();
const handleTitleChange = useCallback(
(newTitle: string) => {
setAppTitle(newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap");
},
[setAppTitle]
);
const navLinkClass = ({ isActive }: NavLinkRenderProps) =>
`text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 ${isActive ? "font-semibold" : ""}`;
return (
<header className={`flex items-center justify-between bg-surface border-b border-border px-4 ${isNarrow ? "py-1 h-[60px]" : "p-2 h-[75px]"}`}>
{screenWidth !== "xs" && screenWidth !== "sm" && (
<h1
contentEditable
suppressContentEditableWarning
className="p-0 outline-none hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
onBlur={(e) => handleTitleChange(e.currentTarget.textContent || "(set title)")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleTitleChange(e.currentTarget.textContent || "(set title)");
e.currentTarget.blur();
}
}}
>
{appTitle}
</h1>
)}
<menu className="flex items-center gap-4">
<NavLink to="/" className={navLinkClass} type="button">
Logs
</NavLink>
<NavLink to="/models" className={navLinkClass} type="button">
Models
</NavLink>
<NavLink to="/activity" className={navLinkClass} type="button">
Activity
</NavLink>
<button className="" onClick={toggleTheme}>
{isDarkMode ? <RiMoonFill /> : <RiSunFill />}
</button>
<ConnectionStatusIcon />
</menu>
</header>
);
}
-293
View File
@@ -1,293 +0,0 @@
import { createContext, useState, useContext, useEffect, useCallback, useMemo, type ReactNode } from "react";
import type { ConnectionState } from "../lib/types";
type ModelStatus = "ready" | "starting" | "stopping" | "stopped" | "shutdown" | "unknown";
const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */
export interface Model {
id: string;
state: ModelStatus;
name: string;
description: string;
unlisted: boolean;
peerID: string;
}
interface APIProviderType {
models: Model[];
listModels: () => Promise<Model[]>;
unloadAllModels: () => Promise<void>;
unloadSingleModel: (model: string) => Promise<void>;
loadModel: (model: string) => Promise<void>;
enableAPIEvents: (enabled: boolean) => void;
proxyLogs: string;
upstreamLogs: string;
metrics: Metrics[];
connectionStatus: ConnectionState;
versionInfo: VersionInfo;
}
interface Metrics {
id: number;
timestamp: string;
model: string;
cache_tokens: number;
input_tokens: number;
output_tokens: number;
prompt_per_second: number;
tokens_per_second: number;
duration_ms: number;
}
interface LogData {
source: "upstream" | "proxy";
data: string;
}
interface APIEventEnvelope {
type: "modelStatus" | "logData" | "metrics";
data: string;
}
interface VersionInfo {
build_date: string;
commit: string;
version: string;
}
const APIContext = createContext<APIProviderType | undefined>(undefined);
type APIProviderProps = {
children: ReactNode;
autoStartAPIEvents?: boolean;
};
let apiEventSource: EventSource | null = null;
export function APIProvider({ children, autoStartAPIEvents = true }: APIProviderProps) {
const [proxyLogs, setProxyLogs] = useState("");
const [upstreamLogs, setUpstreamLogs] = useState("");
const [metrics, setMetrics] = useState<Metrics[]>([]);
const [connectionStatus, setConnectionState] = useState<ConnectionState>("disconnected");
const [versionInfo, setVersionInfo] = useState<VersionInfo>({
build_date: "unknown",
commit: "unknown",
version: "unknown",
});
//const apiEventSource = useRef<EventSource | null>(null);
const [models, setModels] = useState<Model[]>([]);
const appendLog = useCallback((newData: string, setter: React.Dispatch<React.SetStateAction<string>>) => {
setter((prev) => {
const updatedLog = prev + newData;
return updatedLog.length > LOG_LENGTH_LIMIT ? updatedLog.slice(-LOG_LENGTH_LIMIT) : updatedLog;
});
}, []);
const enableAPIEvents = useCallback((enabled: boolean) => {
if (!enabled) {
apiEventSource?.close();
apiEventSource = null;
setMetrics([]);
return;
}
let retryCount = 0;
const initialDelay = 1000; // 1 second
const connect = () => {
apiEventSource?.close();
apiEventSource = new EventSource("/api/events");
setConnectionState("connecting");
apiEventSource.onopen = () => {
// clear everything out on connect to keep things in sync
setProxyLogs("");
setUpstreamLogs("");
setMetrics([]); // clear metrics on reconnect
setModels([]); // clear models on reconnect
retryCount = 0;
setConnectionState("connected");
};
apiEventSource.onmessage = (e: MessageEvent) => {
try {
const message = JSON.parse(e.data) as APIEventEnvelope;
switch (message.type) {
case "modelStatus":
{
const models = JSON.parse(message.data) as Model[];
// sort models by name and id
models.sort((a, b) => {
return (a.name + a.id).localeCompare(b.name + b.id);
});
setModels(models);
}
break;
case "logData":
const logData = JSON.parse(message.data) as LogData;
switch (logData.source) {
case "proxy":
appendLog(logData.data, setProxyLogs);
break;
case "upstream":
appendLog(logData.data, setUpstreamLogs);
break;
}
break;
case "metrics":
{
const newMetrics = JSON.parse(message.data) as Metrics[];
setMetrics((prevMetrics) => {
return [...newMetrics, ...prevMetrics];
});
}
break;
}
} catch (err) {
console.error(e.data, err);
}
};
apiEventSource.onerror = () => {
apiEventSource?.close();
retryCount++;
const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000);
setConnectionState("disconnected");
setTimeout(connect, delay);
};
};
connect();
}, []);
useEffect(() => {
// fetch version
const fetchVersion = async () => {
try {
const response = await fetch("/api/version");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: VersionInfo = await response.json();
setVersionInfo(data);
} catch (error) {
console.error(error);
}
};
if (connectionStatus === "connected") {
fetchVersion();
}
}, [connectionStatus]);
useEffect(() => {
if (autoStartAPIEvents) {
enableAPIEvents(true);
}
return () => {
enableAPIEvents(false);
};
}, [enableAPIEvents, autoStartAPIEvents]);
const listModels = useCallback(async (): Promise<Model[]> => {
try {
const response = await fetch("/api/models/");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data || [];
} catch (error) {
console.error("Failed to fetch models:", error);
return []; // Return empty array as fallback
}
}, []);
const unloadAllModels = useCallback(async () => {
try {
const response = await fetch(`/api/models/unload`, {
method: "POST",
});
if (!response.ok) {
throw new Error(`Failed to unload models: ${response.status}`);
}
} catch (error) {
console.error("Failed to unload models:", error);
throw error; // Re-throw to let calling code handle it
}
}, []);
const unloadSingleModel = useCallback(async (model: string) => {
try {
const response = await fetch(`/api/models/unload/${model}`, {
method: "POST",
});
if (!response.ok) {
throw new Error(`Failed to unload model: ${response.status}`);
}
} catch (error) {
console.error("Failed to unload model", model, error);
throw error;
}
}, []);
const loadModel = useCallback(async (model: string) => {
try {
const response = await fetch(`/upstream/${model}/`, {
method: "GET",
});
if (!response.ok) {
throw new Error(`Failed to load model: ${response.status}`);
}
} catch (error) {
console.error("Failed to load model:", error);
throw error; // Re-throw to let calling code handle it
}
}, []);
const value = useMemo(
() => ({
models,
listModels,
unloadAllModels,
unloadSingleModel,
loadModel,
enableAPIEvents,
proxyLogs,
upstreamLogs,
metrics,
connectionStatus,
versionInfo,
}),
[
models,
listModels,
unloadAllModels,
unloadSingleModel,
loadModel,
enableAPIEvents,
proxyLogs,
upstreamLogs,
metrics,
connectionStatus,
versionInfo,
]
);
return <APIContext.Provider value={value}>{children}</APIContext.Provider>;
}
export function useAPI() {
const context = useContext(APIContext);
if (context === undefined) {
throw new Error("useAPI must be used within an APIProvider");
}
return context;
}
-97
View File
@@ -1,97 +0,0 @@
import { createContext, useContext, useEffect, type ReactNode, useMemo, useState } from "react";
import { usePersistentState } from "../hooks/usePersistentState";
import type { ConnectionState } from "../lib/types";
type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
type ThemeContextType = {
isDarkMode: boolean;
screenWidth: ScreenWidth;
isNarrow: boolean;
toggleTheme: () => void;
// for managing the window title and connection state information
appTitle: string;
setAppTitle: (title: string) => void;
setConnectionState: (state: ConnectionState) => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
type ThemeProviderProps = {
children: ReactNode;
};
export function ThemeProvider({ children }: ThemeProviderProps) {
const [appTitle, setAppTitle] = usePersistentState("app-title", "llama-swap");
const [connectionState, setConnectionState] = useState<ConnectionState>("disconnected");
/**
* Set the document.title with informative information
*/
useEffect(() => {
const connectionIcon = connectionState === "connecting" ? "🟡" : connectionState === "connected" ? "🟢" : "🔴";
document.title = connectionIcon + " " + appTitle; // Set initial title
}, [appTitle, connectionState]);
const [isDarkMode, setIsDarkMode] = usePersistentState<boolean>("theme", false);
const [screenWidth, setScreenWidth] = useState<ScreenWidth>("md"); // Default to md
// matches tailwind classes
// https://tailwindcss.com/docs/responsive-design
useEffect(() => {
const checkInnerWidth = () => {
const innerWidth = window.innerWidth;
if (innerWidth < 640) {
setScreenWidth("xs");
} else if (innerWidth < 768) {
setScreenWidth("sm");
} else if (innerWidth < 1024) {
setScreenWidth("md");
} else if (innerWidth < 1280) {
setScreenWidth("lg");
} else if (innerWidth < 1536) {
setScreenWidth("xl");
} else {
setScreenWidth("2xl");
}
};
checkInnerWidth();
window.addEventListener("resize", checkInnerWidth);
return () => window.removeEventListener("resize", checkInnerWidth);
}, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light");
}, [isDarkMode]);
const toggleTheme = () => setIsDarkMode((prev) => !prev);
const isNarrow = useMemo(() => {
return screenWidth === "xs" || screenWidth === "sm" || screenWidth === "md";
}, [screenWidth]);
return (
<ThemeContext.Provider
value={{
isDarkMode,
toggleTheme,
screenWidth,
isNarrow,
appTitle,
setAppTitle,
setConnectionState,
}}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
-39
View File
@@ -1,39 +0,0 @@
import { useState, useEffect, useCallback } from "react";
export function usePersistentState<T>(key: string, initialValue: T): [T, (value: T | ((prevState: T) => T)) => void] {
const [state, setState] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const saved = localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : initialValue;
} catch (e) {
console.error(`Error parsing stored value for ${key}`, e);
return initialValue;
}
});
const setPersistentState = useCallback(
(value: T | ((prevState: T) => T)) => {
setState((prev) => {
const nextValue = typeof value === "function" ? (value as (prevState: T) => T)(prev) : value;
try {
localStorage.setItem(key, JSON.stringify(nextValue));
} catch (e) {
console.error(`Error saving value for ${key}`, e);
}
return nextValue;
});
},
[key]
);
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(state));
} catch (e) {
console.error(`Error saving value for ${key}`, e);
}
}, [key, state]);
return [state, setPersistentState];
}
-176
View File
@@ -1,176 +0,0 @@
@import "tailwindcss";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme {
--color-background: rgba(252, 252, 249, 1);
--color-surface: rgba(255, 255, 253, 1);
/* text colors */
--color-txtmain: rgba(19, 52, 59, 1);
--color-txtsecondary: rgba(98, 108, 113, 1);
--color-navlink-active: rgba(245, 245, 245, 1);
--color-primary: rgba(50, 184, 198, 1);
--color-primary-hover: rgba(29, 116, 128, 1);
--color-primary-active: rgba(26, 104, 115, 1);
--color-secondary: rgba(94, 82, 64, 0.12);
--color-secondary-hover: rgba(94, 82, 64, 0.2);
--color-secondary-active: rgba(94, 82, 64, 0.25);
--color-border: rgba(94, 82, 64, 0.3);
--color-btn-primary-text: rgba(252, 252, 249, 1);
--color-card-border: rgba(94, 82, 64, 0.12);
--color-card-border-inner: rgba(94, 82, 64, 0.12);
--color-error: rgba(192, 21, 47, 1);
--color-success: rgba(33, 128, 141, 1);
--color-warning: rgb(244, 155, 0);
--color-info: rgba(98, 108, 113, 1);
--color-focus-ring: rgba(33, 128, 141, 0.4);
--color-select-caret: rgba(19, 52, 59, 0.8);
--color-btn-border: rgba(94, 82, 64, 0.7);
}
@layer theme {
/* over ride theme for dark mode */
[data-theme="dark"] {
--color-background: rgba(31, 33, 33, 1);
--color-surface: rgba(38, 40, 40, 1);
/* text colors */
--color-txtmain: rgba(245, 245, 245, 1);
--color-txtsecondary: rgba(167, 169, 169, 0.7);
--color-navlink-active: rgba(245, 245, 245, 1);
--color-primary: rgba(33, 128, 141, 1);
--color-primary-hover: rgba(45, 166, 178, 1);
--color-primary-active: rgba(41, 150, 161, 1);
--color-secondary: rgba(119, 124, 124, 0.15);
--color-secondary-hover: rgba(119, 124, 124, 0.25);
--color-secondary-active: rgba(119, 124, 124, 0.3);
--color-border: rgba(119, 124, 124, 0.3);
--color-error: rgba(255, 84, 89, 1);
--color-success: rgba(50, 184, 198, 1);
--color-warning: rgb(244, 155, 0);
--color-info: rgba(167, 169, 169, 1);
--color-focus-ring: rgba(50, 184, 198, 0.4);
--color-btn-primary-text: rgba(19, 52, 59, 1);
--color-card-border: rgba(119, 124, 124, 0.2);
--color-card-border-inner: rgba(119, 124, 124, 0.15);
--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);
}
}
@layer base {
body {
/* example of how colors using theme colors*/
@apply bg-background text-txtmain;
}
h1 {
@apply text-4xl text-txtmain font-bold pb-4;
}
h2 {
@apply text-3xl text-txtmain font-bold pb-4;
}
h3 {
@apply text-2xl text-txtmain font-bold pb-4;
}
h4 {
@apply text-xl text-txtmain font-bold pb-4;
}
h5 {
@apply text-lg text-txtmain font-bold pb-4;
}
h6 {
@apply text-base text-txtmain font-bold pb-4;
}
}
/* define CSS classes here for specific types of components */
@layer components {
.container {
@apply px-4;
}
/* Tables */
table th {
@apply p-2 font-semibold;
}
table td {
@apply p-2;
}
/* Navigation Header */
.navlink {
@apply text-txtsecondary hover:bg-secondary hover:text-txtmain rounded-lg p-2;
}
.navlink.active {
@apply bg-primary text-navlink-active;
}
/* Card component */
.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 {
@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
View File
@@ -1 +0,0 @@
export type ConnectionState = "connected" | "connecting" | "disconnected";
-16
View File
@@ -1,16 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { ThemeProvider } from "./contexts/ThemeProvider";
import { APIProvider } from "./contexts/APIProvider";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<APIProvider>
<App />
</APIProvider>
</ThemeProvider>
</StrictMode>
);
-120
View File
@@ -1,120 +0,0 @@
import { useMemo } from "react";
import { useAPI } from "../contexts/APIProvider";
const formatSpeed = (speed: number): string => {
return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s";
};
const formatDuration = (ms: number): string => {
return (ms / 1000).toFixed(2) + "s";
};
const formatRelativeTime = (timestamp: string): string => {
const now = new Date();
const date = new Date(timestamp);
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
// Handle future dates by returning "just now"
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";
};
const ActivityPage = () => {
const { metrics } = useAPI();
const sortedMetrics = useMemo(() => {
return [...metrics].sort((a, b) => b.id - a.id);
}, [metrics]);
return (
<div className="p-2">
<h1 className="text-2xl font-bold">Activity</h1>
{metrics.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-600">No metrics data available</p>
</div>
)}
{metrics.length > 0 && (
<div className="card overflow-auto">
<table className="min-w-full divide-y">
<thead className="border-gray-200 dark:border-white/10">
<tr className="text-left text-xs uppercase tracking-wider">
<th className="px-6 py-3">ID</th>
<th className="px-6 py-3">Time</th>
<th className="px-6 py-3">Model</th>
<th className="px-6 py-3">
Cached <Tooltip content="prompt tokens from cache" />
</th>
<th className="px-6 py-3">
Prompt <Tooltip content="new prompt tokens processed" />
</th>
<th className="px-6 py-3">Generated</th>
<th className="px-6 py-3">Prompt Processing</th>
<th className="px-6 py-3">Generation Speed</th>
<th className="px-6 py-3">Duration</th>
</tr>
</thead>
<tbody className="divide-y">
{sortedMetrics.map((metric) => (
<tr key={metric.id} className="whitespace-nowrap text-sm border-gray-200 dark:border-white/10">
<td className="px-4 py-4">{metric.id + 1 /* un-zero index */}</td>
<td className="px-6 py-4">{formatRelativeTime(metric.timestamp)}</td>
<td className="px-6 py-4">{metric.model}</td>
<td className="px-6 py-4">{metric.cache_tokens > 0 ? metric.cache_tokens.toLocaleString() : "-"}</td>
<td className="px-6 py-4">{metric.input_tokens.toLocaleString()}</td>
<td className="px-6 py-4">{metric.output_tokens.toLocaleString()}</td>
<td className="px-6 py-4">{formatSpeed(metric.prompt_per_second)}</td>
<td className="px-6 py-4">{formatSpeed(metric.tokens_per_second)}</td>
<td className="px-6 py-4">{formatDuration(metric.duration_ms)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
interface TooltipProps {
content: string;
}
const Tooltip: React.FC<TooltipProps> = ({ content }) => {
return (
<div className="relative group inline-block">
<div
className="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
className="absolute bottom-full left-1/2 transform -translate-x-1/2
border-4 border-transparent border-b-gray-900"
></div>
</div>
</div>
);
};
export default ActivityPage;
-162
View File
@@ -1,162 +0,0 @@
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useAPI } from "../contexts/APIProvider";
import { usePersistentState } from "../hooks/usePersistentState";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import {
RiTextWrap,
RiAlignJustify,
RiFontSize,
RiMenuSearchLine,
RiMenuSearchFill,
RiCloseCircleFill,
} from "react-icons/ri";
import { useTheme } from "../contexts/ThemeProvider";
const LogViewer = () => {
const { proxyLogs, upstreamLogs } = useAPI();
const { screenWidth } = useTheme();
const direction = screenWidth === "xs" || screenWidth === "sm" ? "vertical" : "horizontal";
return (
<PanelGroup direction={direction} className="gap-2" autoSaveId="logviewer-panel-group">
<Panel id="proxy" defaultSize={50} minSize={5} maxSize={100} collapsible={true}>
<LogPanel id="proxy" title="Proxy Logs" logData={proxyLogs} />
</Panel>
<PanelResizeHandle
className={
direction === "horizontal"
? "w-2 h-full bg-primary hover:bg-success transition-colors rounded"
: "w-full h-2 bg-primary hover:bg-success transition-colors rounded"
}
/>
<Panel id="upstream" defaultSize={50} minSize={5} maxSize={100} collapsible={true}>
<LogPanel id="upstream" title="Upstream Logs" logData={upstreamLogs} />
</Panel>
</PanelGroup>
);
};
interface LogPanelProps {
id: string;
title: string;
logData: string;
}
export const LogPanel = ({ id, title, logData }: LogPanelProps) => {
const [filterRegex, setFilterRegex] = useState("");
const [fontSize, setFontSize] = usePersistentState<"xxs" | "xs" | "small" | "normal">(
`logPanel-${id}-fontSize`,
"normal"
);
const [wrapText, setTextWrap] = usePersistentState(`logPanel-${id}-wrapText`, false);
const [showFilter, setShowFilter] = usePersistentState(`logPanel-${id}-showFilter`, false);
const textWrapClass = useMemo(() => {
return wrapText ? "whitespace-pre-wrap" : "whitespace-pre";
}, [wrapText]);
const toggleFontSize = useCallback(() => {
setFontSize((prev) => {
switch (prev) {
case "xxs":
return "xs";
case "xs":
return "small";
case "small":
return "normal";
case "normal":
return "xxs";
}
});
}, []);
const toggleWrapText = useCallback(() => {
setTextWrap((prev) => !prev);
}, []);
const toggleFilter = useCallback(() => {
if (showFilter) {
setShowFilter(false);
setFilterRegex(""); // Clear filter when closing
} else {
setShowFilter(true);
}
}, [filterRegex, setFilterRegex, showFilter]);
const fontSizeClass = useMemo(() => {
switch (fontSize) {
case "xxs":
return "text-[0.5rem]"; // 0.5rem (8px)
case "xs":
return "text-[0.75rem]"; // 0.75rem (12px)
case "small":
return "text-[0.875rem]"; // 0.875rem (14px)
case "normal":
return "text-base"; // 1rem (16px)
}
}, [fontSize]);
const filteredLogs = useMemo(() => {
if (!filterRegex) return logData;
try {
const regex = new RegExp(filterRegex, "i");
const lines = logData.split("\n");
const filtered = lines.filter((line) => regex.test(line));
return filtered.join("\n");
} catch (e) {
return logData; // Return unfiltered if regex is invalid
}
}, [logData, filterRegex]);
// auto scroll to bottom
const preTagRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (!preTagRef.current) return;
preTagRef.current.scrollTop = preTagRef.current.scrollHeight;
}, [filteredLogs]);
return (
<div className="rounded-lg overflow-hidden flex flex-col bg-gray-950/5 dark:bg-white/10 h-full p-1">
<div className="p-4">
<div className="flex items-center justify-between">
<h3 className="m-0 text-lg p-0">{title}</h3>
<div className="flex gap-2 items-center">
<button className="btn border-0" onClick={toggleFontSize}>
<RiFontSize />
</button>
<button className="btn border-0" onClick={toggleWrapText}>
{wrapText ? <RiTextWrap /> : <RiAlignJustify />}
</button>
<button className="btn border-0" onClick={toggleFilter}>
{showFilter ? <RiMenuSearchFill /> : <RiMenuSearchLine />}
</button>
</div>
</div>
{/* Filtering Options - Full width on mobile, normal on desktop */}
{showFilter && (
<div className="mt-2 w-full">
<div className="flex gap-2 items-center w-full">
<input
type="text"
className="w-full text-sm border border-gray-950/10 dark:border-white/5 p-2 rounded outline-none"
placeholder="Filter logs..."
value={filterRegex}
onChange={(e) => setFilterRegex(e.target.value)}
/>
<button className="pl-2" onClick={() => setFilterRegex("")}>
<RiCloseCircleFill size="24" />
</button>
</div>
</div>
)}
</div>
<div className="rounded-lg bg-background font-mono text-sm flex-1 overflow-hidden">
<pre ref={preTagRef} className={`${textWrapClass} ${fontSizeClass} h-full overflow-auto p-4`}>
{filteredLogs}
</pre>
</div>
</div>
);
};
export default LogViewer;
-527
View File
@@ -1,527 +0,0 @@
import { useState, useCallback, useMemo } from "react";
import { useAPI } from "../contexts/APIProvider";
import { LogPanel } from "./LogViewer";
import { usePersistentState } from "../hooks/usePersistentState";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { useTheme } from "../contexts/ThemeProvider";
import { RiEyeFill, RiEyeOffFill, RiSwapBoxFill, RiEjectLine, RiMenuFill } from "react-icons/ri";
export default function ModelsPage() {
const { isNarrow } = useTheme();
const direction = isNarrow ? "vertical" : "horizontal";
const { upstreamLogs } = useAPI();
return (
<PanelGroup direction={direction} className="gap-2" autoSaveId={"models-panel-group"}>
<Panel id="models" defaultSize={50} minSize={isNarrow ? 0 : 25} maxSize={100} collapsible={isNarrow}>
<ModelsPanel />
</Panel>
<PanelResizeHandle
className={
direction === "horizontal"
? "w-2 h-full bg-primary hover:bg-success transition-colors rounded"
: "w-full h-2 bg-primary hover:bg-success transition-colors rounded"
}
/>
<Panel collapsible={true} defaultSize={50} minSize={0}>
<div className="flex flex-col h-full space-y-4">
{direction === "horizontal" && <StatsPanel />}
<div className="flex-1 min-h-0">
<LogPanel id="modelsupstream" title="Upstream Logs" logData={upstreamLogs} />
</div>
</div>
</Panel>
</PanelGroup>
);
}
function ModelsPanel() {
const { models, loadModel, unloadAllModels, unloadSingleModel } = useAPI();
const { isNarrow } = useTheme();
const [isUnloading, setIsUnloading] = useState(false);
const [showUnlisted, setShowUnlisted] = usePersistentState("showUnlisted", true);
const [showIdorName, setShowIdorName] = usePersistentState<"id" | "name">("showIdorName", "id"); // true = show ID, false = show name
const [menuOpen, setMenuOpen] = useState(false);
const { regularModels, peerModelsByPeerId } = useMemo(() => {
const filtered = models.filter((model) => showUnlisted || !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, typeof peerModels>);
return {
regularModels: filtered.filter((m) => !m.peerID),
peerModelsByPeerId: grouped,
};
}, [models, showUnlisted]);
const handleUnloadAllModels = useCallback(async () => {
setIsUnloading(true);
try {
await unloadAllModels();
} catch (e) {
console.error(e);
} finally {
setTimeout(() => {
setIsUnloading(false);
}, 1000);
}
}, [unloadAllModels]);
const toggleIdorName = useCallback(() => {
setShowIdorName((prev) => (prev === "name" ? "id" : "name"));
}, [showIdorName]);
return (
<div className="card h-full flex flex-col">
<div className="shrink-0">
<div className="flex justify-between items-baseline">
<h2 className={isNarrow ? "text-xl" : ""}>Models</h2>
{isNarrow && (
<div className="relative">
<button className="btn text-base flex items-center gap-2 py-1" onClick={() => setMenuOpen(!menuOpen)}>
<RiMenuFill size="20" />
</button>
{menuOpen && (
<div className="absolute right-0 mt-2 w-48 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-20">
<button
className="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
onClick={() => {
toggleIdorName();
setMenuOpen(false);
}}
>
<RiSwapBoxFill size="20" /> {showIdorName === "id" ? "Show Name" : "Show ID"}
</button>
<button
className="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
onClick={() => {
setShowUnlisted(!showUnlisted);
setMenuOpen(false);
}}
>
{showUnlisted ? <RiEyeOffFill size="20" /> : <RiEyeFill size="20" />}{" "}
{showUnlisted ? "Hide Unlisted" : "Show Unlisted"}
</button>
<button
className="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
onClick={() => {
handleUnloadAllModels();
setMenuOpen(false);
}}
disabled={isUnloading}
>
<RiEjectLine size="24" /> {isUnloading ? "Unloading..." : "Unload All"}
</button>
</div>
)}
</div>
)}
</div>
{!isNarrow && (
<div className="flex justify-between">
<div className="flex gap-2">
<button
className="btn text-base flex items-center gap-2"
onClick={toggleIdorName}
style={{ lineHeight: "1.2" }}
>
<RiSwapBoxFill size="20" /> {showIdorName === "id" ? "ID" : "Name"}
</button>
<button
className="btn text-base flex items-center gap-2"
onClick={() => setShowUnlisted(!showUnlisted)}
style={{ lineHeight: "1.2" }}
>
{showUnlisted ? <RiEyeFill size="20" /> : <RiEyeOffFill size="20" />} unlisted
</button>
</div>
<button
className="btn text-base flex items-center gap-2"
onClick={handleUnloadAllModels}
disabled={isUnloading}
>
<RiEjectLine size="24" /> {isUnloading ? "Unloading..." : "Unload All"}
</button>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
<table className="w-full">
<thead className="sticky top-0 bg-card z-10">
<tr className="text-left border-b border-gray-200 dark:border-white/10 bg-surface">
<th>{showIdorName === "id" ? "Model ID" : "Name"}</th>
<th></th>
<th>State</th>
</tr>
</thead>
<tbody>
{regularModels.map((model) => (
<tr key={model.id} className="border-b hover:bg-secondary-hover border-gray-200">
<td className={`${model.unlisted ? "text-txtsecondary" : ""}`}>
<a href={`/upstream/${model.id}/`} className="font-semibold" target="_blank">
{showIdorName === "id" ? model.id : model.name !== "" ? model.name : model.id}
</a>
{!!model.description && (
<p className={model.unlisted ? "text-opacity-70" : ""}>
<em>{model.description}</em>
</p>
)}
</td>
<td className="w-12">
{model.state === "stopped" ? (
<button className="btn btn--sm" onClick={() => loadModel(model.id)}>
Load
</button>
) : (
<button
className="btn btn--sm"
onClick={() => unloadSingleModel(model.id)}
disabled={model.state !== "ready"}
>
Unload
</button>
)}
</td>
<td className="w-20">
<span className={`w-16 text-center status status--${model.state}`}>{model.state}</span>
</td>
</tr>
))}
</tbody>
</table>
{Object.keys(peerModelsByPeerId).length > 0 && (
<>
<h3 className="mt-8 mb-2">Peer Models</h3>
{Object.entries(peerModelsByPeerId)
.sort(([a], [b]) => a.localeCompare(b))
.map(([peerId, models]) => (
<div key={peerId} className="mb-4">
<table className="w-full">
<thead className="sticky top-0 bg-card z-10">
<tr className="text-left border-b border-gray-200 dark:border-white/10 bg-surface">
<th className="font-semibold">{peerId}</th>
</tr>
</thead>
<tbody>
{models.map((model) => (
<tr key={model.id} className="border-b hover:bg-secondary-hover border-gray-200">
<td className={`pl-8 ${model.unlisted ? "text-txtsecondary" : ""}`}>
<span>{model.id}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</>
)}
</div>
</div>
);
}
interface HistogramData {
bins: number[];
min: number;
max: number;
binSize: number;
p99: number;
p95: number;
p50: number;
}
function TokenHistogram({ data }: { data: HistogramData }) {
const { bins, min, max, p50, p95, p99 } = data;
const maxCount = Math.max(...bins);
const height = 120;
const padding = { top: 10, right: 15, bottom: 25, left: 45 };
// Use viewBox for responsive sizing
const viewBoxWidth = 600;
const chartWidth = viewBoxWidth - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const barWidth = chartWidth / bins.length;
const range = max - min;
// Calculate x position for a given value
const getXPosition = (value: number) => {
return padding.left + ((value - min) / range) * chartWidth;
};
return (
<div className="mt-2 w-full">
<svg viewBox={`0 0 ${viewBoxWidth} ${height}`} className="w-full h-auto" preserveAspectRatio="xMidYMid meet">
{/* Y-axis */}
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={height - padding.bottom}
stroke="currentColor"
strokeWidth="1"
opacity="0.3"
/>
{/* X-axis */}
<line
x1={padding.left}
y1={height - padding.bottom}
x2={viewBoxWidth - padding.right}
y2={height - padding.bottom}
stroke="currentColor"
strokeWidth="1"
opacity="0.3"
/>
{/* Histogram bars */}
{bins.map((count, i) => {
const barHeight = maxCount > 0 ? (count / maxCount) * chartHeight : 0;
const x = padding.left + i * barWidth;
const y = height - padding.bottom - barHeight;
const binStart = min + i * data.binSize;
const binEnd = binStart + data.binSize;
return (
<g key={i}>
<rect
x={x}
y={y}
width={Math.max(barWidth - 1, 1)}
height={barHeight}
fill="currentColor"
opacity="0.6"
className="text-blue-500 dark:text-blue-400 hover:opacity-90 transition-opacity cursor-pointer"
/>
<title>{`${binStart.toFixed(1)} - ${binEnd.toFixed(1)} tokens/sec\nCount: ${count}`}</title>
</g>
);
})}
{/* Percentile lines */}
<line
x1={getXPosition(p50)}
y1={padding.top}
x2={getXPosition(p50)}
y2={height - padding.bottom}
stroke="currentColor"
strokeWidth="2"
strokeDasharray="4 2"
opacity="0.7"
className="text-gray-600 dark:text-gray-400"
/>
<line
x1={getXPosition(p95)}
y1={padding.top}
x2={getXPosition(p95)}
y2={height - padding.bottom}
stroke="currentColor"
strokeWidth="2"
strokeDasharray="4 2"
opacity="0.7"
className="text-orange-500 dark:text-orange-400"
/>
<line
x1={getXPosition(p99)}
y1={padding.top}
x2={getXPosition(p99)}
y2={height - padding.bottom}
stroke="currentColor"
strokeWidth="2"
strokeDasharray="4 2"
opacity="0.7"
className="text-green-500 dark:text-green-400"
/>
{/* X-axis labels */}
<text x={padding.left} y={height - 5} fontSize="10" fill="currentColor" opacity="0.6" textAnchor="start">
{min.toFixed(1)}
</text>
<text
x={viewBoxWidth - padding.right}
y={height - 5}
fontSize="10"
fill="currentColor"
opacity="0.6"
textAnchor="end"
>
{max.toFixed(1)}
</text>
{/* X-axis label */}
<text
x={padding.left + chartWidth / 2}
y={height - 2}
fontSize="10"
fill="currentColor"
opacity="0.6"
textAnchor="middle"
>
Tokens/Second Distribution
</text>
</svg>
</div>
);
}
function StatsPanel() {
const { metrics } = useAPI();
const [totalRequests, totalInputTokens, totalOutputTokens, tokenStats, histogramData] = useMemo(() => {
const totalRequests = metrics.length;
if (totalRequests === 0) {
return [0, 0, 0, { p99: 0, p95: 0, p50: 0 }, null];
}
const totalInputTokens = metrics.reduce((sum, m) => sum + m.input_tokens, 0);
const totalOutputTokens = metrics.reduce((sum, m) => sum + m.output_tokens, 0);
// Calculate token statistics using output_tokens and duration_ms
// Filter out metrics with invalid duration or output tokens
const validMetrics = metrics.filter((m) => m.duration_ms > 0 && m.output_tokens > 0);
if (validMetrics.length === 0) {
return [totalRequests, totalInputTokens, totalOutputTokens, { p99: 0, p95: 0, p50: 0 }, null];
}
// Calculate tokens/second for each valid metric
const tokensPerSecond = validMetrics.map((m) => m.output_tokens / (m.duration_ms / 1000));
// Sort for percentile calculation
const sortedTokensPerSecond = [...tokensPerSecond].sort((a, b) => a - b);
// Calculate percentiles - showing speed thresholds where X% of requests are SLOWER (below)
// P99: 99% of requests are slower than this speed (99th percentile - fast requests)
// P95: 95% of requests are slower than this speed (95th percentile)
// P50: 50% of requests are slower than this speed (median)
const p99 = sortedTokensPerSecond[Math.floor(sortedTokensPerSecond.length * 0.99)];
const p95 = sortedTokensPerSecond[Math.floor(sortedTokensPerSecond.length * 0.95)];
const p50 = sortedTokensPerSecond[Math.floor(sortedTokensPerSecond.length * 0.5)];
// Create histogram data
const min = Math.min(...tokensPerSecond);
const max = Math.max(...tokensPerSecond);
const binCount = Math.min(30, Math.max(10, Math.floor(tokensPerSecond.length / 5))); // Adaptive bin count
const binSize = (max - min) / binCount;
const bins = Array(binCount).fill(0);
tokensPerSecond.forEach((value) => {
const binIndex = Math.min(Math.floor((value - min) / binSize), binCount - 1);
bins[binIndex]++;
});
const histogramData = {
bins,
min,
max,
binSize,
p99,
p95,
p50,
};
return [
totalRequests,
totalInputTokens,
totalOutputTokens,
{
p99: p99.toFixed(2),
p95: p95.toFixed(2),
p50: p50.toFixed(2),
},
histogramData,
];
}, [metrics]);
const nf = new Intl.NumberFormat();
return (
<div className="card">
<div className="rounded-lg overflow-hidden border border-card-border-inner">
<table className="min-w-full divide-y divide-card-border-inner">
<thead className="bg-secondary">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-txtmain">
Requests
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-txtmain border-l border-card-border-inner">
Processed
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-txtmain border-l border-card-border-inner">
Generated
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-txtmain border-l border-card-border-inner">
Token Stats (tokens/sec)
</th>
</tr>
</thead>
<tbody className="bg-surface divide-y divide-card-border-inner">
<tr className="hover:bg-secondary">
<td className="px-4 py-4 text-sm font-semibold text-gray-900 dark:text-white">{totalRequests}</td>
<td className="px-4 py-4 text-sm text-gray-700 dark:text-gray-300 border-l border-gray-200 dark:border-white/10">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{nf.format(totalInputTokens)}</span>
<span className="text-xs text-gray-500 dark:text-gray-400">tokens</span>
</div>
</td>
<td className="px-4 py-4 text-sm text-gray-700 dark:text-gray-300 border-l border-gray-200 dark:border-white/10">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{nf.format(totalOutputTokens)}</span>
<span className="text-xs text-gray-500 dark:text-gray-400">tokens</span>
</div>
</td>
<td className="px-4 py-4 border-l border-gray-200 dark:border-white/10">
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 items-center">
<div className="text-center">
<div className="text-xs text-gray-500 dark:text-gray-400">P50</div>
<div className="mt-1 inline-block rounded-full bg-gray-100 dark:bg-white/5 px-3 py-1 text-sm font-semibold text-gray-800 dark:text-white">
{tokenStats.p50}
</div>
</div>
<div className="text-center">
<div className="text-xs text-gray-500 dark:text-gray-400">P95</div>
<div className="mt-1 inline-block rounded-full bg-gray-100 dark:bg-white/5 px-3 py-1 text-sm font-semibold text-gray-800 dark:text-white">
{tokenStats.p95}
</div>
</div>
<div className="text-center">
<div className="text-xs text-gray-500 dark:text-gray-400">P99</div>
<div className="mt-1 inline-block rounded-full bg-gray-100 dark:bg-white/5 px-3 py-1 text-sm font-semibold text-gray-800 dark:text-white">
{tokenStats.p99}
</div>
</div>
</div>
{histogramData && <TokenHistogram data={histogramData} />}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-27
View File
@@ -1,27 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-25
View File
@@ -1,25 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-21
View File
@@ -1,21 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
base: "/ui/",
build: {
outDir: "../proxy/ui_dist",
assetsDir: "assets",
},
server: {
proxy: {
"/api": "http://localhost:8080", // Proxy API calls to Go backend during development
"/logs": "http://localhost:8080",
"/upstream": "http://localhost:8080",
"/unload": "http://localhost:8080",
},
},
});