mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-04 12:22:22 -04:00
Compare commits
40 Commits
cron/issue
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f52437c81 | ||
|
|
cd516452dd | ||
|
|
3f0db2a9c2 | ||
|
|
137dfcf15a | ||
|
|
750ab91b2b | ||
|
|
08598a8611 | ||
|
|
211aa0a536 | ||
|
|
c86b3b207b | ||
|
|
62316e52a9 | ||
|
|
3090101156 | ||
|
|
8b667cd1ce | ||
|
|
93fe086798 | ||
|
|
2e14511fe2 | ||
|
|
88fdda6211 | ||
|
|
f447faf08d | ||
|
|
6e7c0a4df8 | ||
|
|
e2311045d3 | ||
|
|
6bdb04ab5d | ||
|
|
bd076376be | ||
|
|
d28ccf32b5 | ||
|
|
95bd59d78e | ||
|
|
1741df0bf1 | ||
|
|
b6d2e94153 | ||
|
|
a0f7faaa2a | ||
|
|
133c546c3f | ||
|
|
8a68f3571c | ||
|
|
fd4ec083b9 | ||
|
|
8f74f74b10 | ||
|
|
cd62e8ff18 | ||
|
|
af98e76f84 | ||
|
|
7f9ffd9f54 | ||
|
|
5cb0c1a872 | ||
|
|
cd890b6a26 | ||
|
|
1c0380ad44 | ||
|
|
cb6e4d4391 | ||
|
|
cba54c5ea1 | ||
|
|
f951419207 | ||
|
|
58ea2f5d79 | ||
|
|
b89b0f73e5 | ||
|
|
45cd47cb99 |
@@ -304,7 +304,9 @@ React pages that want to filter the ModelSelector by capability import this symb
|
||||
|
||||
### 4. `docs/content/` (user-facing documentation)
|
||||
|
||||
A new capability deserves its own page under `docs/content/features/`, plus cross-links from related features and an entry in `docs/content/whats-new.md`. See the pattern used by `face-recognition.md` / `object-detection.md`.
|
||||
A new capability deserves its own page under `docs/content/features/`, plus cross-links from related features. See the pattern used by `face-recognition.md` / `object-detection.md`.
|
||||
|
||||
Announcing it is the release's job, not this page's: the capability gets covered in the release blog post under `website/content/blog/`. See [preparing-a-release.md](preparing-a-release.md). `docs/content/whats-new.md` is only a pointer at the blog and GitHub Releases, so there is nothing to add there.
|
||||
|
||||
## Path protection rules
|
||||
|
||||
@@ -334,7 +336,7 @@ When adding a new endpoint:
|
||||
- [ ] Swagger block on the handler: `@Summary`, `@Tags`, `@Param`, `@Success`, `@Router`
|
||||
- [ ] If new capability area (new swagger tag): entry in `instructionDefs` in `core/http/endpoints/localai/api_instructions.go` + test count bumped in `api_instructions_test.go`
|
||||
- [ ] If new `FLAG_*` usecase flag: matching `CAP_*` symbol exported from `core/http/react-ui/src/utils/capabilities.js`
|
||||
- [ ] `docs/content/features/<feature>.md` created; cross-links from related feature pages; entry in `docs/content/whats-new.md`
|
||||
- [ ] `docs/content/features/<feature>.md` created; cross-links from related feature pages; capability covered in the release blog post (see [preparing-a-release.md](preparing-a-release.md))
|
||||
|
||||
**Quality**
|
||||
- [ ] Error responses use `schema.ErrorResponse` format (or `echo.NewHTTPError` with a mapped gRPC status — see the `mapBackendError` helper in `core/http/endpoints/localai/images.go`)
|
||||
|
||||
@@ -4,6 +4,24 @@ set -euo pipefail
|
||||
arch=${1:?target architecture is required}
|
||||
build_type=${2-}
|
||||
|
||||
# SYCL compiles the whole tree with icpx -fsycl, and icpx never finishes
|
||||
# ggml-cpu/arch/x86/repack.cpp at -march=sapphirerapids: the job sits on that one
|
||||
# translation unit until GitHub kills it at 6h. gcc builds the same file in
|
||||
# seconds, so only the SYCL images have to give up the CPU variant matrix.
|
||||
#
|
||||
# ROCm runs out of the same 6h budget for a different reason: volume, not a
|
||||
# stall. hipcc compiles ggml's HIP kernels once per entry in AMDGPU_TARGETS,
|
||||
# which is eleven architectures (gfx908 through gfx1201), and the CPU variant
|
||||
# matrix lands on top of that. The job built in 2h27m before it was added and
|
||||
# has been killed at exactly 6h00m on every run since, so no ROCm llama-cpp
|
||||
# image has been published since 2026-08-01.
|
||||
case "$build_type" in
|
||||
sycl*|hipblas*)
|
||||
echo llama-cpp-fallback
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# GPU arm64 base images do not consistently provide the gcc-14 toolchain needed
|
||||
# to compile ggml's armv9.2 CPU variants. Keep their portable fallback until the
|
||||
# builder images can supply that compiler.
|
||||
|
||||
@@ -4,6 +4,17 @@ set -euo pipefail
|
||||
arch=${1:?target architecture is required}
|
||||
build_type=${2-}
|
||||
|
||||
# SYCL compiles the whole tree with icpx -fsycl, and icpx never finishes
|
||||
# ggml-cpu/arch/x86/repack.cpp at -march=sapphirerapids: the job sits on that one
|
||||
# translation unit until GitHub kills it at 6h. gcc builds the same file in
|
||||
# seconds, so only the SYCL images have to give up the CPU variant matrix.
|
||||
case "$build_type" in
|
||||
sycl*)
|
||||
echo turboquant-fallback
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# GPU arm64 base images do not consistently provide the gcc-14 toolchain needed
|
||||
# to compile ggml's armv9.2 CPU variants. Keep their portable fallback until the
|
||||
# builder images can supply that compiler.
|
||||
|
||||
11
.github/workflows/gh-pages.yml
vendored
11
.github/workflows/gh-pages.yml
vendored
@@ -51,7 +51,16 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
# Track go.mod rather than a literal. Pinned at 1.22 this installed a
|
||||
# toolchain older than the module's `go 1.26.0`, so the `go run` below
|
||||
# downloaded the real one from proxy.golang.org on every run. That
|
||||
# fetch is not always reachable from the runner and the deploy failed
|
||||
# on five of eight consecutive master pushes with:
|
||||
# go: download go1.26.0: ... connect: network is unreachable
|
||||
# ##[error]Command failed: go env GOPATH
|
||||
# Installing the version the module asks for removes the download
|
||||
# instead of depending on it succeeding.
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Setup Hugo
|
||||
|
||||
@@ -195,7 +195,7 @@ For more details, see the [Getting Started guide](https://localai.io/basics/gett
|
||||
- **August 2025**: MLX, MLX-VLM, Diffusers, llama.cpp now supported on Apple Silicon
|
||||
- **July 2025**: All backends migrated outside the main binary — [lightweight, modular architecture](https://github.com/mudler/LocalAI/releases/tag/v3.2.0)
|
||||
|
||||
For older news and full release notes, see [GitHub Releases](https://github.com/mudler/LocalAI/releases) and the [News page](https://localai.io/basics/news/).
|
||||
For older news and full release notes, see [GitHub Releases](https://github.com/mudler/LocalAI/releases) and the [blog](https://localai.io/blog/).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -260,7 +260,7 @@ We also maintain [apex-quant](https://github.com/localai-org/apex-quant), a per-
|
||||
- [Kubernetes installation](https://localai.io/basics/getting_started/#run-localai-in-kubernetes)
|
||||
- [Integrations & community projects](https://localai.io/docs/integrations/)
|
||||
- [Installation video walkthrough](https://www.youtube.com/watch?v=cMVNnlqwfw4)
|
||||
- [Media & blog posts](https://localai.io/basics/news/#media-blogs-social)
|
||||
- [Blog: release write-ups, benchmarks and engineering notes](https://localai.io/blog/)
|
||||
- [Examples](https://github.com/mudler/LocalAI-examples) — including the [realtime voice assistant demo](https://github.com/localai-org/localai-realtime-demo) (Go client for the Realtime API with tool calling)
|
||||
|
||||
## Team
|
||||
|
||||
@@ -15,6 +15,7 @@ service Backend {
|
||||
rpc PredictStream(PredictOptions) returns (stream Reply) {}
|
||||
rpc Embedding(PredictOptions) returns (EmbeddingResult) {}
|
||||
rpc GenerateImage(GenerateImageRequest) returns (Result) {}
|
||||
rpc UpscaleImage(UpscaleImageRequest) returns (Result) {}
|
||||
rpc GenerateVideo(GenerateVideoRequest) returns (Result) {}
|
||||
rpc Generate3D(Generate3DRequest) returns (Result) {}
|
||||
rpc AudioTranscription(TranscriptRequest) returns (TranscriptResult) {}
|
||||
@@ -637,6 +638,12 @@ message GenerateImageRequest {
|
||||
string ModelIdentity = 13;
|
||||
}
|
||||
|
||||
message UpscaleImageRequest {
|
||||
string src = 1; // input image path
|
||||
string dst = 2; // output image path
|
||||
int32 scale = 3; // upscale factor (e.g. 2 or 4)
|
||||
}
|
||||
|
||||
message GenerateVideoRequest {
|
||||
string prompt = 1;
|
||||
string negative_prompt = 2; // Negative prompt for video generation
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# recipe is a make target (not a prepare.sh) so 'make purge && make' is a clean
|
||||
# rebuild and so the bump bot can see the pin.
|
||||
|
||||
AUDIO_CPP_VERSION?=545e29a6f2fde24298cb3b0f07baab4352987ac9
|
||||
AUDIO_CPP_VERSION?=4e3aea2fd99aeaa5924e71c51eb2793846045332
|
||||
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
@@ -69,7 +69,15 @@ target_include_directories(hw_grpc_proto PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
|
||||
|
||||
set(DS4_OBJS "${DS4_DIR}/ds4.o")
|
||||
if(DS4_GPU STREQUAL "cuda")
|
||||
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_cuda.o")
|
||||
list(APPEND DS4_OBJS
|
||||
"${DS4_DIR}/ds4_cuda.o"
|
||||
"${DS4_DIR}/cuda/mmq/ds4_ggml_stubs.o"
|
||||
"${DS4_DIR}/cuda/mmq/ds4_mmq.o"
|
||||
"${DS4_DIR}/cuda/mmq/ds4_mmq_d2r.o"
|
||||
"${DS4_DIR}/cuda/mmq/quantize.o"
|
||||
"${DS4_DIR}/cuda/mmq/mmid.o"
|
||||
"${DS4_DIR}/cuda/mmq/mmvq.o"
|
||||
"${DS4_DIR}/cuda/mmq/ds4_repack.o")
|
||||
elseif(DS4_GPU STREQUAL "metal")
|
||||
list(APPEND DS4_OBJS "${DS4_DIR}/ds4_metal.o")
|
||||
elseif(DS4_GPU STREQUAL "cpu")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# ds4 backend Makefile.
|
||||
#
|
||||
# Upstream pin lives below as DS4_VERSION?=54b36ed9ba42da31b24f2d1a5feb075c2475dbb1
|
||||
# Upstream pin lives below as DS4_VERSION?=b7e9f0091139999b6c070a57590c447c5741da5c
|
||||
# (.github/bump_deps.sh) can find and update it - matches the
|
||||
# llama-cpp / ik-llama-cpp / turboquant convention.
|
||||
|
||||
DS4_VERSION?=54b36ed9ba42da31b24f2d1a5feb075c2475dbb1
|
||||
DS4_VERSION?=b7e9f0091139999b6c070a57590c447c5741da5c
|
||||
DS4_REPO?=https://github.com/antirez/ds4
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
@@ -23,7 +23,9 @@ CMAKE_ARGS ?= -DCMAKE_BUILD_TYPE=Release
|
||||
# are shared by every GPU mode, so append them unconditionally below.
|
||||
ifeq ($(BUILD_TYPE),cublas)
|
||||
CMAKE_ARGS += -DDS4_GPU=cuda
|
||||
DS4_OBJ_TARGET := ds4.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
DS4_OBJ_TARGET := ds4.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o \
|
||||
cuda/mmq/ds4_ggml_stubs.o cuda/mmq/ds4_mmq.o cuda/mmq/ds4_mmq_d2r.o \
|
||||
cuda/mmq/quantize.o cuda/mmq/mmid.o cuda/mmq/mmvq.o cuda/mmq/ds4_repack.o
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
CMAKE_ARGS += -DDS4_GPU=metal
|
||||
DS4_OBJ_TARGET := ds4.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
@@ -55,7 +57,7 @@ ds4:
|
||||
# the right per-platform compile flags (Objective-C/Metal on Darwin, nvcc on Linux+CUDA).
|
||||
ds4/ds4.o: ds4
|
||||
ifeq ($(BUILD_TYPE),cublas)
|
||||
+$(MAKE) -C ds4 ds4.o ds4_cuda.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
+$(MAKE) -C ds4 $(DS4_OBJ_TARGET)
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
+$(MAKE) -C ds4 ds4.o ds4_metal.o ds4_distributed.o ds4_tp.o ds4_ssd.o ds4_layer_pack.o
|
||||
else
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
IK_LLAMA_VERSION?=0be97a7a5ad113f33e08729261649ccea2cdc5ff
|
||||
IK_LLAMA_VERSION?=60389410a1ff01f9d37dcc6261db33b3183bdea2
|
||||
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=a7a6d0d269c896218b6c78e0933bd6a17519d3f6
|
||||
LLAMA_VERSION?=221f0f6356efe2260023208365705ec5d5a7c8f5
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -12,10 +12,11 @@ grep -e "flags" /proc/cpuinfo | head -1
|
||||
|
||||
BINARY=llama-cpp-fallback
|
||||
|
||||
# CPU images and x86 GPU images ship a single llama-cpp-cpu-all built with ggml
|
||||
# CPU images and most x86 GPU images ship a single llama-cpp-cpu-all built with ggml
|
||||
# CPU_ALL_VARIANTS: ggml's backend registry dlopens the best libggml-cpu-*.so for this
|
||||
# host, so no shell-side AVX probing. GPU arm64 images still ship llama-cpp-fallback
|
||||
# until their builder toolchains support ggml's complete arm variant matrix.
|
||||
# until their builder toolchains support ggml's complete arm variant matrix, and so do
|
||||
# the SYCL images, whose icpx compiler hangs on the sapphirerapids variant.
|
||||
if [ -e "$CURDIR"/llama-cpp-cpu-all ]; then
|
||||
BINARY=llama-cpp-cpu-all
|
||||
fi
|
||||
|
||||
@@ -12,11 +12,12 @@ grep -e "flags" /proc/cpuinfo | head -1
|
||||
|
||||
BINARY=turboquant-fallback
|
||||
|
||||
# CPU images and x86 GPU images ship a single turboquant-cpu-all built with ggml
|
||||
# CPU images and most x86 GPU images ship a single turboquant-cpu-all built with ggml
|
||||
# CPU_ALL_VARIANTS: ggml's
|
||||
# backend registry dlopens the best libggml-cpu-*.so for this host, so no shell-side
|
||||
# probing. GPU arm64 images still ship turboquant-fallback until their builder toolchains
|
||||
# support ggml's complete arm variant matrix.
|
||||
# support ggml's complete arm variant matrix, and so do the SYCL images, whose icpx
|
||||
# compiler hangs on the sapphirerapids variant.
|
||||
if [ -e "$CURDIR"/turboquant-cpu-all ]; then
|
||||
BINARY=turboquant-cpu-all
|
||||
fi
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# CrispASR version (release tag)
|
||||
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
|
||||
CRISPASR_VERSION?=66ac7843e319b588f5410051c575affd19424fb3
|
||||
CRISPASR_VERSION?=fe3caf8e363b27572dbdd1a9d37083f25e6decda
|
||||
SO_TARGET?=libgocrispasr.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# stablediffusion.cpp (ggml)
|
||||
STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp
|
||||
STABLEDIFFUSION_GGML_VERSION?=e31a86ce9110b11a98bd5990c329093244c2d1e3
|
||||
STABLEDIFFUSION_GGML_VERSION?=db99efdd6d2a43c7937fd55b3359206c680a75b0
|
||||
|
||||
CMAKE_ARGS+=-DGGML_MAX_NAME=128
|
||||
|
||||
|
||||
@@ -11,7 +11,30 @@ JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || e
|
||||
|
||||
# vllm.cpp version
|
||||
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
|
||||
VLLM_CPP_VERSION?=9e1c9025ae61167a3335454d7cc0de6093c21845
|
||||
VLLM_CPP_VERSION?=9d1fad3cde0acb95eb0bb0a1025f40a0eb614147
|
||||
|
||||
# MLX GEMM provider (darwin/metal only; see the metal branch below for why).
|
||||
# Consumed as the prebuilt pip wheel: building MLX from source needs `xcrun
|
||||
# metal`, i.e. a full Xcode the macOS runners do not have, while the wheel ships
|
||||
# include/, lib/libmlx.dylib and the compiled mlx.metallib ready to link.
|
||||
#
|
||||
# DEFAULT ON, but ONLY because VLLM_CPP_VERSION above is pinned at or past
|
||||
# vllm.cpp 89c46aeb, which SHAPE-GATES the provider to prefill. The ordering is
|
||||
# load-bearing, not incidental:
|
||||
#
|
||||
# pin >= 89c46aeb, MLX on -> 99.1% of MLX-LM (gated: prefill only)
|
||||
# pin < 89c46aeb, MLX on -> ~51% (ungated: it also takes decode)
|
||||
#
|
||||
# MLX's steel GEMM wins prefill (537 ms TTFT against 602) and loses decode badly,
|
||||
# because the provider pays an mx::eval sync plus an output memcpy per call and
|
||||
# decode makes ~112 calls per TOKEN. Ungated it does both; gated it does only the
|
||||
# good half. So if this pin is ever moved BACKWARDS, this default must go with it.
|
||||
VLLM_CPP_MLX?=on
|
||||
MLX_VERSION?=0.29.4
|
||||
MLX_VENV?=$(abspath ./mlx-venv)
|
||||
# Resolved lazily (recursive `=`, not `:=`): the glob only matches once the venv
|
||||
# target has run, and the interpreter version in the path varies per runner.
|
||||
MLX_ROOT=$(shell echo $(MLX_VENV)/lib/python*/site-packages/mlx)
|
||||
|
||||
# The backend consumes only the stable C ABI (libvllm + include/vllm.h), so the
|
||||
# server, examples and tests of the engine are never built here.
|
||||
@@ -49,6 +72,23 @@ else ifeq ($(BUILD_TYPE),vulkan)
|
||||
CMAKE_ARGS+=-DVLLM_CPP_VULKAN=ON -DVLLM_CPP_CUDA=OFF
|
||||
else ifeq ($(BUILD_TYPE),metal)
|
||||
CMAKE_ARGS+=-DVLLM_CPP_METAL=ON
|
||||
# The optional MLX GEMM provider. vllm.cpp keeps it OFF by default because it
|
||||
# is a ~19 MB libmlx.dylib plus a ~105 MB mlx.metallib, and upstream's
|
||||
# position is that it must earn that cost by measurement. It does, on the
|
||||
# only hardware this build targets: measured on an Apple M4 against the
|
||||
# native MSL GEMM in the SAME binary (arms toggled by
|
||||
# VT_OP_PROVIDER_DISABLE=mlx), Qwen3-1.7B-bf16 p=512 g=128, it is 1.5x to
|
||||
# 2.2x aggregate throughput and 2x to 3x faster TTFT, at equal peak memory
|
||||
# and bit-identical output on every parity shape. See vllm.cpp
|
||||
# docs/BENCHMARKS.md "MLX GEMM provider A/B on Apple M4".
|
||||
#
|
||||
# MLX delegates the dense GEMM ONLY: kPagedAttention stays vllm.cpp's own
|
||||
# kernel, because MLX has no paged-KV primitive at all.
|
||||
#
|
||||
# Set VLLM_CPP_MLX=off for a Metal build without it (smaller image, slower).
|
||||
ifeq ($(VLLM_CPP_MLX),on)
|
||||
MLX_ENABLED=1
|
||||
endif
|
||||
else
|
||||
CMAKE_ARGS+=-DVLLM_CPP_CUDA=OFF
|
||||
endif
|
||||
@@ -68,10 +108,35 @@ sources/vllm.cpp:
|
||||
git fetch --depth 1 origin $(VLLM_CPP_VERSION) && \
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
$(LIB): sources/vllm.cpp
|
||||
ifeq ($(MLX_ENABLED),1)
|
||||
# A stamp FILE, not a phony target: a phony prerequisite is always "newer" than
|
||||
# $(LIB) and would re-link libvllm on every invocation. Keyed on the version so
|
||||
# a MLX_VERSION bump reinstalls instead of silently reusing the old wheel.
|
||||
MLX_STAMP=$(MLX_VENV)/.mlx-$(MLX_VERSION).stamp
|
||||
MLX_CMAKE_ARGS=-DVLLM_CPP_MLX=ON -DMLX_ROOT=$(MLX_ROOT)
|
||||
|
||||
$(MLX_STAMP):
|
||||
@if [ ! -x "$(MLX_VENV)/bin/pip" ]; then \
|
||||
python3 -m venv "$(MLX_VENV)" || { echo "vllm-cpp: python3 with venv is required to build the MLX provider; pass VLLM_CPP_MLX=off to build Metal without it" >&2; exit 1; }; \
|
||||
fi
|
||||
"$(MLX_VENV)"/bin/pip install --quiet --disable-pip-version-check "mlx==$(MLX_VERSION)"
|
||||
@# Resolved in the SHELL, not by $(MLX_ROOT): make expands a whole recipe
|
||||
@# before running its first line, so the glob would still be unmatched here.
|
||||
@# Every later use (the cmake args, package.sh) expands after this target has
|
||||
@# completed, where $(MLX_ROOT) does resolve.
|
||||
@root=$$(echo "$(MLX_VENV)"/lib/python*/site-packages/mlx); \
|
||||
test -f "$$root/lib/libmlx.dylib" -a -f "$$root/include/mlx/array.h" || \
|
||||
{ echo "vllm-cpp: mlx==$(MLX_VERSION) did not provide lib/libmlx.dylib + include/mlx/array.h under $$root" >&2; exit 1; }
|
||||
touch $@
|
||||
else
|
||||
MLX_STAMP=
|
||||
MLX_CMAKE_ARGS=
|
||||
endif
|
||||
|
||||
$(LIB): sources/vllm.cpp $(MLX_STAMP)
|
||||
mkdir -p build && \
|
||||
cd build && \
|
||||
cmake ../sources/vllm.cpp $(CMAKE_ARGS) && \
|
||||
cmake ../sources/vllm.cpp $(CMAKE_ARGS) $(MLX_CMAKE_ARGS) && \
|
||||
cmake --build . --config Release -j$(JOBS) --target vllm_shared
|
||||
cp -fL build/$(LIB) ./$(LIB)
|
||||
|
||||
@@ -79,12 +144,12 @@ vllm-cpp: main.go govllmcpp.go backend.go options.go $(LIB)
|
||||
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o vllm-cpp ./
|
||||
|
||||
package: vllm-cpp
|
||||
bash package.sh
|
||||
MLX_ROOT="$(MLX_ROOT)" bash package.sh
|
||||
|
||||
build: package
|
||||
|
||||
clean: purge
|
||||
rm -rf libvllm.so libvllm.dylib package sources/vllm.cpp vllm-cpp
|
||||
rm -rf libvllm.so libvllm.dylib package sources/vllm.cpp vllm-cpp "$(MLX_VENV)"
|
||||
|
||||
purge:
|
||||
rm -rf build
|
||||
|
||||
@@ -41,5 +41,50 @@ options:
|
||||
- max_num_seqs:16
|
||||
```
|
||||
|
||||
## Apple Silicon: the MLX GEMM provider (ON by default, gated to prefill)
|
||||
|
||||
`BUILD_TYPE=metal` builds vllm.cpp's MLX provider for the dense GEMM
|
||||
(`VLLM_CPP_MLX=on`, the default here). It is on because upstream now SHAPE-GATES
|
||||
it to prefill; it was briefly off in this branch's history, and that was correct
|
||||
at the time for an ungated provider.
|
||||
|
||||
The gate matters more than the flag. MLX's steel GEMM wins prefill but loses
|
||||
decode, because the provider pays an `mx::eval` synchronisation plus an output
|
||||
memcpy on every call and decode makes ~112 calls *per token*. Measured on an
|
||||
Apple M4, Qwen3-1.7B-bf16 warm at p=512 g=128:
|
||||
|
||||
| configuration | prefill TTFT | warm throughput |
|
||||
|---|--:|--:|
|
||||
| MLX **gated to prefill** (pin >= 89c46aeb) | **524.5 ms** | **24.37 tok/s, 97.6% of MLX-LM** |
|
||||
| MLX ungated (older pins) | 537 ms | 12.7 tok/s |
|
||||
| MLX off | 602 ms | 23.9 tok/s, 95.9% |
|
||||
|
||||
Ratios are against an MLX-LM baseline measured INTERLEAVED with ours over four
|
||||
ABBA blocks (its spread 0.34%, ours 0.12%). An earlier revision of this file
|
||||
claimed 99.1%; that used a two-run MLX-LM baseline containing an outlier and
|
||||
overstated us by about 1.5 points.
|
||||
|
||||
**`VLLM_CPP_VERSION` and this flag are coupled.** Moving the pin back before
|
||||
`89c46aeb` while leaving `VLLM_CPP_MLX=on` would take the middle row — roughly
|
||||
half throughput. If you roll the pin back, roll the default back with it.
|
||||
|
||||
One caveat: MLX's GEMM is not bit-identical to the native kernel, so an MLX build
|
||||
produces a different greedy sequence than a non-MLX one. That is a property of the
|
||||
provider, not of the gate, and it predates this packaging. Full disposition in
|
||||
vllm.cpp `docs/BENCHMARKS.md`.
|
||||
|
||||
Build knobs:
|
||||
|
||||
- `VLLM_CPP_MLX=off` builds Metal without the provider: ~124 MB smaller, and
|
||||
96.4% of MLX-LM instead of 99.1%.
|
||||
- `MLX_VERSION` pins the wheel (default `0.29.4`). MLX is consumed as the
|
||||
prebuilt pip wheel because building it from source needs `xcrun metal`, i.e. a
|
||||
full Xcode the macOS runners do not have.
|
||||
|
||||
Packaging vendors `libmlx.dylib`, `mlx.metallib` and MLX's MIT license into
|
||||
`package/lib/`, and rewrites `libvllm.dylib`'s rpath to `@loader_path/lib`
|
||||
(re-signing it, since `install_name_tool` invalidates the signature). The
|
||||
metallib must stay beside `libmlx.dylib`: MLX looks for it there.
|
||||
|
||||
Testing: `make test` runs the unit specs; export `VLLM_CPP_MODEL=<model>` (and
|
||||
optionally `VLLM_CPP_LIBRARY=<libvllm path>`) to enable the e2e specs.
|
||||
|
||||
@@ -43,6 +43,50 @@ elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
|
||||
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
|
||||
elif [ $(uname -s) = "Darwin" ]; then
|
||||
echo "Detected Darwin"
|
||||
# Vendor the optional MLX GEMM provider, when libvllm was built against it.
|
||||
# Three facts drive every line below, each verified on an Apple M4 before it
|
||||
# was written:
|
||||
# 1. libvllm.dylib carries an LC_LOAD_DYLIB on @rpath/libmlx.dylib, and its
|
||||
# build-time LC_RPATH points inside the build venv. That path does not
|
||||
# exist on a user's machine, so it must become @loader_path/lib.
|
||||
# 2. MLX finds its ~100 MB mlx.metallib beside its OWN dylib, so the two
|
||||
# files have to land in the same directory or every Metal op dies with
|
||||
# "Failed to load the default metallib".
|
||||
# 3. install_name_tool invalidates the code signature, and macOS refuses to
|
||||
# load an arm64 image whose signature does not match, so the patched
|
||||
# library must be re-signed ad-hoc afterwards.
|
||||
if otool -L "$CURDIR/package/libvllm.dylib" 2>/dev/null | grep -q "libmlx.dylib"; then
|
||||
MLX_LIB_DIR="${MLX_ROOT}/lib"
|
||||
if [ ! -f "$MLX_LIB_DIR/libmlx.dylib" ] || [ ! -f "$MLX_LIB_DIR/mlx.metallib" ]; then
|
||||
echo "Error: libvllm.dylib links libmlx.dylib but $MLX_LIB_DIR is missing libmlx.dylib/mlx.metallib" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Vendoring the MLX GEMM provider from $MLX_LIB_DIR"
|
||||
cp -fLv "$MLX_LIB_DIR/libmlx.dylib" "$CURDIR/package/lib/"
|
||||
cp -fLv "$MLX_LIB_DIR/mlx.metallib" "$CURDIR/package/lib/"
|
||||
# MLX is MIT and we redistribute its binaries, so its license ships with
|
||||
# them. mlx-metal is the wheel carrying the dylib and the metallib.
|
||||
MLX_LICENSE=$(ls "${MLX_ROOT}"/../mlx_metal-*.dist-info/licenses/LICENSE 2>/dev/null | head -1)
|
||||
if [ -z "$MLX_LICENSE" ]; then
|
||||
MLX_LICENSE=$(ls "${MLX_ROOT}"/../mlx-*.dist-info/licenses/LICENSE 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$MLX_LICENSE" ]; then
|
||||
echo "Error: could not find the MLX LICENSE to redistribute alongside libmlx.dylib" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -fLv "$MLX_LICENSE" "$CURDIR/package/lib/LICENSE.mlx"
|
||||
# Drop every build-tree rpath, then point at the packaged copy.
|
||||
otool -l "$CURDIR/package/libvllm.dylib" | awk '/LC_RPATH/{f=1;next} f&&/ path /{print $2;f=0}' | while read -r rp; do
|
||||
install_name_tool -delete_rpath "$rp" "$CURDIR/package/libvllm.dylib" 2>/dev/null || true
|
||||
done
|
||||
install_name_tool -add_rpath "@loader_path/lib" "$CURDIR/package/libvllm.dylib"
|
||||
codesign -f -s - "$CURDIR/package/libvllm.dylib"
|
||||
# A broken rpath must fail the BUILD, not the user's first inference.
|
||||
if ! otool -l "$CURDIR/package/libvllm.dylib" | grep -q "@loader_path/lib"; then
|
||||
echo "Error: libvllm.dylib did not get the @loader_path/lib rpath" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Error: Could not detect architecture"
|
||||
exit 1
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# whisper.cpp version
|
||||
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
|
||||
WHISPER_CPP_VERSION?=2ca53bb45e38748d07b310eeb36245a7157ac882
|
||||
WHISPER_CPP_VERSION?=64d57d3df5c8dacee098577257edcaa154bf5ef3
|
||||
SO_TARGET?=libgowhisper.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -883,6 +883,34 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
|
||||
return backend_pb2.Result(message="Media generated", success=True)
|
||||
|
||||
def UpscaleImage(self, request, context):
|
||||
try:
|
||||
if not request.src:
|
||||
return backend_pb2.Result(success=False, message="No source image provided")
|
||||
if not request.dst:
|
||||
return backend_pb2.Result(success=False, message="No destination path provided")
|
||||
|
||||
scale = request.scale if request.scale > 0 else 2
|
||||
image = Image.open(request.src).convert("RGB")
|
||||
|
||||
# If the loaded pipeline supports upscaling (e.g. StableDiffusionUpscalePipeline),
|
||||
# use it; otherwise fall back to high-quality Lanczos resize.
|
||||
if self.pipe is not None and self.PipelineType in ("StableDiffusionUpscalePipeline", "StableDiffusionLatentUpscalePipeline"):
|
||||
print(f"UpscaleImage: using diffusers upscale pipeline ({self.PipelineType})", file=sys.stderr)
|
||||
upscaled = self.pipe(prompt="", image=image).images[0]
|
||||
else:
|
||||
# Fallback: high-quality Lanczos resize
|
||||
print(f"UpscaleImage: no upscale pipeline loaded, using Lanczos resize (scale={scale})", file=sys.stderr)
|
||||
new_w = image.width * scale
|
||||
new_h = image.height * scale
|
||||
upscaled = image.resize((new_w, new_h), Image.LANCZOS)
|
||||
|
||||
upscaled.save(request.dst)
|
||||
return backend_pb2.Result(message="Image upscaled", success=True)
|
||||
except Exception as e:
|
||||
print(f"UpscaleImage error: {e}", file=sys.stderr)
|
||||
return backend_pb2.Result(success=False, message=str(e))
|
||||
|
||||
def GenerateVideo(self, request, context):
|
||||
try:
|
||||
prompt = request.prompt
|
||||
|
||||
@@ -15,3 +15,12 @@ sglang[all]>=0.5.11
|
||||
# load-bearing for flash-attn-4, and this is the narrower change. Raise the
|
||||
# bound once 0.46.0 final ships.
|
||||
nvidia-modelopt<0.46
|
||||
|
||||
# Same failure mode as the nvidia-modelopt bound above, via a different
|
||||
# package. sglang -> flashinfer-python -> cuda-tile, unbounded, and the
|
||||
# global --prerelease=allow resolves it to 1.6.0rc3, whose build backend
|
||||
# imports wheel_stub without declaring it in build-system.requires. With
|
||||
# --no-build-isolation nothing installs it and the build dies with
|
||||
# "No module named 'wheel_stub'". 1.5.0 is the newest stable release.
|
||||
# Raise the bound once 1.6.0 final ships.
|
||||
cuda-tile<1.6
|
||||
|
||||
@@ -15,3 +15,12 @@ sglang[all]>=0.5.11
|
||||
# load-bearing for flash-attn-4, and this is the narrower change. Raise the
|
||||
# bound once 0.46.0 final ships.
|
||||
nvidia-modelopt<0.46
|
||||
|
||||
# Same failure mode as the nvidia-modelopt bound above, via a different
|
||||
# package. sglang -> flashinfer-python -> cuda-tile, unbounded, and the
|
||||
# global --prerelease=allow resolves it to 1.6.0rc3, whose build backend
|
||||
# imports wheel_stub without declaring it in build-system.requires. With
|
||||
# --no-build-isolation nothing installs it and the build dies with
|
||||
# "No module named 'wheel_stub'". 1.5.0 is the newest stable release.
|
||||
# Raise the bound once 1.6.0 final ships.
|
||||
cuda-tile<1.6
|
||||
|
||||
@@ -13,3 +13,12 @@
|
||||
# FunctionCallParser, ReasoningParser); the [all] extras are optional
|
||||
# accelerators not required at import time.
|
||||
sglang>=0.5.11
|
||||
|
||||
# Same failure mode the cublas profiles carry an nvidia-modelopt bound for,
|
||||
# reached through a different package. sglang -> flashinfer-python ->
|
||||
# cuda-tile, unbounded, and the global --prerelease=allow resolves it to
|
||||
# 1.6.0rc3, whose build backend imports wheel_stub without declaring it in
|
||||
# build-system.requires. With --no-build-isolation nothing installs it and
|
||||
# the build dies with "No module named 'wheel_stub'". 1.5.0 is the newest
|
||||
# stable release. Raise the bound once 1.6.0 final ships.
|
||||
cuda-tile<1.6
|
||||
|
||||
@@ -553,12 +553,17 @@ func (a *Application) start() error {
|
||||
// once at startup and reused across chat sessions that opt in via metadata.
|
||||
if !a.applicationConfig.DisableLocalAIAssistant {
|
||||
holder := mcpTools.NewLocalAIAssistantHolder()
|
||||
var nodeRegistry *nodes.NodeRegistry
|
||||
if a.distributed != nil {
|
||||
nodeRegistry = a.distributed.Registry
|
||||
}
|
||||
assistantClient := localaiInproc.New(
|
||||
a.applicationConfig,
|
||||
a.applicationConfig.SystemState,
|
||||
a.backendLoader,
|
||||
a.modelLoader,
|
||||
a.galleryService,
|
||||
nodeRegistry,
|
||||
)
|
||||
// Wire usage tracking so the assistant's get_usage_stats tool
|
||||
// returns real data; nil values keep the tool returning a clear
|
||||
|
||||
37
core/backend/upscale.go
Normal file
37
core/backend/upscale.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
model "github.com/mudler/LocalAI/pkg/model"
|
||||
)
|
||||
|
||||
// ImageUpscale loads the model specified in modelConfig and calls UpscaleImage
|
||||
// on the backend, writing the result to dst.
|
||||
func ImageUpscale(ctx context.Context, src, dst string, scale int, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func() error, error) {
|
||||
opts := ModelOptions(modelConfig, appConfig, model.WithContext(ctx))
|
||||
inferenceModel, err := loader.Load(opts...)
|
||||
if err != nil {
|
||||
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fn := func() error {
|
||||
_, err := inferenceModel.UpscaleImage(
|
||||
ctx,
|
||||
&proto.UpscaleImageRequest{
|
||||
Src: src,
|
||||
Dst: dst,
|
||||
Scale: int32(scale),
|
||||
},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
return fn, nil
|
||||
}
|
||||
|
||||
// ImageUpscaleFunc is a test-friendly indirection.
|
||||
var ImageUpscaleFunc = ImageUpscale
|
||||
@@ -44,6 +44,7 @@ const (
|
||||
MethodPredictStream GRPCMethod = "PredictStream"
|
||||
MethodEmbedding GRPCMethod = "Embedding"
|
||||
MethodGenerateImage GRPCMethod = "GenerateImage"
|
||||
MethodUpscaleImage GRPCMethod = "UpscaleImage"
|
||||
MethodGenerateVideo GRPCMethod = "GenerateVideo"
|
||||
MethodGenerate3D GRPCMethod = "Generate3D"
|
||||
MethodAudioTranscription GRPCMethod = "AudioTranscription"
|
||||
@@ -348,7 +349,7 @@ var BackendCapabilities = map[string]BackendCapability{
|
||||
|
||||
// --- Image/video generation backends ---
|
||||
"diffusers": {
|
||||
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodGenerateVideo},
|
||||
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodUpscaleImage, MethodGenerateVideo},
|
||||
PossibleUsecases: []string{UsecaseImage, UsecaseVideo},
|
||||
DefaultUsecases: []string{UsecaseImage},
|
||||
Description: "HuggingFace diffusers — Stable Diffusion, Flux, video generation",
|
||||
|
||||
@@ -39,6 +39,8 @@ var RouteFeatureRegistry = []RouteFeature{
|
||||
{"POST", "/images/generations", FeatureImages},
|
||||
{"POST", "/v1/images/inpainting", FeatureImages},
|
||||
{"POST", "/images/inpainting", FeatureImages},
|
||||
{"POST", "/v1/images/upscale", FeatureImages},
|
||||
{"POST", "/images/upscale", FeatureImages},
|
||||
|
||||
// Audio transcription
|
||||
{"POST", "/v1/audio/transcriptions", FeatureAudioTranscription},
|
||||
@@ -116,6 +118,10 @@ var RouteFeatureRegistry = []RouteFeature{
|
||||
// Rerank
|
||||
{"POST", "/v1/rerank", FeatureRerank},
|
||||
|
||||
// Moderation
|
||||
{"POST", "/v1/moderations", FeatureModeration},
|
||||
{"POST", "/moderations", FeatureModeration},
|
||||
|
||||
// Stores
|
||||
{"POST", "/stores/set", FeatureStores},
|
||||
{"POST", "/stores/delete", FeatureStores},
|
||||
@@ -191,6 +197,7 @@ func APIFeatureMetas() []FeatureMeta {
|
||||
{FeatureEmbeddings, "Embeddings", true},
|
||||
{FeatureSound, "Sound Generation", true},
|
||||
{FeatureRealtime, "Realtime", true},
|
||||
{FeatureModeration, "Moderation", true},
|
||||
{FeatureRerank, "Rerank", true},
|
||||
{FeatureTokenize, "Tokenize", true},
|
||||
{FeatureMCP, "MCP", true},
|
||||
|
||||
24
core/http/auth/features_moderation_test.go
Normal file
24
core/http/auth/features_moderation_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
. "github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Moderation feature registration", func() {
|
||||
It("registers both moderation routes as default-on API features", func() {
|
||||
Expect(APIFeatures).To(ContainElement(FeatureModeration))
|
||||
|
||||
patterns := []string{}
|
||||
for _, route := range RouteFeatureRegistry {
|
||||
if route.Feature == FeatureModeration {
|
||||
patterns = append(patterns, route.Pattern)
|
||||
}
|
||||
}
|
||||
Expect(patterns).To(ConsistOf("/v1/moderations", "/moderations"))
|
||||
|
||||
metas := APIFeatureMetas()
|
||||
Expect(metas).To(ContainElement(FeatureMeta{Key: FeatureModeration, Label: "Moderation", DefaultValue: true}))
|
||||
})
|
||||
})
|
||||
@@ -59,10 +59,14 @@ func ok(c echo.Context) error {
|
||||
func newAuthTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||
e := echo.New()
|
||||
e.Use(auth.Middleware(db, appConfig))
|
||||
if db != nil {
|
||||
e.Use(auth.RequireRouteFeature(db))
|
||||
}
|
||||
|
||||
// API routes (require auth)
|
||||
e.GET("/v1/models", ok)
|
||||
e.POST("/v1/chat/completions", ok)
|
||||
e.POST("/v1/moderations", ok)
|
||||
e.GET("/api/settings", ok)
|
||||
e.POST("/api/settings", ok)
|
||||
|
||||
@@ -81,10 +85,14 @@ func newAuthTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo
|
||||
func newAdminTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||
e := echo.New()
|
||||
e.Use(auth.Middleware(db, appConfig))
|
||||
if db != nil {
|
||||
e.Use(auth.RequireRouteFeature(db))
|
||||
}
|
||||
|
||||
// Regular routes
|
||||
e.GET("/v1/models", ok)
|
||||
e.POST("/v1/chat/completions", ok)
|
||||
e.POST("/v1/moderations", ok)
|
||||
|
||||
// Admin-only routes
|
||||
adminMw := auth.RequireAdmin()
|
||||
|
||||
@@ -91,6 +91,19 @@ var _ = Describe("Auth Middleware", func() {
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("allows authenticated users to call moderation by default", func() {
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
rec := doRequest(app, http.MethodPost, "/v1/moderations", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("blocks moderation when the user's feature is disabled", func() {
|
||||
Expect(auth.UpdateUserPermissions(db, user.ID, auth.PermissionMap{auth.FeatureModeration: false})).To(Succeed())
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
rec := doRequest(app, http.MethodPost, "/v1/moderations", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("allows requests with valid session as Bearer token", func() {
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(sessionID))
|
||||
@@ -156,6 +169,11 @@ var _ = Describe("Auth Middleware", func() {
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 for unauthenticated moderation requests", func() {
|
||||
rec := doRequest(app, http.MethodPost, "/v1/moderations")
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 for unauthenticated 3D generation requests", func() {
|
||||
rec := doRequest(app, http.MethodPost, "/3d/generations")
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
|
||||
@@ -51,6 +51,7 @@ const (
|
||||
FeatureEmbeddings = "embeddings"
|
||||
FeatureSound = "sound"
|
||||
FeatureRealtime = "realtime"
|
||||
FeatureModeration = "moderation"
|
||||
FeatureRerank = "rerank"
|
||||
FeatureTokenize = "tokenize"
|
||||
FeatureMCP = "mcp"
|
||||
@@ -75,7 +76,7 @@ var APIFeatures = []string{
|
||||
FeatureChat, FeatureImages, FeatureAudioSpeech, FeatureAudioTranscription,
|
||||
FeatureAudioDiarization, FeatureAudioClassification,
|
||||
FeatureVAD, FeatureDetection, FeatureVideo, Feature3D, FeatureEmbeddings, FeatureSound,
|
||||
FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
|
||||
FeatureRealtime, FeatureModeration, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
|
||||
FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform,
|
||||
FeaturePIIFilter,
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ var instructionDefs = []instructionDef{
|
||||
Tags: []string{"inference", "embeddings"},
|
||||
Intro: "Set \"stream\": true for SSE streaming. Supports tool/function calling when the model config has function templates configured.",
|
||||
},
|
||||
{
|
||||
Name: "moderation",
|
||||
Description: "OpenAI-compatible text moderation using a local completion model",
|
||||
Tags: []string{"moderation"},
|
||||
Intro: "POST /v1/moderations accepts a text string or array plus a LocalAI completion model. LocalAI constrains the model to the OpenAI moderation category schema and returns one result per input. Multimodal moderation inputs are not yet supported.",
|
||||
},
|
||||
{
|
||||
Name: "audio",
|
||||
Description: "Text-to-speech, voice activity detection, transcription, speaker diarization, sound classification, and sound generation",
|
||||
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("API Instructions Endpoints", func() {
|
||||
|
||||
instructions, ok := resp["instructions"].([]any)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(instructions).To(HaveLen(18))
|
||||
Expect(instructions).To(HaveLen(19))
|
||||
|
||||
// Verify each instruction has required fields and correct URL format
|
||||
for _, s := range instructions {
|
||||
@@ -69,6 +69,7 @@ var _ = Describe("API Instructions Endpoints", func() {
|
||||
|
||||
Expect(names).To(ContainElements(
|
||||
"chat-inference",
|
||||
"moderation",
|
||||
"config-management",
|
||||
"model-management",
|
||||
"monitoring",
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// @Tags monitoring
|
||||
// @Success 200 {object} schema.SystemInformationResponse "Response"
|
||||
// @Router /system [get]
|
||||
func SystemInformations(ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
func SystemInformations(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
availableBackends := []string{}
|
||||
loadedModels := ml.ListLoadedModels()
|
||||
@@ -25,7 +25,14 @@ func SystemInformations(ml *model.ModelLoader, appConfig *config.ApplicationConf
|
||||
|
||||
sysmodels := []schema.SysInfoModel{}
|
||||
for _, m := range loadedModels {
|
||||
sysmodels = append(sysmodels, schema.SysInfoModel{ID: m.ID})
|
||||
entry := schema.SysInfoModel{ID: m.ID}
|
||||
// The loader tracks only the ID. Which engine is serving a model is
|
||||
// the first thing an operator wants beside its name, and it is one
|
||||
// config lookup away.
|
||||
if cfg, ok := cl.GetModelConfig(m.ID); ok {
|
||||
entry.Backend = cfg.Backend
|
||||
}
|
||||
sysmodels = append(sysmodels, entry)
|
||||
}
|
||||
return c.JSON(200,
|
||||
schema.SystemInformationResponse{
|
||||
|
||||
@@ -3,6 +3,7 @@ package localai
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
@@ -85,6 +86,35 @@ func GetAPITracesEndpoint() echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// GetAPITracesSummaryEndpoint returns counted totals over a recent window
|
||||
// @Summary Summarize recent API traces
|
||||
// @Description Returns request, failure and latency totals over a recent window, plus a bucketed series for sparklines. Exists so callers wanting three numbers do not have to fetch the whole trace list and count it themselves.
|
||||
// @Tags monitoring
|
||||
// @Produce json
|
||||
// @Param hours query int false "Window in hours (default 24, max 168)"
|
||||
// @Success 200 {object} middleware.TraceSummary "Counted trace totals"
|
||||
// @Router /api/traces/summary [get]
|
||||
func GetAPITracesSummaryEndpoint() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
hours := 24
|
||||
if raw := c.QueryParam("hours"); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
|
||||
hours = v
|
||||
}
|
||||
}
|
||||
// A week is plenty for a dashboard, and the trace buffer is bounded
|
||||
// anyway; an unbounded window would just scan the whole buffer.
|
||||
if hours > 168 {
|
||||
hours = 168
|
||||
}
|
||||
return c.JSON(http.StatusOK, middleware.GetTracesSummary(time.Duration(hours)*time.Hour, traceSummaryBuckets))
|
||||
}
|
||||
}
|
||||
|
||||
// Enough columns for a sparkline to show a shape, few enough that each one
|
||||
// still holds a meaningful count on a quiet installation.
|
||||
const traceSummaryBuckets = 12
|
||||
|
||||
// GetAPITraceEndpoint returns a single API trace with its full payload
|
||||
// @Summary Get one API trace
|
||||
// @Description Returns a single captured API exchange, including the request and response bodies omitted from the list response
|
||||
|
||||
@@ -84,6 +84,22 @@ func (stubClient) ListNodes(_ context.Context) ([]localaitools.Node, error) {
|
||||
return []localaitools.Node{}, nil
|
||||
}
|
||||
|
||||
func (stubClient) ListScheduling(_ context.Context) ([]localaitools.ModelSchedulingConfig, error) {
|
||||
return []localaitools.ModelSchedulingConfig{}, nil
|
||||
}
|
||||
|
||||
func (stubClient) GetScheduling(_ context.Context, _ string) (*localaitools.ModelSchedulingConfig, error) {
|
||||
return &localaitools.ModelSchedulingConfig{}, nil
|
||||
}
|
||||
|
||||
func (stubClient) SetScheduling(_ context.Context, _ localaitools.SetSchedulingRequest) (*localaitools.ModelSchedulingConfig, error) {
|
||||
return &localaitools.ModelSchedulingConfig{}, nil
|
||||
}
|
||||
|
||||
func (stubClient) DeleteScheduling(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (stubClient) SetNodeVRAMBudget(_ context.Context, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
190
core/http/endpoints/openai/moderations.go
Normal file
190
core/http/endpoints/openai/moderations.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
"github.com/mudler/LocalAI/core/templates"
|
||||
"github.com/mudler/LocalAI/pkg/functions"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
)
|
||||
|
||||
var moderationCategories = []string{
|
||||
"harassment",
|
||||
"harassment/threatening",
|
||||
"hate",
|
||||
"hate/threatening",
|
||||
"illicit",
|
||||
"illicit/violent",
|
||||
"self-harm",
|
||||
"self-harm/intent",
|
||||
"self-harm/instructions",
|
||||
"sexual",
|
||||
"sexual/minors",
|
||||
"violence",
|
||||
"violence/graphic",
|
||||
}
|
||||
|
||||
type moderationGenerator func(context.Context, string, *config.ModelConfig) (string, backend.TokenUsage, error)
|
||||
|
||||
type generatedModeration struct {
|
||||
Categories map[string]bool `json:"categories"`
|
||||
CategoryScores map[string]float64 `json:"category_scores"`
|
||||
}
|
||||
|
||||
// ModerationEndpoint implements the text input subset of OpenAI's moderation
|
||||
// API using any LocalAI completion model and constrained JSON generation.
|
||||
// @Summary Classify text for potentially harmful content.
|
||||
// @Tags moderation
|
||||
// @Param request body schema.ModerationRequest true "query params"
|
||||
// @Success 200 {object} schema.ModerationResponse "Response"
|
||||
// @Router /v1/moderations [post]
|
||||
func ModerationEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
return moderationEndpoint(func(ctx context.Context, input string, cfg *config.ModelConfig) (string, backend.TokenUsage, error) {
|
||||
prompt := moderationPrompt(input)
|
||||
var messages schema.Messages
|
||||
if cfg.TemplateConfig.UseTokenizerTemplate {
|
||||
messages = schema.Messages{{Role: "user", Content: prompt}}
|
||||
prompt = ""
|
||||
} else if evaluator != nil {
|
||||
if rendered, err := evaluator.EvaluateTemplateForPrompt(templates.CompletionPromptTemplate, *cfg, templates.PromptTemplateData{Input: prompt, SystemPrompt: cfg.SystemPrompt}); err == nil {
|
||||
prompt = rendered
|
||||
}
|
||||
}
|
||||
|
||||
predict, err := backend.ModelInferenceFunc(ctx, prompt, messages, nil, nil, nil, ml, cfg, cl, appConfig, nil, "", "", nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
return "", backend.TokenUsage{}, err
|
||||
}
|
||||
response, err := predict()
|
||||
return response.Response, response.Usage, err
|
||||
})
|
||||
}
|
||||
|
||||
func moderationEndpoint(generate moderationGenerator) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.ModerationRequest)
|
||||
if !ok || input == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid moderation request")
|
||||
}
|
||||
if len(input.Input) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "input must contain at least one text string")
|
||||
}
|
||||
if generate == nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "moderation generator is unavailable")
|
||||
}
|
||||
|
||||
modelConfig, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
|
||||
if !ok || modelConfig == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "moderation model configuration is unavailable")
|
||||
}
|
||||
|
||||
grammar, err := moderationGrammar()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to build moderation grammar").SetInternal(err)
|
||||
}
|
||||
cfg := *modelConfig
|
||||
cfg.Grammar = grammar
|
||||
maxTokens := 512
|
||||
cfg.Maxtokens = &maxTokens
|
||||
|
||||
results := make([]schema.ModerationResult, 0, len(input.Input))
|
||||
promptTokens, completionTokens := 0, 0
|
||||
for _, text := range input.Input {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "input strings must not be empty")
|
||||
}
|
||||
raw, usage, err := generate(c.Request().Context(), text, &cfg)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "moderation inference failed").SetInternal(err)
|
||||
}
|
||||
result, err := parseModerationResult(raw)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "invalid moderation result: "+err.Error()).SetInternal(err)
|
||||
}
|
||||
results = append(results, result)
|
||||
promptTokens += usage.Prompt
|
||||
completionTokens += usage.Completion
|
||||
}
|
||||
|
||||
middleware.StampUsage(c, input.Model, promptTokens, completionTokens)
|
||||
return c.JSON(http.StatusOK, schema.ModerationResponse{
|
||||
ID: "modr-" + uuid.NewString(),
|
||||
Model: input.Model,
|
||||
Results: results,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func moderationPrompt(input string) string {
|
||||
encoded, _ := json.Marshal(input)
|
||||
return `Classify the supplied text for safety moderation. Return only the JSON object required by the grammar.
|
||||
For each category, set the boolean to true only when the text matches that category. Give each category a confidence score from 0 to 1.
|
||||
The categories are harassment, harassment/threatening, hate, hate/threatening, illicit, illicit/violent, self-harm, self-harm/intent, self-harm/instructions, sexual, sexual/minors, violence, and violence/graphic.
|
||||
Text to classify: ` + string(encoded)
|
||||
}
|
||||
|
||||
func moderationGrammar() (string, error) {
|
||||
boolProperties := map[string]any{}
|
||||
scoreProperties := map[string]any{}
|
||||
for _, category := range moderationCategories {
|
||||
boolProperties[category] = map[string]any{"type": "boolean"}
|
||||
scoreProperties[category] = map[string]any{"type": "number"}
|
||||
}
|
||||
structure := functions.JSONFunctionStructure{AnyOf: []functions.Item{{
|
||||
Type: "object",
|
||||
Properties: map[string]any{
|
||||
"categories": map[string]any{
|
||||
"type": "object",
|
||||
"properties": boolProperties,
|
||||
"required": moderationCategories,
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"category_scores": map[string]any{
|
||||
"type": "object",
|
||||
"properties": scoreProperties,
|
||||
"required": moderationCategories,
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
}}}
|
||||
return structure.Grammar()
|
||||
}
|
||||
|
||||
func parseModerationResult(raw string) (schema.ModerationResult, error) {
|
||||
var generated generatedModeration
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &generated); err != nil {
|
||||
return schema.ModerationResult{}, err
|
||||
}
|
||||
|
||||
result := schema.ModerationResult{
|
||||
Categories: make(map[string]bool, len(moderationCategories)),
|
||||
CategoryScores: make(map[string]float64, len(moderationCategories)),
|
||||
CategoryAppliedInputTypes: make(map[string][]string, len(moderationCategories)),
|
||||
}
|
||||
for _, category := range moderationCategories {
|
||||
flagged, exists := generated.Categories[category]
|
||||
if !exists {
|
||||
return schema.ModerationResult{}, fmt.Errorf("missing category %q", category)
|
||||
}
|
||||
score, exists := generated.CategoryScores[category]
|
||||
if !exists || math.IsNaN(score) || math.IsInf(score, 0) || score < 0 || score > 1 {
|
||||
return schema.ModerationResult{}, fmt.Errorf("category %q has an invalid score", category)
|
||||
}
|
||||
result.Categories[category] = flagged
|
||||
result.CategoryScores[category] = score
|
||||
result.CategoryAppliedInputTypes[category] = []string{"text"}
|
||||
result.Flagged = result.Flagged || flagged
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
105
core/http/endpoints/openai/moderations_test.go
Normal file
105
core/http/endpoints/openai/moderations_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Moderations endpoint", func() {
|
||||
It("classifies each text input and returns the OpenAI response shape", func() {
|
||||
inputs := []string{}
|
||||
generate := func(_ context.Context, input string, cfg *config.ModelConfig) (string, backend.TokenUsage, error) {
|
||||
inputs = append(inputs, input)
|
||||
Expect(cfg.Grammar).To(ContainSubstring("harassment"))
|
||||
return `{
|
||||
"categories":{"harassment":true,"harassment/threatening":false,"hate":false,"hate/threatening":false,"illicit":false,"illicit/violent":false,"self-harm":false,"self-harm/intent":false,"self-harm/instructions":false,"sexual":false,"sexual/minors":false,"violence":false,"violence/graphic":false},
|
||||
"category_scores":{"harassment":0.9,"harassment/threatening":0.1,"hate":0,"hate/threatening":0,"illicit":0,"illicit/violent":0,"self-harm":0,"self-harm/intent":0,"self-harm/instructions":0,"sexual":0,"sexual/minors":0,"violence":0,"violence/graphic":0}
|
||||
}`, backend.TokenUsage{Prompt: 12, Completion: 8}, nil
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/moderations", strings.NewReader(`{"model":"guard","input":["first","second"]}`))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := e.NewContext(req, rec)
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, &schema.ModerationRequest{
|
||||
BasicModelRequest: schema.BasicModelRequest{Model: "guard"},
|
||||
Input: schema.ModerationInput{"first", "second"},
|
||||
})
|
||||
modelConfig := &config.ModelConfig{Name: "guard"}
|
||||
modelConfig.Model = "guard.gguf"
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, modelConfig)
|
||||
|
||||
Expect(moderationEndpoint(generate)(ctx)).To(Succeed())
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(inputs).To(Equal([]string{"first", "second"}))
|
||||
|
||||
var response schema.ModerationResponse
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &response)).To(Succeed())
|
||||
Expect(response.ID).To(HavePrefix("modr-"))
|
||||
Expect(response.Model).To(Equal("guard"))
|
||||
Expect(response.Results).To(HaveLen(2))
|
||||
Expect(response.Results[0].Flagged).To(BeTrue())
|
||||
Expect(response.Results[0].Categories["harassment"]).To(BeTrue())
|
||||
Expect(response.Results[0].CategoryAppliedInputTypes["harassment"]).To(Equal([]string{"text"}))
|
||||
})
|
||||
|
||||
It("rejects an empty input list", func() {
|
||||
e := echo.New()
|
||||
ctx := e.NewContext(httptest.NewRequest(http.MethodPost, "/v1/moderations", nil), httptest.NewRecorder())
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, &schema.ModerationRequest{
|
||||
BasicModelRequest: schema.BasicModelRequest{Model: "guard"},
|
||||
})
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Name: "guard"})
|
||||
|
||||
err := moderationEndpoint(nil)(ctx)
|
||||
Expect(err).To(MatchError(ContainSubstring("input must contain at least one text string")))
|
||||
Expect(err.(*echo.HTTPError).Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("surfaces malformed classifier output without returning a partial result", func() {
|
||||
generate := func(context.Context, string, *config.ModelConfig) (string, backend.TokenUsage, error) {
|
||||
return "not-json", backend.TokenUsage{}, nil
|
||||
}
|
||||
e := echo.New()
|
||||
ctx := e.NewContext(httptest.NewRequest(http.MethodPost, "/v1/moderations", nil), httptest.NewRecorder())
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, &schema.ModerationRequest{
|
||||
BasicModelRequest: schema.BasicModelRequest{Model: "guard"},
|
||||
Input: schema.ModerationInput{"text"},
|
||||
})
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Name: "guard"})
|
||||
|
||||
err := moderationEndpoint(generate)(ctx)
|
||||
Expect(err).To(MatchError(ContainSubstring("invalid moderation result")))
|
||||
Expect(err.(*echo.HTTPError).Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Moderation input", func() {
|
||||
DescribeTable("accepts OpenAI text input forms",
|
||||
func(body string, expected schema.ModerationInput) {
|
||||
var req schema.ModerationRequest
|
||||
Expect(json.Unmarshal([]byte(body), &req)).To(Succeed())
|
||||
Expect(req.Input).To(Equal(expected))
|
||||
},
|
||||
Entry("single text", `{"input":"hello"}`, schema.ModerationInput{"hello"}),
|
||||
Entry("text array", `{"input":["hello","world"]}`, schema.ModerationInput{"hello", "world"}),
|
||||
)
|
||||
|
||||
It("rejects multimodal input in the text-only MVP", func() {
|
||||
var req schema.ModerationRequest
|
||||
err := json.Unmarshal([]byte(`{"input":[{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}`), &req)
|
||||
Expect(err).To(MatchError(ContainSubstring("text string or array of text strings")))
|
||||
})
|
||||
})
|
||||
134
core/http/endpoints/openai/upscale.go
Normal file
134
core/http/endpoints/openai/upscale.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
model "github.com/mudler/LocalAI/pkg/model"
|
||||
)
|
||||
|
||||
// UpscaleEndpoint handles POST /v1/images/upscale
|
||||
//
|
||||
// @Summary Image upscaling
|
||||
// @Description Upscale an image using a specified model (e.g. stable-diffusion-x4-upscaler). Accepts multipart/form-data.
|
||||
// @Tags images
|
||||
// @Accept multipart/form-data
|
||||
// @Produce application/json
|
||||
// @Param model formData string true "Upscaler model identifier (e.g. stable-diffusion-x4-upscaler)"
|
||||
// @Param image formData file true "Input image file"
|
||||
// @Param scale formData int false "Upscale factor: 2 or 4 (default 2)"
|
||||
// @Success 200 {object} schema.OpenAIResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /v1/images/upscale [post]
|
||||
func UpscaleEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
modelName := c.FormValue("model")
|
||||
scaleStr := c.FormValue("scale")
|
||||
|
||||
if modelName == "" {
|
||||
xlog.Error("Upscale Endpoint - missing model")
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "missing model")
|
||||
}
|
||||
|
||||
scale := 2
|
||||
if scaleStr != "" {
|
||||
v, err := strconv.Atoi(scaleStr)
|
||||
if err != nil || (v != 2 && v != 4) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "scale must be 2 or 4")
|
||||
}
|
||||
scale = v
|
||||
}
|
||||
|
||||
// Read uploaded image
|
||||
imageFile, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
xlog.Error("Upscale Endpoint - missing image file", "error", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "missing image file")
|
||||
}
|
||||
|
||||
imgSrc, err := imageFile.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer imgSrc.Close()
|
||||
imgBytes, err := io.ReadAll(imgSrc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get model config from middleware context
|
||||
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
|
||||
if !ok || cfg == nil {
|
||||
xlog.Error("Upscale Endpoint - model config not found in context")
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
tmpDir := filepath.Join(appConfig.GeneratedContentDir, "images")
|
||||
if err := os.MkdirAll(tmpDir, 0750); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to prepare storage")
|
||||
}
|
||||
|
||||
// Write input image to a temp file
|
||||
srcTmp, err := os.CreateTemp(tmpDir, "upscale_src_")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := srcTmp.Write(imgBytes); err != nil {
|
||||
_ = srcTmp.Close()
|
||||
_ = os.Remove(srcTmp.Name())
|
||||
return err
|
||||
}
|
||||
if err := srcTmp.Close(); err != nil {
|
||||
xlog.Warn("Upscale Endpoint - failed to close src temp file", "error", err)
|
||||
}
|
||||
srcPath := srcTmp.Name()
|
||||
defer os.Remove(srcPath)
|
||||
|
||||
// Prepare output file path
|
||||
id := uuid.New().String()
|
||||
dstPath := filepath.Join(tmpDir, fmt.Sprintf("upscale_%s.png", id))
|
||||
|
||||
fn, err := backend.ImageUpscaleFunc(c.Request().Context(), srcPath, dstPath, scale, ml, *cfg, appConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(); err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL := middleware.BaseURL(c)
|
||||
imgURL, err := url.JoinPath(baseURL, "generated-images", filepath.Base(dstPath))
|
||||
if err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
return err
|
||||
}
|
||||
|
||||
created := int(time.Now().Unix())
|
||||
resp := &schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Data: []schema.Item{{URL: imgURL}},
|
||||
Usage: &schema.OpenAIUsage{
|
||||
InputTokensDetails: &schema.InputTokensDetails{},
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
89
core/http/endpoints/openai/upscale_test.go
Normal file
89
core/http/endpoints/openai/upscale_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
model "github.com/mudler/LocalAI/pkg/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Image upscaling", func() {
|
||||
var (
|
||||
appConfig *config.ApplicationConfig
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "upscale")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
appConfig = config.NewApplicationConfig(config.WithGeneratedContentDir(tmpDir))
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
Expect(os.RemoveAll(tmpDir)).To(Succeed())
|
||||
})
|
||||
|
||||
It("stores the result in the directory served by /generated-images", func() {
|
||||
original := backend.ImageUpscaleFunc
|
||||
backend.ImageUpscaleFunc = func(_ context.Context, _, dst string, scale int, _ *model.ModelLoader, _ config.ModelConfig, _ *config.ApplicationConfig) (func() error, error) {
|
||||
Expect(scale).To(Equal(4))
|
||||
return func() error {
|
||||
return os.WriteFile(dst, []byte("PNGDATA"), 0o644)
|
||||
}, nil
|
||||
}
|
||||
DeferCleanup(func() { backend.ImageUpscaleFunc = original })
|
||||
|
||||
req, _ := makeMultipartRequest(
|
||||
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "4"},
|
||||
map[string][]byte{"image": []byte("IMAGEDATA")},
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := echo.New().NewContext(req, rec)
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Backend: "diffusers"})
|
||||
|
||||
Expect(UpscaleEndpoint(nil, nil, appConfig)(ctx)).To(Succeed())
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response schema.OpenAIResponse
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &response)).To(Succeed())
|
||||
Expect(response.Data).To(HaveLen(1))
|
||||
Expect(response.Data[0].URL).To(ContainSubstring("/generated-images/upscale_"))
|
||||
|
||||
filename := filepath.Base(response.Data[0].URL)
|
||||
contents, err := os.ReadFile(filepath.Join(tmpDir, "images", filename))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(contents).To(Equal([]byte("PNGDATA")))
|
||||
})
|
||||
|
||||
It("rejects unsupported scale factors", func() {
|
||||
req, _ := makeMultipartRequest(
|
||||
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "3"},
|
||||
map[string][]byte{"image": []byte("IMAGEDATA")},
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx := echo.New().NewContext(req, rec)
|
||||
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Backend: "diffusers"})
|
||||
|
||||
err := UpscaleEndpoint(nil, nil, appConfig)(ctx)
|
||||
var httpErr *echo.HTTPError
|
||||
Expect(err).To(MatchError(ContainSubstring("scale must be 2 or 4")))
|
||||
Expect(err).To(BeAssignableToTypeOf(httpErr))
|
||||
httpErr = err.(*echo.HTTPError)
|
||||
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(httpErr.Message).To(Equal("scale must be 2 or 4"))
|
||||
Expect(bytes.TrimSpace(rec.Body.Bytes())).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
109
core/http/middleware/trace_summary.go
Normal file
109
core/http/middleware/trace_summary.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TraceSummary is the counted view of the trace buffer.
|
||||
//
|
||||
// It exists so a caller that wants "how many, how many failed, how slow" does
|
||||
// not have to fetch every exchange and count them in the browser. The Operate
|
||||
// overview needs exactly those three numbers, and the trace list is capped in
|
||||
// the thousands, so shipping it across the wire to produce a single integer is
|
||||
// waste that grows with the buffer.
|
||||
type TraceSummary struct {
|
||||
Total int `json:"total"`
|
||||
Errors int `json:"errors"`
|
||||
P95Millis int64 `json:"p95_ms"`
|
||||
WindowHours int `json:"window_hours"`
|
||||
Buckets []TraceBucket `json:"buckets"`
|
||||
}
|
||||
|
||||
// TraceBucket is one column of a sparkline: oldest first, so the series reads
|
||||
// left to right the way a chart is drawn.
|
||||
type TraceBucket struct {
|
||||
Start time.Time `json:"start"`
|
||||
Count int `json:"count"`
|
||||
Errors int `json:"errors"`
|
||||
}
|
||||
|
||||
// GetTracesSummary counts the buffered exchanges over the given window.
|
||||
func GetTracesSummary(window time.Duration, buckets int) TraceSummary {
|
||||
return summarize(GetTraces(), window, buckets)
|
||||
}
|
||||
|
||||
func summarize(traces []APIExchange, window time.Duration, buckets int) TraceSummary {
|
||||
if buckets < 1 {
|
||||
buckets = 1
|
||||
}
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-window)
|
||||
|
||||
summary := TraceSummary{
|
||||
WindowHours: int(window.Hours()),
|
||||
// Never nil: a nil slice serialises as null and breaks .map() on the
|
||||
// other side, which is a silent runtime error rather than an empty chart.
|
||||
Buckets: make([]TraceBucket, buckets),
|
||||
}
|
||||
|
||||
bucketWidth := window / time.Duration(buckets)
|
||||
for i := range summary.Buckets {
|
||||
summary.Buckets[i].Start = cutoff.Add(time.Duration(i) * bucketWidth)
|
||||
}
|
||||
|
||||
durations := make([]time.Duration, 0, len(traces))
|
||||
for _, t := range traces {
|
||||
if t.Timestamp.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
summary.Total++
|
||||
failed := isFailure(t)
|
||||
if failed {
|
||||
summary.Errors++
|
||||
}
|
||||
durations = append(durations, t.Duration)
|
||||
|
||||
// Clamp rather than skip: a request timestamped a hair in the future
|
||||
// (clock skew, or arriving mid-call) still belongs in the newest column.
|
||||
idx := int(t.Timestamp.Sub(cutoff) / bucketWidth)
|
||||
if idx >= buckets {
|
||||
idx = buckets - 1
|
||||
}
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
summary.Buckets[idx].Count++
|
||||
if failed {
|
||||
summary.Buckets[idx].Errors++
|
||||
}
|
||||
}
|
||||
|
||||
summary.P95Millis = percentileMillis(durations, 0.95)
|
||||
return summary
|
||||
}
|
||||
|
||||
// A 4xx is the caller getting it wrong, which is not the installation being
|
||||
// unhealthy. Only 5xx and a transport-level error count against the runtime.
|
||||
func isFailure(t APIExchange) bool {
|
||||
return t.Error != "" || t.Response.Status >= 500
|
||||
}
|
||||
|
||||
func percentileMillis(durations []time.Duration, p float64) int64 {
|
||||
if len(durations) == 0 {
|
||||
return 0
|
||||
}
|
||||
slices.Sort(durations)
|
||||
// Nearest-rank: the smallest value at or above the pth percentile.
|
||||
rank := int(math.Ceil(p*float64(len(durations)))) - 1
|
||||
if rank < 0 {
|
||||
rank = 0
|
||||
}
|
||||
if rank >= len(durations) {
|
||||
rank = len(durations) - 1
|
||||
}
|
||||
return durations[rank].Milliseconds()
|
||||
}
|
||||
79
core/http/middleware/trace_summary_test.go
Normal file
79
core/http/middleware/trace_summary_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("API trace summary", func() {
|
||||
exchange := func(age time.Duration, status int, dur time.Duration) APIExchange {
|
||||
return APIExchange{
|
||||
Timestamp: time.Now().Add(-age),
|
||||
Duration: dur,
|
||||
Response: APIExchangeResponse{Status: status},
|
||||
}
|
||||
}
|
||||
|
||||
It("counts only what falls inside the window", func() {
|
||||
traces := []APIExchange{
|
||||
exchange(1*time.Hour, 200, 10*time.Millisecond),
|
||||
exchange(2*time.Hour, 200, 10*time.Millisecond),
|
||||
// Older than the window: must not be counted at all.
|
||||
exchange(48*time.Hour, 500, 10*time.Millisecond),
|
||||
}
|
||||
s := summarize(traces, 24*time.Hour, 6)
|
||||
Expect(s.Total).To(Equal(2))
|
||||
Expect(s.Errors).To(BeZero())
|
||||
})
|
||||
|
||||
It("treats 5xx and a transport error as failures, but not 4xx", func() {
|
||||
traces := []APIExchange{
|
||||
exchange(time.Minute, 500, time.Millisecond),
|
||||
exchange(time.Minute, 503, time.Millisecond),
|
||||
// A client sending a bad request is not the server failing.
|
||||
exchange(time.Minute, 404, time.Millisecond),
|
||||
exchange(time.Minute, 200, time.Millisecond),
|
||||
}
|
||||
traces[3].Error = "connection reset"
|
||||
|
||||
s := summarize(traces, 24*time.Hour, 6)
|
||||
Expect(s.Total).To(Equal(4))
|
||||
Expect(s.Errors).To(Equal(3))
|
||||
})
|
||||
|
||||
It("reports p95 as a real percentile rather than the slowest request", func() {
|
||||
traces := make([]APIExchange, 0, 100)
|
||||
for i := 1; i <= 100; i++ {
|
||||
traces = append(traces, exchange(time.Minute, 200, time.Duration(i)*time.Millisecond))
|
||||
}
|
||||
s := summarize(traces, 24*time.Hour, 6)
|
||||
// 95th of 1..100ms, not the 100ms max.
|
||||
Expect(s.P95Millis).To(BeNumerically("~", 95, 1))
|
||||
})
|
||||
|
||||
It("buckets oldest-first so a sparkline reads left to right", func() {
|
||||
traces := []APIExchange{
|
||||
exchange(30*time.Minute, 200, time.Millisecond),
|
||||
exchange(30*time.Minute, 200, time.Millisecond),
|
||||
exchange(5*time.Hour, 200, time.Millisecond),
|
||||
}
|
||||
s := summarize(traces, 6*time.Hour, 6)
|
||||
Expect(s.Buckets).To(HaveLen(6))
|
||||
Expect(s.Buckets[0].Count).To(Equal(1), "the 5h-old request lands in the first bucket")
|
||||
Expect(s.Buckets[5].Count).To(Equal(2), "the recent pair lands in the last")
|
||||
})
|
||||
|
||||
It("returns an empty, non-nil summary when nothing has been traced", func() {
|
||||
s := summarize(nil, 24*time.Hour, 6)
|
||||
Expect(s.Total).To(BeZero())
|
||||
Expect(s.Errors).To(BeZero())
|
||||
Expect(s.P95Millis).To(BeZero())
|
||||
// A nil slice serialises as null and breaks .map() in the browser.
|
||||
Expect(s.Buckets).NotTo(BeNil())
|
||||
Expect(s.Buckets).To(HaveLen(6))
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,40 @@ test('lists live operations and cancels one from a labelled button', async ({ pa
|
||||
expect(cancelledPath).toBe('/api/operations/job-gemma/cancel')
|
||||
})
|
||||
|
||||
test('pauses a model download without invoking destructive cancel', async ({ page }) => {
|
||||
await stub(page, {
|
||||
operations: [{
|
||||
id: 'gemma-3-27b-it',
|
||||
name: 'gemma-3-27b-it',
|
||||
jobID: 'job-gemma',
|
||||
progress: 22,
|
||||
taskType: 'installation',
|
||||
isBackend: false,
|
||||
isQueued: false,
|
||||
isDeletion: false,
|
||||
cancellable: true,
|
||||
phase: 'downloading',
|
||||
}],
|
||||
})
|
||||
|
||||
const requests = []
|
||||
await page.route('**/api/operations/job-gemma/pause', (route) => {
|
||||
requests.push(new URL(route.request().url()).pathname)
|
||||
return route.fulfill({ contentType: 'application/json', body: '{}' })
|
||||
})
|
||||
await page.route('**/api/operations/job-gemma/cancel', (route) => {
|
||||
requests.push(new URL(route.request().url()).pathname)
|
||||
return route.fulfill({ contentType: 'application/json', body: '{}' })
|
||||
})
|
||||
|
||||
await page.goto('/app/activity')
|
||||
|
||||
const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' })
|
||||
await card.locator('.operation-card__pause').click()
|
||||
|
||||
await expect.poll(() => requests).toEqual(['/api/operations/job-gemma/pause'])
|
||||
})
|
||||
|
||||
test('separates an unacknowledged failure from the record', async ({ page }) => {
|
||||
await stub(page, {
|
||||
operations: [{
|
||||
|
||||
@@ -5,7 +5,9 @@ test.describe('Admin console', () => {
|
||||
await page.goto('/app/backends')
|
||||
const rail = page.locator('.console-rail')
|
||||
await expect(rail).toBeVisible()
|
||||
for (const group of ['Inference', 'Cluster', 'Observability', 'Access', 'System']) {
|
||||
// Four groups since the overview landed: Inference folded into Runtime
|
||||
// (both are "the runtime right now"), Access and System into Administration.
|
||||
for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) {
|
||||
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
23
core/http/react-ui/e2e/backends-notice.spec.js
Normal file
23
core/http/react-ui/e2e/backends-notice.spec.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// A notice is a hairline with a coloured left edge, not a filled panel. A tint
|
||||
// makes every notice shout at the weight of an error, which is how notices stop
|
||||
// being read — and it is the same treatment the Operate overview uses for the
|
||||
// rows that want a decision.
|
||||
|
||||
test('the backends notice is an edge, not a filled card', async ({ page }) => {
|
||||
// The upgrade banner is the notice worth pinning, so make one exist.
|
||||
await page.route('**/api/backends/upgrades', route => route.fulfill({
|
||||
json: { 'llama-cpp': { backend_name: 'llama-cpp', installed_version: '0.9.4', available_version: '0.9.7' } },
|
||||
}))
|
||||
await page.goto('/app/backends')
|
||||
const notice = page.locator('.bk-notice', { hasText: /update/i }).first()
|
||||
await expect(notice).toBeVisible()
|
||||
const s = await notice.evaluate(el => {
|
||||
const cs = getComputedStyle(el)
|
||||
return { bg: cs.backgroundColor, left: parseFloat(cs.borderLeftWidth), top: parseFloat(cs.borderTopWidth) }
|
||||
})
|
||||
expect(s.bg).toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
|
||||
expect(s.left).toBeGreaterThanOrEqual(3)
|
||||
expect(s.top).toBeLessThanOrEqual(1)
|
||||
})
|
||||
55
core/http/react-ui/e2e/chat-transcript.spec.js
Normal file
55
core/http/react-ui/e2e/chat-transcript.spec.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Chat reads as a transcript rather than a bubble thread (mock 04).
|
||||
|
||||
const CHAT = {
|
||||
chats: [{
|
||||
id: 'c1', name: 'Transcript', model: 'mock-model',
|
||||
history: [
|
||||
{ role: 'user', content: 'Which backends do I have?' },
|
||||
{ role: 'assistant', content: 'Seven are installed.' },
|
||||
],
|
||||
}],
|
||||
activeChatId: 'c1',
|
||||
}
|
||||
|
||||
test.describe('Chat transcript', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(chat => {
|
||||
localStorage.setItem('localai_chats_data', JSON.stringify(chat))
|
||||
}, CHAT)
|
||||
await page.goto('/app/chat')
|
||||
})
|
||||
|
||||
test('neither role is a filled, rounded bubble', async ({ page }) => {
|
||||
const user = page.locator('.chat-message-user .chat-message-content').first()
|
||||
await expect(user).toBeVisible()
|
||||
const cs = await user.evaluate(el => {
|
||||
const s = getComputedStyle(el)
|
||||
return { radius: s.borderTopLeftRadius, shadow: s.boxShadow }
|
||||
})
|
||||
// A rounded filled bubble carries the speaker in shape and side; a
|
||||
// transcript carries it in words, which survives being read aloud.
|
||||
expect(cs.radius).toBe('0px')
|
||||
expect(cs.shadow).toBe('none')
|
||||
})
|
||||
|
||||
test('both turns run full width in one column, not left and right', async ({ page }) => {
|
||||
const user = page.locator('.chat-message-user').first()
|
||||
const assistant = page.locator('.chat-message-assistant').first()
|
||||
const [u, a] = [await user.boundingBox(), await assistant.boundingBox()]
|
||||
expect(Math.abs(u.x - a.x)).toBeLessThan(2)
|
||||
})
|
||||
|
||||
test('every turn says who is speaking', async ({ page }) => {
|
||||
await expect(page.locator('.chat-message-user .chat-message-model')).toHaveText('You')
|
||||
await expect(page.locator('.chat-message-assistant .chat-message-model').first())
|
||||
.toHaveText('mock-model')
|
||||
})
|
||||
|
||||
test('turns are separated by a rule', async ({ page }) => {
|
||||
const border = await page.locator('.chat-message').first()
|
||||
.evaluate(el => getComputedStyle(el).borderBottomStyle)
|
||||
expect(border).toBe('solid')
|
||||
})
|
||||
})
|
||||
50
core/http/react-ui/e2e/chrome-audit.spec.js
Normal file
50
core/http/react-ui/e2e/chrome-audit.spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// A standing guard against the two defects an earlier automated edit left
|
||||
// scattered through the pages: icons stripped of their fa-* class (which render
|
||||
// nothing at all), and controls left with the user agent's own chrome, which is
|
||||
// a pale grey button on a dark ground.
|
||||
const ROUTES = [
|
||||
'/app', '/app/chat', '/app/models', '/app/studio', '/app/talk',
|
||||
'/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs',
|
||||
'/app/fine-tune', '/app/quantize', '/app/face', '/app/voice',
|
||||
'/app/manage', '/app/backends', '/app/activity', '/app/operate',
|
||||
'/app/settings', '/app/traces', '/app/usage', '/app/nodes', '/app/p2p',
|
||||
'/app/voice-library', '/app/voice-library/new', '/app/account',
|
||||
]
|
||||
|
||||
test('no page renders a dead icon or a default-chrome control', async ({ page }) => {
|
||||
// One test walks every route, so its budget has to scale with the list rather
|
||||
// than sit on Playwright's per-test default of 30s. At 25 routes that default
|
||||
// allows ~1.2s per navigation, which holds on a developer machine and does
|
||||
// not on a loaded CI runner: the suite went red on the commit that added this
|
||||
// spec, timing out mid-loop at waitForTimeout rather than at any single goto,
|
||||
// which is what cumulative slowness looks like as opposed to one hung route.
|
||||
// Six seconds a route absorbs a slow runner and still fails promptly if a
|
||||
// route really does hang.
|
||||
test.setTimeout(ROUTES.length * 6_000)
|
||||
|
||||
const findings = []
|
||||
for (const route of ROUTES) {
|
||||
await page.goto(route)
|
||||
await page.waitForTimeout(400)
|
||||
const found = await page.evaluate(() => {
|
||||
const out = []
|
||||
for (const el of document.querySelectorAll('button, a')) {
|
||||
if (el.getBoundingClientRect().width === 0) continue
|
||||
const cs = getComputedStyle(el)
|
||||
if (cs.borderTopStyle === 'outset' || cs.backgroundColor === 'rgb(239, 239, 239)') {
|
||||
out.push(`default-chrome: "${(el.textContent || '').trim().slice(0, 24)}" [${el.className}]`)
|
||||
}
|
||||
}
|
||||
for (const i of document.querySelectorAll('i')) {
|
||||
if (!/\bfa-/.test((i.className || '').toString())) {
|
||||
out.push(`dead-icon: [${i.className}]`)
|
||||
}
|
||||
}
|
||||
return [...new Set(out)]
|
||||
})
|
||||
for (const f of found) findings.push(`${route} — ${f}`)
|
||||
}
|
||||
expect(findings).toEqual([])
|
||||
})
|
||||
101
core/http/react-ui/e2e/console-narrow.spec.js
Normal file
101
core/http/react-ui/e2e/console-narrow.spec.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Small-screen behaviour of the Operate console and the dashboard stat cards.
|
||||
//
|
||||
// Both defects here are about a narrow viewport but neither is only a narrow
|
||||
// viewport problem: the stat cards were being laid out by the wrong rule at
|
||||
// every width, and the rail's height was never bounded.
|
||||
|
||||
test.describe('Operate console on a narrow screen', () => {
|
||||
test('expanding the rail leaves the page still on screen', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 800 })
|
||||
await page.goto('/app/manage')
|
||||
|
||||
const toggle = page.locator('.console-rail-toggle')
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click()
|
||||
await expect(page.locator('.console-rail-groups')).toBeVisible()
|
||||
|
||||
// Thirteen destinations in one column is taller than a phone. If opening
|
||||
// the menu pushes the page's own heading past the fold, the menu has
|
||||
// replaced the page instead of annotating it.
|
||||
// Manage titles itself with .view-bar__title rather than .page-title.
|
||||
const heading = page.locator('.page-title, .view-bar__title').first()
|
||||
const box = await heading.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box.y).toBeLessThan(800)
|
||||
})
|
||||
|
||||
test('the rail scrolls internally rather than growing without bound', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 800 })
|
||||
await page.goto('/app/manage')
|
||||
await page.locator('.console-rail-toggle').click()
|
||||
|
||||
const groups = page.locator('.console-rail-groups')
|
||||
await expect(groups).toBeVisible()
|
||||
const height = await groups.evaluate(el => el.getBoundingClientRect().height)
|
||||
expect(height).toBeLessThan(800)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Headline figures', () => {
|
||||
// Host used shadowed StatCards; it now shares the Operate overview's hairline
|
||||
// figure strip, so the guard is that its labels stay legible, not that it
|
||||
// keeps a card gap.
|
||||
for (const width of [768, 1024]) {
|
||||
test(`Host figure labels are not clipped at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await page.goto('/app/manage')
|
||||
const labels = page.locator('.stat-strip__label')
|
||||
await expect(labels.first()).toBeVisible()
|
||||
const clipped = await labels.evaluateAll(els =>
|
||||
els.filter(el => el.scrollWidth > el.clientWidth + 1).map(el => el.textContent))
|
||||
expect(clipped).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('a Host figure routes into the thing it counts', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 1000 })
|
||||
await page.goto('/app/manage')
|
||||
const cell = page.locator('.stat-strip__cell').first()
|
||||
await expect(cell).toBeVisible()
|
||||
// A count is worth more when it is also the way to what it counted.
|
||||
await expect(cell).toHaveJSProperty('tagName', 'BUTTON')
|
||||
})
|
||||
|
||||
test('the figure strip keeps its height inside the flex column', async ({ page }) => {
|
||||
// .page--app is a flex column whose split view takes flex:1, so a child
|
||||
// with no intrinsic minimum gets shrunk to nothing. This strip did exactly
|
||||
// that and rendered 2px tall with four invisible cells.
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await page.goto('/app/manage')
|
||||
const strip = page.locator('.manage-summary')
|
||||
await expect(strip).toBeVisible()
|
||||
const h = await strip.evaluate(el => el.getBoundingClientRect().height)
|
||||
expect(h).toBeGreaterThan(40)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Headline figure contrast', () => {
|
||||
test('every figure is legible against the cell it sits on', async ({ page }) => {
|
||||
// A <button> does not inherit colour, so a value with no tone rule fell
|
||||
// back to the UA's `buttontext` — pure black on the dark ground, invisible.
|
||||
await page.setViewportSize({ width: 1440, height: 950 })
|
||||
await page.goto('/app/manage')
|
||||
const bad = await page.locator('.stat-strip__value').evaluateAll(els => els
|
||||
.map(el => ({ text: el.textContent, color: getComputedStyle(el).color }))
|
||||
.filter(v => v.color === 'rgb(0, 0, 0)'))
|
||||
expect(bad).toEqual([])
|
||||
})
|
||||
|
||||
test('the strip keeps its top margin against the shared shorthand', async ({ page }) => {
|
||||
// `.stat-strip` declares `margin: 0 0 ...` later in the file, which was
|
||||
// silently resetting this element's top margin and leaving it flush
|
||||
// against the resources panel above it.
|
||||
await page.setViewportSize({ width: 1440, height: 950 })
|
||||
await page.goto('/app/manage')
|
||||
const top = await page.locator('.manage-summary')
|
||||
.evaluate(el => parseFloat(getComputedStyle(el).marginTop))
|
||||
expect(top).toBeGreaterThan(12)
|
||||
})
|
||||
})
|
||||
103
core/http/react-ui/e2e/home-lanes.spec.js
Normal file
103
core/http/react-ui/e2e/home-lanes.spec.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Home's resident-model list and the app footer.
|
||||
|
||||
const SYS_INFO = {
|
||||
backends: ['llama-cpp'],
|
||||
loaded_models: [
|
||||
{ id: 'qwen3-8b-instruct', backend: 'llama-cpp' },
|
||||
{ id: 'parakeet-tdt-0.6b' },
|
||||
],
|
||||
}
|
||||
|
||||
async function mockLoaded(page) {
|
||||
await page.route('**/system', route => route.fulfill({ json: SYS_INFO }))
|
||||
await page.route('**/v1/models', route =>
|
||||
route.fulfill({ json: { data: [{ id: 'qwen3-8b-instruct' }, { id: 'parakeet-tdt-0.6b' }] } }))
|
||||
}
|
||||
|
||||
test.describe('Home resident models', () => {
|
||||
test('resident models read as lanes, not status chips', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
const lanes = page.locator('.home-loaded .lane')
|
||||
await expect(lanes).toHaveCount(2)
|
||||
// Model ids are identifiers, so they are set in mono like every other
|
||||
// identifier in the app.
|
||||
const family = await lanes.first().locator('.lane__name').evaluate(
|
||||
el => getComputedStyle(el).fontFamily.toLowerCase())
|
||||
expect(family).toMatch(/mono|consol|menlo/)
|
||||
})
|
||||
|
||||
test('each lane keeps its stop control', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
const lane = page.locator('.home-loaded .lane').first()
|
||||
await expect(lane.getByRole('button', { name: /stop/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('the header reports how many are resident as a figure', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
const stat = page.locator('[data-testid="home-stat-loaded"]')
|
||||
await expect(stat).toBeVisible()
|
||||
await expect(stat).toContainText('2')
|
||||
// Digits that sit in a column need to line up.
|
||||
const numeric = await stat.locator('.home-stat__value').evaluate(
|
||||
el => getComputedStyle(el).fontVariantNumeric)
|
||||
expect(numeric).toContain('tabular-nums')
|
||||
})
|
||||
|
||||
test('a resident model names the engine serving it', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
// Lanes are sorted by id, so target by content rather than position.
|
||||
const qwen = page.locator('.home-loaded .lane', { hasText: 'qwen3-8b-instruct' })
|
||||
await expect(qwen).toContainText('llama-cpp')
|
||||
})
|
||||
|
||||
test('a model without a config shows no engine rather than a guess', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
// parakeet has no backend in the payload; the column stays blank.
|
||||
const parakeet = page.locator('.home-loaded .lane', { hasText: 'parakeet-tdt-0.6b' })
|
||||
await expect(parakeet).not.toContainText('llama-cpp')
|
||||
})
|
||||
|
||||
test('jump-back-in offers the three places worth returning to', async ({ page }) => {
|
||||
await mockLoaded(page)
|
||||
await page.goto('/app')
|
||||
const lanes = page.locator('.lanes--jump .lane')
|
||||
await expect(lanes).toHaveCount(3)
|
||||
await expect(lanes.first()).toContainText('Discover')
|
||||
})
|
||||
|
||||
test('nothing resident still says so', async ({ page }) => {
|
||||
await page.route('**/system', route =>
|
||||
route.fulfill({ json: { backends: ['llama-cpp'], loaded_models: [] } }))
|
||||
await page.route('**/v1/models', route => route.fulfill({ json: { data: [{ id: 'a-model' }] } }))
|
||||
await page.goto('/app')
|
||||
await expect(page.locator('.home-loaded-empty')).toBeVisible()
|
||||
await expect(page.locator('.home-loaded .lane')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('App footer', () => {
|
||||
test('is one line, not three stacked rows', async ({ page }) => {
|
||||
await page.goto('/app')
|
||||
const footer = page.locator('.app-footer')
|
||||
await expect(footer).toBeVisible()
|
||||
// Three centred rows of chrome cost more vertical space than the content
|
||||
// they sit under is usually worth.
|
||||
const height = await footer.evaluate(el => el.getBoundingClientRect().height)
|
||||
expect(height).toBeLessThan(56)
|
||||
})
|
||||
|
||||
test('keeps every link it had', async ({ page }) => {
|
||||
await page.goto('/app')
|
||||
const footer = page.locator('.app-footer')
|
||||
for (const name of [/github/i, /documentation/i, /author/i]) {
|
||||
await expect(footer.getByRole('link', { name })).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1786,6 +1786,19 @@ test.describe("Models Gallery - Discover split view", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("a build that fits at some context sizes warns rather than erroring", async ({
|
||||
page,
|
||||
}) => {
|
||||
await railItem(page, "llama-model").click();
|
||||
const verdict = page.locator(".discover__chart-verdict");
|
||||
await expect(verdict).toBeVisible();
|
||||
// A model that fits at 32k but not 64k is a trade-off, and #11288 keeps a
|
||||
// test on such a build still being installable. Only "fits nowhere" earns
|
||||
// the error tone; anything short of that warns.
|
||||
await expect(verdict).toHaveClass(/discover__chart-verdict--warn/);
|
||||
await expect(verdict).not.toHaveClass(/discover__chart-verdict--bad/);
|
||||
});
|
||||
|
||||
test("a host with no GPU gets no chart rather than an unanchored one", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -56,81 +56,23 @@ async function gotoModels(page) {
|
||||
}
|
||||
|
||||
test.describe("Models gallery - recommended panel prominence", () => {
|
||||
test("first visit with nothing installed shows the panel expanded", async ({ page }) => {
|
||||
test("it is a section in the flow, not a dismissable card", async ({ page }) => {
|
||||
await mockGallery(page, 0);
|
||||
await gotoModels(page);
|
||||
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(grid(page)).toBeVisible();
|
||||
await expect(grid(page).getByText("tiny-chat")).toBeVisible();
|
||||
await expect(panel(page)).toBeVisible();
|
||||
// No close button and no collapse: this is the one thing the page has to
|
||||
// say about the machine it runs on, not an interruption to be shut.
|
||||
await expect(panel(page).locator("button[aria-expanded]")).toHaveCount(0);
|
||||
await expect(panel(page).getByRole("button", { name: /dismiss|close/i })).toHaveCount(0);
|
||||
// And no card chrome, so it sits in the pane rather than on top of it.
|
||||
const border = await panel(page).evaluate((el) => getComputedStyle(el).borderTopWidth);
|
||||
expect(parseFloat(border)).toBe(0);
|
||||
});
|
||||
|
||||
test("a user with models installed gets it collapsed by default", async ({ page }) => {
|
||||
await mockGallery(page, 12);
|
||||
await gotoModels(page);
|
||||
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(grid(page)).toBeHidden();
|
||||
// Collapsed is a summary, not a removal: the heading stays on the page.
|
||||
await expect(panel(page).getByText("Recommended for your hardware")).toBeVisible();
|
||||
await expect(panel(page).getByText("2 models suggested")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the collapsed summary expands again on activation", async ({ page }) => {
|
||||
await mockGallery(page, 12);
|
||||
await gotoModels(page);
|
||||
|
||||
await expect(grid(page)).toBeHidden();
|
||||
await toggle(page).click();
|
||||
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(grid(page)).toBeVisible();
|
||||
await expect(page.evaluate((k) => localStorage.getItem(k), COLLAPSE_KEY)).resolves.toBe("0");
|
||||
});
|
||||
|
||||
test("the collapse choice persists across a reload", async ({ page }) => {
|
||||
await mockGallery(page, 0);
|
||||
await gotoModels(page);
|
||||
await expect(grid(page)).toBeVisible();
|
||||
|
||||
await toggle(page).click();
|
||||
await expect(grid(page)).toBeHidden();
|
||||
|
||||
await page.reload();
|
||||
await expect(panel(page)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(grid(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test("dismissing it persists across a reload", async ({ page }) => {
|
||||
await mockGallery(page, 0);
|
||||
await gotoModels(page);
|
||||
|
||||
await panel(page).getByRole("button", { name: "Dismiss recommendations" }).click();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(page.evaluate((k) => localStorage.getItem(k), DISMISS_KEY)).resolves.toBe("1");
|
||||
|
||||
await page.reload();
|
||||
// The rail having entries is the marker that the page finished rendering
|
||||
// without the panel. It used to be the table, which no longer exists.
|
||||
await expect(
|
||||
page.locator('[data-testid="discover-rail-item"]').first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the toggle is keyboard operable and exposes its state", async ({ page }) => {
|
||||
await mockGallery(page, 12);
|
||||
await gotoModels(page);
|
||||
|
||||
await toggle(page).focus();
|
||||
await expect(toggle(page)).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
// aria-controls must resolve to the region it actually shows and hides.
|
||||
await expect(toggle(page)).toHaveAttribute("aria-controls", "rec-models-content");
|
||||
await expect(grid(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("recommendations render and their install buttons still work", async ({ page }) => {
|
||||
await mockGallery(page, 0);
|
||||
@@ -141,11 +83,23 @@ test.describe("Models gallery - recommended panel prominence", () => {
|
||||
});
|
||||
await gotoModels(page);
|
||||
|
||||
const card = grid(page).locator(".rec-models-item", { hasText: "tiny-chat" });
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByText("512.0 MB")).toBeVisible();
|
||||
await card.getByRole("button", { name: "Install" }).click();
|
||||
// Ranked candidates read in fit order, so these are lanes now rather than
|
||||
// a grid of equal cards.
|
||||
const row = grid(page).locator(".lane", { hasText: "tiny-chat" });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByText("512.0 MB")).toBeVisible();
|
||||
await row.getByRole("button", { name: "Install" }).click();
|
||||
|
||||
await expect.poll(() => installed).toBe("tiny-chat");
|
||||
});
|
||||
|
||||
test("the best fit is called out, the rest are alternatives", async ({ page }) => {
|
||||
await mockGallery(page, 0);
|
||||
await gotoModels(page);
|
||||
const rows = grid(page).locator(".lane");
|
||||
await expect(rows.first().locator(".lane__tag--evidence")).toHaveText("Best fit");
|
||||
// One opinion per page: the others are alternatives, not runners-up worth
|
||||
// their own colour.
|
||||
await expect(grid(page).locator(".lane__tag--evidence")).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
172
core/http/react-ui/e2e/operate-overview.spec.js
Normal file
172
core/http/react-ui/e2e/operate-overview.spec.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Operate overview (src/pages/OperateOverview.jsx).
|
||||
//
|
||||
// The page exists to answer "is anything wrong" without visiting four other
|
||||
// pages, so the tests are written against that behaviour rather than against
|
||||
// the markup: what does it say when nothing is wrong, and does each source of
|
||||
// trouble actually surface.
|
||||
|
||||
const OVERVIEW = '[data-testid="operate-overview"]'
|
||||
const CLEAR = '[data-testid="operate-attention-clear"]'
|
||||
const ITEM = '[data-testid="operate-attention-item"]'
|
||||
|
||||
const NO_UPGRADES = {}
|
||||
const ONE_UPGRADE = {
|
||||
'llama-cpp': {
|
||||
backend_name: 'llama-cpp',
|
||||
installed_version: '0.9.4',
|
||||
available_version: '0.9.7',
|
||||
},
|
||||
}
|
||||
|
||||
// A quiet installation: nothing running, nothing stale, every node healthy.
|
||||
async function mockQuiet(page, { upgrades = NO_UPGRADES, operations = [] } = {}) {
|
||||
await page.route('**/api/backends/upgrades', route =>
|
||||
route.fulfill({ json: upgrades }))
|
||||
await page.route('**/api/operations', route =>
|
||||
route.fulfill({ json: operations }))
|
||||
await page.route('**/api/nodes', route =>
|
||||
route.fulfill({ json: [{ id: 'node-a', status: 'healthy', healthy: true }] }))
|
||||
}
|
||||
|
||||
test.describe('Operate overview', () => {
|
||||
test('Operate opens the overview, not whichever page happens to be first', async ({ page }) => {
|
||||
await mockQuiet(page)
|
||||
await page.goto('/app')
|
||||
await page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' }).click()
|
||||
// Today this lands on /app/backends purely because Backends is the first
|
||||
// entry in operateConsole.groups — an ordering accident, not a decision.
|
||||
await expect(page).toHaveURL(/\/app\/operate$/)
|
||||
await expect(page.locator(OVERVIEW)).toBeVisible()
|
||||
})
|
||||
|
||||
test('says so plainly when nothing needs attention', async ({ page }) => {
|
||||
await mockQuiet(page)
|
||||
await page.goto('/app/operate')
|
||||
await expect(page.locator(CLEAR)).toBeVisible()
|
||||
// The empty state is one line, not a panel full of reassuring green.
|
||||
await expect(page.locator(ITEM)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a stale backend becomes an attention item naming the version jump', async ({ page }) => {
|
||||
await mockQuiet(page, { upgrades: ONE_UPGRADE })
|
||||
await page.goto('/app/operate')
|
||||
const item = page.locator(ITEM, { hasText: 'llama-cpp' })
|
||||
await expect(item).toBeVisible()
|
||||
await expect(item).toContainText('0.9.4')
|
||||
await expect(item).toContainText('0.9.7')
|
||||
await expect(page.locator(CLEAR)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a failed operation becomes an attention item', async ({ page }) => {
|
||||
await mockQuiet(page, {
|
||||
operations: [{ id: 'op-1', name: 'qwen3-8b', type: 'install', error: 'no space left on device' }],
|
||||
})
|
||||
await page.goto('/app/operate')
|
||||
await expect(page.locator(ITEM, { hasText: 'qwen3-8b' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('the rail reports backend updates alongside the label', async ({ page }) => {
|
||||
await mockQuiet(page, { upgrades: ONE_UPGRADE })
|
||||
await page.goto('/app/operate')
|
||||
const backends = page.locator('.console-rail a.nav-item[href="/app/backends"]')
|
||||
await expect(backends).toBeVisible()
|
||||
await expect(backends.locator('.nav-signal')).toContainText('1')
|
||||
})
|
||||
|
||||
test('the rail groups Runtime, Cluster, Observability and Administration', async ({ page }) => {
|
||||
await mockQuiet(page)
|
||||
await page.goto('/app/operate')
|
||||
const rail = page.locator('.console-rail')
|
||||
for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) {
|
||||
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
|
||||
}
|
||||
// Six headings for thirteen items was the defect; the old pairs are gone.
|
||||
for (const gone of ['Inference', 'Access']) {
|
||||
await expect(rail.locator('.console-group-title', { hasText: new RegExp(`^${gone}$`) })).toHaveCount(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('regrouping does not change what a non-distributed host can see', async ({ page }) => {
|
||||
await page.route('**/api/features', route =>
|
||||
route.fulfill({ json: { distributed: false, agents: true, mcp: true } }))
|
||||
await mockQuiet(page)
|
||||
await page.goto('/app/operate')
|
||||
const rail = page.locator('.console-rail')
|
||||
await expect(rail.locator('a.nav-item[href="/app/backends"]')).toBeVisible()
|
||||
// Gating is the thing most likely to break silently when items move group.
|
||||
await expect(rail.locator('a.nav-item[href="/app/nodes"]')).toHaveCount(0)
|
||||
await expect(rail.locator('a.nav-item[href="/app/scheduling"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the sidebar keeps its operations badge', async ({ page }) => {
|
||||
// Regression guard: this change edits the same config the badge reads, and
|
||||
// the badge deliberately lives on the always-visible sidebar entry rather
|
||||
// than the collapsible rail.
|
||||
await mockQuiet(page, {
|
||||
operations: [{ id: 'op-1', name: 'qwen3-8b', type: 'install', progress: 40 }],
|
||||
})
|
||||
await page.goto('/app')
|
||||
await expect(page.locator('.sidebar-nav .nav-badge')).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not poll the summary away from Operate', async ({ page }) => {
|
||||
let upgradeCalls = 0
|
||||
await page.route('**/api/backends/upgrades', route => {
|
||||
upgradeCalls += 1
|
||||
route.fulfill({ json: NO_UPGRADES })
|
||||
})
|
||||
await page.route('**/api/operations', route => route.fulfill({ json: [] }))
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.locator('.sidebar')).toBeVisible()
|
||||
await page.waitForTimeout(1500)
|
||||
// Nobody asked for this data outside Operate; a dashboard-shaped poll on
|
||||
// every page is exactly what OperationsContext exists to avoid.
|
||||
expect(upgradeCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Operate overview headline', () => {
|
||||
const SUMMARY = {
|
||||
total: 18402, errors: 37, p95_ms: 842, window_hours: 24,
|
||||
buckets: Array.from({ length: 12 }, (_, i) => ({ count: 100 + i * 10, errors: i })),
|
||||
}
|
||||
|
||||
test('reports counted totals rather than fetching the trace list', async ({ page }) => {
|
||||
let listCalls = 0
|
||||
await page.route('**/api/traces?**', route => { listCalls += 1; route.fulfill({ json: [] }) })
|
||||
await page.route('**/api/traces/summary', route => route.fulfill({ json: SUMMARY }))
|
||||
await mockQuiet(page)
|
||||
await page.goto('/app/operate')
|
||||
const headline = page.locator('.operate-headline')
|
||||
await expect(headline).toBeVisible()
|
||||
await expect(headline).toContainText('18,402')
|
||||
await expect(headline).toContainText('37')
|
||||
await expect(headline).toContainText('842')
|
||||
// The whole point of the endpoint: three numbers, not the buffer.
|
||||
expect(listCalls).toBe(0)
|
||||
})
|
||||
|
||||
test('a quiet installation keeps the grid and says why it is empty', async ({ page }) => {
|
||||
// Hiding the grid removed the page's structure exactly when someone was
|
||||
// most likely to be looking at it, and "0 failed" is information.
|
||||
await page.route('**/api/traces/summary', route =>
|
||||
route.fulfill({ json: { total: 0, errors: 0, p95_ms: 0, window_hours: 24, buckets: [] } }))
|
||||
await mockQuiet(page)
|
||||
await page.goto('/app/operate')
|
||||
await expect(page.locator('.operate-headline')).toBeVisible()
|
||||
await expect(page.locator('.operate-headline__cell')).toHaveCount(4)
|
||||
await expect(page.locator('.operate-headline__note')).toBeVisible()
|
||||
})
|
||||
|
||||
test('the sections state counts rather than listing their destinations', async ({ page }) => {
|
||||
await page.route('**/api/traces/summary', route =>
|
||||
route.fulfill({ json: { total: 18402, errors: 37, p95_ms: 842, window_hours: 24, buckets: [] } }))
|
||||
await mockQuiet(page)
|
||||
await page.goto('/app/operate')
|
||||
const runtime = page.locator('.lanes--sections .lane').first()
|
||||
await expect(runtime).toContainText('backends')
|
||||
await expect(runtime).toContainText('running')
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ const PAGES = [
|
||||
['/app/account', 'Account'],
|
||||
['/app/studio', 'Studio'],
|
||||
['/app/manage', 'Manage'],
|
||||
['/app/operate', 'Operate overview'],
|
||||
['/app/backends', 'Backends'],
|
||||
['/app/activity', 'Activity'],
|
||||
['/app/settings', 'Settings'],
|
||||
|
||||
155
core/http/react-ui/e2e/studio-overview.spec.js
Normal file
155
core/http/react-ui/e2e/studio-overview.spec.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Studio overview (src/pages/StudioOverview.jsx).
|
||||
//
|
||||
// Studio was a tab strip over six generators that opened on Images and told you
|
||||
// nothing about what this machine could actually run. The tests are about that:
|
||||
// what the strip reports before you click, and the difference between a
|
||||
// modality that is switched off and one that merely has no model.
|
||||
|
||||
const OVERVIEW = '[data-testid="studio-overview"]'
|
||||
const MODALITY = '[data-testid="studio-modality"]'
|
||||
const tabFor = (page, key) => page.locator(`.studio-tab[data-tab="${key}"]`)
|
||||
|
||||
const model = (id, ...capabilities) => ({ id, capabilities })
|
||||
|
||||
// Images and speech covered, video and sound not. 3D and transform are feature
|
||||
// flags rather than models, so they are controlled separately.
|
||||
const SOME_MODELS = {
|
||||
data: [
|
||||
model('flux.1-schnell', 'FLAG_IMAGE'),
|
||||
model('kokoro-82m', 'FLAG_TTS'),
|
||||
model('qwen3-8b', 'FLAG_CHAT'),
|
||||
],
|
||||
}
|
||||
|
||||
async function mockCapabilities(page, payload = SOME_MODELS) {
|
||||
await page.route('**/api/models/capabilities', route => route.fulfill({ json: payload }))
|
||||
}
|
||||
|
||||
test.describe('Studio overview', () => {
|
||||
test('Studio opens on the overview rather than dropping into Images', async ({ page }) => {
|
||||
await mockCapabilities(page)
|
||||
await page.goto('/app/studio')
|
||||
await expect(page.locator(OVERVIEW)).toBeVisible()
|
||||
})
|
||||
|
||||
test('a generator path opens that generator', async ({ page }) => {
|
||||
await mockCapabilities(page)
|
||||
await page.goto('/app/studio/images')
|
||||
await expect(page.locator(OVERVIEW)).toHaveCount(0)
|
||||
await expect(page.locator('.media-layout')).toBeVisible()
|
||||
})
|
||||
|
||||
test('an unrecognised tab falls back to the overview, not to Images', async ({ page }) => {
|
||||
await mockCapabilities(page)
|
||||
await page.goto('/app/studio/nonsense')
|
||||
await expect(page.locator(OVERVIEW)).toBeVisible()
|
||||
})
|
||||
|
||||
test('the tab strip reports which modalities have a model', async ({ page }) => {
|
||||
await mockCapabilities(page)
|
||||
await page.goto('/app/studio')
|
||||
// Filled: something installed advertises the capability.
|
||||
await expect(tabFor(page, 'images').locator('.studio-tab__dot--on')).toBeVisible()
|
||||
await expect(tabFor(page, 'tts').locator('.studio-tab__dot--on')).toBeVisible()
|
||||
// Hollow: the modality is available, nothing serves it yet.
|
||||
await expect(tabFor(page, 'video').locator('.studio-tab__dot--off')).toBeVisible()
|
||||
await expect(tabFor(page, 'sound').locator('.studio-tab__dot--off')).toBeVisible()
|
||||
})
|
||||
|
||||
test('a modality with no model offers a way to install one', async ({ page }) => {
|
||||
await mockCapabilities(page)
|
||||
await page.goto('/app/studio')
|
||||
const video = page.locator(`${MODALITY}[data-modality="video"]`)
|
||||
await expect(video).toBeVisible()
|
||||
// The point of the lane: not a dead tab, a route to fixing it.
|
||||
await expect(video.locator('a[href*="/app/models"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('a modality with a model names it instead of offering an install', async ({ page }) => {
|
||||
await mockCapabilities(page)
|
||||
await page.goto('/app/studio')
|
||||
const images = page.locator(`${MODALITY}[data-modality="images"]`)
|
||||
await expect(images).toContainText('flux.1-schnell')
|
||||
await expect(images.locator('a[href*="/app/models"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a disabled feature gets no tab and no lane at all', async ({ page }) => {
|
||||
// Switched off is a different thing from "no model installed", and
|
||||
// conflating them is how someone ends up staring at a control that cannot
|
||||
// work. 3d is a permission rather than an /api/features entry, and
|
||||
// hasFeature() short-circuits to true for admins and for auth-off
|
||||
// installations, so withholding it needs a real non-admin session.
|
||||
await page.route('**/api/auth/status', route => route.fulfill({
|
||||
json: {
|
||||
authEnabled: true,
|
||||
user: { name: 'someone', role: 'user', permissions: { images: true, video: true, tts: true, sound: true } },
|
||||
},
|
||||
}))
|
||||
await mockCapabilities(page)
|
||||
await page.goto('/app/studio')
|
||||
await expect(page.locator(OVERVIEW)).toBeVisible()
|
||||
await expect(tabFor(page, 'threed')).toHaveCount(0)
|
||||
await expect(page.locator(`${MODALITY}[data-modality="threed"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('asks the capabilities endpoint once, not once per modality', async ({ page }) => {
|
||||
let calls = 0
|
||||
await page.route('**/api/models/capabilities', route => {
|
||||
calls += 1
|
||||
route.fulfill({ json: SOME_MODELS })
|
||||
})
|
||||
await page.goto('/app/studio')
|
||||
await expect(page.locator(OVERVIEW)).toBeVisible()
|
||||
await page.waitForTimeout(500)
|
||||
// useModels() fetches the whole list and filters in the browser, so one
|
||||
// hook per modality would be six identical requests on every mount.
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test('an installation with no models at all still renders every modality', async ({ page }) => {
|
||||
await mockCapabilities(page, { data: [] })
|
||||
await page.goto('/app/studio')
|
||||
await expect(page.locator(OVERVIEW)).toBeVisible()
|
||||
await expect(page.locator(MODALITY).first()).toBeVisible()
|
||||
await expect(page.locator('.studio-tab__dot--on')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('recent outputs surface what was generated earlier', async ({ page }) => {
|
||||
await mockCapabilities(page)
|
||||
// History is localStorage, written by each generator. The overview is the
|
||||
// first place it is read across modalities rather than within one.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('localai_image_history', JSON.stringify([
|
||||
{ id: 'i1', createdAt: Date.now(), model: 'flux.1-schnell', prompt: 'a brass orrery', elapsedMs: 6100 },
|
||||
]))
|
||||
})
|
||||
await page.goto('/app/studio')
|
||||
const shelf = page.locator('[data-testid="studio-recent"]')
|
||||
await expect(shelf).toBeVisible()
|
||||
await expect(shelf).toContainText('flux.1-schnell')
|
||||
})
|
||||
|
||||
test('no history means no empty shelf', async ({ page }) => {
|
||||
await mockCapabilities(page)
|
||||
await page.goto('/app/studio')
|
||||
await expect(page.locator('[data-testid="studio-recent"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the overview is reachable back from a generator tab', async ({ page }) => {
|
||||
await mockCapabilities(page)
|
||||
await page.goto('/app/studio/images')
|
||||
await tabFor(page, 'overview').click()
|
||||
await expect(page.locator(OVERVIEW)).toBeVisible()
|
||||
})
|
||||
|
||||
test('a legacy ?tab= link is redirected to its path', async ({ page }) => {
|
||||
// Bookmarks and older docs still use the query form; they must keep working
|
||||
// and must land on the canonical URL rather than a second spelling of it.
|
||||
await mockCapabilities(page)
|
||||
await page.goto('/app/studio?tab=images')
|
||||
await expect(page).toHaveURL(/\/app\/studio\/images$/)
|
||||
await expect(page.locator('.media-layout')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
test.describe('Studio - Transform', () => {
|
||||
test('Studio exposes a Transform tab that renders Audio Transform', async ({ page }) => {
|
||||
await page.goto('/app/studio?tab=transform')
|
||||
await page.goto('/app/studio/transform')
|
||||
await expect(page.locator('.studio-tab', { hasText: 'Transform' })).toBeVisible()
|
||||
await expect(page.locator('h1.page-title', { hasText: 'Audio Transform' })).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
49
core/http/react-ui/e2e/studio-workbench.spec.js
Normal file
49
core/http/react-ui/e2e/studio-workbench.spec.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// The generator workbenches (mock 5b/5c): the control column and the record of
|
||||
// what the form actually sent.
|
||||
|
||||
test.describe('Studio workbench', () => {
|
||||
test('the control column is a hairline field stack, not a shadowed card', async ({ page }) => {
|
||||
await page.goto('/app/studio/images')
|
||||
const controls = page.locator('.media-controls')
|
||||
await expect(controls).toBeVisible()
|
||||
const style = await controls.evaluate(el => {
|
||||
const cs = getComputedStyle(el)
|
||||
return { shadow: cs.boxShadow, radius: cs.borderTopLeftRadius }
|
||||
})
|
||||
expect(style.shadow).toBe('none')
|
||||
expect(style.radius).toBe('0px')
|
||||
})
|
||||
|
||||
test('fields are separated by a rule and labelled in caps', async ({ page }) => {
|
||||
await page.goto('/app/studio/images')
|
||||
const label = page.locator('.media-controls .form-label').first()
|
||||
await expect(label).toBeVisible()
|
||||
const cs = await label.evaluate(el => getComputedStyle(el).textTransform)
|
||||
expect(cs).toBe('uppercase')
|
||||
})
|
||||
|
||||
test('no request is shown before one has been made', async ({ page }) => {
|
||||
// A panel describing a request nobody sent is a tutorial, not a record.
|
||||
await page.goto('/app/studio/images')
|
||||
await expect(page.locator('.request-panel')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('generating records the request that was actually sent', async ({ page }) => {
|
||||
await page.route('**/api/models/capabilities', route =>
|
||||
route.fulfill({ json: { data: [{ id: 'flux-mock', capabilities: ['FLAG_IMAGE'] }] } }))
|
||||
await page.route('**/v1/images/generations', route =>
|
||||
route.fulfill({ json: { data: [{ url: 'https://example.invalid/a.png' }] } }))
|
||||
|
||||
await page.goto('/app/studio/images')
|
||||
await page.locator('.media-controls textarea').first().fill('a brass orrery')
|
||||
await page.getByRole('button', { name: /generate/i }).click()
|
||||
|
||||
const panel = page.locator('.request-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toContainText('/v1/images/generations')
|
||||
await expect(panel).toContainText('a brass orrery')
|
||||
await expect(panel.getByRole('button', { name: /curl/i })).toBeVisible()
|
||||
})
|
||||
})
|
||||
15
core/http/react-ui/e2e/theme-default.spec.js
Normal file
15
core/http/react-ui/e2e/theme-default.spec.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
test.describe('Theme default', () => {
|
||||
test('a fresh install opens dark even when the OS prefers light', async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: 'light' })
|
||||
await page.goto('/app')
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
})
|
||||
|
||||
test('a stored choice still wins', async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem('localai-theme', 'light'))
|
||||
await page.goto('/app')
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
})
|
||||
})
|
||||
@@ -276,9 +276,11 @@ test.describe('3D generation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/app/studio?tab=threed')
|
||||
await page.goto('/app/studio/threed')
|
||||
await expect(page.getByRole('button', { name: '3D', exact: true })).toHaveCount(0)
|
||||
await expect(page.locator('.studio-tab', { hasText: 'Images' })).toHaveClass(/studio-tab-active/)
|
||||
// Falls back to the overview rather than Images. Landing on Images was
|
||||
// never a decision, only the first entry in the tab array.
|
||||
await expect(page.locator('.studio-tab[data-tab="overview"]')).toHaveClass(/studio-tab-active/)
|
||||
|
||||
await page.goto('/app/3d')
|
||||
await expect(page).toHaveURL(/\/app\/?$/)
|
||||
|
||||
42
core/http/react-ui/e2e/traces-latency.spec.js
Normal file
42
core/http/react-ui/e2e/traces-latency.spec.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Traces rows carry latency as a shape, not only as a number buried in the
|
||||
// expanded detail (mock 6d).
|
||||
|
||||
const TRACES = [
|
||||
{ id: '1', timestamp: new Date().toISOString(), duration: 4_200_000_000,
|
||||
request: { method: 'POST', path: '/v1/chat/completions' }, response: { status: 500 }, error: 'context length exceeded' },
|
||||
{ id: '2', timestamp: new Date().toISOString(), duration: 980_000_000,
|
||||
request: { method: 'POST', path: '/v1/chat/completions' }, response: { status: 200 } },
|
||||
{ id: '3', timestamp: new Date().toISOString(), duration: 186_000_000,
|
||||
request: { method: 'POST', path: '/v1/embeddings' }, response: { status: 200 } },
|
||||
]
|
||||
|
||||
test.describe('Traces latency', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/traces**', route => route.fulfill({ json: TRACES }))
|
||||
await page.goto('/app/traces')
|
||||
})
|
||||
|
||||
test('every row shows a latency bar and figure', async ({ page }) => {
|
||||
const cells = page.locator('.lat')
|
||||
await expect(cells).toHaveCount(3)
|
||||
await expect(cells.first()).toContainText('4.20s')
|
||||
})
|
||||
|
||||
test('the bar is scaled against the slowest request in view', async ({ page }) => {
|
||||
// Wait for the rows: goto alone does not guarantee the fetch has painted.
|
||||
await expect(page.locator('.lat__bar i')).toHaveCount(3)
|
||||
const widths = await page.locator('.lat__bar i').evaluateAll(
|
||||
els => els.map(el => parseFloat(el.style.width)))
|
||||
// 4.2s is the slowest, so it is full; 186ms is a sliver of it.
|
||||
expect(widths[0]).toBe(100)
|
||||
expect(widths[2]).toBeLessThan(20)
|
||||
})
|
||||
|
||||
test('a slow request is marked, not just long', async ({ page }) => {
|
||||
await expect(page.locator('.lat')).toHaveCount(3)
|
||||
// Colour carries the threshold; the figure carries the value.
|
||||
await expect(page.locator('.lat__bar--slow')).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
33
core/http/react-ui/e2e/voice-library-empty.spec.js
Normal file
33
core/http/react-ui/e2e/voice-library-empty.spec.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// The empty voice library must offer its action, visibly, inside the panel.
|
||||
|
||||
test.describe('Voice library empty state', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/voice-profiles', route => route.fulfill({ json: [] }))
|
||||
await page.goto('/app/voice-library')
|
||||
})
|
||||
|
||||
test('the create action is visible and a normal size', async ({ page }) => {
|
||||
const action = page.locator('.empty-state__actions a.btn').first()
|
||||
await expect(action).toBeVisible()
|
||||
const box = await action.boundingBox()
|
||||
// It had been carrying the panel's own min-height:430px, which made it an
|
||||
// invisible box that pushed itself out of view.
|
||||
expect(box.height).toBeLessThan(80)
|
||||
})
|
||||
|
||||
test('the action sits inside the panel, not past its edge', async ({ page }) => {
|
||||
const panel = page.locator('.empty-state').first()
|
||||
const action = page.locator('.empty-state__actions a.btn').first()
|
||||
const [p, a] = [await panel.boundingBox(), await action.boundingBox()]
|
||||
expect(a.y + a.height).toBeLessThanOrEqual(p.y + p.height + 1)
|
||||
})
|
||||
|
||||
test('the panel renders its icon', async ({ page }) => {
|
||||
const icon = page.locator('.empty-state-icon').first()
|
||||
await expect(icon).toBeVisible()
|
||||
const box = await icon.boundingBox()
|
||||
expect(box.width).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
624
|
||||
538
|
||||
|
||||
90
core/http/react-ui/package-lock.json
generated
90
core/http/react-ui/package-lock.json
generated
@@ -21,9 +21,10 @@
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@modelcontextprotocol/ext-apps": "^1.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hono": "4.12.34",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.6",
|
||||
@@ -635,12 +636,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz",
|
||||
"integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
@@ -944,11 +945,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
|
||||
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
||||
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
@@ -1718,10 +1720,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -2876,9 +2879,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3432,9 +3435,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.31",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
|
||||
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
|
||||
"version": "4.12.34",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.34.tgz",
|
||||
"integrity": "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -4193,9 +4196,9 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -4383,16 +4386,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-processinfo/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-processinfo/node_modules/glob": {
|
||||
@@ -5278,16 +5281,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nyc/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/nyc/node_modules/convert-source-map": {
|
||||
@@ -5974,10 +5977,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/quick-temp/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
@@ -6569,16 +6573,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/spawn-wrap/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/spawn-wrap/node_modules/foreground-child": {
|
||||
@@ -6902,16 +6906,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/glob": {
|
||||
@@ -7134,9 +7138,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"coverage:report": "nyc report"
|
||||
},
|
||||
"overrides": {
|
||||
"hono": "4.12.25"
|
||||
"hono": "4.12.34"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
@@ -35,10 +35,10 @@
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@modelcontextprotocol/ext-apps": "^1.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hono": "4.12.25",
|
||||
"hono": "4.12.34",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.6",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"timeLeft": "{{value}} left",
|
||||
"cancel": "Cancel",
|
||||
"cancelLabel": "Cancel {{name}}",
|
||||
"pause": "Pause",
|
||||
"pauseLabel": "Pause {{name}} and keep downloaded data",
|
||||
"retry": "Retry",
|
||||
"retryLabel": "Retry {{name}}",
|
||||
"nodeCount": "{{count}} nodes",
|
||||
@@ -143,5 +145,36 @@
|
||||
"explorer": {
|
||||
"title": "Explorer",
|
||||
"subtitle": "Dateien und Konfiguration durchsuchen"
|
||||
},
|
||||
"operate": {
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"subtitle": "Everything running on this installation, and anything that wants a decision.",
|
||||
"attention": {
|
||||
"heading": "Needs attention",
|
||||
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
|
||||
"backendUpdate": "Update available: {{from}} → {{to}}"
|
||||
},
|
||||
"sections": {
|
||||
"heading": "Sections",
|
||||
"runtime": "Runtime",
|
||||
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
|
||||
"cluster": "Cluster",
|
||||
"clusterSummary": "{{nodes}} nodes",
|
||||
"observability": "Observability",
|
||||
"observabilitySummary": "Usage and traces",
|
||||
"administration": "Administration",
|
||||
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
|
||||
"clusterSingle": "Single node",
|
||||
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
|
||||
},
|
||||
"headline": {
|
||||
"requests": "Requests · {{hours}}h",
|
||||
"errors": "Failed requests",
|
||||
"p95": "p95 latency",
|
||||
"quiet": "No requests served in this window yet.",
|
||||
"host": "Host memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"newChat": "Neuer Chat",
|
||||
"clearAll": "Alle löschen",
|
||||
"deleteAllTitle": "Alle Unterhaltungen löschen"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"modelsLoaded_other": "{{count}} models loaded",
|
||||
"noModelsLoaded": "No models loaded",
|
||||
"nodes_one": "{{count}} node",
|
||||
"nodes_other": "{{count}} nodes"
|
||||
"nodes_other": "{{count}} nodes",
|
||||
"loadedLabel": "Loaded",
|
||||
"nodesLabel": "Nodes"
|
||||
},
|
||||
"assistant": {
|
||||
"title": "LocalAI per Chat verwalten",
|
||||
@@ -47,7 +49,8 @@
|
||||
"count_one": "{{count}} Modell geladen",
|
||||
"count_other": "{{count}} Modelle geladen",
|
||||
"stop": "Modell stoppen",
|
||||
"stopAll": "Alle stoppen"
|
||||
"stopAll": "Alle stoppen",
|
||||
"serving": "Serving"
|
||||
},
|
||||
"stopDialog": {
|
||||
"title": "Modell stoppen",
|
||||
@@ -88,5 +91,14 @@
|
||||
"browse": "Browse the API",
|
||||
"hide": "Hide endpoints",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"jump": {
|
||||
"heading": "Jump back in",
|
||||
"discover": "Discover",
|
||||
"discoverSummary": "Browse the gallery and install models",
|
||||
"create": "Create",
|
||||
"createSummary": "Open a chat, image or voice session",
|
||||
"operate": "Operate",
|
||||
"operateSummary": "{{models}} models configured · nodes, activity and traces"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,32 @@
|
||||
"video": "Video",
|
||||
"tts": "TTS",
|
||||
"sound": "Audio",
|
||||
"transform": "Transform",
|
||||
"overview": "Overview"
|
||||
},
|
||||
"overview": {
|
||||
"eyebrow": "{{ready}} of {{total}} modalities ready",
|
||||
"title": "Studio",
|
||||
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
|
||||
"canMake": "What you can make",
|
||||
"running": "Running now",
|
||||
"recent": "Recent outputs",
|
||||
"noModel": "No model installed",
|
||||
"install": "Install a model",
|
||||
"ready": "Ready",
|
||||
"seconds": "{{seconds}}s",
|
||||
"describe": {
|
||||
"images": "Text to image, image to image, reference images",
|
||||
"video": "Text to video and image to video",
|
||||
"threed": "Image to mesh reconstruction",
|
||||
"tts": "Text to speech using your voice library",
|
||||
"sound": "Music and sound effects from a prompt",
|
||||
"transform": "Separation, enhancement and voice conversion"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"create": "Create",
|
||||
"voice": "Voice",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
@@ -157,5 +183,10 @@
|
||||
"clearMessage": "Alle Verlaufseinträge entfernen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"clearConfirm": "Löschen",
|
||||
"cleared": "Verlauf gelöscht"
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request",
|
||||
"copyCurl": "Copy as curl",
|
||||
"copied": "Copied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"installStarted": "{{model}} wird installiert…",
|
||||
"installFailed": "Installation fehlgeschlagen: {{message}}",
|
||||
"dismiss": "Empfehlungen ausblenden",
|
||||
"summary": "{{n}} Modelle vorgeschlagen"
|
||||
"summary": "{{n}} Modelle vorgeschlagen",
|
||||
"bestFit": "Best fit",
|
||||
"alternative": "Also fits"
|
||||
},
|
||||
"stats": {
|
||||
"available": "Verfügbar",
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"observability": "Observability",
|
||||
"access": "Access",
|
||||
"system": "System",
|
||||
"activity": "Activity"
|
||||
"activity": "Activity",
|
||||
"runtime": "Laufzeit",
|
||||
"administration": "Verwaltung"
|
||||
},
|
||||
"items": {
|
||||
"home": "Start",
|
||||
@@ -57,7 +59,8 @@
|
||||
"settings": "Einstellungen",
|
||||
"api": "API",
|
||||
"middleware": "Middleware",
|
||||
"activity": "Aktivität"
|
||||
"activity": "Aktivität",
|
||||
"overview": "Übersicht"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"timeLeft": "{{value}} left",
|
||||
"cancel": "Cancel",
|
||||
"cancelLabel": "Cancel {{name}}",
|
||||
"pause": "Pause",
|
||||
"pauseLabel": "Pause {{name}} and keep downloaded data",
|
||||
"retry": "Retry",
|
||||
"retryLabel": "Retry {{name}}",
|
||||
"nodeCount": "{{count}} nodes",
|
||||
@@ -166,5 +168,36 @@
|
||||
"explorer": {
|
||||
"title": "Explorer",
|
||||
"subtitle": "Browse files and configuration"
|
||||
},
|
||||
"operate": {
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"subtitle": "Everything running on this installation, and anything that wants a decision.",
|
||||
"attention": {
|
||||
"heading": "Needs attention",
|
||||
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
|
||||
"backendUpdate": "Update available: {{from}} → {{to}}"
|
||||
},
|
||||
"sections": {
|
||||
"heading": "Sections",
|
||||
"runtime": "Runtime",
|
||||
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
|
||||
"cluster": "Cluster",
|
||||
"clusterSummary": "{{nodes}} nodes",
|
||||
"observability": "Observability",
|
||||
"observabilitySummary": "Usage and traces",
|
||||
"administration": "Administration",
|
||||
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
|
||||
"clusterSingle": "Single node",
|
||||
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
|
||||
},
|
||||
"headline": {
|
||||
"requests": "Requests · {{hours}}h",
|
||||
"errors": "Failed requests",
|
||||
"p95": "p95 latency",
|
||||
"quiet": "No requests served in this window yet.",
|
||||
"host": "Host memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,5 +124,8 @@
|
||||
"newChat": "New chat",
|
||||
"clearAll": "Clear all",
|
||||
"deleteAllTitle": "Delete all conversations"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"modelsLoaded_other": "{{count}} models loaded",
|
||||
"noModelsLoaded": "No models loaded",
|
||||
"nodes_one": "{{count}} node",
|
||||
"nodes_other": "{{count}} nodes"
|
||||
"nodes_other": "{{count}} nodes",
|
||||
"loadedLabel": "Loaded",
|
||||
"nodesLabel": "Nodes"
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Manage LocalAI by chatting",
|
||||
@@ -47,7 +49,8 @@
|
||||
"count_one": "{{count}} model loaded",
|
||||
"count_other": "{{count}} models loaded",
|
||||
"stop": "Stop model",
|
||||
"stopAll": "Stop all"
|
||||
"stopAll": "Stop all",
|
||||
"serving": "Serving"
|
||||
},
|
||||
"stopDialog": {
|
||||
"title": "Stop Model",
|
||||
@@ -103,5 +106,14 @@
|
||||
"browse": "Browse the API",
|
||||
"hide": "Hide endpoints",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"jump": {
|
||||
"heading": "Jump back in",
|
||||
"discover": "Discover",
|
||||
"discoverSummary": "Browse the gallery and install models",
|
||||
"create": "Create",
|
||||
"createSummary": "Open a chat, image or voice session",
|
||||
"operate": "Operate",
|
||||
"operateSummary": "{{models}} models configured · nodes, activity and traces"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,33 @@
|
||||
"tts": "TTS",
|
||||
"sound": "Sound",
|
||||
"transform": "Transform",
|
||||
"threed": "3D"
|
||||
"threed": "3D",
|
||||
"overview": "Overview"
|
||||
},
|
||||
"overview": {
|
||||
"eyebrow": "{{ready}} of {{total}} modalities ready",
|
||||
"title": "Studio",
|
||||
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
|
||||
"canMake": "What you can make",
|
||||
"running": "Running now",
|
||||
"recent": "Recent outputs",
|
||||
"noModel": "No model installed",
|
||||
"install": "Install a model",
|
||||
"ready": "Ready",
|
||||
"seconds": "{{seconds}}s",
|
||||
"describe": {
|
||||
"images": "Text to image, image to image, reference images",
|
||||
"video": "Text to video and image to video",
|
||||
"threed": "Image to mesh reconstruction",
|
||||
"tts": "Text to speech using your voice library",
|
||||
"sound": "Music and sound effects from a prompt",
|
||||
"transform": "Separation, enhancement and voice conversion"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"create": "Create",
|
||||
"voice": "Voice",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
@@ -426,5 +452,10 @@
|
||||
"clearMessage": "Remove all history entries? This cannot be undone.",
|
||||
"clearConfirm": "Clear",
|
||||
"cleared": "History cleared"
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request",
|
||||
"copyCurl": "Copy as curl",
|
||||
"copied": "Copied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"installStarted": "Installing {{model}}…",
|
||||
"installFailed": "Install failed: {{message}}",
|
||||
"dismiss": "Dismiss recommendations",
|
||||
"summary": "{{n}} models suggested"
|
||||
"summary": "{{n}} models suggested",
|
||||
"bestFit": "Best fit",
|
||||
"alternative": "Also fits"
|
||||
},
|
||||
"stats": {
|
||||
"available": "Available",
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"observability": "Observability",
|
||||
"access": "Access",
|
||||
"system": "System",
|
||||
"activity": "Activity"
|
||||
"activity": "Activity",
|
||||
"runtime": "Runtime",
|
||||
"administration": "Administration"
|
||||
},
|
||||
"items": {
|
||||
"home": "Home",
|
||||
@@ -58,7 +60,8 @@
|
||||
"system": "System",
|
||||
"settings": "Settings",
|
||||
"api": "API",
|
||||
"activity": "Activity"
|
||||
"activity": "Activity",
|
||||
"overview": "Overview"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"timeLeft": "{{value}} left",
|
||||
"cancel": "Cancel",
|
||||
"cancelLabel": "Cancel {{name}}",
|
||||
"pause": "Pause",
|
||||
"pauseLabel": "Pause {{name}} and keep downloaded data",
|
||||
"retry": "Retry",
|
||||
"retryLabel": "Retry {{name}}",
|
||||
"nodeCount": "{{count}} nodes",
|
||||
@@ -143,5 +145,36 @@
|
||||
"explorer": {
|
||||
"title": "Explorador",
|
||||
"subtitle": "Explora archivos y configuración"
|
||||
},
|
||||
"operate": {
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"subtitle": "Everything running on this installation, and anything that wants a decision.",
|
||||
"attention": {
|
||||
"heading": "Needs attention",
|
||||
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
|
||||
"backendUpdate": "Update available: {{from}} → {{to}}"
|
||||
},
|
||||
"sections": {
|
||||
"heading": "Sections",
|
||||
"runtime": "Runtime",
|
||||
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
|
||||
"cluster": "Cluster",
|
||||
"clusterSummary": "{{nodes}} nodes",
|
||||
"observability": "Observability",
|
||||
"observabilitySummary": "Usage and traces",
|
||||
"administration": "Administration",
|
||||
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
|
||||
"clusterSingle": "Single node",
|
||||
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
|
||||
},
|
||||
"headline": {
|
||||
"requests": "Requests · {{hours}}h",
|
||||
"errors": "Failed requests",
|
||||
"p95": "p95 latency",
|
||||
"quiet": "No requests served in this window yet.",
|
||||
"host": "Host memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"newChat": "Nuevo chat",
|
||||
"clearAll": "Borrar todo",
|
||||
"deleteAllTitle": "Eliminar todas las conversaciones"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"modelsLoaded_other": "{{count}} models loaded",
|
||||
"noModelsLoaded": "No models loaded",
|
||||
"nodes_one": "{{count}} node",
|
||||
"nodes_other": "{{count}} nodes"
|
||||
"nodes_other": "{{count}} nodes",
|
||||
"loadedLabel": "Loaded",
|
||||
"nodesLabel": "Nodes"
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Administra LocalAI chateando",
|
||||
@@ -47,7 +49,8 @@
|
||||
"count_one": "{{count}} modelo cargado",
|
||||
"count_other": "{{count}} modelos cargados",
|
||||
"stop": "Detener modelo",
|
||||
"stopAll": "Detener todos"
|
||||
"stopAll": "Detener todos",
|
||||
"serving": "Serving"
|
||||
},
|
||||
"stopDialog": {
|
||||
"title": "Detener modelo",
|
||||
@@ -88,5 +91,14 @@
|
||||
"browse": "Browse the API",
|
||||
"hide": "Hide endpoints",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"jump": {
|
||||
"heading": "Jump back in",
|
||||
"discover": "Discover",
|
||||
"discoverSummary": "Browse the gallery and install models",
|
||||
"create": "Create",
|
||||
"createSummary": "Open a chat, image or voice session",
|
||||
"operate": "Operate",
|
||||
"operateSummary": "{{models}} models configured · nodes, activity and traces"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,32 @@
|
||||
"video": "Video",
|
||||
"tts": "TTS",
|
||||
"sound": "Sonido",
|
||||
"transform": "Transform",
|
||||
"overview": "Overview"
|
||||
},
|
||||
"overview": {
|
||||
"eyebrow": "{{ready}} of {{total}} modalities ready",
|
||||
"title": "Studio",
|
||||
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
|
||||
"canMake": "What you can make",
|
||||
"running": "Running now",
|
||||
"recent": "Recent outputs",
|
||||
"noModel": "No model installed",
|
||||
"install": "Install a model",
|
||||
"ready": "Ready",
|
||||
"seconds": "{{seconds}}s",
|
||||
"describe": {
|
||||
"images": "Text to image, image to image, reference images",
|
||||
"video": "Text to video and image to video",
|
||||
"threed": "Image to mesh reconstruction",
|
||||
"tts": "Text to speech using your voice library",
|
||||
"sound": "Music and sound effects from a prompt",
|
||||
"transform": "Separation, enhancement and voice conversion"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"create": "Create",
|
||||
"voice": "Voice",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
@@ -157,5 +183,10 @@
|
||||
"clearMessage": "¿Eliminar todas las entradas del historial? Esto no se puede deshacer.",
|
||||
"clearConfirm": "Borrar",
|
||||
"cleared": "Historial borrado"
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request",
|
||||
"copyCurl": "Copy as curl",
|
||||
"copied": "Copied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"installStarted": "Instalando {{model}}…",
|
||||
"installFailed": "Error al instalar: {{message}}",
|
||||
"dismiss": "Descartar recomendaciones",
|
||||
"summary": "{{n}} modelos sugeridos"
|
||||
"summary": "{{n}} modelos sugeridos",
|
||||
"bestFit": "Best fit",
|
||||
"alternative": "Also fits"
|
||||
},
|
||||
"stats": {
|
||||
"available": "Disponibles",
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"observability": "Observability",
|
||||
"access": "Access",
|
||||
"system": "System",
|
||||
"activity": "Activity"
|
||||
"activity": "Activity",
|
||||
"runtime": "Runtime",
|
||||
"administration": "Administración"
|
||||
},
|
||||
"items": {
|
||||
"home": "Inicio",
|
||||
@@ -57,7 +59,8 @@
|
||||
"settings": "Configuración",
|
||||
"api": "API",
|
||||
"middleware": "Middleware",
|
||||
"activity": "Actividad"
|
||||
"activity": "Actividad",
|
||||
"overview": "Resumen"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"timeLeft": "{{value}} left",
|
||||
"cancel": "Cancel",
|
||||
"cancelLabel": "Cancel {{name}}",
|
||||
"pause": "Pause",
|
||||
"pauseLabel": "Pause {{name}} and keep downloaded data",
|
||||
"retry": "Retry",
|
||||
"retryLabel": "Retry {{name}}",
|
||||
"nodeCount": "{{count}} nodes",
|
||||
@@ -166,5 +168,36 @@
|
||||
"explorer": {
|
||||
"title": "Penjelajah",
|
||||
"subtitle": "Jelajahi file dan konfigurasi"
|
||||
},
|
||||
"operate": {
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"subtitle": "Everything running on this installation, and anything that wants a decision.",
|
||||
"attention": {
|
||||
"heading": "Needs attention",
|
||||
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
|
||||
"backendUpdate": "Update available: {{from}} → {{to}}"
|
||||
},
|
||||
"sections": {
|
||||
"heading": "Sections",
|
||||
"runtime": "Runtime",
|
||||
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
|
||||
"cluster": "Cluster",
|
||||
"clusterSummary": "{{nodes}} nodes",
|
||||
"observability": "Observability",
|
||||
"observabilitySummary": "Usage and traces",
|
||||
"administration": "Administration",
|
||||
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
|
||||
"clusterSingle": "Single node",
|
||||
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
|
||||
},
|
||||
"headline": {
|
||||
"requests": "Requests · {{hours}}h",
|
||||
"errors": "Failed requests",
|
||||
"p95": "p95 latency",
|
||||
"quiet": "No requests served in this window yet.",
|
||||
"host": "Host memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"newChat": "Obrolan baru",
|
||||
"clearAll": "Hapus semua",
|
||||
"deleteAllTitle": "Hapus semua percakapan"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"modelsLoaded_other": "{{count}} model dimuat",
|
||||
"noModelsLoaded": "Tidak ada model yang dimuat",
|
||||
"nodes_one": "{{count}} node",
|
||||
"nodes_other": "{{count}} nodes"
|
||||
"nodes_other": "{{count}} nodes",
|
||||
"loadedLabel": "Loaded",
|
||||
"nodesLabel": "Nodes"
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Kelola LocalAI melalui obrolan",
|
||||
@@ -47,7 +49,8 @@
|
||||
"count_one": "{{count}} model dimuat",
|
||||
"count_other": "{{count}} model dimuat",
|
||||
"stop": "Hentikan model",
|
||||
"stopAll": "Hentikan semua"
|
||||
"stopAll": "Hentikan semua",
|
||||
"serving": "Serving"
|
||||
},
|
||||
"stopDialog": {
|
||||
"title": "Hentikan Model",
|
||||
@@ -88,5 +91,14 @@
|
||||
"browse": "Jelajahi API",
|
||||
"hide": "Sembunyikan endpoint",
|
||||
"dismiss": "Abaikan"
|
||||
},
|
||||
"jump": {
|
||||
"heading": "Jump back in",
|
||||
"discover": "Discover",
|
||||
"discoverSummary": "Browse the gallery and install models",
|
||||
"create": "Create",
|
||||
"createSummary": "Open a chat, image or voice session",
|
||||
"operate": "Operate",
|
||||
"operateSummary": "{{models}} models configured · nodes, activity and traces"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,33 @@
|
||||
"video": "Video",
|
||||
"tts": "TTS",
|
||||
"sound": "Suara",
|
||||
"transform": "Transformasi"
|
||||
"transform": "Transformasi",
|
||||
"overview": "Overview"
|
||||
},
|
||||
"overview": {
|
||||
"eyebrow": "{{ready}} of {{total}} modalities ready",
|
||||
"title": "Studio",
|
||||
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
|
||||
"canMake": "What you can make",
|
||||
"running": "Running now",
|
||||
"recent": "Recent outputs",
|
||||
"noModel": "No model installed",
|
||||
"install": "Install a model",
|
||||
"ready": "Ready",
|
||||
"seconds": "{{seconds}}s",
|
||||
"describe": {
|
||||
"images": "Text to image, image to image, reference images",
|
||||
"video": "Text to video and image to video",
|
||||
"threed": "Image to mesh reconstruction",
|
||||
"tts": "Text to speech using your voice library",
|
||||
"sound": "Music and sound effects from a prompt",
|
||||
"transform": "Separation, enhancement and voice conversion"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"create": "Create",
|
||||
"voice": "Voice",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
@@ -204,5 +230,10 @@
|
||||
"clearMessage": "Hapus semua entri riwayat? Tindakan ini tidak dapat dibatalkan.",
|
||||
"clearConfirm": "Hapus",
|
||||
"cleared": "Riwayat dihapus"
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request",
|
||||
"copyCurl": "Copy as curl",
|
||||
"copied": "Copied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"installStarted": "Menginstal {{model}}…",
|
||||
"installFailed": "Instalasi gagal: {{message}}",
|
||||
"dismiss": "Tutup rekomendasi",
|
||||
"summary": "{{n}} model disarankan"
|
||||
"summary": "{{n}} model disarankan",
|
||||
"bestFit": "Best fit",
|
||||
"alternative": "Also fits"
|
||||
},
|
||||
"stats": {
|
||||
"available": "Tersedia",
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"observability": "Observabilitas",
|
||||
"access": "Akses",
|
||||
"system": "Sistem",
|
||||
"activity": "Activity"
|
||||
"activity": "Activity",
|
||||
"runtime": "Runtime",
|
||||
"administration": "Administrasi"
|
||||
},
|
||||
"items": {
|
||||
"home": "Beranda",
|
||||
@@ -57,7 +59,8 @@
|
||||
"system": "Sistem",
|
||||
"settings": "Pengaturan",
|
||||
"api": "API",
|
||||
"activity": "Aktivitas"
|
||||
"activity": "Aktivitas",
|
||||
"overview": "Ikhtisar"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"timeLeft": "{{value}} left",
|
||||
"cancel": "Cancel",
|
||||
"cancelLabel": "Cancel {{name}}",
|
||||
"pause": "Pause",
|
||||
"pauseLabel": "Pause {{name}} and keep downloaded data",
|
||||
"retry": "Retry",
|
||||
"retryLabel": "Retry {{name}}",
|
||||
"nodeCount": "{{count}} nodes",
|
||||
@@ -143,5 +145,36 @@
|
||||
"explorer": {
|
||||
"title": "Esplora risorse",
|
||||
"subtitle": "Sfoglia file e configurazioni"
|
||||
},
|
||||
"operate": {
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"subtitle": "Everything running on this installation, and anything that wants a decision.",
|
||||
"attention": {
|
||||
"heading": "Needs attention",
|
||||
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
|
||||
"backendUpdate": "Update available: {{from}} → {{to}}"
|
||||
},
|
||||
"sections": {
|
||||
"heading": "Sections",
|
||||
"runtime": "Runtime",
|
||||
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
|
||||
"cluster": "Cluster",
|
||||
"clusterSummary": "{{nodes}} nodes",
|
||||
"observability": "Observability",
|
||||
"observabilitySummary": "Usage and traces",
|
||||
"administration": "Administration",
|
||||
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
|
||||
"clusterSingle": "Single node",
|
||||
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
|
||||
},
|
||||
"headline": {
|
||||
"requests": "Requests · {{hours}}h",
|
||||
"errors": "Failed requests",
|
||||
"p95": "p95 latency",
|
||||
"quiet": "No requests served in this window yet.",
|
||||
"host": "Host memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"newChat": "Nuova chat",
|
||||
"clearAll": "Cancella tutto",
|
||||
"deleteAllTitle": "Elimina tutte le conversazioni"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"modelsLoaded_other": "{{count}} modelli caricati",
|
||||
"noModelsLoaded": "Nessun modello caricato",
|
||||
"nodes_one": "{{count}} nodo",
|
||||
"nodes_other": "{{count}} nodi"
|
||||
"nodes_other": "{{count}} nodi",
|
||||
"loadedLabel": "Loaded",
|
||||
"nodesLabel": "Nodes"
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Gestisci LocalAI chattando",
|
||||
@@ -47,7 +49,8 @@
|
||||
"count_one": "{{count}} modello caricato",
|
||||
"count_other": "{{count}} modelli caricati",
|
||||
"stop": "Ferma modello",
|
||||
"stopAll": "Ferma tutti"
|
||||
"stopAll": "Ferma tutti",
|
||||
"serving": "Serving"
|
||||
},
|
||||
"stopDialog": {
|
||||
"title": "Ferma modello",
|
||||
@@ -88,5 +91,14 @@
|
||||
"browse": "Esplora le API",
|
||||
"hide": "Nascondi gli endpoint",
|
||||
"dismiss": "Ignora"
|
||||
},
|
||||
"jump": {
|
||||
"heading": "Jump back in",
|
||||
"discover": "Discover",
|
||||
"discoverSummary": "Browse the gallery and install models",
|
||||
"create": "Create",
|
||||
"createSummary": "Open a chat, image or voice session",
|
||||
"operate": "Operate",
|
||||
"operateSummary": "{{models}} models configured · nodes, activity and traces"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,32 @@
|
||||
"video": "Video",
|
||||
"tts": "TTS",
|
||||
"sound": "Audio",
|
||||
"transform": "Transform",
|
||||
"overview": "Overview"
|
||||
},
|
||||
"overview": {
|
||||
"eyebrow": "{{ready}} of {{total}} modalities ready",
|
||||
"title": "Studio",
|
||||
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
|
||||
"canMake": "What you can make",
|
||||
"running": "Running now",
|
||||
"recent": "Recent outputs",
|
||||
"noModel": "No model installed",
|
||||
"install": "Install a model",
|
||||
"ready": "Ready",
|
||||
"seconds": "{{seconds}}s",
|
||||
"describe": {
|
||||
"images": "Text to image, image to image, reference images",
|
||||
"video": "Text to video and image to video",
|
||||
"threed": "Image to mesh reconstruction",
|
||||
"tts": "Text to speech using your voice library",
|
||||
"sound": "Music and sound effects from a prompt",
|
||||
"transform": "Separation, enhancement and voice conversion"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"create": "Create",
|
||||
"voice": "Voice",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
@@ -157,5 +183,10 @@
|
||||
"clearMessage": "Rimuovere tutte le voci della cronologia? Questa azione non può essere annullata.",
|
||||
"clearConfirm": "Cancella",
|
||||
"cleared": "Cronologia cancellata"
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request",
|
||||
"copyCurl": "Copy as curl",
|
||||
"copied": "Copied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"installStarted": "Installazione di {{model}}…",
|
||||
"installFailed": "Installazione non riuscita: {{message}}",
|
||||
"dismiss": "Nascondi i consigli",
|
||||
"summary": "{{n}} modelli suggeriti"
|
||||
"summary": "{{n}} modelli suggeriti",
|
||||
"bestFit": "Best fit",
|
||||
"alternative": "Also fits"
|
||||
},
|
||||
"stats": {
|
||||
"available": "Disponibili",
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"observability": "Observability",
|
||||
"access": "Access",
|
||||
"system": "System",
|
||||
"activity": "Activity"
|
||||
"activity": "Activity",
|
||||
"runtime": "Runtime",
|
||||
"administration": "Amministrazione"
|
||||
},
|
||||
"items": {
|
||||
"home": "Home",
|
||||
@@ -57,7 +59,8 @@
|
||||
"settings": "Impostazioni",
|
||||
"api": "API",
|
||||
"middleware": "Middleware",
|
||||
"activity": "Attività"
|
||||
"activity": "Attività",
|
||||
"overview": "Panoramica"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"timeLeft": "{{value}} left",
|
||||
"cancel": "Cancel",
|
||||
"cancelLabel": "Cancel {{name}}",
|
||||
"pause": "Pause",
|
||||
"pauseLabel": "Pause {{name}} and keep downloaded data",
|
||||
"retry": "Retry",
|
||||
"retryLabel": "Retry {{name}}",
|
||||
"nodeCount": "{{count}} nodes",
|
||||
@@ -166,5 +168,36 @@
|
||||
"explorer": {
|
||||
"title": "탐색기",
|
||||
"subtitle": "파일과 구성을 둘러봅니다"
|
||||
},
|
||||
"operate": {
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"subtitle": "Everything running on this installation, and anything that wants a decision.",
|
||||
"attention": {
|
||||
"heading": "Needs attention",
|
||||
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
|
||||
"backendUpdate": "Update available: {{from}} → {{to}}"
|
||||
},
|
||||
"sections": {
|
||||
"heading": "Sections",
|
||||
"runtime": "Runtime",
|
||||
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
|
||||
"cluster": "Cluster",
|
||||
"clusterSummary": "{{nodes}} nodes",
|
||||
"observability": "Observability",
|
||||
"observabilitySummary": "Usage and traces",
|
||||
"administration": "Administration",
|
||||
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
|
||||
"clusterSingle": "Single node",
|
||||
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
|
||||
},
|
||||
"headline": {
|
||||
"requests": "Requests · {{hours}}h",
|
||||
"errors": "Failed requests",
|
||||
"p95": "p95 latency",
|
||||
"quiet": "No requests served in this window yet.",
|
||||
"host": "Host memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"newChat": "새 채팅",
|
||||
"clearAll": "모두 지우기",
|
||||
"deleteAllTitle": "모든 대화 삭제"
|
||||
},
|
||||
"message": {
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"modelsLoaded_other": "{{count}} models loaded",
|
||||
"noModelsLoaded": "No models loaded",
|
||||
"nodes_one": "{{count}} node",
|
||||
"nodes_other": "{{count}} nodes"
|
||||
"nodes_other": "{{count}} nodes",
|
||||
"loadedLabel": "Loaded",
|
||||
"nodesLabel": "Nodes"
|
||||
},
|
||||
"assistant": {
|
||||
"title": "채팅으로 LocalAI 관리",
|
||||
@@ -47,7 +49,8 @@
|
||||
"count_one": "모델 {{count}}개 로드됨",
|
||||
"count_other": "모델 {{count}}개 로드됨",
|
||||
"stop": "모델 중지",
|
||||
"stopAll": "모두 중지"
|
||||
"stopAll": "모두 중지",
|
||||
"serving": "Serving"
|
||||
},
|
||||
"stopDialog": {
|
||||
"title": "모델 중지",
|
||||
@@ -88,5 +91,14 @@
|
||||
"browse": "Browse the API",
|
||||
"hide": "Hide endpoints",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"jump": {
|
||||
"heading": "Jump back in",
|
||||
"discover": "Discover",
|
||||
"discoverSummary": "Browse the gallery and install models",
|
||||
"create": "Create",
|
||||
"createSummary": "Open a chat, image or voice session",
|
||||
"operate": "Operate",
|
||||
"operateSummary": "{{models}} models configured · nodes, activity and traces"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,32 @@
|
||||
"video": "비디오",
|
||||
"tts": "TTS",
|
||||
"sound": "사운드",
|
||||
"transform": "Transform",
|
||||
"overview": "Overview"
|
||||
},
|
||||
"overview": {
|
||||
"eyebrow": "{{ready}} of {{total}} modalities ready",
|
||||
"title": "Studio",
|
||||
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
|
||||
"canMake": "What you can make",
|
||||
"running": "Running now",
|
||||
"recent": "Recent outputs",
|
||||
"noModel": "No model installed",
|
||||
"install": "Install a model",
|
||||
"ready": "Ready",
|
||||
"seconds": "{{seconds}}s",
|
||||
"describe": {
|
||||
"images": "Text to image, image to image, reference images",
|
||||
"video": "Text to video and image to video",
|
||||
"threed": "Image to mesh reconstruction",
|
||||
"tts": "Text to speech using your voice library",
|
||||
"sound": "Music and sound effects from a prompt",
|
||||
"transform": "Separation, enhancement and voice conversion"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"create": "Create",
|
||||
"voice": "Voice",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
@@ -157,5 +183,10 @@
|
||||
"clearMessage": "모든 기록 항목을 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"clearConfirm": "지우기",
|
||||
"cleared": "기록이 지워졌습니다"
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request",
|
||||
"copyCurl": "Copy as curl",
|
||||
"copied": "Copied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"installStarted": "{{model}} 설치 중…",
|
||||
"installFailed": "설치 실패: {{message}}",
|
||||
"dismiss": "추천 닫기",
|
||||
"summary": "추천 모델 {{n}}개"
|
||||
"summary": "추천 모델 {{n}}개",
|
||||
"bestFit": "Best fit",
|
||||
"alternative": "Also fits"
|
||||
},
|
||||
"stats": {
|
||||
"available": "사용 가능",
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"observability": "Observability",
|
||||
"access": "Access",
|
||||
"system": "System",
|
||||
"activity": "Activity"
|
||||
"activity": "Activity",
|
||||
"runtime": "런타임",
|
||||
"administration": "관리"
|
||||
},
|
||||
"items": {
|
||||
"home": "홈",
|
||||
@@ -57,7 +59,8 @@
|
||||
"system": "시스템",
|
||||
"settings": "설정",
|
||||
"api": "API",
|
||||
"activity": "활동"
|
||||
"activity": "활동",
|
||||
"overview": "개요"
|
||||
},
|
||||
"footer": {
|
||||
"github": "GitHub",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"timeLeft": "{{value}} left",
|
||||
"cancel": "Cancel",
|
||||
"cancelLabel": "Cancel {{name}}",
|
||||
"pause": "Pause",
|
||||
"pauseLabel": "Pause {{name}} and keep downloaded data",
|
||||
"retry": "Retry",
|
||||
"retryLabel": "Retry {{name}}",
|
||||
"nodeCount": "{{count}} nodes",
|
||||
@@ -143,5 +145,36 @@
|
||||
"explorer": {
|
||||
"title": "资源浏览器",
|
||||
"subtitle": "浏览文件和配置"
|
||||
},
|
||||
"operate": {
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"subtitle": "Everything running on this installation, and anything that wants a decision.",
|
||||
"attention": {
|
||||
"heading": "Needs attention",
|
||||
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
|
||||
"backendUpdate": "Update available: {{from}} → {{to}}"
|
||||
},
|
||||
"sections": {
|
||||
"heading": "Sections",
|
||||
"runtime": "Runtime",
|
||||
"runtimeSummary": "{{backends}} backends · {{models}} models · {{updates}} updates · {{running}} running",
|
||||
"cluster": "Cluster",
|
||||
"clusterSummary": "{{nodes}} nodes",
|
||||
"observability": "Observability",
|
||||
"observabilitySummary": "Usage and traces",
|
||||
"administration": "Administration",
|
||||
"administrationSummary": "Users, middleware and settings · {{memory}} memory in use",
|
||||
"clusterSingle": "Single node",
|
||||
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
|
||||
},
|
||||
"headline": {
|
||||
"requests": "Requests · {{hours}}h",
|
||||
"errors": "Failed requests",
|
||||
"p95": "p95 latency",
|
||||
"quiet": "No requests served in this window yet.",
|
||||
"host": "Host memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user