mirror of
https://github.com/mudler/LocalAI.git
synced 2026-02-03 03:02:38 -05:00
Compare commits
7 Commits
v3.10.0
...
copilot/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ecda78be4 | ||
|
|
8da5ef7231 | ||
|
|
4758996936 | ||
|
|
9a50215867 | ||
|
|
4435c8af57 | ||
|
|
65a57daba6 | ||
|
|
b5465cbc3a |
114
.github/workflows/backend.yml
vendored
114
.github/workflows/backend.yml
vendored
@@ -105,19 +105,6 @@ jobs:
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "12"
|
||||
cuda-minor-version: "9"
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-nvidia-cuda-12-pocket-tts'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "pocket-tts"
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "12"
|
||||
cuda-minor-version: "0"
|
||||
@@ -137,7 +124,7 @@ jobs:
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-nvidia-cuda-12-llama-cpp'
|
||||
runs-on: 'bigger-runner'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "llama-cpp"
|
||||
@@ -353,19 +340,6 @@ jobs:
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "13"
|
||||
cuda-minor-version: "0"
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-nvidia-cuda-13-pocket-tts'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "pocket-tts"
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'cublas'
|
||||
cuda-major-version: "13"
|
||||
cuda-minor-version: "0"
|
||||
@@ -431,19 +405,6 @@ jobs:
|
||||
backend: "vibevoice"
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
- build-type: 'l4t'
|
||||
cuda-major-version: "13"
|
||||
cuda-minor-version: "0"
|
||||
platforms: 'linux/arm64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-nvidia-l4t-cuda-13-arm64-pocket-tts'
|
||||
runs-on: 'ubuntu-24.04-arm'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
ubuntu-version: '2404'
|
||||
backend: "pocket-tts"
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
- build-type: 'l4t'
|
||||
cuda-major-version: "13"
|
||||
cuda-minor-version: "0"
|
||||
@@ -680,26 +641,13 @@ jobs:
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'hipblas'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-rocm-hipblas-pocket-tts'
|
||||
runs-on: 'arc-runner-set'
|
||||
base-image: "rocm/dev-ubuntu-24.04:6.4.4"
|
||||
skip-drivers: 'false'
|
||||
backend: "pocket-tts"
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'hipblas'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-rocm-hipblas-faster-whisper'
|
||||
runs-on: 'bigger-runner'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "rocm/dev-ubuntu-24.04:6.4.4"
|
||||
skip-drivers: 'false'
|
||||
backend: "faster-whisper"
|
||||
@@ -712,7 +660,7 @@ jobs:
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-rocm-hipblas-coqui'
|
||||
runs-on: 'bigger-runner'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "rocm/dev-ubuntu-24.04:6.4.4"
|
||||
skip-drivers: 'false'
|
||||
backend: "coqui"
|
||||
@@ -824,19 +772,6 @@ jobs:
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2204'
|
||||
- build-type: 'l4t'
|
||||
cuda-major-version: "12"
|
||||
cuda-minor-version: "0"
|
||||
platforms: 'linux/arm64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-nvidia-l4t-pocket-tts'
|
||||
runs-on: 'ubuntu-24.04-arm'
|
||||
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
|
||||
skip-drivers: 'true'
|
||||
backend: "pocket-tts"
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2204'
|
||||
- build-type: 'l4t'
|
||||
cuda-major-version: "12"
|
||||
cuda-minor-version: "0"
|
||||
@@ -890,19 +825,6 @@ jobs:
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'intel'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-intel-pocket-tts'
|
||||
runs-on: 'arc-runner-set'
|
||||
base-image: "intel/oneapi-basekit:2025.3.0-0-devel-ubuntu24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "pocket-tts"
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'intel'
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
@@ -963,7 +885,7 @@ jobs:
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-cpu-llama-cpp'
|
||||
runs-on: 'bigger-runner'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "llama-cpp"
|
||||
@@ -989,7 +911,7 @@ jobs:
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-gpu-vulkan-llama-cpp'
|
||||
runs-on: 'bigger-runner'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "llama-cpp"
|
||||
@@ -1330,6 +1252,19 @@ jobs:
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: 'l4t'
|
||||
cuda-major-version: "12"
|
||||
cuda-minor-version: "0"
|
||||
platforms: 'linux/arm64'
|
||||
skip-drivers: 'true'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-nvidia-l4t-arm64-neutts'
|
||||
base-image: "nvcr.io/nvidia/l4t-jetpack:r36.4.0"
|
||||
runs-on: 'ubuntu-24.04-arm'
|
||||
backend: "neutts"
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2204'
|
||||
- build-type: ''
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
@@ -1343,19 +1278,6 @@ jobs:
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
- build-type: ''
|
||||
cuda-major-version: ""
|
||||
cuda-minor-version: ""
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
tag-latest: 'auto'
|
||||
tag-suffix: '-cpu-pocket-tts'
|
||||
runs-on: 'ubuntu-latest'
|
||||
base-image: "ubuntu:24.04"
|
||||
skip-drivers: 'false'
|
||||
backend: "pocket-tts"
|
||||
dockerfile: "./backend/Dockerfile.python"
|
||||
context: "./"
|
||||
ubuntu-version: '2404'
|
||||
backend-jobs-darwin:
|
||||
uses: ./.github/workflows/backend_build_darwin.yml
|
||||
strategy:
|
||||
|
||||
21
.github/workflows/test-extra.yml
vendored
21
.github/workflows/test-extra.yml
vendored
@@ -265,23 +265,4 @@ jobs:
|
||||
- name: Test moonshine
|
||||
run: |
|
||||
make --jobs=5 --output-sync=target -C backend/python/moonshine
|
||||
make --jobs=5 --output-sync=target -C backend/python/moonshine test
|
||||
tests-pocket-tts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install build-essential ffmpeg
|
||||
sudo apt-get install -y ca-certificates cmake curl patch python3-pip
|
||||
# Install UV
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
pip install --user --no-cache-dir grpcio-tools==1.64.1
|
||||
- name: Test pocket-tts
|
||||
run: |
|
||||
make --jobs=5 --output-sync=target -C backend/python/pocket-tts
|
||||
make --jobs=5 --output-sync=target -C backend/python/pocket-tts test
|
||||
make --jobs=5 --output-sync=target -C backend/python/moonshine test
|
||||
18
Dockerfile
18
Dockerfile
@@ -42,22 +42,22 @@ RUN <<EOT bash
|
||||
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
|
||||
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils mesa-vulkan-drivers
|
||||
if [ "amd64" = "$TARGETARCH" ]; then
|
||||
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
|
||||
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
|
||||
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
|
||||
wget "https://sdk.lunarg.com/sdk/download/1.4.328.1/linux/vulkansdk-linux-x86_64-1.4.328.1.tar.xz" && \
|
||||
tar -xf vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
|
||||
rm vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
|
||||
mkdir -p /opt/vulkan-sdk && \
|
||||
mv 1.4.335.0 /opt/vulkan-sdk/ && \
|
||||
cd /opt/vulkan-sdk/1.4.335.0 && \
|
||||
mv 1.4.328.1 /opt/vulkan-sdk/ && \
|
||||
cd /opt/vulkan-sdk/1.4.328.1 && \
|
||||
./vulkansdk --no-deps --maxjobs \
|
||||
vulkan-loader \
|
||||
vulkan-validationlayers \
|
||||
vulkan-extensionlayer \
|
||||
vulkan-tools \
|
||||
shaderc && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/bin/* /usr/bin/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/include/* /usr/include/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/share/* /usr/share/ && \
|
||||
rm -rf /opt/vulkan-sdk
|
||||
fi
|
||||
if [ "arm64" = "$TARGETARCH" ]; then
|
||||
|
||||
30
Makefile
30
Makefile
@@ -1,5 +1,5 @@
|
||||
# Disable parallel execution for backend builds
|
||||
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/piper backends/stablediffusion-ggml backends/whisper backends/faster-whisper backends/silero-vad backends/local-store backends/huggingface backends/rfdetr backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/stablediffusion-ggml-darwin backends/vllm backends/moonshine backends/pocket-tts
|
||||
.NOTPARALLEL: backends/diffusers backends/llama-cpp backends/piper backends/stablediffusion-ggml backends/whisper backends/faster-whisper backends/silero-vad backends/local-store backends/huggingface backends/rfdetr backends/kitten-tts backends/kokoro backends/chatterbox backends/llama-cpp-darwin backends/neutts build-darwin-python-backend build-darwin-go-backend backends/mlx backends/diffuser-darwin backends/mlx-vlm backends/mlx-audio backends/stablediffusion-ggml-darwin backends/vllm backends/moonshine
|
||||
|
||||
GOCMD=go
|
||||
GOTEST=$(GOCMD) test
|
||||
@@ -9,7 +9,7 @@ LAUNCHER_BINARY_NAME=local-ai-launcher
|
||||
|
||||
CUDA_MAJOR_VERSION?=13
|
||||
CUDA_MINOR_VERSION?=0
|
||||
UBUNTU_VERSION?=2404
|
||||
UBUNTU_VERSION?=2204
|
||||
UBUNTU_CODENAME?=noble
|
||||
|
||||
GORELEASER?=
|
||||
@@ -316,7 +316,6 @@ prepare-test-extra: protogen-python
|
||||
$(MAKE) -C backend/python/vllm
|
||||
$(MAKE) -C backend/python/vibevoice
|
||||
$(MAKE) -C backend/python/moonshine
|
||||
$(MAKE) -C backend/python/pocket-tts
|
||||
|
||||
test-extra: prepare-test-extra
|
||||
$(MAKE) -C backend/python/transformers test
|
||||
@@ -325,7 +324,6 @@ test-extra: prepare-test-extra
|
||||
$(MAKE) -C backend/python/vllm test
|
||||
$(MAKE) -C backend/python/vibevoice test
|
||||
$(MAKE) -C backend/python/moonshine test
|
||||
$(MAKE) -C backend/python/pocket-tts test
|
||||
|
||||
DOCKER_IMAGE?=local-ai
|
||||
DOCKER_AIO_IMAGE?=local-ai-aio
|
||||
@@ -449,16 +447,17 @@ BACKEND_FASTER_WHISPER = faster-whisper|python|.|false|true
|
||||
BACKEND_COQUI = coqui|python|.|false|true
|
||||
BACKEND_BARK = bark|python|.|false|true
|
||||
BACKEND_EXLLAMA2 = exllama2|python|.|false|true
|
||||
BACKEND_RFDETR = rfdetr|python|.|false|true
|
||||
BACKEND_KITTEN_TTS = kitten-tts|python|.|false|true
|
||||
BACKEND_NEUTTS = neutts|python|.|false|true
|
||||
BACKEND_KOKORO = kokoro|python|.|false|true
|
||||
BACKEND_VLLM = vllm|python|.|false|true
|
||||
BACKEND_DIFFUSERS = diffusers|python|.|--progress=plain|true
|
||||
BACKEND_CHATTERBOX = chatterbox|python|.|false|true
|
||||
BACKEND_VIBEVOICE = vibevoice|python|.|--progress=plain|true
|
||||
BACKEND_MOONSHINE = moonshine|python|.|false|true
|
||||
BACKEND_POCKET_TTS = pocket-tts|python|.|false|true
|
||||
|
||||
# Python backends with ./backend context
|
||||
BACKEND_RFDETR = rfdetr|python|./backend|false|true
|
||||
BACKEND_KITTEN_TTS = kitten-tts|python|./backend|false|true
|
||||
BACKEND_NEUTTS = neutts|python|./backend|false|true
|
||||
BACKEND_KOKORO = kokoro|python|./backend|false|true
|
||||
BACKEND_VLLM = vllm|python|./backend|false|true
|
||||
BACKEND_DIFFUSERS = diffusers|python|./backend|--progress=plain|true
|
||||
BACKEND_CHATTERBOX = chatterbox|python|./backend|false|true
|
||||
BACKEND_VIBEVOICE = vibevoice|python|./backend|--progress=plain|true
|
||||
BACKEND_MOONSHINE = moonshine|python|./backend|false|true
|
||||
|
||||
# Helper function to build docker image for a backend
|
||||
# Usage: $(call docker-build-backend,BACKEND_NAME,DOCKERFILE_TYPE,BUILD_CONTEXT,PROGRESS_FLAG,NEEDS_BACKEND_ARG)
|
||||
@@ -504,13 +503,12 @@ $(eval $(call generate-docker-build-target,$(BACKEND_DIFFUSERS)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_CHATTERBOX)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_VIBEVOICE)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_MOONSHINE)))
|
||||
$(eval $(call generate-docker-build-target,$(BACKEND_POCKET_TTS)))
|
||||
|
||||
# Pattern rule for docker-save targets
|
||||
docker-save-%: backend-images
|
||||
docker save local-ai-backend:$* -o backend-images/$*.tar
|
||||
|
||||
docker-build-backends: docker-build-llama-cpp docker-build-rerankers docker-build-vllm docker-build-transformers docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-coqui docker-build-bark docker-build-chatterbox docker-build-vibevoice docker-build-exllama2 docker-build-moonshine docker-build-pocket-tts
|
||||
docker-build-backends: docker-build-llama-cpp docker-build-rerankers docker-build-vllm docker-build-transformers docker-build-diffusers docker-build-kokoro docker-build-faster-whisper docker-build-coqui docker-build-bark docker-build-chatterbox docker-build-vibevoice docker-build-exllama2 docker-build-moonshine
|
||||
|
||||
########################################################
|
||||
### END Backends
|
||||
|
||||
21
README.md
21
README.md
@@ -111,8 +111,6 @@
|
||||
|
||||
## 💻 Quickstart
|
||||
|
||||
> ⚠️ **Note:** The `install.sh` script is currently experiencing issues due to the heavy changes currently undergoing in LocalAI and may produce broken or misconfigured installations. Please use Docker installation (see below) or manual binary installation until [issue #8032](https://github.com/mudler/LocalAI/issues/8032) is resolved.
|
||||
|
||||
Run the installer script:
|
||||
|
||||
```bash
|
||||
@@ -130,7 +128,7 @@ For more installation options, see [Installer Options](https://localai.io/instal
|
||||
|
||||
> Note: the DMGs are not signed by Apple as quarantined. See https://github.com/mudler/LocalAI/issues/6268 for a workaround, fix is tracked here: https://github.com/mudler/LocalAI/issues/6244
|
||||
|
||||
### Containers (Docker, podman, ...)
|
||||
Or run with docker:
|
||||
|
||||
> **💡 Docker Run vs Docker Start**
|
||||
>
|
||||
@@ -139,13 +137,13 @@ For more installation options, see [Installer Options](https://localai.io/instal
|
||||
>
|
||||
> If you've already run LocalAI before and want to start it again, use: `docker start -i local-ai`
|
||||
|
||||
#### CPU only image:
|
||||
### CPU only image:
|
||||
|
||||
```bash
|
||||
docker run -ti --name local-ai -p 8080:8080 localai/localai:latest
|
||||
```
|
||||
|
||||
#### NVIDIA GPU Images:
|
||||
### NVIDIA GPU Images:
|
||||
|
||||
```bash
|
||||
# CUDA 13.0
|
||||
@@ -162,25 +160,25 @@ docker run -ti --name local-ai -p 8080:8080 --gpus all localai/localai:latest-nv
|
||||
docker run -ti --name local-ai -p 8080:8080 --gpus all localai/localai:latest-nvidia-l4t-arm64-cuda-13
|
||||
```
|
||||
|
||||
#### AMD GPU Images (ROCm):
|
||||
### AMD GPU Images (ROCm):
|
||||
|
||||
```bash
|
||||
docker run -ti --name local-ai -p 8080:8080 --device=/dev/kfd --device=/dev/dri --group-add=video localai/localai:latest-gpu-hipblas
|
||||
```
|
||||
|
||||
#### Intel GPU Images (oneAPI):
|
||||
### Intel GPU Images (oneAPI):
|
||||
|
||||
```bash
|
||||
docker run -ti --name local-ai -p 8080:8080 --device=/dev/dri/card1 --device=/dev/dri/renderD128 localai/localai:latest-gpu-intel
|
||||
```
|
||||
|
||||
#### Vulkan GPU Images:
|
||||
### Vulkan GPU Images:
|
||||
|
||||
```bash
|
||||
docker run -ti --name local-ai -p 8080:8080 localai/localai:latest-gpu-vulkan
|
||||
```
|
||||
|
||||
#### AIO Images (pre-downloaded models):
|
||||
### AIO Images (pre-downloaded models):
|
||||
|
||||
```bash
|
||||
# CPU version
|
||||
@@ -297,7 +295,6 @@ LocalAI supports a comprehensive range of AI backends with multiple acceleration
|
||||
| **silero-vad** | Voice Activity Detection | CPU |
|
||||
| **neutts** | Text-to-speech with voice cloning | CUDA 12/13, ROCm, CPU |
|
||||
| **vibevoice** | Real-time TTS with voice cloning | CUDA 12/13, ROCm, Intel, CPU |
|
||||
| **pocket-tts** | Lightweight CPU-based TTS | CUDA 12/13, ROCm, Intel, CPU |
|
||||
|
||||
### Image & Video Generation
|
||||
| Backend | Description | Acceleration Support |
|
||||
@@ -319,8 +316,8 @@ LocalAI supports a comprehensive range of AI backends with multiple acceleration
|
||||
|-------------------|-------------------|------------------|
|
||||
| **NVIDIA CUDA 12** | All CUDA-compatible backends | Nvidia hardware |
|
||||
| **NVIDIA CUDA 13** | All CUDA-compatible backends | Nvidia hardware |
|
||||
| **AMD ROCm** | llama.cpp, whisper, vllm, transformers, diffusers, rerankers, coqui, kokoro, bark, neutts, vibevoice, pocket-tts | AMD Graphics |
|
||||
| **Intel oneAPI** | llama.cpp, whisper, stablediffusion, vllm, transformers, diffusers, rfdetr, rerankers, exllama2, coqui, kokoro, bark, vibevoice, pocket-tts | Intel Arc, Intel iGPUs |
|
||||
| **AMD ROCm** | llama.cpp, whisper, vllm, transformers, diffusers, rerankers, coqui, kokoro, bark, neutts, vibevoice | AMD Graphics |
|
||||
| **Intel oneAPI** | llama.cpp, whisper, stablediffusion, vllm, transformers, diffusers, rfdetr, rerankers, exllama2, coqui, kokoro, bark, vibevoice | Intel Arc, Intel iGPUs |
|
||||
| **Apple Metal** | llama.cpp, whisper, diffusers, MLX, MLX-VLM, bark-cpp | Apple M1/M2/M3+ |
|
||||
| **Vulkan** | llama.cpp, whisper, stablediffusion | Cross-platform GPUs |
|
||||
| **NVIDIA Jetson (CUDA 12)** | llama.cpp, whisper, stablediffusion, diffusers, rfdetr | ARM64 embedded AI (AGX Orin, etc.) |
|
||||
|
||||
@@ -47,22 +47,22 @@ RUN <<EOT bash
|
||||
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
|
||||
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
|
||||
if [ "amd64" = "$TARGETARCH" ]; then
|
||||
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
|
||||
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
|
||||
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
|
||||
wget "https://sdk.lunarg.com/sdk/download/1.4.328.1/linux/vulkansdk-linux-x86_64-1.4.328.1.tar.xz" && \
|
||||
tar -xf vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
|
||||
rm vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
|
||||
mkdir -p /opt/vulkan-sdk && \
|
||||
mv 1.4.335.0 /opt/vulkan-sdk/ && \
|
||||
cd /opt/vulkan-sdk/1.4.335.0 && \
|
||||
mv 1.4.328.1 /opt/vulkan-sdk/ && \
|
||||
cd /opt/vulkan-sdk/1.4.328.1 && \
|
||||
./vulkansdk --no-deps --maxjobs \
|
||||
vulkan-loader \
|
||||
vulkan-validationlayers \
|
||||
vulkan-extensionlayer \
|
||||
vulkan-tools \
|
||||
shaderc && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/bin/* /usr/bin/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/include/* /usr/include/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/share/* /usr/share/ && \
|
||||
rm -rf /opt/vulkan-sdk
|
||||
fi
|
||||
if [ "arm64" = "$TARGETARCH" ]; then
|
||||
|
||||
@@ -104,22 +104,22 @@ RUN <<EOT bash
|
||||
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
|
||||
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
|
||||
if [ "amd64" = "$TARGETARCH" ]; then
|
||||
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
|
||||
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
|
||||
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
|
||||
wget "https://sdk.lunarg.com/sdk/download/1.4.328.1/linux/vulkansdk-linux-x86_64-1.4.328.1.tar.xz" && \
|
||||
tar -xf vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
|
||||
rm vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
|
||||
mkdir -p /opt/vulkan-sdk && \
|
||||
mv 1.4.335.0 /opt/vulkan-sdk/ && \
|
||||
cd /opt/vulkan-sdk/1.4.335.0 && \
|
||||
mv 1.4.328.1 /opt/vulkan-sdk/ && \
|
||||
cd /opt/vulkan-sdk/1.4.328.1 && \
|
||||
./vulkansdk --no-deps --maxjobs \
|
||||
vulkan-loader \
|
||||
vulkan-validationlayers \
|
||||
vulkan-extensionlayer \
|
||||
vulkan-tools \
|
||||
shaderc && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/bin/* /usr/bin/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/include/* /usr/include/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/share/* /usr/share/ && \
|
||||
rm -rf /opt/vulkan-sdk
|
||||
fi
|
||||
if [ "arm64" = "$TARGETARCH" ]; then
|
||||
|
||||
@@ -61,22 +61,22 @@ RUN <<EOT bash
|
||||
ocaml-core ninja-build pkg-config libxml2-dev wayland-protocols python3-jsonschema \
|
||||
clang-format qtbase5-dev qt6-base-dev libxcb-glx0-dev sudo xz-utils
|
||||
if [ "amd64" = "$TARGETARCH" ]; then
|
||||
wget "https://sdk.lunarg.com/sdk/download/1.4.335.0/linux/vulkansdk-linux-x86_64-1.4.335.0.tar.xz" && \
|
||||
tar -xf vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
|
||||
rm vulkansdk-linux-x86_64-1.4.335.0.tar.xz && \
|
||||
wget "https://sdk.lunarg.com/sdk/download/1.4.328.1/linux/vulkansdk-linux-x86_64-1.4.328.1.tar.xz" && \
|
||||
tar -xf vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
|
||||
rm vulkansdk-linux-x86_64-1.4.328.1.tar.xz && \
|
||||
mkdir -p /opt/vulkan-sdk && \
|
||||
mv 1.4.335.0 /opt/vulkan-sdk/ && \
|
||||
cd /opt/vulkan-sdk/1.4.335.0 && \
|
||||
mv 1.4.328.1 /opt/vulkan-sdk/ && \
|
||||
cd /opt/vulkan-sdk/1.4.328.1 && \
|
||||
./vulkansdk --no-deps --maxjobs \
|
||||
vulkan-loader \
|
||||
vulkan-validationlayers \
|
||||
vulkan-extensionlayer \
|
||||
vulkan-tools \
|
||||
shaderc && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/bin/* /usr/bin/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/include/* /usr/include/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.335.0/x86_64/share/* /usr/share/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/bin/* /usr/bin/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/lib/* /usr/lib/x86_64-linux-gnu/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/include/* /usr/include/ && \
|
||||
cp -rfv /opt/vulkan-sdk/1.4.328.1/x86_64/share/* /usr/share/ && \
|
||||
rm -rf /opt/vulkan-sdk
|
||||
fi
|
||||
if [ "arm64" = "$TARGETARCH" ]; then
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=2fbde785bc106ae1c4102b0e82b9b41d9c466579
|
||||
LLAMA_VERSION?=b1377188784f9aea26b8abde56d4aee8c733eec7
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -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?=9565c7f6bd5fcff124c589147b2621244f2c4aa1
|
||||
STABLEDIFFUSION_GGML_VERSION?=0e52afc6513cc2dea9a1a017afc4a008d5acf2b0
|
||||
|
||||
CMAKE_ARGS+=-DGGML_MAX_NAME=128
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# whisper.cpp version
|
||||
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
|
||||
WHISPER_CPP_VERSION?=f53dc74843e97f19f94a79241357f74ad5b691a6
|
||||
WHISPER_CPP_VERSION?=679bdb53dbcbfb3e42685f50c7ff367949fd4d48
|
||||
SO_TARGET?=libgowhisper.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -428,28 +428,6 @@
|
||||
nvidia-l4t-cuda-12: "nvidia-l4t-vibevoice"
|
||||
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-vibevoice"
|
||||
icon: https://avatars.githubusercontent.com/u/6154722?s=200&v=4
|
||||
- &pocket-tts
|
||||
urls:
|
||||
- https://github.com/kyutai-labs/pocket-tts
|
||||
description: |
|
||||
Pocket TTS is a lightweight text-to-speech model designed to run efficiently on CPUs.
|
||||
tags:
|
||||
- text-to-speech
|
||||
- TTS
|
||||
license: mit
|
||||
name: "pocket-tts"
|
||||
alias: "pocket-tts"
|
||||
capabilities:
|
||||
nvidia: "cuda12-pocket-tts"
|
||||
intel: "intel-pocket-tts"
|
||||
amd: "rocm-pocket-tts"
|
||||
nvidia-l4t: "nvidia-l4t-pocket-tts"
|
||||
default: "cpu-pocket-tts"
|
||||
nvidia-cuda-13: "cuda13-pocket-tts"
|
||||
nvidia-cuda-12: "cuda12-pocket-tts"
|
||||
nvidia-l4t-cuda-12: "nvidia-l4t-pocket-tts"
|
||||
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-pocket-tts"
|
||||
icon: https://avatars.githubusercontent.com/u/6154722?s=200&v=4
|
||||
- &piper
|
||||
name: "piper"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-piper"
|
||||
@@ -537,14 +515,18 @@
|
||||
default: "cpu-neutts"
|
||||
nvidia: "cuda12-neutts"
|
||||
amd: "rocm-neutts"
|
||||
nvidia-l4t: "nvidia-l4t-neutts"
|
||||
nvidia-cuda-12: "cuda12-neutts"
|
||||
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-neutts"
|
||||
- !!merge <<: *neutts
|
||||
name: "neutts-development"
|
||||
capabilities:
|
||||
default: "cpu-neutts-development"
|
||||
nvidia: "cuda12-neutts-development"
|
||||
amd: "rocm-neutts-development"
|
||||
nvidia-l4t: "nvidia-l4t-neutts-development"
|
||||
nvidia-cuda-12: "cuda12-neutts-development"
|
||||
nvidia-l4t-cuda-12: "nvidia-l4t-arm64-neutts-development"
|
||||
- !!merge <<: *llamacpp
|
||||
name: "llama-cpp-development"
|
||||
capabilities:
|
||||
@@ -574,6 +556,11 @@
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-neutts"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-rocm-hipblas-neutts
|
||||
- !!merge <<: *neutts
|
||||
name: "nvidia-l4t-arm64-neutts"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-arm64-neutts"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-nvidia-l4t-arm64-neutts
|
||||
- !!merge <<: *neutts
|
||||
name: "cpu-neutts-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-neutts"
|
||||
@@ -589,6 +576,11 @@
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-rocm-hipblas-neutts"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-rocm-hipblas-neutts
|
||||
- !!merge <<: *neutts
|
||||
name: "nvidia-l4t-arm64-neutts-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-arm64-neutts"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-nvidia-l4t-arm64-neutts
|
||||
- !!merge <<: *mlx
|
||||
name: "mlx-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-mlx"
|
||||
@@ -1613,86 +1605,3 @@
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-vibevoice"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-vibevoice
|
||||
## pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "pocket-tts-development"
|
||||
capabilities:
|
||||
nvidia: "cuda12-pocket-tts-development"
|
||||
intel: "intel-pocket-tts-development"
|
||||
amd: "rocm-pocket-tts-development"
|
||||
nvidia-l4t: "nvidia-l4t-pocket-tts-development"
|
||||
default: "cpu-pocket-tts-development"
|
||||
nvidia-cuda-13: "cuda13-pocket-tts-development"
|
||||
nvidia-cuda-12: "cuda12-pocket-tts-development"
|
||||
nvidia-l4t-cuda-12: "nvidia-l4t-pocket-tts-development"
|
||||
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-pocket-tts-development"
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "cpu-pocket-tts"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-cpu-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "cpu-pocket-tts-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-cpu-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "cuda12-pocket-tts"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-nvidia-cuda-12-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "cuda12-pocket-tts-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-12-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-nvidia-cuda-12-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "cuda13-pocket-tts"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-nvidia-cuda-13-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "cuda13-pocket-tts-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-nvidia-cuda-13-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-nvidia-cuda-13-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "intel-pocket-tts"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-intel-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "intel-pocket-tts-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-intel-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-intel-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "rocm-pocket-tts"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-gpu-rocm-hipblas-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "rocm-pocket-tts-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-gpu-rocm-hipblas-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-gpu-rocm-hipblas-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "nvidia-l4t-pocket-tts"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-nvidia-l4t-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "nvidia-l4t-pocket-tts-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-nvidia-l4t-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "cuda13-nvidia-l4t-arm64-pocket-tts"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:latest-nvidia-l4t-cuda-13-arm64-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:latest-nvidia-l4t-cuda-13-arm64-pocket-tts
|
||||
- !!merge <<: *pocket-tts
|
||||
name: "cuda13-nvidia-l4t-arm64-pocket-tts-development"
|
||||
uri: "quay.io/go-skynet/local-ai-backends:master-nvidia-l4t-cuda-13-arm64-pocket-tts"
|
||||
mirrors:
|
||||
- localai/localai-backends:master-nvidia-l4t-cuda-13-arm64-pocket-tts
|
||||
|
||||
@@ -41,14 +41,6 @@ from optimum.quanto import freeze, qfloat8, quantize
|
||||
from transformers import T5EncoderModel
|
||||
from safetensors.torch import load_file
|
||||
|
||||
# Import LTX-2 specific utilities
|
||||
try:
|
||||
from diffusers.pipelines.ltx2.export_utils import encode_video as ltx2_encode_video
|
||||
LTX2_AVAILABLE = True
|
||||
except ImportError:
|
||||
LTX2_AVAILABLE = False
|
||||
ltx2_encode_video = None
|
||||
|
||||
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
|
||||
COMPEL = os.environ.get("COMPEL", "0") == "1"
|
||||
XPU = os.environ.get("XPU", "0") == "1"
|
||||
@@ -298,20 +290,6 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
pipe.enable_model_cpu_offload()
|
||||
return pipe
|
||||
|
||||
# LTX2ImageToVideoPipeline - needs img2vid flag, CPU offload, and special handling
|
||||
if pipeline_type == "LTX2ImageToVideoPipeline":
|
||||
self.img2vid = True
|
||||
self.ltx2_pipeline = True
|
||||
pipe = load_diffusers_pipeline(
|
||||
class_name="LTX2ImageToVideoPipeline",
|
||||
model_id=request.Model,
|
||||
torch_dtype=torchType,
|
||||
variant=variant
|
||||
)
|
||||
if not DISABLE_CPU_OFFLOAD:
|
||||
pipe.enable_model_cpu_offload()
|
||||
return pipe
|
||||
|
||||
# ================================================================
|
||||
# Dynamic pipeline loading - the default path for most pipelines
|
||||
# Uses the dynamic loader to instantiate any pipeline by class name
|
||||
@@ -426,7 +404,6 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
fromSingleFile = request.Model.startswith("http") or request.Model.startswith("/") or local
|
||||
self.img2vid = False
|
||||
self.txt2vid = False
|
||||
self.ltx2_pipeline = False
|
||||
|
||||
# Load pipeline using dynamic loader
|
||||
# Special cases that require custom initialization are handled first
|
||||
@@ -709,44 +686,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
print(f"Generating video with {kwargs=}", file=sys.stderr)
|
||||
|
||||
# Generate video frames based on pipeline type
|
||||
if self.ltx2_pipeline or self.PipelineType == "LTX2ImageToVideoPipeline":
|
||||
# LTX-2 image-to-video generation with audio
|
||||
if not LTX2_AVAILABLE:
|
||||
return backend_pb2.Result(success=False, message="LTX-2 pipeline requires diffusers.pipelines.ltx2.export_utils")
|
||||
|
||||
# LTX-2 uses 'image' parameter instead of 'start_image'
|
||||
if request.start_image:
|
||||
image = load_image(request.start_image)
|
||||
kwargs["image"] = image
|
||||
# Remove start_image if it was added
|
||||
kwargs.pop("start_image", None)
|
||||
|
||||
# LTX-2 uses 'frame_rate' instead of 'fps'
|
||||
frame_rate = float(fps)
|
||||
kwargs["frame_rate"] = frame_rate
|
||||
|
||||
# LTX-2 requires output_type="np" and return_dict=False
|
||||
kwargs["output_type"] = "np"
|
||||
kwargs["return_dict"] = False
|
||||
|
||||
# Generate video and audio
|
||||
video, audio = self.pipe(**kwargs)
|
||||
|
||||
# Convert video to uint8 format
|
||||
video = (video * 255).round().astype("uint8")
|
||||
video = torch.from_numpy(video)
|
||||
|
||||
# Use LTX-2's encode_video function which handles audio
|
||||
ltx2_encode_video(
|
||||
video[0],
|
||||
fps=frame_rate,
|
||||
audio=audio[0].float().cpu(),
|
||||
audio_sample_rate=self.pipe.vocoder.config.output_sampling_rate,
|
||||
output_path=request.dst,
|
||||
)
|
||||
|
||||
return backend_pb2.Result(message="Video generated successfully", success=True)
|
||||
elif self.PipelineType == "WanPipeline":
|
||||
if self.PipelineType == "WanPipeline":
|
||||
# WAN2.2 text-to-video generation
|
||||
output = self.pipe(**kwargs)
|
||||
frames = output.frames[0] # WAN2.2 returns frames in this format
|
||||
@@ -787,7 +727,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
else:
|
||||
return backend_pb2.Result(success=False, message=f"Pipeline {self.PipelineType} does not support video generation")
|
||||
|
||||
# Export video (for non-LTX-2 pipelines)
|
||||
# Export video
|
||||
export_to_video(frames, request.dst, fps=fps)
|
||||
|
||||
return backend_pb2.Result(message="Video generated successfully", success=True)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
.PHONY: pocket-tts
|
||||
pocket-tts:
|
||||
bash install.sh
|
||||
|
||||
.PHONY: run
|
||||
run: pocket-tts
|
||||
@echo "Running pocket-tts..."
|
||||
bash run.sh
|
||||
@echo "pocket-tts run."
|
||||
|
||||
.PHONY: test
|
||||
test: pocket-tts
|
||||
@echo "Testing pocket-tts..."
|
||||
bash test.sh
|
||||
@echo "pocket-tts tested."
|
||||
|
||||
.PHONY: protogen-clean
|
||||
protogen-clean:
|
||||
$(RM) backend_pb2_grpc.py backend_pb2.py
|
||||
|
||||
.PHONY: clean
|
||||
clean: protogen-clean
|
||||
rm -rf venv __pycache__
|
||||
@@ -1,255 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
This is an extra gRPC server of LocalAI for Pocket TTS
|
||||
"""
|
||||
from concurrent import futures
|
||||
import time
|
||||
import argparse
|
||||
import signal
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
import scipy.io.wavfile
|
||||
import backend_pb2
|
||||
import backend_pb2_grpc
|
||||
import torch
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
import grpc
|
||||
|
||||
def is_float(s):
|
||||
"""Check if a string can be converted to float."""
|
||||
try:
|
||||
float(s)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def is_int(s):
|
||||
"""Check if a string can be converted to int."""
|
||||
try:
|
||||
int(s)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
|
||||
|
||||
# If MAX_WORKERS are specified in the environment use it, otherwise default to 1
|
||||
MAX_WORKERS = int(os.environ.get('PYTHON_GRPC_MAX_WORKERS', '1'))
|
||||
|
||||
# Implement the BackendServicer class with the service methods
|
||||
class BackendServicer(backend_pb2_grpc.BackendServicer):
|
||||
"""
|
||||
BackendServicer is the class that implements the gRPC service
|
||||
"""
|
||||
def Health(self, request, context):
|
||||
return backend_pb2.Reply(message=bytes("OK", 'utf-8'))
|
||||
|
||||
def LoadModel(self, request, context):
|
||||
# Get device
|
||||
if torch.cuda.is_available():
|
||||
print("CUDA is available", file=sys.stderr)
|
||||
device = "cuda"
|
||||
else:
|
||||
print("CUDA is not available", file=sys.stderr)
|
||||
device = "cpu"
|
||||
mps_available = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
|
||||
if mps_available:
|
||||
device = "mps"
|
||||
if not torch.cuda.is_available() and request.CUDA:
|
||||
return backend_pb2.Result(success=False, message="CUDA is not available")
|
||||
|
||||
# Normalize potential 'mpx' typo to 'mps'
|
||||
if device == "mpx":
|
||||
print("Note: device 'mpx' detected, treating it as 'mps'.", file=sys.stderr)
|
||||
device = "mps"
|
||||
|
||||
# Validate mps availability if requested
|
||||
if device == "mps" and not torch.backends.mps.is_available():
|
||||
print("Warning: MPS not available. Falling back to CPU.", file=sys.stderr)
|
||||
device = "cpu"
|
||||
|
||||
self.device = device
|
||||
|
||||
options = request.Options
|
||||
|
||||
# empty dict
|
||||
self.options = {}
|
||||
|
||||
# The options are a list of strings in this form optname:optvalue
|
||||
# We are storing all the options in a dict so we can use it later when
|
||||
# generating the audio
|
||||
for opt in options:
|
||||
if ":" not in opt:
|
||||
continue
|
||||
key, value = opt.split(":", 1) # Split only on first colon
|
||||
# if value is a number, convert it to the appropriate type
|
||||
if is_float(value):
|
||||
value = float(value)
|
||||
elif is_int(value):
|
||||
value = int(value)
|
||||
elif value.lower() in ["true", "false"]:
|
||||
value = value.lower() == "true"
|
||||
self.options[key] = value
|
||||
|
||||
# Default voice for caching
|
||||
self.default_voice_url = self.options.get("default_voice", None)
|
||||
self._voice_cache = {}
|
||||
|
||||
try:
|
||||
print("Loading Pocket TTS model", file=sys.stderr)
|
||||
self.tts_model = TTSModel.load_model()
|
||||
print(f"Model loaded successfully. Sample rate: {self.tts_model.sample_rate}", file=sys.stderr)
|
||||
|
||||
# Pre-load default voice if specified
|
||||
if self.default_voice_url:
|
||||
try:
|
||||
print(f"Pre-loading default voice: {self.default_voice_url}", file=sys.stderr)
|
||||
voice_state = self.tts_model.get_state_for_audio_prompt(self.default_voice_url)
|
||||
self._voice_cache[self.default_voice_url] = voice_state
|
||||
print("Default voice loaded successfully", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to pre-load default voice: {e}", file=sys.stderr)
|
||||
|
||||
except Exception as err:
|
||||
return backend_pb2.Result(success=False, message=f"Unexpected {err=}, {type(err)=}")
|
||||
|
||||
return backend_pb2.Result(message="Model loaded successfully", success=True)
|
||||
|
||||
def _get_voice_state(self, voice_input):
|
||||
"""
|
||||
Get voice state from cache or load it.
|
||||
voice_input can be:
|
||||
- HuggingFace URL (e.g., hf://kyutai/tts-voices/alba-mackenna/casual.wav)
|
||||
- Local file path
|
||||
- None (use default)
|
||||
"""
|
||||
# Use default if no voice specified
|
||||
if not voice_input:
|
||||
voice_input = self.default_voice_url
|
||||
|
||||
if not voice_input:
|
||||
return None
|
||||
|
||||
# Check cache first
|
||||
if voice_input in self._voice_cache:
|
||||
return self._voice_cache[voice_input]
|
||||
|
||||
# Load voice state
|
||||
try:
|
||||
print(f"Loading voice from: {voice_input}", file=sys.stderr)
|
||||
voice_state = self.tts_model.get_state_for_audio_prompt(voice_input)
|
||||
self._voice_cache[voice_input] = voice_state
|
||||
return voice_state
|
||||
except Exception as e:
|
||||
print(f"Error loading voice from {voice_input}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def TTS(self, request, context):
|
||||
try:
|
||||
# Determine voice input
|
||||
# Priority: request.voice > AudioPath (from ModelOptions) > default
|
||||
voice_input = None
|
||||
|
||||
if request.voice:
|
||||
voice_input = request.voice
|
||||
elif hasattr(request, 'AudioPath') and request.AudioPath:
|
||||
# Use AudioPath as voice file
|
||||
if os.path.isabs(request.AudioPath):
|
||||
voice_input = request.AudioPath
|
||||
elif hasattr(request, 'ModelFile') and request.ModelFile:
|
||||
model_file_base = os.path.dirname(request.ModelFile)
|
||||
voice_input = os.path.join(model_file_base, request.AudioPath)
|
||||
elif hasattr(request, 'ModelPath') and request.ModelPath:
|
||||
voice_input = os.path.join(request.ModelPath, request.AudioPath)
|
||||
else:
|
||||
voice_input = request.AudioPath
|
||||
|
||||
# Get voice state
|
||||
voice_state = self._get_voice_state(voice_input)
|
||||
if voice_state is None:
|
||||
return backend_pb2.Result(
|
||||
success=False,
|
||||
message=f"Voice not found or failed to load: {voice_input}. Please provide a valid voice URL or file path."
|
||||
)
|
||||
|
||||
# Prepare text
|
||||
text = request.text.strip()
|
||||
|
||||
if not text:
|
||||
return backend_pb2.Result(
|
||||
success=False,
|
||||
message="Text is empty"
|
||||
)
|
||||
|
||||
print(f"Generating audio for text: {text[:50]}...", file=sys.stderr)
|
||||
|
||||
# Generate audio
|
||||
audio = self.tts_model.generate_audio(voice_state, text)
|
||||
|
||||
# Audio is a 1D torch tensor containing PCM data
|
||||
if audio is None or audio.numel() == 0:
|
||||
return backend_pb2.Result(
|
||||
success=False,
|
||||
message="No audio generated"
|
||||
)
|
||||
|
||||
# Save audio to file
|
||||
output_path = request.dst
|
||||
if not output_path:
|
||||
output_path = "/tmp/pocket-tts-output.wav"
|
||||
|
||||
# Ensure output directory exists
|
||||
output_dir = os.path.dirname(output_path)
|
||||
if output_dir and not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Convert torch tensor to numpy and save
|
||||
audio_numpy = audio.numpy()
|
||||
scipy.io.wavfile.write(output_path, self.tts_model.sample_rate, audio_numpy)
|
||||
print(f"Saved audio to {output_path}", file=sys.stderr)
|
||||
|
||||
except Exception as err:
|
||||
print(f"Error in TTS: {err}", file=sys.stderr)
|
||||
print(traceback.format_exc(), file=sys.stderr)
|
||||
return backend_pb2.Result(success=False, message=f"Unexpected {err=}, {type(err)=}")
|
||||
|
||||
return backend_pb2.Result(success=True)
|
||||
|
||||
def serve(address):
|
||||
server = grpc.server(futures.ThreadPoolExecutor(max_workers=MAX_WORKERS),
|
||||
options=[
|
||||
('grpc.max_message_length', 50 * 1024 * 1024), # 50MB
|
||||
('grpc.max_send_message_length', 50 * 1024 * 1024), # 50MB
|
||||
('grpc.max_receive_message_length', 50 * 1024 * 1024), # 50MB
|
||||
])
|
||||
backend_pb2_grpc.add_BackendServicer_to_server(BackendServicer(), server)
|
||||
server.add_insecure_port(address)
|
||||
server.start()
|
||||
print("Server started. Listening on: " + address, file=sys.stderr)
|
||||
|
||||
# Define the signal handler function
|
||||
def signal_handler(sig, frame):
|
||||
print("Received termination signal. Shutting down...")
|
||||
server.stop(0)
|
||||
sys.exit(0)
|
||||
|
||||
# Set the signal handlers for SIGINT and SIGTERM
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(_ONE_DAY_IN_SECONDS)
|
||||
except KeyboardInterrupt:
|
||||
server.stop(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run the gRPC server.")
|
||||
parser.add_argument(
|
||||
"--addr", default="localhost:50051", help="The address to bind the server to."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
serve(args.addr)
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
backend_dir=$(dirname $0)
|
||||
if [ -d $backend_dir/common ]; then
|
||||
source $backend_dir/common/libbackend.sh
|
||||
else
|
||||
source $backend_dir/../common/libbackend.sh
|
||||
fi
|
||||
|
||||
# This is here because the Intel pip index is broken and returns 200 status codes for every package name, it just doesn't return any package links.
|
||||
# This makes uv think that the package exists in the Intel pip index, and by default it stops looking at other pip indexes once it finds a match.
|
||||
# We need uv to continue falling through to the pypi default index to find optimum[openvino] in the pypi index
|
||||
# the --upgrade actually allows us to *downgrade* torch to the version provided in the Intel pip index
|
||||
if [ "x${BUILD_PROFILE}" == "xintel" ]; then
|
||||
EXTRA_PIP_INSTALL_FLAGS+=" --upgrade --index-strategy=unsafe-first-match"
|
||||
fi
|
||||
|
||||
# Use python 3.12 for l4t
|
||||
if [ "x${BUILD_PROFILE}" == "xl4t13" ]; then
|
||||
PYTHON_VERSION="3.12"
|
||||
PYTHON_PATCH="12"
|
||||
PY_STANDALONE_TAG="20251120"
|
||||
fi
|
||||
|
||||
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
|
||||
USE_PIP=true
|
||||
fi
|
||||
|
||||
installRequirements
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
backend_dir=$(dirname $0)
|
||||
if [ -d $backend_dir/common ]; then
|
||||
source $backend_dir/common/libbackend.sh
|
||||
else
|
||||
source $backend_dir/../common/libbackend.sh
|
||||
fi
|
||||
|
||||
python3 -m grpc_tools.protoc -I../.. -I./ --python_out=. --grpc_python_out=. backend.proto
|
||||
@@ -1,4 +0,0 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||
pocket-tts
|
||||
scipy
|
||||
torch
|
||||
@@ -1,4 +0,0 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cu121
|
||||
pocket-tts
|
||||
scipy
|
||||
torch
|
||||
@@ -1,4 +0,0 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cu130
|
||||
pocket-tts
|
||||
scipy
|
||||
torch
|
||||
@@ -1,4 +0,0 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/rocm6.3
|
||||
pocket-tts
|
||||
scipy
|
||||
torch==2.7.1+rocm6.3
|
||||
@@ -1,4 +0,0 @@
|
||||
--extra-index-url https://pytorch-extension.intel.com/release-whl/stable/xpu/us/
|
||||
pocket-tts
|
||||
scipy
|
||||
torch==2.5.1+cxx11.abi
|
||||
@@ -1,4 +0,0 @@
|
||||
--extra-index-url https://pypi.jetson-ai-lab.io/jp6/cu129/
|
||||
pocket-tts
|
||||
scipy
|
||||
torch
|
||||
@@ -1,4 +0,0 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cu130
|
||||
pocket-tts
|
||||
scipy
|
||||
torch
|
||||
@@ -1,4 +0,0 @@
|
||||
pocket-tts
|
||||
scipy
|
||||
torch==2.7.1
|
||||
torchvision==0.22.1
|
||||
@@ -1,4 +0,0 @@
|
||||
grpcio==1.71.0
|
||||
protobuf
|
||||
certifi
|
||||
packaging==24.1
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
backend_dir=$(dirname $0)
|
||||
if [ -d $backend_dir/common ]; then
|
||||
source $backend_dir/common/libbackend.sh
|
||||
else
|
||||
source $backend_dir/../common/libbackend.sh
|
||||
fi
|
||||
|
||||
startBackend $@
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
A test script to test the gRPC service
|
||||
"""
|
||||
import unittest
|
||||
import subprocess
|
||||
import time
|
||||
import os
|
||||
import tempfile
|
||||
import backend_pb2
|
||||
import backend_pb2_grpc
|
||||
|
||||
import grpc
|
||||
|
||||
|
||||
class TestBackendServicer(unittest.TestCase):
|
||||
"""
|
||||
TestBackendServicer is the class that tests the gRPC service
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
This method sets up the gRPC service by starting the server
|
||||
"""
|
||||
self.service = subprocess.Popen(["python3", "backend.py", "--addr", "localhost:50051"])
|
||||
time.sleep(30)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""
|
||||
This method tears down the gRPC service by terminating the server
|
||||
"""
|
||||
self.service.terminate()
|
||||
self.service.wait()
|
||||
|
||||
def test_server_startup(self):
|
||||
"""
|
||||
This method tests if the server starts up successfully
|
||||
"""
|
||||
try:
|
||||
self.setUp()
|
||||
with grpc.insecure_channel("localhost:50051") as channel:
|
||||
stub = backend_pb2_grpc.BackendStub(channel)
|
||||
response = stub.Health(backend_pb2.HealthMessage())
|
||||
self.assertEqual(response.message, b'OK')
|
||||
except Exception as err:
|
||||
print(err)
|
||||
self.fail("Server failed to start")
|
||||
finally:
|
||||
self.tearDown()
|
||||
|
||||
def test_load_model(self):
|
||||
"""
|
||||
This method tests if the model is loaded successfully
|
||||
"""
|
||||
try:
|
||||
self.setUp()
|
||||
with grpc.insecure_channel("localhost:50051") as channel:
|
||||
stub = backend_pb2_grpc.BackendStub(channel)
|
||||
response = stub.LoadModel(backend_pb2.ModelOptions())
|
||||
print(response)
|
||||
self.assertTrue(response.success)
|
||||
self.assertEqual(response.message, "Model loaded successfully")
|
||||
except Exception as err:
|
||||
print(err)
|
||||
self.fail("LoadModel service failed")
|
||||
finally:
|
||||
self.tearDown()
|
||||
|
||||
def test_tts_with_hf_voice(self):
|
||||
"""
|
||||
This method tests TTS generation with HuggingFace voice URL
|
||||
"""
|
||||
try:
|
||||
self.setUp()
|
||||
with grpc.insecure_channel("localhost:50051") as channel:
|
||||
stub = backend_pb2_grpc.BackendStub(channel)
|
||||
# Load model
|
||||
response = stub.LoadModel(backend_pb2.ModelOptions())
|
||||
self.assertTrue(response.success)
|
||||
|
||||
# Create temporary output file
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp_file:
|
||||
output_path = tmp_file.name
|
||||
|
||||
# Test TTS with HuggingFace voice URL
|
||||
tts_request = backend_pb2.TTSRequest(
|
||||
text="Hello world, this is a test.",
|
||||
dst=output_path,
|
||||
voice="azelma"
|
||||
)
|
||||
tts_response = stub.TTS(tts_request)
|
||||
self.assertTrue(tts_response.success)
|
||||
|
||||
# Verify output file exists and is not empty
|
||||
self.assertTrue(os.path.exists(output_path))
|
||||
self.assertGreater(os.path.getsize(output_path), 0)
|
||||
|
||||
# Cleanup
|
||||
os.unlink(output_path)
|
||||
except Exception as err:
|
||||
print(err)
|
||||
self.fail("TTS service failed")
|
||||
finally:
|
||||
self.tearDown()
|
||||
|
||||
def test_tts_with_default_voice(self):
|
||||
"""
|
||||
This method tests TTS generation with default voice (via AudioPath in LoadModel)
|
||||
"""
|
||||
try:
|
||||
self.setUp()
|
||||
with grpc.insecure_channel("localhost:50051") as channel:
|
||||
stub = backend_pb2_grpc.BackendStub(channel)
|
||||
# Load model with default voice
|
||||
load_request = backend_pb2.ModelOptions(
|
||||
Options=["default_voice:azelma"]
|
||||
)
|
||||
response = stub.LoadModel(load_request)
|
||||
self.assertTrue(response.success)
|
||||
|
||||
# Create temporary output file
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp_file:
|
||||
output_path = tmp_file.name
|
||||
|
||||
# Test TTS without specifying voice (should use default)
|
||||
tts_request = backend_pb2.TTSRequest(
|
||||
text="Hello world, this is a test.",
|
||||
dst=output_path
|
||||
)
|
||||
tts_response = stub.TTS(tts_request)
|
||||
self.assertTrue(tts_response.success)
|
||||
|
||||
# Verify output file exists and is not empty
|
||||
self.assertTrue(os.path.exists(output_path))
|
||||
self.assertGreater(os.path.getsize(output_path), 0)
|
||||
|
||||
# Cleanup
|
||||
os.unlink(output_path)
|
||||
except Exception as err:
|
||||
print(err)
|
||||
self.fail("TTS service with default voice failed")
|
||||
finally:
|
||||
self.tearDown()
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
backend_dir=$(dirname $0)
|
||||
if [ -d $backend_dir/common ]; then
|
||||
source $backend_dir/common/libbackend.sh
|
||||
else
|
||||
source $backend_dir/../common/libbackend.sh
|
||||
fi
|
||||
|
||||
runUnittests
|
||||
@@ -6,4 +6,4 @@ transformers
|
||||
bitsandbytes
|
||||
outetts
|
||||
sentence-transformers==5.2.0
|
||||
protobuf==6.33.4
|
||||
protobuf==6.33.2
|
||||
@@ -6,4 +6,4 @@ transformers
|
||||
bitsandbytes
|
||||
outetts
|
||||
sentence-transformers==5.2.0
|
||||
protobuf==6.33.4
|
||||
protobuf==6.33.2
|
||||
@@ -6,4 +6,4 @@ transformers
|
||||
bitsandbytes
|
||||
outetts
|
||||
sentence-transformers==5.2.0
|
||||
protobuf==6.33.4
|
||||
protobuf==6.33.2
|
||||
@@ -8,4 +8,4 @@ bitsandbytes
|
||||
outetts
|
||||
bitsandbytes
|
||||
sentence-transformers==5.2.0
|
||||
protobuf==6.33.4
|
||||
protobuf==6.33.2
|
||||
@@ -10,4 +10,4 @@ intel-extension-for-transformers
|
||||
bitsandbytes
|
||||
outetts
|
||||
sentence-transformers==5.2.0
|
||||
protobuf==6.33.4
|
||||
protobuf==6.33.2
|
||||
@@ -1,5 +1,5 @@
|
||||
grpcio==1.76.0
|
||||
protobuf==6.33.4
|
||||
protobuf==6.33.2
|
||||
certifi
|
||||
setuptools
|
||||
scipy==1.15.1
|
||||
|
||||
@@ -56,7 +56,7 @@ type RunCMD struct {
|
||||
UseSubtleKeyComparison bool `env:"LOCALAI_SUBTLE_KEY_COMPARISON" default:"false" help:"If true, API Key validation comparisons will be performed using constant-time comparisons rather than simple equality. This trades off performance on each request for resiliancy against timing attacks." group:"hardening"`
|
||||
DisableApiKeyRequirementForHttpGet bool `env:"LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET" default:"false" help:"If true, a valid API key is not required to issue GET requests to portions of the web ui. This should only be enabled in secure testing environments" group:"hardening"`
|
||||
DisableMetricsEndpoint bool `env:"LOCALAI_DISABLE_METRICS_ENDPOINT,DISABLE_METRICS_ENDPOINT" default:"false" help:"Disable the /metrics endpoint" group:"api"`
|
||||
HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/image/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"`
|
||||
HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"`
|
||||
Peer2Peer bool `env:"LOCALAI_P2P,P2P" name:"p2p" default:"false" help:"Enable P2P mode" group:"p2p"`
|
||||
Peer2PeerDHTInterval int `env:"LOCALAI_P2P_DHT_INTERVAL,P2P_DHT_INTERVAL" default:"360" name:"p2p-dht-interval" help:"Interval for DHT refresh (used during token generation)" group:"p2p"`
|
||||
Peer2PeerOTPInterval int `env:"LOCALAI_P2P_OTP_INTERVAL,P2P_OTP_INTERVAL" default:"9000" name:"p2p-otp-interval" help:"Interval for OTP refresh (used during token generation)" group:"p2p"`
|
||||
@@ -83,7 +83,6 @@ type RunCMD struct {
|
||||
EnableTracing bool `env:"LOCALAI_ENABLE_TRACING,ENABLE_TRACING" help:"Enable API tracing" group:"api"`
|
||||
TracingMaxItems int `env:"LOCALAI_TRACING_MAX_ITEMS" default:"1024" help:"Maximum number of traces to keep" group:"api"`
|
||||
AgentJobRetentionDays int `env:"LOCALAI_AGENT_JOB_RETENTION_DAYS,AGENT_JOB_RETENTION_DAYS" default:"30" help:"Number of days to keep agent job history (default: 30)" group:"api"`
|
||||
OpenResponsesStoreTTL string `env:"LOCALAI_OPEN_RESPONSES_STORE_TTL,OPEN_RESPONSES_STORE_TTL" default:"0" help:"TTL for Open Responses store (e.g., 1h, 30m, 0 = no expiration)" group:"api"`
|
||||
|
||||
Version bool
|
||||
}
|
||||
@@ -250,15 +249,6 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
|
||||
opts = append(opts, config.WithLRUEvictionRetryInterval(dur))
|
||||
}
|
||||
|
||||
// Handle Open Responses store TTL
|
||||
if r.OpenResponsesStoreTTL != "" && r.OpenResponsesStoreTTL != "0" {
|
||||
dur, err := time.ParseDuration(r.OpenResponsesStoreTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Open Responses store TTL: %w", err)
|
||||
}
|
||||
opts = append(opts, config.WithOpenResponsesStoreTTL(dur))
|
||||
}
|
||||
|
||||
// split ":" to get backend name and the uri
|
||||
for _, v := range r.ExternalGRPCBackends {
|
||||
backend := v[:strings.IndexByte(v, ':')]
|
||||
|
||||
@@ -86,8 +86,6 @@ type ApplicationConfig struct {
|
||||
|
||||
AgentJobRetentionDays int // Default: 30 days
|
||||
|
||||
OpenResponsesStoreTTL time.Duration // TTL for Open Responses store (0 = no expiration)
|
||||
|
||||
PathWithoutAuth []string
|
||||
}
|
||||
|
||||
@@ -469,12 +467,6 @@ func WithAgentJobRetentionDays(days int) AppOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithOpenResponsesStoreTTL(ttl time.Duration) AppOption {
|
||||
return func(o *ApplicationConfig) {
|
||||
o.OpenResponsesStoreTTL = ttl
|
||||
}
|
||||
}
|
||||
|
||||
func WithEnforcedPredownloadScans(enforced bool) AppOption {
|
||||
return func(o *ApplicationConfig) {
|
||||
o.EnforcePredownloadScans = enforced
|
||||
@@ -602,12 +594,6 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||
} else {
|
||||
lruEvictionRetryInterval = "1s" // default
|
||||
}
|
||||
var openResponsesStoreTTL string
|
||||
if o.OpenResponsesStoreTTL > 0 {
|
||||
openResponsesStoreTTL = o.OpenResponsesStoreTTL.String()
|
||||
} else {
|
||||
openResponsesStoreTTL = "0" // default: no expiration
|
||||
}
|
||||
|
||||
return RuntimeSettings{
|
||||
WatchdogEnabled: &watchdogEnabled,
|
||||
@@ -642,7 +628,6 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||
AutoloadBackendGalleries: &autoloadBackendGalleries,
|
||||
ApiKeys: &apiKeys,
|
||||
AgentJobRetentionDays: &agentJobRetentionDays,
|
||||
OpenResponsesStoreTTL: &openResponsesStoreTTL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,14 +769,6 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
|
||||
if settings.AgentJobRetentionDays != nil {
|
||||
o.AgentJobRetentionDays = *settings.AgentJobRetentionDays
|
||||
}
|
||||
if settings.OpenResponsesStoreTTL != nil {
|
||||
if *settings.OpenResponsesStoreTTL == "0" || *settings.OpenResponsesStoreTTL == "" {
|
||||
o.OpenResponsesStoreTTL = 0 // No expiration
|
||||
} else if dur, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err == nil {
|
||||
o.OpenResponsesStoreTTL = dur
|
||||
}
|
||||
// This setting doesn't require restart, can be updated dynamically
|
||||
}
|
||||
// Note: ApiKeys requires special handling (merging with startup keys) - handled in caller
|
||||
|
||||
return requireRestart
|
||||
|
||||
@@ -60,7 +60,4 @@ type RuntimeSettings struct {
|
||||
|
||||
// Agent settings
|
||||
AgentJobRetentionDays *int `json:"agent_job_retention_days,omitempty"`
|
||||
|
||||
// Open Responses settings
|
||||
OpenResponsesStoreTTL *string `json:"open_responses_store_ttl,omitempty"` // TTL for stored responses (e.g., "1h", "30m", "0" = no expiration)
|
||||
}
|
||||
|
||||
@@ -108,15 +108,7 @@ func API(application *application.Application) (*echo.Echo, error) {
|
||||
req := c.Request()
|
||||
res := c.Response()
|
||||
err := next(c)
|
||||
|
||||
// Fix for #7989: Reduce log verbosity of Web UI polling
|
||||
// If the path is /api/operations and the request was successful (200),
|
||||
// we log it at DEBUG level (hidden by default) instead of INFO.
|
||||
if req.URL.Path == "/api/operations" && res.Status == 200 {
|
||||
xlog.Debug("HTTP request", "method", req.Method, "path", req.URL.Path, "status", res.Status)
|
||||
} else {
|
||||
xlog.Info("HTTP request", "method", req.Method, "path", req.URL.Path, "status", res.Status)
|
||||
}
|
||||
xlog.Info("HTTP request", "method", req.Method, "path", req.URL.Path, "status", res.Status)
|
||||
return err
|
||||
}
|
||||
})
|
||||
@@ -193,8 +185,6 @@ func API(application *application.Application) (*echo.Echo, error) {
|
||||
corsConfig.AllowOrigins = strings.Split(application.ApplicationConfig().CORSAllowOrigins, ",")
|
||||
}
|
||||
e.Use(middleware.CORSWithConfig(corsConfig))
|
||||
} else {
|
||||
e.Use(middleware.CORS())
|
||||
}
|
||||
|
||||
// CSRF middleware
|
||||
@@ -216,7 +206,6 @@ func API(application *application.Application) (*echo.Echo, error) {
|
||||
routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application)
|
||||
routes.RegisterOpenAIRoutes(e, requestExtractor, application)
|
||||
routes.RegisterAnthropicRoutes(e, requestExtractor, application)
|
||||
routes.RegisterOpenResponsesRoutes(e, requestExtractor, application)
|
||||
if !application.ApplicationConfig().DisableWebUI {
|
||||
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application)
|
||||
routes.RegisterUIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/application"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/endpoints/openresponses"
|
||||
"github.com/mudler/LocalAI/core/p2p"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
"github.com/mudler/xlog"
|
||||
@@ -85,16 +84,6 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||
})
|
||||
}
|
||||
}
|
||||
if settings.OpenResponsesStoreTTL != nil {
|
||||
if *settings.OpenResponsesStoreTTL != "0" && *settings.OpenResponsesStoreTTL != "" {
|
||||
if _, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
||||
Success: false,
|
||||
Error: "Invalid open_responses_store_ttl format: " + err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save to file
|
||||
if appConfig.DynamicConfigsDir == "" {
|
||||
@@ -155,22 +144,6 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||
xlog.Info("Updated LRU eviction retry settings", "maxRetries", maxRetries, "retryInterval", retryInterval)
|
||||
}
|
||||
|
||||
// Update Open Responses store TTL dynamically
|
||||
if settings.OpenResponsesStoreTTL != nil {
|
||||
ttl := time.Duration(0)
|
||||
if *settings.OpenResponsesStoreTTL != "0" && *settings.OpenResponsesStoreTTL != "" {
|
||||
if dur, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err == nil {
|
||||
ttl = dur
|
||||
} else {
|
||||
xlog.Warn("Invalid Open Responses store TTL format", "ttl", *settings.OpenResponsesStoreTTL, "error", err)
|
||||
}
|
||||
}
|
||||
// Import the store package
|
||||
store := openresponses.GetGlobalStore()
|
||||
store.SetTTL(ttl)
|
||||
xlog.Info("Updated Open Responses store TTL", "ttl", ttl)
|
||||
}
|
||||
|
||||
// Check if agent job retention changed
|
||||
agentJobChanged := settings.AgentJobRetentionDays != nil
|
||||
|
||||
|
||||
@@ -65,13 +65,9 @@ func WelcomeEndpoint(appConfig *config.ApplicationConfig,
|
||||
// The client expects a JSON response
|
||||
return c.JSON(200, summary)
|
||||
} else {
|
||||
// Check if this is the manage route
|
||||
templateName := "views/index"
|
||||
if strings.HasSuffix(c.Request().URL.Path, "/manage") || c.Request().URL.Path == "/manage" {
|
||||
templateName = "views/manage"
|
||||
}
|
||||
// Render appropriate template
|
||||
return c.Render(200, templateName, summary)
|
||||
// Serve the SPA for both index and manage routes
|
||||
// The SPA handles routing client-side via Alpine.js
|
||||
return c.Render(200, "views/spa", summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,453 +0,0 @@
|
||||
package openresponses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// ResponseStore provides thread-safe storage for Open Responses API responses
|
||||
type ResponseStore struct {
|
||||
mu sync.RWMutex
|
||||
responses map[string]*StoredResponse
|
||||
ttl time.Duration // Time-to-live for stored responses (0 = no expiration)
|
||||
cleanupCtx context.Context
|
||||
cleanupCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// StreamedEvent represents a buffered SSE event for streaming resume
|
||||
type StreamedEvent struct {
|
||||
SequenceNumber int `json:"sequence_number"`
|
||||
EventType string `json:"event_type"`
|
||||
Data []byte `json:"data"` // JSON-serialized event
|
||||
}
|
||||
|
||||
// StoredResponse contains a complete response with its input request and output items
|
||||
type StoredResponse struct {
|
||||
Request *schema.OpenResponsesRequest
|
||||
Response *schema.ORResponseResource
|
||||
Items map[string]*schema.ORItemField // item_id -> item mapping for quick lookup
|
||||
StoredAt time.Time
|
||||
ExpiresAt *time.Time // nil if no expiration
|
||||
|
||||
// Background execution support
|
||||
CancelFunc context.CancelFunc // For cancellation of background tasks
|
||||
StreamEvents []StreamedEvent // Buffered events for streaming resume
|
||||
StreamEnabled bool // Was created with stream=true
|
||||
IsBackground bool // Was created with background=true
|
||||
EventsChan chan struct{} // Signals new events for live subscribers
|
||||
mu sync.RWMutex // Protect concurrent access to this response
|
||||
}
|
||||
|
||||
var (
|
||||
globalStore *ResponseStore
|
||||
storeOnce sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalStore returns the singleton response store instance
|
||||
func GetGlobalStore() *ResponseStore {
|
||||
storeOnce.Do(func() {
|
||||
globalStore = NewResponseStore(0) // Default: no TTL, will be updated from appConfig
|
||||
})
|
||||
return globalStore
|
||||
}
|
||||
|
||||
// SetTTL updates the TTL for the store
|
||||
// This will affect all new responses stored after this call
|
||||
func (s *ResponseStore) SetTTL(ttl time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Stop existing cleanup loop if running
|
||||
if s.cleanupCancel != nil {
|
||||
s.cleanupCancel()
|
||||
s.cleanupCancel = nil
|
||||
s.cleanupCtx = nil
|
||||
}
|
||||
|
||||
s.ttl = ttl
|
||||
|
||||
// If TTL > 0, start cleanup loop
|
||||
if ttl > 0 {
|
||||
s.cleanupCtx, s.cleanupCancel = context.WithCancel(context.Background())
|
||||
go s.cleanupLoop(s.cleanupCtx)
|
||||
}
|
||||
|
||||
xlog.Debug("Updated Open Responses store TTL", "ttl", ttl, "cleanup_running", ttl > 0)
|
||||
}
|
||||
|
||||
// NewResponseStore creates a new response store with optional TTL
|
||||
// If ttl is 0, responses are stored indefinitely
|
||||
func NewResponseStore(ttl time.Duration) *ResponseStore {
|
||||
store := &ResponseStore{
|
||||
responses: make(map[string]*StoredResponse),
|
||||
ttl: ttl,
|
||||
}
|
||||
|
||||
// Start cleanup goroutine if TTL is set
|
||||
if ttl > 0 {
|
||||
store.cleanupCtx, store.cleanupCancel = context.WithCancel(context.Background())
|
||||
go store.cleanupLoop(store.cleanupCtx)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// Store stores a response with its request and items
|
||||
func (s *ResponseStore) Store(responseID string, request *schema.OpenResponsesRequest, response *schema.ORResponseResource) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Build item index for quick lookup
|
||||
items := make(map[string]*schema.ORItemField)
|
||||
for i := range response.Output {
|
||||
item := &response.Output[i]
|
||||
if item.ID != "" {
|
||||
items[item.ID] = item
|
||||
}
|
||||
}
|
||||
|
||||
stored := &StoredResponse{
|
||||
Request: request,
|
||||
Response: response,
|
||||
Items: items,
|
||||
StoredAt: time.Now(),
|
||||
ExpiresAt: nil,
|
||||
}
|
||||
|
||||
// Set expiration if TTL is configured
|
||||
if s.ttl > 0 {
|
||||
expiresAt := time.Now().Add(s.ttl)
|
||||
stored.ExpiresAt = &expiresAt
|
||||
}
|
||||
|
||||
s.responses[responseID] = stored
|
||||
xlog.Debug("Stored Open Responses response", "response_id", responseID, "items_count", len(items))
|
||||
}
|
||||
|
||||
// Get retrieves a stored response by ID
|
||||
func (s *ResponseStore) Get(responseID string) (*StoredResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
stored, exists := s.responses[responseID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("response not found: %s", responseID)
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if stored.ExpiresAt != nil && time.Now().After(*stored.ExpiresAt) {
|
||||
// Expired, but we'll return it anyway and let caller handle cleanup
|
||||
return nil, fmt.Errorf("response expired: %s", responseID)
|
||||
}
|
||||
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
// GetItem retrieves a specific item from a stored response
|
||||
func (s *ResponseStore) GetItem(responseID, itemID string) (*schema.ORItemField, error) {
|
||||
stored, err := s.Get(responseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
item, exists := stored.Items[itemID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("item not found: %s in response %s", itemID, responseID)
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// FindItem searches for an item across all stored responses
|
||||
// Returns the item and the response ID it was found in
|
||||
func (s *ResponseStore) FindItem(itemID string) (*schema.ORItemField, string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
for responseID, stored := range s.responses {
|
||||
// Skip expired responses
|
||||
if stored.ExpiresAt != nil && now.After(*stored.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
|
||||
if item, exists := stored.Items[itemID]; exists {
|
||||
return item, responseID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("item not found in any stored response: %s", itemID)
|
||||
}
|
||||
|
||||
// Delete removes a response from storage
|
||||
func (s *ResponseStore) Delete(responseID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.responses, responseID)
|
||||
xlog.Debug("Deleted Open Responses response", "response_id", responseID)
|
||||
}
|
||||
|
||||
// Cleanup removes expired responses
|
||||
func (s *ResponseStore) Cleanup() int {
|
||||
if s.ttl == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
count := 0
|
||||
for id, stored := range s.responses {
|
||||
if stored.ExpiresAt != nil && now.After(*stored.ExpiresAt) {
|
||||
delete(s.responses, id)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
xlog.Debug("Cleaned up expired Open Responses", "count", count)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// cleanupLoop runs periodic cleanup of expired responses
|
||||
func (s *ResponseStore) cleanupLoop(ctx context.Context) {
|
||||
if s.ttl == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(s.ttl / 2) // Cleanup at half TTL interval
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
xlog.Debug("Stopped Open Responses store cleanup loop")
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.Cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count returns the number of stored responses
|
||||
func (s *ResponseStore) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.responses)
|
||||
}
|
||||
|
||||
// StoreBackground stores a background response with cancel function and optional streaming support
|
||||
func (s *ResponseStore) StoreBackground(responseID string, request *schema.OpenResponsesRequest, response *schema.ORResponseResource, cancelFunc context.CancelFunc, streamEnabled bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Build item index for quick lookup
|
||||
items := make(map[string]*schema.ORItemField)
|
||||
for i := range response.Output {
|
||||
item := &response.Output[i]
|
||||
if item.ID != "" {
|
||||
items[item.ID] = item
|
||||
}
|
||||
}
|
||||
|
||||
stored := &StoredResponse{
|
||||
Request: request,
|
||||
Response: response,
|
||||
Items: items,
|
||||
StoredAt: time.Now(),
|
||||
ExpiresAt: nil,
|
||||
CancelFunc: cancelFunc,
|
||||
StreamEvents: []StreamedEvent{},
|
||||
StreamEnabled: streamEnabled,
|
||||
IsBackground: true,
|
||||
EventsChan: make(chan struct{}, 100), // Buffered channel for event notifications
|
||||
}
|
||||
|
||||
// Set expiration if TTL is configured
|
||||
if s.ttl > 0 {
|
||||
expiresAt := time.Now().Add(s.ttl)
|
||||
stored.ExpiresAt = &expiresAt
|
||||
}
|
||||
|
||||
s.responses[responseID] = stored
|
||||
xlog.Debug("Stored background Open Responses response", "response_id", responseID, "stream_enabled", streamEnabled)
|
||||
}
|
||||
|
||||
// UpdateStatus updates the status of a stored response
|
||||
func (s *ResponseStore) UpdateStatus(responseID string, status string, completedAt *int64) error {
|
||||
s.mu.RLock()
|
||||
stored, exists := s.responses[responseID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("response not found: %s", responseID)
|
||||
}
|
||||
|
||||
stored.mu.Lock()
|
||||
defer stored.mu.Unlock()
|
||||
|
||||
stored.Response.Status = status
|
||||
stored.Response.CompletedAt = completedAt
|
||||
|
||||
xlog.Debug("Updated response status", "response_id", responseID, "status", status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateResponse updates the entire response object for a stored response
|
||||
func (s *ResponseStore) UpdateResponse(responseID string, response *schema.ORResponseResource) error {
|
||||
s.mu.RLock()
|
||||
stored, exists := s.responses[responseID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("response not found: %s", responseID)
|
||||
}
|
||||
|
||||
stored.mu.Lock()
|
||||
defer stored.mu.Unlock()
|
||||
|
||||
// Rebuild item index
|
||||
items := make(map[string]*schema.ORItemField)
|
||||
for i := range response.Output {
|
||||
item := &response.Output[i]
|
||||
if item.ID != "" {
|
||||
items[item.ID] = item
|
||||
}
|
||||
}
|
||||
|
||||
stored.Response = response
|
||||
stored.Items = items
|
||||
|
||||
xlog.Debug("Updated response", "response_id", responseID, "status", response.Status, "items_count", len(items))
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppendEvent appends a streaming event to the buffer for resume support
|
||||
func (s *ResponseStore) AppendEvent(responseID string, event *schema.ORStreamEvent) error {
|
||||
s.mu.RLock()
|
||||
stored, exists := s.responses[responseID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("response not found: %s", responseID)
|
||||
}
|
||||
|
||||
// Serialize the event
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal event: %w", err)
|
||||
}
|
||||
|
||||
stored.mu.Lock()
|
||||
stored.StreamEvents = append(stored.StreamEvents, StreamedEvent{
|
||||
SequenceNumber: event.SequenceNumber,
|
||||
EventType: event.Type,
|
||||
Data: data,
|
||||
})
|
||||
stored.mu.Unlock()
|
||||
|
||||
// Notify any subscribers of new event
|
||||
select {
|
||||
case stored.EventsChan <- struct{}{}:
|
||||
default:
|
||||
// Channel full, subscribers will catch up
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEventsAfter returns all events with sequence number greater than startingAfter
|
||||
func (s *ResponseStore) GetEventsAfter(responseID string, startingAfter int) ([]StreamedEvent, error) {
|
||||
s.mu.RLock()
|
||||
stored, exists := s.responses[responseID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("response not found: %s", responseID)
|
||||
}
|
||||
|
||||
stored.mu.RLock()
|
||||
defer stored.mu.RUnlock()
|
||||
|
||||
var result []StreamedEvent
|
||||
for _, event := range stored.StreamEvents {
|
||||
if event.SequenceNumber > startingAfter {
|
||||
result = append(result, event)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Cancel cancels a background response if it's still in progress
|
||||
func (s *ResponseStore) Cancel(responseID string) (*schema.ORResponseResource, error) {
|
||||
s.mu.RLock()
|
||||
stored, exists := s.responses[responseID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("response not found: %s", responseID)
|
||||
}
|
||||
|
||||
stored.mu.Lock()
|
||||
defer stored.mu.Unlock()
|
||||
|
||||
// If already in a terminal state, just return the response (idempotent)
|
||||
status := stored.Response.Status
|
||||
if status == schema.ORStatusCompleted || status == schema.ORStatusFailed ||
|
||||
status == schema.ORStatusIncomplete || status == schema.ORStatusCancelled {
|
||||
xlog.Debug("Response already in terminal state", "response_id", responseID, "status", status)
|
||||
return stored.Response, nil
|
||||
}
|
||||
|
||||
// Cancel the context if available
|
||||
if stored.CancelFunc != nil {
|
||||
stored.CancelFunc()
|
||||
xlog.Debug("Cancelled background response", "response_id", responseID)
|
||||
}
|
||||
|
||||
// Update status to cancelled
|
||||
now := time.Now().Unix()
|
||||
stored.Response.Status = schema.ORStatusCancelled
|
||||
stored.Response.CompletedAt = &now
|
||||
|
||||
return stored.Response, nil
|
||||
}
|
||||
|
||||
// GetEventsChan returns the events notification channel for a response
|
||||
func (s *ResponseStore) GetEventsChan(responseID string) (chan struct{}, error) {
|
||||
s.mu.RLock()
|
||||
stored, exists := s.responses[responseID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("response not found: %s", responseID)
|
||||
}
|
||||
|
||||
return stored.EventsChan, nil
|
||||
}
|
||||
|
||||
// IsStreamEnabled checks if a response was created with streaming enabled
|
||||
func (s *ResponseStore) IsStreamEnabled(responseID string) (bool, error) {
|
||||
s.mu.RLock()
|
||||
stored, exists := s.responses[responseID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return false, fmt.Errorf("response not found: %s", responseID)
|
||||
}
|
||||
|
||||
stored.mu.RLock()
|
||||
defer stored.mu.RUnlock()
|
||||
|
||||
return stored.StreamEnabled, nil
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package openresponses
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestStore(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "ResponseStore Suite")
|
||||
}
|
||||
@@ -1,626 +0,0 @@
|
||||
package openresponses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ResponseStore", func() {
|
||||
var store *ResponseStore
|
||||
|
||||
BeforeEach(func() {
|
||||
store = NewResponseStore(0) // No TTL for most tests
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up
|
||||
})
|
||||
|
||||
Describe("Store and Get", func() {
|
||||
It("should store and retrieve a response", func() {
|
||||
responseID := "resp_test123"
|
||||
request := &schema.OpenResponsesRequest{
|
||||
Model: "test-model",
|
||||
Input: "Hello",
|
||||
}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Status: "completed",
|
||||
Model: "test-model",
|
||||
Output: []schema.ORItemField{
|
||||
{
|
||||
Type: "message",
|
||||
ID: "msg_123",
|
||||
Status: "completed",
|
||||
Role: "assistant",
|
||||
Content: []schema.ORContentPart{{
|
||||
Type: "output_text",
|
||||
Text: "Hello, world!",
|
||||
Annotations: []schema.ORAnnotation{},
|
||||
Logprobs: []schema.ORLogProb{},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
store.Store(responseID, request, response)
|
||||
|
||||
stored, err := store.Get(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored).ToNot(BeNil())
|
||||
Expect(stored.Response.ID).To(Equal(responseID))
|
||||
Expect(stored.Request.Model).To(Equal("test-model"))
|
||||
Expect(len(stored.Items)).To(Equal(1))
|
||||
Expect(stored.Items["msg_123"]).ToNot(BeNil())
|
||||
Expect(stored.Items["msg_123"].ID).To(Equal("msg_123"))
|
||||
})
|
||||
|
||||
It("should return error for non-existent response", func() {
|
||||
_, err := store.Get("nonexistent")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not found"))
|
||||
})
|
||||
|
||||
It("should index all items by ID", func() {
|
||||
responseID := "resp_test456"
|
||||
request := &schema.OpenResponsesRequest{
|
||||
Model: "test-model",
|
||||
Input: "Test",
|
||||
}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Output: []schema.ORItemField{
|
||||
{
|
||||
Type: "message",
|
||||
ID: "msg_1",
|
||||
Status: "completed",
|
||||
Role: "assistant",
|
||||
},
|
||||
{
|
||||
Type: "function_call",
|
||||
ID: "fc_1",
|
||||
Status: "completed",
|
||||
CallID: "fc_1",
|
||||
Name: "test_function",
|
||||
Arguments: `{"arg": "value"}`,
|
||||
},
|
||||
{
|
||||
Type: "message",
|
||||
ID: "msg_2",
|
||||
Status: "completed",
|
||||
Role: "assistant",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
store.Store(responseID, request, response)
|
||||
|
||||
stored, err := store.Get(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(stored.Items)).To(Equal(3))
|
||||
Expect(stored.Items["msg_1"]).ToNot(BeNil())
|
||||
Expect(stored.Items["fc_1"]).ToNot(BeNil())
|
||||
Expect(stored.Items["msg_2"]).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should handle items without IDs", func() {
|
||||
responseID := "resp_test789"
|
||||
request := &schema.OpenResponsesRequest{
|
||||
Model: "test-model",
|
||||
Input: "Test",
|
||||
}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Output: []schema.ORItemField{
|
||||
{
|
||||
Type: "message",
|
||||
ID: "", // No ID
|
||||
Status: "completed",
|
||||
Role: "assistant",
|
||||
},
|
||||
{
|
||||
Type: "message",
|
||||
ID: "msg_with_id",
|
||||
Status: "completed",
|
||||
Role: "assistant",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
store.Store(responseID, request, response)
|
||||
|
||||
stored, err := store.Get(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Only items with IDs are indexed
|
||||
Expect(len(stored.Items)).To(Equal(1))
|
||||
Expect(stored.Items["msg_with_id"]).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetItem", func() {
|
||||
It("should retrieve a specific item by ID", func() {
|
||||
responseID := "resp_item_test"
|
||||
itemID := "msg_specific"
|
||||
request := &schema.OpenResponsesRequest{
|
||||
Model: "test-model",
|
||||
Input: "Test",
|
||||
}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Output: []schema.ORItemField{
|
||||
{
|
||||
Type: "message",
|
||||
ID: itemID,
|
||||
Status: "completed",
|
||||
Role: "assistant",
|
||||
Content: []schema.ORContentPart{{
|
||||
Type: "output_text",
|
||||
Text: "Specific message",
|
||||
Annotations: []schema.ORAnnotation{},
|
||||
Logprobs: []schema.ORLogProb{},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
store.Store(responseID, request, response)
|
||||
|
||||
item, err := store.GetItem(responseID, itemID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(item).ToNot(BeNil())
|
||||
Expect(item.ID).To(Equal(itemID))
|
||||
Expect(item.Type).To(Equal("message"))
|
||||
})
|
||||
|
||||
It("should return error for non-existent item", func() {
|
||||
responseID := "resp_item_test2"
|
||||
request := &schema.OpenResponsesRequest{
|
||||
Model: "test-model",
|
||||
Input: "Test",
|
||||
}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Output: []schema.ORItemField{
|
||||
{
|
||||
Type: "message",
|
||||
ID: "msg_existing",
|
||||
Status: "completed",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
store.Store(responseID, request, response)
|
||||
|
||||
_, err := store.GetItem(responseID, "nonexistent_item")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("item not found"))
|
||||
})
|
||||
|
||||
It("should return error for non-existent response when getting item", func() {
|
||||
_, err := store.GetItem("nonexistent_response", "any_item")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("response not found"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FindItem", func() {
|
||||
It("should find an item across all stored responses", func() {
|
||||
// Store first response
|
||||
responseID1 := "resp_find_1"
|
||||
itemID1 := "msg_find_1"
|
||||
store.Store(responseID1, &schema.OpenResponsesRequest{Model: "test"}, &schema.ORResponseResource{
|
||||
ID: responseID1,
|
||||
Object: "response",
|
||||
Output: []schema.ORItemField{
|
||||
{Type: "message", ID: itemID1, Status: "completed"},
|
||||
},
|
||||
})
|
||||
|
||||
// Store second response
|
||||
responseID2 := "resp_find_2"
|
||||
itemID2 := "msg_find_2"
|
||||
store.Store(responseID2, &schema.OpenResponsesRequest{Model: "test"}, &schema.ORResponseResource{
|
||||
ID: responseID2,
|
||||
Object: "response",
|
||||
Output: []schema.ORItemField{
|
||||
{Type: "message", ID: itemID2, Status: "completed"},
|
||||
},
|
||||
})
|
||||
|
||||
// Find item from first response
|
||||
item, foundResponseID, err := store.FindItem(itemID1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(item).ToNot(BeNil())
|
||||
Expect(item.ID).To(Equal(itemID1))
|
||||
Expect(foundResponseID).To(Equal(responseID1))
|
||||
|
||||
// Find item from second response
|
||||
item, foundResponseID, err = store.FindItem(itemID2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(item).ToNot(BeNil())
|
||||
Expect(item.ID).To(Equal(itemID2))
|
||||
Expect(foundResponseID).To(Equal(responseID2))
|
||||
})
|
||||
|
||||
It("should return error when item not found in any response", func() {
|
||||
_, _, err := store.FindItem("nonexistent_item")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("item not found in any stored response"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("should delete a stored response", func() {
|
||||
responseID := "resp_delete_test"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
}
|
||||
|
||||
store.Store(responseID, request, response)
|
||||
Expect(store.Count()).To(Equal(1))
|
||||
|
||||
store.Delete(responseID)
|
||||
Expect(store.Count()).To(Equal(0))
|
||||
|
||||
_, err := store.Get(responseID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should handle deleting non-existent response gracefully", func() {
|
||||
// Should not panic
|
||||
store.Delete("nonexistent")
|
||||
Expect(store.Count()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
It("should return correct count of stored responses", func() {
|
||||
Expect(store.Count()).To(Equal(0))
|
||||
|
||||
store.Store("resp_1", &schema.OpenResponsesRequest{Model: "test"}, &schema.ORResponseResource{ID: "resp_1", Object: "response"})
|
||||
Expect(store.Count()).To(Equal(1))
|
||||
|
||||
store.Store("resp_2", &schema.OpenResponsesRequest{Model: "test"}, &schema.ORResponseResource{ID: "resp_2", Object: "response"})
|
||||
Expect(store.Count()).To(Equal(2))
|
||||
|
||||
store.Delete("resp_1")
|
||||
Expect(store.Count()).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TTL and Expiration", func() {
|
||||
It("should set expiration when TTL is configured", func() {
|
||||
ttlStore := NewResponseStore(100 * time.Millisecond)
|
||||
responseID := "resp_ttl_test"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{ID: responseID, Object: "response"}
|
||||
|
||||
ttlStore.Store(responseID, request, response)
|
||||
|
||||
stored, err := ttlStore.Get(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored.ExpiresAt).ToNot(BeNil())
|
||||
Expect(stored.ExpiresAt.After(time.Now())).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should not set expiration when TTL is 0", func() {
|
||||
responseID := "resp_no_ttl"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{ID: responseID, Object: "response"}
|
||||
|
||||
store.Store(responseID, request, response)
|
||||
|
||||
stored, err := store.Get(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored.ExpiresAt).To(BeNil())
|
||||
})
|
||||
|
||||
It("should clean up expired responses", func() {
|
||||
ttlStore := NewResponseStore(50 * time.Millisecond)
|
||||
responseID := "resp_expire_test"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{ID: responseID, Object: "response"}
|
||||
|
||||
ttlStore.Store(responseID, request, response)
|
||||
Expect(ttlStore.Count()).To(Equal(1))
|
||||
|
||||
// Wait for expiration (longer than TTL and cleanup interval)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Cleanup should remove expired response (may have already been cleaned by goroutine)
|
||||
count := ttlStore.Cleanup()
|
||||
// Count might be 0 if cleanup goroutine already ran, or 1 if we're first
|
||||
Expect(count).To(BeNumerically(">=", 0))
|
||||
Expect(ttlStore.Count()).To(Equal(0))
|
||||
|
||||
_, err := ttlStore.Get(responseID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for expired response", func() {
|
||||
ttlStore := NewResponseStore(50 * time.Millisecond)
|
||||
responseID := "resp_expire_error"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{ID: responseID, Object: "response"}
|
||||
|
||||
ttlStore.Store(responseID, request, response)
|
||||
|
||||
// Wait for expiration (but not long enough for cleanup goroutine to remove it)
|
||||
time.Sleep(75 * time.Millisecond)
|
||||
|
||||
// Try to get before cleanup goroutine removes it
|
||||
_, err := ttlStore.Get(responseID)
|
||||
// Error could be "expired" or "not found" (if cleanup already ran)
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Either error message is acceptable
|
||||
errMsg := err.Error()
|
||||
Expect(errMsg).To(Or(ContainSubstring("expired"), ContainSubstring("not found")))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Thread Safety", func() {
|
||||
It("should handle concurrent stores and gets", func() {
|
||||
// This is a basic concurrency test
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
responseID := fmt.Sprintf("resp_concurrent_%d", id)
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Output: []schema.ORItemField{
|
||||
{Type: "message", ID: fmt.Sprintf("msg_%d", id), Status: "completed"},
|
||||
},
|
||||
}
|
||||
store.Store(responseID, request, response)
|
||||
|
||||
// Retrieve immediately
|
||||
stored, err := store.Get(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored).ToNot(BeNil())
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
Expect(store.Count()).To(Equal(10))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetGlobalStore", func() {
|
||||
It("should return singleton instance", func() {
|
||||
store1 := GetGlobalStore()
|
||||
store2 := GetGlobalStore()
|
||||
Expect(store1).To(Equal(store2))
|
||||
})
|
||||
|
||||
It("should persist data across GetGlobalStore calls", func() {
|
||||
globalStore := GetGlobalStore()
|
||||
responseID := "resp_global_test"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{ID: responseID, Object: "response"}
|
||||
|
||||
globalStore.Store(responseID, request, response)
|
||||
|
||||
// Get store again
|
||||
globalStore2 := GetGlobalStore()
|
||||
stored, err := globalStore2.Get(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Background Mode Support", func() {
|
||||
It("should store background response with cancel function", func() {
|
||||
responseID := "resp_bg_test"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Status: schema.ORStatusQueued,
|
||||
}
|
||||
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
store.StoreBackground(responseID, request, response, cancel, true)
|
||||
|
||||
stored, err := store.Get(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored).ToNot(BeNil())
|
||||
Expect(stored.IsBackground).To(BeTrue())
|
||||
Expect(stored.StreamEnabled).To(BeTrue())
|
||||
Expect(stored.CancelFunc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should update status of stored response", func() {
|
||||
responseID := "resp_status_test"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Status: schema.ORStatusQueued,
|
||||
}
|
||||
|
||||
store.Store(responseID, request, response)
|
||||
|
||||
err := store.UpdateStatus(responseID, schema.ORStatusInProgress, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
stored, err := store.Get(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored.Response.Status).To(Equal(schema.ORStatusInProgress))
|
||||
})
|
||||
|
||||
It("should append and retrieve streaming events", func() {
|
||||
responseID := "resp_events_test"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Status: schema.ORStatusInProgress,
|
||||
}
|
||||
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
store.StoreBackground(responseID, request, response, cancel, true)
|
||||
|
||||
// Append events
|
||||
event1 := &schema.ORStreamEvent{
|
||||
Type: "response.created",
|
||||
SequenceNumber: 0,
|
||||
}
|
||||
event2 := &schema.ORStreamEvent{
|
||||
Type: "response.in_progress",
|
||||
SequenceNumber: 1,
|
||||
}
|
||||
event3 := &schema.ORStreamEvent{
|
||||
Type: "response.output_text.delta",
|
||||
SequenceNumber: 2,
|
||||
}
|
||||
|
||||
err := store.AppendEvent(responseID, event1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = store.AppendEvent(responseID, event2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = store.AppendEvent(responseID, event3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Get all events after -1 (all events)
|
||||
events, err := store.GetEventsAfter(responseID, -1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(events).To(HaveLen(3))
|
||||
|
||||
// Get events after sequence 1
|
||||
events, err = store.GetEventsAfter(responseID, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(events).To(HaveLen(1))
|
||||
Expect(events[0].SequenceNumber).To(Equal(2))
|
||||
})
|
||||
|
||||
It("should cancel an in-progress response", func() {
|
||||
responseID := "resp_cancel_test"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Status: schema.ORStatusInProgress,
|
||||
}
|
||||
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
store.StoreBackground(responseID, request, response, cancel, false)
|
||||
|
||||
// Cancel the response
|
||||
cancelledResponse, err := store.Cancel(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(cancelledResponse.Status).To(Equal(schema.ORStatusCancelled))
|
||||
Expect(cancelledResponse.CompletedAt).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should be idempotent when cancelling already completed response", func() {
|
||||
responseID := "resp_idempotent_cancel"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
completedAt := time.Now().Unix()
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Status: schema.ORStatusCompleted,
|
||||
CompletedAt: &completedAt,
|
||||
}
|
||||
|
||||
store.Store(responseID, request, response)
|
||||
|
||||
// Try to cancel a completed response
|
||||
cancelledResponse, err := store.Cancel(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Status should remain completed (not changed to cancelled)
|
||||
Expect(cancelledResponse.Status).To(Equal(schema.ORStatusCompleted))
|
||||
})
|
||||
|
||||
It("should check if streaming is enabled", func() {
|
||||
responseID := "resp_stream_check"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Status: schema.ORStatusQueued,
|
||||
}
|
||||
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
store.StoreBackground(responseID, request, response, cancel, true)
|
||||
|
||||
enabled, err := store.IsStreamEnabled(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(enabled).To(BeTrue())
|
||||
|
||||
// Store another without streaming
|
||||
responseID2 := "resp_no_stream"
|
||||
store.StoreBackground(responseID2, request, response, cancel, false)
|
||||
|
||||
enabled2, err := store.IsStreamEnabled(responseID2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(enabled2).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should notify subscribers of new events", func() {
|
||||
responseID := "resp_events_chan"
|
||||
request := &schema.OpenResponsesRequest{Model: "test"}
|
||||
response := &schema.ORResponseResource{
|
||||
ID: responseID,
|
||||
Object: "response",
|
||||
Status: schema.ORStatusInProgress,
|
||||
}
|
||||
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
store.StoreBackground(responseID, request, response, cancel, true)
|
||||
|
||||
eventsChan, err := store.GetEventsChan(responseID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(eventsChan).ToNot(BeNil())
|
||||
|
||||
// Append an event
|
||||
event := &schema.ORStreamEvent{
|
||||
Type: "response.output_text.delta",
|
||||
SequenceNumber: 0,
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
store.AppendEvent(responseID, event)
|
||||
}()
|
||||
|
||||
// Wait for notification
|
||||
select {
|
||||
case <-eventsChan:
|
||||
// Event received
|
||||
case <-time.After(1 * time.Second):
|
||||
Fail("Timeout waiting for event notification")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,33 +1,13 @@
|
||||
package http_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var (
|
||||
tmpdir string
|
||||
modelDir string
|
||||
)
|
||||
|
||||
func TestLocalAI(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
|
||||
var err error
|
||||
tmpdir, err = os.MkdirTemp("", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
modelDir = filepath.Join(tmpdir, "models")
|
||||
err = os.Mkdir(modelDir, 0750)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
AfterSuite(func() {
|
||||
err := os.RemoveAll(tmpdir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
RunSpecs(t, "LocalAI HTTP test suite")
|
||||
}
|
||||
|
||||
@@ -484,103 +484,3 @@ func mergeOpenAIRequestAndModelConfig(config *config.ModelConfig, input *schema.
|
||||
}
|
||||
return fmt.Errorf("unable to validate configuration after merging")
|
||||
}
|
||||
|
||||
func (re *RequestExtractor) SetOpenResponsesRequest(c echo.Context) error {
|
||||
input, ok := c.Get(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OpenResponsesRequest)
|
||||
if !ok || input.Model == "" {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
cfg, ok := c.Get(CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
|
||||
if !ok || cfg == nil {
|
||||
return echo.ErrBadRequest
|
||||
}
|
||||
|
||||
// Extract or generate the correlation ID (Open Responses uses x-request-id)
|
||||
correlationID := c.Request().Header.Get("x-request-id")
|
||||
if correlationID == "" {
|
||||
correlationID = uuid.New().String()
|
||||
}
|
||||
c.Response().Header().Set("x-request-id", correlationID)
|
||||
|
||||
// Use the request context directly - Echo properly supports context cancellation!
|
||||
reqCtx := c.Request().Context()
|
||||
c1, cancel := context.WithCancel(re.applicationConfig.Context)
|
||||
|
||||
// Cancel when request context is cancelled (client disconnects)
|
||||
go func() {
|
||||
select {
|
||||
case <-reqCtx.Done():
|
||||
cancel()
|
||||
case <-c1.Done():
|
||||
// Already cancelled
|
||||
}
|
||||
}()
|
||||
|
||||
// Add the correlation ID to the new context
|
||||
ctxWithCorrelationID := context.WithValue(c1, CorrelationIDKey, correlationID)
|
||||
|
||||
input.Context = ctxWithCorrelationID
|
||||
input.Cancel = cancel
|
||||
|
||||
err := mergeOpenResponsesRequestAndModelConfig(cfg, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.Model == "" {
|
||||
xlog.Debug("replacing empty cfg.Model with input value", "input.Model", input.Model)
|
||||
cfg.Model = input.Model
|
||||
}
|
||||
|
||||
c.Set(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input)
|
||||
c.Set(CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeOpenResponsesRequestAndModelConfig(config *config.ModelConfig, input *schema.OpenResponsesRequest) error {
|
||||
// Temperature
|
||||
if input.Temperature != nil {
|
||||
config.Temperature = input.Temperature
|
||||
}
|
||||
|
||||
// TopP
|
||||
if input.TopP != nil {
|
||||
config.TopP = input.TopP
|
||||
}
|
||||
|
||||
// MaxOutputTokens -> Maxtokens
|
||||
if input.MaxOutputTokens != nil {
|
||||
config.Maxtokens = input.MaxOutputTokens
|
||||
}
|
||||
|
||||
// Convert tools to functions - this will be handled in the endpoint handler
|
||||
// We just validate that tools are present if needed
|
||||
|
||||
// Handle tool_choice
|
||||
if input.ToolChoice != nil {
|
||||
switch tc := input.ToolChoice.(type) {
|
||||
case string:
|
||||
// "auto", "required", or "none"
|
||||
if tc == "required" {
|
||||
config.SetFunctionCallString("required")
|
||||
} else if tc == "none" {
|
||||
// Don't use tools - handled in endpoint
|
||||
}
|
||||
// "auto" is default - let model decide
|
||||
case map[string]interface{}:
|
||||
// Specific tool: {type:"function", name:"..."}
|
||||
if tcType, ok := tc["type"].(string); ok && tcType == "function" {
|
||||
if name, ok := tc["name"].(string); ok {
|
||||
config.SetFunctionCallString(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if valid, _ := config.Validate(); valid {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unable to validate configuration after merging")
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/application"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/endpoints/openresponses"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
)
|
||||
|
||||
func RegisterOpenResponsesRoutes(app *echo.Echo,
|
||||
re *middleware.RequestExtractor,
|
||||
application *application.Application) {
|
||||
|
||||
// Open Responses API endpoint
|
||||
responsesHandler := openresponses.ResponsesEndpoint(
|
||||
application.ModelConfigLoader(),
|
||||
application.ModelLoader(),
|
||||
application.TemplatesEvaluator(),
|
||||
application.ApplicationConfig(),
|
||||
)
|
||||
|
||||
responsesMiddleware := []echo.MiddlewareFunc{
|
||||
middleware.TraceMiddleware(application),
|
||||
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
|
||||
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenResponsesRequest) }),
|
||||
setOpenResponsesRequestContext(re),
|
||||
}
|
||||
|
||||
// Main Open Responses endpoint
|
||||
app.POST("/v1/responses", responsesHandler, responsesMiddleware...)
|
||||
|
||||
// Also support without version prefix for compatibility
|
||||
app.POST("/responses", responsesHandler, responsesMiddleware...)
|
||||
|
||||
// GET /responses/:id - Retrieve a response (for polling background requests)
|
||||
getResponseHandler := openresponses.GetResponseEndpoint()
|
||||
app.GET("/v1/responses/:id", getResponseHandler, middleware.TraceMiddleware(application))
|
||||
app.GET("/responses/:id", getResponseHandler, middleware.TraceMiddleware(application))
|
||||
|
||||
// POST /responses/:id/cancel - Cancel a background response
|
||||
cancelResponseHandler := openresponses.CancelResponseEndpoint()
|
||||
app.POST("/v1/responses/:id/cancel", cancelResponseHandler, middleware.TraceMiddleware(application))
|
||||
app.POST("/responses/:id/cancel", cancelResponseHandler, middleware.TraceMiddleware(application))
|
||||
}
|
||||
|
||||
// setOpenResponsesRequestContext sets up the context and cancel function for Open Responses requests
|
||||
func setOpenResponsesRequestContext(re *middleware.RequestExtractor) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if err := re.SetOpenResponsesRequest(c); err != nil {
|
||||
return err
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package routes
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
||||
"github.com/mudler/LocalAI/core/http/middleware"
|
||||
"github.com/mudler/LocalAI/core/services"
|
||||
@@ -115,258 +114,24 @@ func RegisterUIRoutes(app *echo.Echo,
|
||||
registerBackendGalleryRoutes(app, appConfig, galleryService, processingOps)
|
||||
}
|
||||
|
||||
app.GET("/talk", func(c echo.Context) error {
|
||||
modelConfigs, _ := services.ListModels(cl, ml, config.NoFilterFn, services.SKIP_IF_CONFIGURED)
|
||||
// Talk route - now served by SPA
|
||||
app.GET("/talk", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
if len(modelConfigs) == 0 {
|
||||
// If no model is available redirect to the index which suggests how to install models
|
||||
return c.Redirect(302, middleware.BaseURL(c))
|
||||
}
|
||||
// Chat routes - now served by SPA
|
||||
app.GET("/chat", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"Title": "LocalAI - Talk",
|
||||
"BaseURL": middleware.BaseURL(c),
|
||||
"ModelsConfig": modelConfigs,
|
||||
"Model": modelConfigs[0],
|
||||
// Show the Chat page with specific model
|
||||
app.GET("/chat/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
// Text2Image routes - now served by SPA
|
||||
app.GET("/text2image/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
// Render index
|
||||
return c.Render(200, "views/talk", summary)
|
||||
})
|
||||
app.GET("/text2image", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
app.GET("/chat", func(c echo.Context) error {
|
||||
modelConfigs := cl.GetAllModelsConfigs()
|
||||
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
|
||||
// TTS routes - now served by SPA
|
||||
app.GET("/tts/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
if len(modelConfigs)+len(modelsWithoutConfig) == 0 {
|
||||
// If no model is available redirect to the index which suggests how to install models
|
||||
return c.Redirect(302, middleware.BaseURL(c))
|
||||
}
|
||||
modelThatCanBeUsed := ""
|
||||
galleryConfigs := map[string]*gallery.ModelConfig{}
|
||||
|
||||
for _, m := range modelConfigs {
|
||||
cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
galleryConfigs[m.Name] = cfg
|
||||
}
|
||||
|
||||
title := "LocalAI - Chat"
|
||||
var modelContextSize *int
|
||||
|
||||
for _, b := range modelConfigs {
|
||||
if b.HasUsecases(config.FLAG_CHAT) {
|
||||
modelThatCanBeUsed = b.Name
|
||||
title = "LocalAI - Chat with " + modelThatCanBeUsed
|
||||
if b.LLMConfig.ContextSize != nil {
|
||||
modelContextSize = b.LLMConfig.ContextSize
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"Title": title,
|
||||
"BaseURL": middleware.BaseURL(c),
|
||||
"ModelsWithoutConfig": modelsWithoutConfig,
|
||||
"GalleryConfig": galleryConfigs,
|
||||
"ModelsConfig": modelConfigs,
|
||||
"Model": modelThatCanBeUsed,
|
||||
"ContextSize": modelContextSize,
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
|
||||
// Render index
|
||||
return c.Render(200, "views/chat", summary)
|
||||
})
|
||||
|
||||
// Show the Chat page
|
||||
app.GET("/chat/:model", func(c echo.Context) error {
|
||||
modelConfigs := cl.GetAllModelsConfigs()
|
||||
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
|
||||
|
||||
galleryConfigs := map[string]*gallery.ModelConfig{}
|
||||
modelName := c.Param("model")
|
||||
var modelContextSize *int
|
||||
|
||||
for _, m := range modelConfigs {
|
||||
cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
galleryConfigs[m.Name] = cfg
|
||||
if m.Name == modelName && m.LLMConfig.ContextSize != nil {
|
||||
modelContextSize = m.LLMConfig.ContextSize
|
||||
}
|
||||
}
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"Title": "LocalAI - Chat with " + modelName,
|
||||
"BaseURL": middleware.BaseURL(c),
|
||||
"ModelsConfig": modelConfigs,
|
||||
"GalleryConfig": galleryConfigs,
|
||||
"ModelsWithoutConfig": modelsWithoutConfig,
|
||||
"Model": modelName,
|
||||
"ContextSize": modelContextSize,
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
|
||||
// Render index
|
||||
return c.Render(200, "views/chat", summary)
|
||||
})
|
||||
|
||||
app.GET("/image/:model", func(c echo.Context) error {
|
||||
modelConfigs := cl.GetAllModelsConfigs()
|
||||
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"Title": "LocalAI - Generate images with " + c.Param("model"),
|
||||
"BaseURL": middleware.BaseURL(c),
|
||||
"ModelsConfig": modelConfigs,
|
||||
"ModelsWithoutConfig": modelsWithoutConfig,
|
||||
"Model": c.Param("model"),
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
|
||||
// Render index
|
||||
return c.Render(200, "views/image", summary)
|
||||
})
|
||||
|
||||
app.GET("/image", func(c echo.Context) error {
|
||||
modelConfigs := cl.GetAllModelsConfigs()
|
||||
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
|
||||
|
||||
if len(modelConfigs)+len(modelsWithoutConfig) == 0 {
|
||||
// If no model is available redirect to the index which suggests how to install models
|
||||
return c.Redirect(302, middleware.BaseURL(c))
|
||||
}
|
||||
|
||||
modelThatCanBeUsed := ""
|
||||
title := "LocalAI - Generate images"
|
||||
|
||||
for _, b := range modelConfigs {
|
||||
if b.HasUsecases(config.FLAG_IMAGE) {
|
||||
modelThatCanBeUsed = b.Name
|
||||
title = "LocalAI - Generate images with " + modelThatCanBeUsed
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"Title": title,
|
||||
"BaseURL": middleware.BaseURL(c),
|
||||
"ModelsConfig": modelConfigs,
|
||||
"ModelsWithoutConfig": modelsWithoutConfig,
|
||||
"Model": modelThatCanBeUsed,
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
|
||||
// Render index
|
||||
return c.Render(200, "views/image", summary)
|
||||
})
|
||||
|
||||
app.GET("/tts/:model", func(c echo.Context) error {
|
||||
modelConfigs := cl.GetAllModelsConfigs()
|
||||
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"Title": "LocalAI - Generate images with " + c.Param("model"),
|
||||
"BaseURL": middleware.BaseURL(c),
|
||||
"ModelsConfig": modelConfigs,
|
||||
"ModelsWithoutConfig": modelsWithoutConfig,
|
||||
"Model": c.Param("model"),
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
|
||||
// Render index
|
||||
return c.Render(200, "views/tts", summary)
|
||||
})
|
||||
|
||||
app.GET("/tts", func(c echo.Context) error {
|
||||
modelConfigs := cl.GetAllModelsConfigs()
|
||||
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
|
||||
|
||||
if len(modelConfigs)+len(modelsWithoutConfig) == 0 {
|
||||
// If no model is available redirect to the index which suggests how to install models
|
||||
return c.Redirect(302, middleware.BaseURL(c))
|
||||
}
|
||||
|
||||
modelThatCanBeUsed := ""
|
||||
title := "LocalAI - Generate audio"
|
||||
|
||||
for _, b := range modelConfigs {
|
||||
if b.HasUsecases(config.FLAG_TTS) {
|
||||
modelThatCanBeUsed = b.Name
|
||||
title = "LocalAI - Generate audio with " + modelThatCanBeUsed
|
||||
break
|
||||
}
|
||||
}
|
||||
summary := map[string]interface{}{
|
||||
"Title": title,
|
||||
"BaseURL": middleware.BaseURL(c),
|
||||
"ModelsConfig": modelConfigs,
|
||||
"ModelsWithoutConfig": modelsWithoutConfig,
|
||||
"Model": modelThatCanBeUsed,
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
|
||||
// Render index
|
||||
return c.Render(200, "views/tts", summary)
|
||||
})
|
||||
|
||||
app.GET("/video/:model", func(c echo.Context) error {
|
||||
modelConfigs := cl.GetAllModelsConfigs()
|
||||
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"Title": "LocalAI - Generate videos with " + c.Param("model"),
|
||||
"BaseURL": middleware.BaseURL(c),
|
||||
"ModelsConfig": modelConfigs,
|
||||
"ModelsWithoutConfig": modelsWithoutConfig,
|
||||
"Model": c.Param("model"),
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
|
||||
// Render index
|
||||
return c.Render(200, "views/video", summary)
|
||||
})
|
||||
|
||||
app.GET("/video", func(c echo.Context) error {
|
||||
modelConfigs := cl.GetAllModelsConfigs()
|
||||
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
|
||||
|
||||
if len(modelConfigs)+len(modelsWithoutConfig) == 0 {
|
||||
// If no model is available redirect to the index which suggests how to install models
|
||||
return c.Redirect(302, middleware.BaseURL(c))
|
||||
}
|
||||
|
||||
modelThatCanBeUsed := ""
|
||||
title := "LocalAI - Generate videos"
|
||||
|
||||
for _, b := range modelConfigs {
|
||||
if b.HasUsecases(config.FLAG_VIDEO) {
|
||||
modelThatCanBeUsed = b.Name
|
||||
title = "LocalAI - Generate videos with " + modelThatCanBeUsed
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"Title": title,
|
||||
"BaseURL": middleware.BaseURL(c),
|
||||
"ModelsConfig": modelConfigs,
|
||||
"ModelsWithoutConfig": modelsWithoutConfig,
|
||||
"Model": modelThatCanBeUsed,
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
|
||||
// Render index
|
||||
return c.Render(200, "views/video", summary)
|
||||
})
|
||||
app.GET("/tts", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
// Traces UI
|
||||
app.GET("/traces", func(c echo.Context) error {
|
||||
|
||||
411
core/http/static/spa-home.js
Normal file
411
core/http/static/spa-home.js
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* SPA Home View JavaScript
|
||||
* Contains Alpine.js components and functions for the home view
|
||||
*/
|
||||
|
||||
// Home input form component
|
||||
function homeInputForm() {
|
||||
return {
|
||||
selectedModel: '',
|
||||
inputValue: '',
|
||||
shiftPressed: false,
|
||||
fileName: '',
|
||||
imageFiles: [],
|
||||
audioFiles: [],
|
||||
textFiles: [],
|
||||
attachedFiles: [],
|
||||
mcpMode: false,
|
||||
mcpAvailable: false,
|
||||
mcpModels: {},
|
||||
currentPlaceholder: 'Send a message...',
|
||||
placeholderIndex: 0,
|
||||
charIndex: 0,
|
||||
isTyping: false,
|
||||
typingTimeout: null,
|
||||
displayTimeout: null,
|
||||
placeholderMessages: [
|
||||
'What is Nuclear fusion?',
|
||||
'How does a combustion engine work?',
|
||||
'Explain quantum computing',
|
||||
'What causes climate change?',
|
||||
'How do neural networks learn?',
|
||||
'What is the theory of relativity?',
|
||||
'How does photosynthesis work?',
|
||||
'Explain the water cycle',
|
||||
'What is machine learning?',
|
||||
'How do black holes form?',
|
||||
'What is DNA and how does it work?',
|
||||
'Explain the greenhouse effect',
|
||||
'How does the immune system work?',
|
||||
'What is artificial intelligence?',
|
||||
'How do solar panels generate electricity?',
|
||||
'Explain the process of evolution',
|
||||
'What is the difference between weather and climate?',
|
||||
'How does the human brain process information?',
|
||||
'What is the structure of an atom?',
|
||||
'How do vaccines work?',
|
||||
'Explain the concept of entropy',
|
||||
'What is the speed of light?',
|
||||
'How does gravity work?',
|
||||
'What is the difference between mass and weight?'
|
||||
],
|
||||
|
||||
init() {
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.startTypingAnimation();
|
||||
// Build MCP models map from data attributes
|
||||
this.buildMCPModelsMap();
|
||||
// Select first model by default
|
||||
this.$nextTick(() => {
|
||||
const select = this.$el.querySelector('select');
|
||||
if (select && select.options.length > 1) {
|
||||
const firstModelOption = select.options[1];
|
||||
if (firstModelOption && firstModelOption.value) {
|
||||
this.selectedModel = firstModelOption.value;
|
||||
this.checkMCPAvailability();
|
||||
}
|
||||
}
|
||||
});
|
||||
// Watch for changes to selectedModel to update MCP availability
|
||||
this.$watch('selectedModel', () => {
|
||||
this.checkMCPAvailability();
|
||||
});
|
||||
},
|
||||
|
||||
buildMCPModelsMap() {
|
||||
const select = this.$el.querySelector('select');
|
||||
if (!select) return;
|
||||
this.mcpModels = {};
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
const option = select.options[i];
|
||||
if (option.value) {
|
||||
const hasMcpAttr = option.getAttribute('data-has-mcp');
|
||||
this.mcpModels[option.value] = hasMcpAttr === 'true';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
checkMCPAvailability() {
|
||||
if (!this.selectedModel) {
|
||||
this.mcpAvailable = false;
|
||||
this.mcpMode = false;
|
||||
return;
|
||||
}
|
||||
const hasMCP = this.mcpModels[this.selectedModel] === true;
|
||||
this.mcpAvailable = hasMCP;
|
||||
if (!hasMCP) {
|
||||
this.mcpMode = false;
|
||||
}
|
||||
},
|
||||
|
||||
startTypingAnimation() {
|
||||
if (this.isTyping) return;
|
||||
this.typeNextPlaceholder();
|
||||
},
|
||||
|
||||
typeNextPlaceholder() {
|
||||
if (this.isTyping) return;
|
||||
this.isTyping = true;
|
||||
this.charIndex = 0;
|
||||
const message = this.placeholderMessages[this.placeholderIndex];
|
||||
this.currentPlaceholder = '';
|
||||
window.currentPlaceholderText = '';
|
||||
|
||||
const typeChar = () => {
|
||||
if (this.charIndex < message.length) {
|
||||
this.currentPlaceholder = message.substring(0, this.charIndex + 1);
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.charIndex++;
|
||||
this.typingTimeout = setTimeout(typeChar, 30);
|
||||
} else {
|
||||
this.isTyping = false;
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.displayTimeout = setTimeout(() => {
|
||||
this.placeholderIndex = (this.placeholderIndex + 1) % this.placeholderMessages.length;
|
||||
this.typeNextPlaceholder();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
typeChar();
|
||||
},
|
||||
|
||||
pauseTyping() {
|
||||
if (this.typingTimeout) {
|
||||
clearTimeout(this.typingTimeout);
|
||||
this.typingTimeout = null;
|
||||
}
|
||||
if (this.displayTimeout) {
|
||||
clearTimeout(this.displayTimeout);
|
||||
this.displayTimeout = null;
|
||||
}
|
||||
this.isTyping = false;
|
||||
},
|
||||
|
||||
resumeTyping() {
|
||||
if (!this.inputValue.trim() && !this.isTyping) {
|
||||
this.startTypingAnimation();
|
||||
}
|
||||
},
|
||||
|
||||
handleFocus() {
|
||||
if (this.isTyping && this.placeholderIndex < this.placeholderMessages.length) {
|
||||
const fullMessage = this.placeholderMessages[this.placeholderIndex];
|
||||
this.currentPlaceholder = fullMessage;
|
||||
window.currentPlaceholderText = fullMessage;
|
||||
}
|
||||
this.pauseTyping();
|
||||
},
|
||||
|
||||
handleBlur() {
|
||||
if (!this.inputValue.trim()) {
|
||||
this.resumeTyping();
|
||||
}
|
||||
},
|
||||
|
||||
handleInput() {
|
||||
if (this.inputValue.trim()) {
|
||||
this.pauseTyping();
|
||||
} else {
|
||||
this.resumeTyping();
|
||||
}
|
||||
},
|
||||
|
||||
handleFileSelection(files, fileType) {
|
||||
Array.from(files).forEach(file => {
|
||||
const exists = this.attachedFiles.some(f => f.name === file.name && f.type === fileType);
|
||||
if (!exists) {
|
||||
this.attachedFiles.push({ name: file.name, type: fileType });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
removeAttachedFile(fileType, fileName) {
|
||||
const index = this.attachedFiles.findIndex(f => f.name === fileName && f.type === fileType);
|
||||
if (index !== -1) {
|
||||
this.attachedFiles.splice(index, 1);
|
||||
}
|
||||
if (fileType === 'image') {
|
||||
this.imageFiles = this.imageFiles.filter(f => f.name !== fileName);
|
||||
} else if (fileType === 'audio') {
|
||||
this.audioFiles = this.audioFiles.filter(f => f.name !== fileName);
|
||||
} else if (fileType === 'file') {
|
||||
this.textFiles = this.textFiles.filter(f => f.name !== fileName);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Start chat function for SPA - navigates to chat view instead of full page redirect
|
||||
function startChatSPA(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const form = event ? event.target.closest('form') : document.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
const alpineComponent = form.closest('[x-data]');
|
||||
const select = alpineComponent ? alpineComponent.querySelector('select') : null;
|
||||
const textarea = form.querySelector('textarea');
|
||||
|
||||
const selectedModel = select ? select.value : '';
|
||||
let message = textarea ? textarea.value : '';
|
||||
|
||||
if (!message.trim() && window.currentPlaceholderText) {
|
||||
message = window.currentPlaceholderText;
|
||||
}
|
||||
|
||||
if (!selectedModel || !message.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get MCP mode from checkbox
|
||||
let mcpMode = false;
|
||||
const mcpToggle = document.getElementById('spa_home_mcp_toggle');
|
||||
if (mcpToggle && mcpToggle.checked) {
|
||||
mcpMode = true;
|
||||
}
|
||||
|
||||
// Store message and files in localStorage for chat view to pick up
|
||||
const chatData = {
|
||||
message: message,
|
||||
imageFiles: [],
|
||||
audioFiles: [],
|
||||
textFiles: [],
|
||||
mcpMode: mcpMode
|
||||
};
|
||||
|
||||
// Convert files to base64 for storage
|
||||
const imageInput = document.getElementById('spa_home_input_image');
|
||||
const audioInput = document.getElementById('spa_home_input_audio');
|
||||
const fileInput = document.getElementById('spa_home_input_file');
|
||||
|
||||
const filePromises = [
|
||||
...Array.from(imageInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
),
|
||||
...Array.from(audioInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
),
|
||||
...Array.from(fileInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsText(file);
|
||||
})
|
||||
)
|
||||
];
|
||||
|
||||
const navigateToChat = () => {
|
||||
// Store in localStorage
|
||||
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData));
|
||||
|
||||
// Use SPA router to navigate to chat
|
||||
if (window.Alpine && Alpine.store('router')) {
|
||||
Alpine.store('router').navigate('chat', { model: selectedModel });
|
||||
} else {
|
||||
// Fallback to full page redirect if router not available
|
||||
window.location.href = `/chat/${selectedModel}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (filePromises.length > 0) {
|
||||
Promise.all(filePromises).then(files => {
|
||||
files.forEach(file => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
chatData.imageFiles.push(file);
|
||||
} else if (file.type.startsWith('audio/')) {
|
||||
chatData.audioFiles.push(file);
|
||||
} else {
|
||||
chatData.textFiles.push(file);
|
||||
}
|
||||
});
|
||||
navigateToChat();
|
||||
}).catch(err => {
|
||||
console.error('Error processing files:', err);
|
||||
navigateToChat();
|
||||
});
|
||||
} else {
|
||||
navigateToChat();
|
||||
}
|
||||
}
|
||||
|
||||
// Resource Monitor component (GPU if available, otherwise RAM)
|
||||
function resourceMonitor() {
|
||||
return {
|
||||
resourceData: null,
|
||||
pollInterval: null,
|
||||
|
||||
async fetchResourceData() {
|
||||
try {
|
||||
const response = await fetch('/api/resources');
|
||||
if (response.ok) {
|
||||
this.resourceData = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching resource data:', error);
|
||||
}
|
||||
},
|
||||
|
||||
startPolling() {
|
||||
this.fetchResourceData();
|
||||
this.pollInterval = setInterval(() => this.fetchResourceData(), 5000);
|
||||
},
|
||||
|
||||
stopPolling() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Stop individual model
|
||||
async function stopModel(modelName) {
|
||||
if (!confirm(`Are you sure you want to stop "${modelName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ model: modelName })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping model:', error);
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
}
|
||||
|
||||
// Stop all loaded models
|
||||
async function stopAllModels(component) {
|
||||
// Get loaded models from DOM
|
||||
const loadedModelElements = document.querySelectorAll('[data-loaded-model]');
|
||||
const loadedModelNames = Array.from(loadedModelElements).map(el => {
|
||||
const span = el.querySelector('span.truncate');
|
||||
return span ? span.textContent.trim() : '';
|
||||
}).filter(name => name.length > 0);
|
||||
|
||||
if (loadedModelNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to stop all ${loadedModelNames.length} loaded model(s)?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component.stoppingAll = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const stopPromises = loadedModelNames.map(modelName =>
|
||||
fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ model: modelName })
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(stopPromises);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error stopping models:', error);
|
||||
alert('Failed to stop some models');
|
||||
if (component) {
|
||||
component.stoppingAll = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions available globally
|
||||
window.homeInputForm = homeInputForm;
|
||||
window.startChatSPA = startChatSPA;
|
||||
window.resourceMonitor = resourceMonitor;
|
||||
window.stopModel = stopModel;
|
||||
window.stopAllModels = stopAllModels;
|
||||
148
core/http/static/spa-router.js
Normal file
148
core/http/static/spa-router.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* LocalAI SPA Router
|
||||
* Client-side routing for the single-page application
|
||||
*/
|
||||
|
||||
// Define routes and their corresponding view IDs
|
||||
const SPA_ROUTES = {
|
||||
'home': { title: 'LocalAI', viewId: 'view-home', paths: ['/', ''] },
|
||||
'chat': { title: 'LocalAI - Chat', viewId: 'view-chat', paths: ['/chat'] },
|
||||
'text2image': { title: 'LocalAI - Images', viewId: 'view-text2image', paths: ['/text2image'] },
|
||||
'tts': { title: 'LocalAI - TTS', viewId: 'view-tts', paths: ['/tts'] },
|
||||
'talk': { title: 'LocalAI - Talk', viewId: 'view-talk', paths: ['/talk'] },
|
||||
'manage': { title: 'LocalAI - System', viewId: 'view-manage', paths: ['/manage'] },
|
||||
'browse': { title: 'LocalAI - Model Gallery', viewId: 'view-browse', paths: ['/browse'] }
|
||||
};
|
||||
|
||||
// Parse URL path to determine route
|
||||
function parseUrlPath(pathname) {
|
||||
// Remove trailing slash
|
||||
pathname = pathname.replace(/\/$/, '') || '/';
|
||||
|
||||
// Check for hash-based routes first (for SPA navigation)
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash) {
|
||||
const hashParts = hash.split('/');
|
||||
const route = hashParts[0];
|
||||
const model = hashParts[1] || null;
|
||||
if (SPA_ROUTES[route]) {
|
||||
return { route, params: model ? { model } : {} };
|
||||
}
|
||||
}
|
||||
|
||||
// Check path-based routes
|
||||
for (const [route, config] of Object.entries(SPA_ROUTES)) {
|
||||
for (const path of config.paths) {
|
||||
if (pathname === path) {
|
||||
return { route, params: {} };
|
||||
}
|
||||
// Check for parameterized routes like /chat/:model
|
||||
if (pathname.startsWith(path + '/')) {
|
||||
const param = pathname.slice(path.length + 1);
|
||||
if (param) {
|
||||
return { route, params: { model: param } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to home
|
||||
return { route: 'home', params: {} };
|
||||
}
|
||||
|
||||
// Initialize the router store for Alpine.js
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// Parse initial route from URL
|
||||
const initialRoute = parseUrlPath(window.location.pathname);
|
||||
|
||||
Alpine.store('router', {
|
||||
currentRoute: initialRoute.route,
|
||||
routeParams: initialRoute.params,
|
||||
previousRoute: null,
|
||||
|
||||
/**
|
||||
* Navigate to a route
|
||||
* @param {string} route - The route name to navigate to
|
||||
* @param {Object} params - Optional parameters for the route
|
||||
*/
|
||||
navigate(route, params = {}) {
|
||||
if (!SPA_ROUTES[route]) {
|
||||
console.warn(`Unknown route: ${route}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousRoute = this.currentRoute;
|
||||
this.currentRoute = route;
|
||||
this.routeParams = params;
|
||||
|
||||
// Update document title
|
||||
document.title = SPA_ROUTES[route].title;
|
||||
|
||||
// Update URL without page reload using history API
|
||||
const url = route === 'home' ? '/' : `/#${route}`;
|
||||
if (params.model) {
|
||||
window.history.pushState({ route, params }, '', `/#${route}/${params.model}`);
|
||||
} else {
|
||||
window.history.pushState({ route, params }, '', url);
|
||||
}
|
||||
|
||||
// Scroll to top on navigation
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Emit custom event for route change listeners
|
||||
window.dispatchEvent(new CustomEvent('spa:navigate', {
|
||||
detail: { route, params, previousRoute: this.previousRoute }
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the current route matches
|
||||
* @param {string} route - The route to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRoute(route) {
|
||||
return this.currentRoute === route;
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to chat with a specific model
|
||||
* @param {string} model - The model name
|
||||
*/
|
||||
navigateToChat(model) {
|
||||
this.navigate('chat', { model });
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to text2image with a specific model
|
||||
* @param {string} model - The model name
|
||||
*/
|
||||
navigateToText2Image(model) {
|
||||
this.navigate('text2image', { model });
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to TTS with a specific model
|
||||
* @param {string} model - The model name
|
||||
*/
|
||||
navigateToTTS(model) {
|
||||
this.navigate('tts', { model });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle browser back/forward buttons
|
||||
window.addEventListener('popstate', (event) => {
|
||||
if (event.state && event.state.route) {
|
||||
Alpine.store('router').currentRoute = event.state.route;
|
||||
Alpine.store('router').routeParams = event.state.params || {};
|
||||
} else {
|
||||
// Parse URL for route
|
||||
const parsed = parseUrlPath(window.location.pathname);
|
||||
Alpine.store('router').currentRoute = parsed.route;
|
||||
Alpine.store('router').routeParams = parsed.params;
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
window.SPA_ROUTES = SPA_ROUTES;
|
||||
window.parseUrlPath = parseUrlPath;
|
||||
@@ -1,300 +0,0 @@
|
||||
// Helper function to convert file to base64
|
||||
function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
// Remove data:image/...;base64, prefix if present
|
||||
const base64 = reader.result.split(',')[1] || reader.result;
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function genVideo(event) {
|
||||
event.preventDefault();
|
||||
promptVideo();
|
||||
}
|
||||
|
||||
async function promptVideo() {
|
||||
const loader = document.getElementById("loader");
|
||||
const input = document.getElementById("input");
|
||||
const generateBtn = document.getElementById("generate-btn");
|
||||
const resultDiv = document.getElementById("result");
|
||||
const resultPlaceholder = document.getElementById("result-placeholder");
|
||||
|
||||
// Show loader and disable form
|
||||
loader.classList.remove("hidden");
|
||||
if (resultPlaceholder) {
|
||||
resultPlaceholder.style.display = "none";
|
||||
}
|
||||
input.disabled = true;
|
||||
generateBtn.disabled = true;
|
||||
|
||||
// Store the prompt for later restoration
|
||||
const prompt = input.value.trim();
|
||||
if (!prompt) {
|
||||
alert("Please enter a prompt");
|
||||
loader.classList.add("hidden");
|
||||
if (resultPlaceholder) {
|
||||
resultPlaceholder.style.display = "flex";
|
||||
}
|
||||
input.disabled = false;
|
||||
generateBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all form values
|
||||
const model = document.getElementById("video-model").value;
|
||||
const size = document.getElementById("video-size").value;
|
||||
const negativePrompt = document.getElementById("negative-prompt").value.trim();
|
||||
|
||||
// Parse size into width and height
|
||||
const sizeParts = size.split("x");
|
||||
let width = 512;
|
||||
let height = 512;
|
||||
if (sizeParts.length === 2) {
|
||||
width = parseInt(sizeParts[0]) || 512;
|
||||
height = parseInt(sizeParts[1]) || 512;
|
||||
}
|
||||
|
||||
// Video-specific parameters
|
||||
const secondsInput = document.getElementById("video-seconds").value.trim();
|
||||
const seconds = secondsInput ? secondsInput : undefined;
|
||||
const fpsInput = document.getElementById("video-fps").value.trim();
|
||||
const fps = fpsInput ? parseInt(fpsInput) : 16;
|
||||
const framesInput = document.getElementById("video-frames").value.trim();
|
||||
const numFrames = framesInput ? parseInt(framesInput) : undefined;
|
||||
|
||||
// Advanced parameters
|
||||
const stepInput = document.getElementById("video-steps").value.trim();
|
||||
const step = stepInput ? parseInt(stepInput) : undefined;
|
||||
const seedInput = document.getElementById("video-seed").value.trim();
|
||||
const seed = seedInput ? parseInt(seedInput) : undefined;
|
||||
const cfgScaleInput = document.getElementById("video-cfg-scale").value.trim();
|
||||
const cfgScale = cfgScaleInput ? parseFloat(cfgScaleInput) : undefined;
|
||||
|
||||
// Prepare request body
|
||||
const requestBody = {
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
width: width,
|
||||
height: height,
|
||||
fps: fps,
|
||||
};
|
||||
|
||||
if (negativePrompt) {
|
||||
requestBody.negative_prompt = negativePrompt;
|
||||
}
|
||||
|
||||
if (seconds !== undefined) {
|
||||
requestBody.seconds = seconds;
|
||||
}
|
||||
|
||||
if (numFrames !== undefined) {
|
||||
requestBody.num_frames = numFrames;
|
||||
}
|
||||
|
||||
if (step !== undefined) {
|
||||
requestBody.step = step;
|
||||
}
|
||||
|
||||
if (seed !== undefined) {
|
||||
requestBody.seed = seed;
|
||||
}
|
||||
|
||||
if (cfgScale !== undefined) {
|
||||
requestBody.cfg_scale = cfgScale;
|
||||
}
|
||||
|
||||
// Handle file inputs
|
||||
try {
|
||||
// Start image (for img2video)
|
||||
const startImageInput = document.getElementById("start-image");
|
||||
if (startImageInput.files.length > 0) {
|
||||
const base64 = await fileToBase64(startImageInput.files[0]);
|
||||
requestBody.start_image = base64;
|
||||
}
|
||||
|
||||
// End image
|
||||
const endImageInput = document.getElementById("end-image");
|
||||
if (endImageInput.files.length > 0) {
|
||||
const base64 = await fileToBase64(endImageInput.files[0]);
|
||||
requestBody.end_image = base64;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing image files:", error);
|
||||
resultDiv.innerHTML = '<p class="text-xs text-red-500 p-2">Error processing image files: ' + error.message + '</p>';
|
||||
loader.classList.add("hidden");
|
||||
if (resultPlaceholder) {
|
||||
resultPlaceholder.style.display = "none";
|
||||
}
|
||||
input.disabled = false;
|
||||
generateBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Make API request
|
||||
try {
|
||||
const response = await fetch("v1/videos/generations", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (json.error) {
|
||||
// Display error
|
||||
resultDiv.innerHTML = '<p class="text-xs text-red-500 p-2">Error: ' + json.error.message + '</p>';
|
||||
loader.classList.add("hidden");
|
||||
if (resultPlaceholder) {
|
||||
resultPlaceholder.style.display = "none";
|
||||
}
|
||||
input.disabled = false;
|
||||
generateBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear result div and hide placeholder
|
||||
resultDiv.innerHTML = '';
|
||||
if (resultPlaceholder) {
|
||||
resultPlaceholder.style.display = "none";
|
||||
}
|
||||
|
||||
// Display generated video
|
||||
if (json.data && json.data.length > 0) {
|
||||
json.data.forEach((item, index) => {
|
||||
const videoContainer = document.createElement("div");
|
||||
videoContainer.className = "flex flex-col";
|
||||
|
||||
// Create video element
|
||||
const video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.className = "w-full h-auto rounded-lg";
|
||||
video.preload = "metadata";
|
||||
|
||||
if (item.url) {
|
||||
video.src = item.url;
|
||||
} else if (item.b64_json) {
|
||||
video.src = "data:video/mp4;base64," + item.b64_json;
|
||||
} else {
|
||||
return; // Skip invalid items
|
||||
}
|
||||
|
||||
videoContainer.appendChild(video);
|
||||
|
||||
// Create caption container
|
||||
const captionDiv = document.createElement("div");
|
||||
captionDiv.className = "mt-2 p-2 bg-[var(--color-bg-secondary)] rounded-lg text-xs";
|
||||
|
||||
// Prompt caption
|
||||
const promptCaption = document.createElement("p");
|
||||
promptCaption.className = "text-[var(--color-text-primary)] mb-1.5 break-words";
|
||||
promptCaption.innerHTML = '<strong>Prompt:</strong> ' + escapeHtml(prompt);
|
||||
captionDiv.appendChild(promptCaption);
|
||||
|
||||
// Negative prompt if provided
|
||||
if (negativePrompt) {
|
||||
const negativeCaption = document.createElement("p");
|
||||
negativeCaption.className = "text-[var(--color-text-secondary)] mb-1.5 break-words";
|
||||
negativeCaption.innerHTML = '<strong>Negative Prompt:</strong> ' + escapeHtml(negativePrompt);
|
||||
captionDiv.appendChild(negativeCaption);
|
||||
}
|
||||
|
||||
// Generation details
|
||||
const detailsDiv = document.createElement("div");
|
||||
detailsDiv.className = "flex flex-wrap gap-3 text-[10px] text-[var(--color-text-secondary)] mt-1.5";
|
||||
detailsDiv.innerHTML = `
|
||||
<span><strong>Size:</strong> ${width}x${height}</span>
|
||||
${fps ? `<span><strong>FPS:</strong> ${fps}</span>` : ''}
|
||||
${numFrames !== undefined ? `<span><strong>Frames:</strong> ${numFrames}</span>` : ''}
|
||||
${seconds !== undefined ? `<span><strong>Duration:</strong> ${seconds}s</span>` : ''}
|
||||
${step !== undefined ? `<span><strong>Steps:</strong> ${step}</span>` : ''}
|
||||
${seed !== undefined ? `<span><strong>Seed:</strong> ${seed}</span>` : ''}
|
||||
${cfgScale !== undefined ? `<span><strong>CFG Scale:</strong> ${cfgScale}</span>` : ''}
|
||||
`;
|
||||
captionDiv.appendChild(detailsDiv);
|
||||
|
||||
// Copy prompt button
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.className = "mt-1.5 px-2 py-0.5 text-[10px] bg-[var(--color-primary)] text-white rounded hover:opacity-80";
|
||||
copyBtn.innerHTML = '<i class="fas fa-copy mr-1"></i>Copy Prompt';
|
||||
copyBtn.onclick = () => {
|
||||
navigator.clipboard.writeText(prompt).then(() => {
|
||||
copyBtn.innerHTML = '<i class="fas fa-check mr-1"></i>Copied!';
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = '<i class="fas fa-copy mr-1"></i>Copy Prompt';
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
captionDiv.appendChild(copyBtn);
|
||||
|
||||
videoContainer.appendChild(captionDiv);
|
||||
resultDiv.appendChild(videoContainer);
|
||||
});
|
||||
// Hide placeholder when videos are displayed
|
||||
if (resultPlaceholder) {
|
||||
resultPlaceholder.style.display = "none";
|
||||
}
|
||||
} else {
|
||||
resultDiv.innerHTML = '<p class="text-xs text-[var(--color-text-secondary)] p-2">No videos were generated.</p>';
|
||||
if (resultPlaceholder) {
|
||||
resultPlaceholder.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating video:", error);
|
||||
resultDiv.innerHTML = '<p class="text-xs text-red-500 p-2">Error: ' + error.message + '</p>';
|
||||
if (resultPlaceholder) {
|
||||
resultPlaceholder.style.display = "none";
|
||||
}
|
||||
} finally {
|
||||
// Hide loader and re-enable form
|
||||
loader.classList.add("hidden");
|
||||
input.disabled = false;
|
||||
generateBtn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to escape HTML
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const input = document.getElementById("input");
|
||||
const form = document.getElementById("genvideo");
|
||||
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
|
||||
if (form) {
|
||||
form.addEventListener("submit", genVideo);
|
||||
}
|
||||
|
||||
// Handle Enter key press in the prompt input (but allow Shift+Enter for new lines)
|
||||
if (input) {
|
||||
input.addEventListener("keydown", function(event) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
genVideo(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hide loader initially
|
||||
const loader = document.getElementById("loader");
|
||||
if (loader) {
|
||||
loader.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
@@ -315,7 +315,7 @@
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if eq . "FLAG_IMAGE" }}
|
||||
<a href="image/{{$backendCfg.Name}}" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-success)]/10 text-green-300 hover:bg-[var(--color-success)]/20 transition-colors" title="Image">
|
||||
<a href="text2image/{{$backendCfg.Name}}" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-success)]/10 text-green-300 hover:bg-[var(--color-success)]/20 transition-colors" title="Image">
|
||||
<i class="fas fa-image text-[8px] mr-1"></i>Image
|
||||
</a>
|
||||
{{ end }}
|
||||
|
||||
@@ -25,12 +25,9 @@
|
||||
<a href="chat/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fa-solid fa-comments text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Chat
|
||||
</a>
|
||||
<a href="image/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<a href="text2image/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-image text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Images
|
||||
</a>
|
||||
<a href="video/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-video text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Video
|
||||
</a>
|
||||
<a href="tts/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fa-solid fa-music text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>TTS
|
||||
</a>
|
||||
@@ -88,12 +85,9 @@
|
||||
<a href="chat/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fa-solid fa-comments text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Chat
|
||||
</a>
|
||||
<a href="image/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<a href="text2image/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-image text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Images
|
||||
</a>
|
||||
<a href="video/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-video text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Video
|
||||
</a>
|
||||
<a href="tts/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fa-solid fa-music text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>TTS
|
||||
</a>
|
||||
|
||||
154
core/http/views/partials/spa_navbar.html
Normal file
154
core/http/views/partials/spa_navbar.html
Normal file
@@ -0,0 +1,154 @@
|
||||
<nav class="bg-[var(--color-bg-primary)] shadow-2xl border-b border-[var(--color-bg-secondary)]">
|
||||
<div class="container mx-auto px-4 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<!-- Logo Image -->
|
||||
<a href="#" @click.prevent="$store.router.navigate('home')" class="flex items-center group">
|
||||
<img src="static/logo_horizontal.png"
|
||||
alt="LocalAI Logo"
|
||||
class="h-10 mr-3 brightness-110 transition-all duration-300 group-hover:brightness-125 group-hover:drop-shadow-[0_0_8px_var(--color-primary-border)]">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Menu button for small screens -->
|
||||
<div class="lg:hidden">
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] focus:outline-none p-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)]">
|
||||
<i class="fas fa-bars fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation links -->
|
||||
<div class="hidden lg:flex lg:items-center lg:justify-end lg:space-x-1">
|
||||
<a href="#" @click.prevent="$store.router.navigate('home')"
|
||||
:class="$store.router.currentRoute === 'home' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-home text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Home
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('chat')"
|
||||
:class="$store.router.currentRoute === 'chat' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fa-solid fa-comments text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Chat
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('text2image')"
|
||||
:class="$store.router.currentRoute === 'text2image' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-image text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Images
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('tts')"
|
||||
:class="$store.router.currentRoute === 'tts' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fa-solid fa-music text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>TTS
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('talk')"
|
||||
:class="$store.router.currentRoute === 'talk' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fa-solid fa-phone text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Talk
|
||||
</a>
|
||||
<a href="agent-jobs" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-tasks text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Agent Jobs
|
||||
</a>
|
||||
<a href="traces/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-chart-line text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Traces
|
||||
</a>
|
||||
<a href="swagger/index.html" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-code text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>API
|
||||
</a>
|
||||
|
||||
<!-- System Dropdown -->
|
||||
<div class="relative" @click.away="settingsOpen = false">
|
||||
<button @click="settingsOpen = !settingsOpen"
|
||||
class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-cog text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Settings
|
||||
<i class="fas fa-chevron-down ml-1 text-xs transition-transform" :class="settingsOpen ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
<div x-show="settingsOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute top-full right-0 mt-1 w-48 bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded-lg shadow-lg z-50 py-1">
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse'); settingsOpen = false" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-primary)] px-3 py-2 text-sm transition-colors flex items-center">
|
||||
<i class="fas fa-brain text-[var(--color-primary)] mr-2 text-xs"></i>Models
|
||||
</a>
|
||||
<a href="browse/backends" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-primary)] px-3 py-2 text-sm transition-colors flex items-center">
|
||||
<i class="fas fa-server text-[var(--color-primary)] mr-2 text-xs"></i>Backends
|
||||
</a>
|
||||
<a href="p2p/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-primary)] px-3 py-2 text-sm transition-colors flex items-center">
|
||||
<i class="fa-solid fa-circle-nodes text-[var(--color-primary)] mr-2 text-xs"></i>Swarm
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('manage'); settingsOpen = false" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-primary)] px-3 py-2 text-sm transition-colors flex items-center">
|
||||
<i class="fas fa-cog text-[var(--color-primary)] mr-2 text-xs"></i>System
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible menu for small screens -->
|
||||
<div class="lg:hidden" x-show="mobileMenuOpen" x-transition>
|
||||
<div class="pt-3 pb-2 space-y-1 border-t border-[var(--color-bg-secondary)] mt-2">
|
||||
<a href="#" @click.prevent="$store.router.navigate('home'); mobileMenuOpen = false"
|
||||
:class="$store.router.currentRoute === 'home' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="block hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-home text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Home
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('chat'); mobileMenuOpen = false"
|
||||
:class="$store.router.currentRoute === 'chat' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="block hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fa-solid fa-comments text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Chat
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('text2image'); mobileMenuOpen = false"
|
||||
:class="$store.router.currentRoute === 'text2image' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="block hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-image text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Images
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('tts'); mobileMenuOpen = false"
|
||||
:class="$store.router.currentRoute === 'tts' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="block hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fa-solid fa-music text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>TTS
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('talk'); mobileMenuOpen = false"
|
||||
:class="$store.router.currentRoute === 'talk' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="block hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fa-solid fa-phone text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Talk
|
||||
</a>
|
||||
<a href="agent-jobs" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-tasks text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Agent Jobs
|
||||
</a>
|
||||
<a href="traces/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-chart-line text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Traces
|
||||
</a>
|
||||
<a href="swagger/index.html" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-code text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>API
|
||||
</a>
|
||||
|
||||
<!-- System with submenu -->
|
||||
<div>
|
||||
<button @click="mobileSettingsOpen = !mobileSettingsOpen"
|
||||
class="w-full text-left text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center justify-between text-sm">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-cog text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Settings
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-xs transition-transform" :class="mobileSettingsOpen ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
<div x-show="mobileSettingsOpen" x-transition class="overflow-hidden">
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse'); mobileMenuOpen = false" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] pl-8 pr-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-brain text-[var(--color-primary)] mr-3 w-5 text-center text-xs"></i>Models
|
||||
</a>
|
||||
<a href="browse/backends" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] pl-8 pr-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-server text-[var(--color-primary)] mr-3 w-5 text-center text-xs"></i>Backends
|
||||
</a>
|
||||
<a href="p2p/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] pl-8 pr-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fa-solid fa-circle-nodes text-[var(--color-primary)] mr-3 w-5 text-center text-xs"></i>Swarm
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('manage'); mobileMenuOpen = false" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] pl-8 pr-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-cog text-[var(--color-primary)] mr-3 w-5 text-center text-xs"></i>System
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -485,28 +485,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open Responses Settings Section -->
|
||||
<div class="bg-[var(--color-bg-secondary)] border border-[var(--color-accent)]/20 rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
|
||||
<i class="fas fa-database mr-2 text-[var(--color-accent)] text-sm"></i>
|
||||
Open Responses Settings
|
||||
</h2>
|
||||
<p class="text-xs text-[var(--color-text-secondary)] mb-4">
|
||||
Configure Open Responses API response storage
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Store TTL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-2">Response Store TTL</label>
|
||||
<p class="text-xs text-[var(--color-text-secondary)] mb-2">Time-to-live for stored responses (e.g., 1h, 30m, 0 = no expiration)</p>
|
||||
<input type="text" x-model="settings.open_responses_store_ttl"
|
||||
placeholder="0"
|
||||
class="w-full px-3 py-2 bg-[var(--color-bg-primary)] border border-[var(--color-accent)]/20 rounded text-sm text-[var(--color-text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/50">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Settings Section -->
|
||||
<div class="bg-[var(--color-bg-secondary)] border border-[var(--color-error-light)] rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
|
||||
@@ -655,8 +633,7 @@ function settingsDashboard() {
|
||||
galleries_json: '[]',
|
||||
backend_galleries_json: '[]',
|
||||
api_keys_text: '',
|
||||
agent_job_retention_days: 30,
|
||||
open_responses_store_ttl: '0'
|
||||
agent_job_retention_days: 30
|
||||
},
|
||||
sourceInfo: '',
|
||||
saving: false,
|
||||
@@ -703,8 +680,7 @@ function settingsDashboard() {
|
||||
galleries_json: JSON.stringify(data.galleries || [], null, 2),
|
||||
backend_galleries_json: JSON.stringify(data.backend_galleries || [], null, 2),
|
||||
api_keys_text: (data.api_keys || []).join('\n'),
|
||||
agent_job_retention_days: data.agent_job_retention_days || 30,
|
||||
open_responses_store_ttl: data.open_responses_store_ttl || '0'
|
||||
agent_job_retention_days: data.agent_job_retention_days || 30
|
||||
};
|
||||
this.sourceInfo = data.source || 'default';
|
||||
} else {
|
||||
@@ -862,9 +838,6 @@ function settingsDashboard() {
|
||||
if (this.settings.agent_job_retention_days !== undefined) {
|
||||
payload.agent_job_retention_days = parseInt(this.settings.agent_job_retention_days) || 30;
|
||||
}
|
||||
if (this.settings.open_responses_store_ttl !== undefined) {
|
||||
payload.open_responses_store_ttl = this.settings.open_responses_store_ttl;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
|
||||
565
core/http/views/spa.html
Normal file
565
core/http/views/spa.html
Normal file
@@ -0,0 +1,565 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{template "views/partials/head" .}}
|
||||
|
||||
<!-- Critical Alpine.js component functions must be defined before Alpine loads -->
|
||||
<script>
|
||||
// Resource Monitor component (GPU if available, otherwise RAM)
|
||||
function resourceMonitor() {
|
||||
return {
|
||||
resourceData: null,
|
||||
pollInterval: null,
|
||||
|
||||
async fetchResourceData() {
|
||||
try {
|
||||
const response = await fetch('/api/resources');
|
||||
if (response.ok) {
|
||||
this.resourceData = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching resource data:', error);
|
||||
}
|
||||
},
|
||||
|
||||
startPolling() {
|
||||
this.fetchResourceData();
|
||||
this.pollInterval = setInterval(() => this.fetchResourceData(), 5000);
|
||||
},
|
||||
|
||||
stopPolling() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Format bytes helper
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Home input form component
|
||||
function homeInputForm() {
|
||||
return {
|
||||
selectedModel: '',
|
||||
inputValue: '',
|
||||
shiftPressed: false,
|
||||
fileName: '',
|
||||
imageFiles: [],
|
||||
audioFiles: [],
|
||||
textFiles: [],
|
||||
attachedFiles: [],
|
||||
mcpMode: false,
|
||||
mcpAvailable: false,
|
||||
mcpModels: {},
|
||||
currentPlaceholder: 'Send a message...',
|
||||
placeholderIndex: 0,
|
||||
charIndex: 0,
|
||||
isTyping: false,
|
||||
typingTimeout: null,
|
||||
displayTimeout: null,
|
||||
placeholderMessages: [
|
||||
'What is Nuclear fusion?',
|
||||
'How does a combustion engine work?',
|
||||
'Explain quantum computing',
|
||||
'What causes climate change?',
|
||||
'How do neural networks learn?',
|
||||
'What is the theory of relativity?',
|
||||
'How does photosynthesis work?',
|
||||
'Explain the water cycle',
|
||||
'What is machine learning?',
|
||||
'How do black holes form?'
|
||||
],
|
||||
|
||||
init() {
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.startTypingAnimation();
|
||||
this.buildMCPModelsMap();
|
||||
this.$nextTick(() => {
|
||||
const select = this.$el.querySelector('select');
|
||||
if (select && select.options.length > 1) {
|
||||
const firstModelOption = select.options[1];
|
||||
if (firstModelOption && firstModelOption.value) {
|
||||
this.selectedModel = firstModelOption.value;
|
||||
this.checkMCPAvailability();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.$watch('selectedModel', () => {
|
||||
this.checkMCPAvailability();
|
||||
});
|
||||
},
|
||||
|
||||
buildMCPModelsMap() {
|
||||
const select = this.$el.querySelector('select');
|
||||
if (!select) return;
|
||||
this.mcpModels = {};
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
const option = select.options[i];
|
||||
if (option.value) {
|
||||
const hasMcpAttr = option.getAttribute('data-has-mcp');
|
||||
this.mcpModels[option.value] = hasMcpAttr === 'true';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
checkMCPAvailability() {
|
||||
if (!this.selectedModel) {
|
||||
this.mcpAvailable = false;
|
||||
this.mcpMode = false;
|
||||
return;
|
||||
}
|
||||
const hasMCP = this.mcpModels[this.selectedModel] === true;
|
||||
this.mcpAvailable = hasMCP;
|
||||
if (!hasMCP) {
|
||||
this.mcpMode = false;
|
||||
}
|
||||
},
|
||||
|
||||
startTypingAnimation() {
|
||||
if (this.isTyping) return;
|
||||
this.typeNextPlaceholder();
|
||||
},
|
||||
|
||||
typeNextPlaceholder() {
|
||||
if (this.isTyping) return;
|
||||
this.isTyping = true;
|
||||
this.charIndex = 0;
|
||||
const message = this.placeholderMessages[this.placeholderIndex];
|
||||
this.currentPlaceholder = '';
|
||||
window.currentPlaceholderText = '';
|
||||
|
||||
const typeChar = () => {
|
||||
if (this.charIndex < message.length) {
|
||||
this.currentPlaceholder = message.substring(0, this.charIndex + 1);
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.charIndex++;
|
||||
this.typingTimeout = setTimeout(typeChar, 30);
|
||||
} else {
|
||||
this.isTyping = false;
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.displayTimeout = setTimeout(() => {
|
||||
this.placeholderIndex = (this.placeholderIndex + 1) % this.placeholderMessages.length;
|
||||
this.typeNextPlaceholder();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
typeChar();
|
||||
},
|
||||
|
||||
pauseTyping() {
|
||||
if (this.typingTimeout) {
|
||||
clearTimeout(this.typingTimeout);
|
||||
this.typingTimeout = null;
|
||||
}
|
||||
if (this.displayTimeout) {
|
||||
clearTimeout(this.displayTimeout);
|
||||
this.displayTimeout = null;
|
||||
}
|
||||
this.isTyping = false;
|
||||
},
|
||||
|
||||
resumeTyping() {
|
||||
if (!this.inputValue.trim() && !this.isTyping) {
|
||||
this.startTypingAnimation();
|
||||
}
|
||||
},
|
||||
|
||||
handleFocus() {
|
||||
if (this.isTyping && this.placeholderIndex < this.placeholderMessages.length) {
|
||||
const fullMessage = this.placeholderMessages[this.placeholderIndex];
|
||||
this.currentPlaceholder = fullMessage;
|
||||
window.currentPlaceholderText = fullMessage;
|
||||
}
|
||||
this.pauseTyping();
|
||||
},
|
||||
|
||||
handleBlur() {
|
||||
if (!this.inputValue.trim()) {
|
||||
this.resumeTyping();
|
||||
}
|
||||
},
|
||||
|
||||
handleInput() {
|
||||
if (this.inputValue.trim()) {
|
||||
this.pauseTyping();
|
||||
} else {
|
||||
this.resumeTyping();
|
||||
}
|
||||
},
|
||||
|
||||
handleFileSelection(files, fileType) {
|
||||
Array.from(files).forEach(file => {
|
||||
const exists = this.attachedFiles.some(f => f.name === file.name && f.type === fileType);
|
||||
if (!exists) {
|
||||
this.attachedFiles.push({ name: file.name, type: fileType });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
removeAttachedFile(fileType, fileName) {
|
||||
const index = this.attachedFiles.findIndex(f => f.name === fileName && f.type === fileType);
|
||||
if (index !== -1) {
|
||||
this.attachedFiles.splice(index, 1);
|
||||
}
|
||||
if (fileType === 'image') {
|
||||
this.imageFiles = this.imageFiles.filter(f => f.name !== fileName);
|
||||
} else if (fileType === 'audio') {
|
||||
this.audioFiles = this.audioFiles.filter(f => f.name !== fileName);
|
||||
} else if (fileType === 'file') {
|
||||
this.textFiles = this.textFiles.filter(f => f.name !== fileName);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Start chat function for SPA
|
||||
function startChatSPA(event) {
|
||||
if (event) event.preventDefault();
|
||||
const form = event ? event.target.closest('form') : document.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
const alpineComponent = form.closest('[x-data]');
|
||||
const select = alpineComponent ? alpineComponent.querySelector('select') : null;
|
||||
const textarea = form.querySelector('textarea');
|
||||
|
||||
const selectedModel = select ? select.value : '';
|
||||
let message = textarea ? textarea.value : '';
|
||||
|
||||
if (!message.trim() && window.currentPlaceholderText) {
|
||||
message = window.currentPlaceholderText;
|
||||
}
|
||||
|
||||
if (!selectedModel || !message.trim()) return;
|
||||
|
||||
let mcpMode = false;
|
||||
const mcpToggle = document.getElementById('spa_home_mcp_toggle');
|
||||
if (mcpToggle && mcpToggle.checked) mcpMode = true;
|
||||
|
||||
const chatData = { message, imageFiles: [], audioFiles: [], textFiles: [], mcpMode };
|
||||
const imageInput = document.getElementById('spa_home_input_image');
|
||||
const audioInput = document.getElementById('spa_home_input_audio');
|
||||
const fileInput = document.getElementById('spa_home_input_file');
|
||||
|
||||
const filePromises = [
|
||||
...Array.from(imageInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
),
|
||||
...Array.from(audioInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
),
|
||||
...Array.from(fileInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsText(file);
|
||||
})
|
||||
)
|
||||
];
|
||||
|
||||
const navigateToChat = () => {
|
||||
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData));
|
||||
if (window.Alpine && Alpine.store('router')) {
|
||||
Alpine.store('router').navigate('chat', { model: selectedModel });
|
||||
} else {
|
||||
window.location.href = `/chat/${selectedModel}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (filePromises.length > 0) {
|
||||
Promise.all(filePromises).then(files => {
|
||||
files.forEach(file => {
|
||||
if (file.type.startsWith('image/')) chatData.imageFiles.push(file);
|
||||
else if (file.type.startsWith('audio/')) chatData.audioFiles.push(file);
|
||||
else chatData.textFiles.push(file);
|
||||
});
|
||||
navigateToChat();
|
||||
}).catch(() => navigateToChat());
|
||||
} else {
|
||||
navigateToChat();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop individual model
|
||||
async function stopModel(modelName) {
|
||||
if (!confirm(`Are you sure you want to stop "${modelName}"?`)) return;
|
||||
try {
|
||||
const response = await fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: modelName })
|
||||
});
|
||||
if (response.ok) {
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping model:', error);
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
}
|
||||
|
||||
// Stop all loaded models
|
||||
async function stopAllModels(component) {
|
||||
const loadedModelElements = document.querySelectorAll('[data-loaded-model]');
|
||||
const loadedModelNames = Array.from(loadedModelElements).map(el => {
|
||||
const span = el.querySelector('span.truncate');
|
||||
return span ? span.textContent.trim() : '';
|
||||
}).filter(name => name.length > 0);
|
||||
|
||||
if (loadedModelNames.length === 0) return;
|
||||
if (!confirm(`Are you sure you want to stop all ${loadedModelNames.length} loaded model(s)?`)) return;
|
||||
|
||||
if (component) component.stoppingAll = true;
|
||||
|
||||
try {
|
||||
const stopPromises = loadedModelNames.map(modelName =>
|
||||
fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: modelName })
|
||||
})
|
||||
);
|
||||
await Promise.all(stopPromises);
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} catch (error) {
|
||||
console.error('Error stopping models:', error);
|
||||
alert('Failed to stop some models');
|
||||
if (component) component.stoppingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Make available globally
|
||||
window.resourceMonitor = resourceMonitor;
|
||||
window.formatBytes = formatBytes;
|
||||
window.homeInputForm = homeInputForm;
|
||||
window.startChatSPA = startChatSPA;
|
||||
window.stopModel = stopModel;
|
||||
window.stopAllModels = stopAllModels;
|
||||
|
||||
// ========================================
|
||||
// SPA Router - Alpine.js Store Definition
|
||||
// Must be defined before Alpine.js initializes
|
||||
// ========================================
|
||||
|
||||
// Define routes and their corresponding view IDs
|
||||
const SPA_ROUTES = {
|
||||
'home': { title: 'LocalAI', viewId: 'view-home', paths: ['/', ''] },
|
||||
'chat': { title: 'LocalAI - Chat', viewId: 'view-chat', paths: ['/chat'] },
|
||||
'text2image': { title: 'LocalAI - Images', viewId: 'view-text2image', paths: ['/text2image'] },
|
||||
'tts': { title: 'LocalAI - TTS', viewId: 'view-tts', paths: ['/tts'] },
|
||||
'talk': { title: 'LocalAI - Talk', viewId: 'view-talk', paths: ['/talk'] },
|
||||
'manage': { title: 'LocalAI - System', viewId: 'view-manage', paths: ['/manage'] },
|
||||
'browse': { title: 'LocalAI - Model Gallery', viewId: 'view-browse', paths: ['/browse'] }
|
||||
};
|
||||
|
||||
// Parse URL path to determine route
|
||||
function parseUrlPath(pathname) {
|
||||
pathname = pathname.replace(/\/$/, '') || '/';
|
||||
|
||||
// Check for hash-based routes first
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash) {
|
||||
const hashParts = hash.split('/');
|
||||
const route = hashParts[0];
|
||||
const model = hashParts[1] || null;
|
||||
if (SPA_ROUTES[route]) {
|
||||
return { route, params: model ? { model } : {} };
|
||||
}
|
||||
}
|
||||
|
||||
// Check path-based routes
|
||||
for (const [route, config] of Object.entries(SPA_ROUTES)) {
|
||||
for (const path of config.paths) {
|
||||
if (pathname === path) {
|
||||
return { route, params: {} };
|
||||
}
|
||||
if (pathname.startsWith(path + '/')) {
|
||||
const param = pathname.slice(path.length + 1);
|
||||
if (param) {
|
||||
return { route, params: { model: param } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { route: 'home', params: {} };
|
||||
}
|
||||
|
||||
// Register the router store with Alpine.js on init event
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const initialRoute = parseUrlPath(window.location.pathname);
|
||||
|
||||
Alpine.store('router', {
|
||||
currentRoute: initialRoute.route,
|
||||
routeParams: initialRoute.params,
|
||||
previousRoute: null,
|
||||
|
||||
navigate(route, params = {}) {
|
||||
if (!SPA_ROUTES[route]) {
|
||||
console.warn('Unknown route:', route);
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousRoute = this.currentRoute;
|
||||
this.currentRoute = route;
|
||||
this.routeParams = params;
|
||||
|
||||
document.title = SPA_ROUTES[route].title;
|
||||
|
||||
const url = route === 'home' ? '/' : '/#' + route;
|
||||
if (params.model) {
|
||||
window.history.pushState({ route, params }, '', '/#' + route + '/' + params.model);
|
||||
} else {
|
||||
window.history.pushState({ route, params }, '', url);
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
window.dispatchEvent(new CustomEvent('spa:navigate', {
|
||||
detail: { route, params, previousRoute: this.previousRoute }
|
||||
}));
|
||||
},
|
||||
|
||||
isRoute(route) {
|
||||
return this.currentRoute === route;
|
||||
},
|
||||
|
||||
navigateToChat(model) {
|
||||
this.navigate('chat', { model });
|
||||
},
|
||||
|
||||
navigateToText2Image(model) {
|
||||
this.navigate('text2image', { model });
|
||||
},
|
||||
|
||||
navigateToTTS(model) {
|
||||
this.navigate('tts', { model });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle browser back/forward buttons
|
||||
window.addEventListener('popstate', (event) => {
|
||||
if (window.Alpine && Alpine.store('router')) {
|
||||
if (event.state && event.state.route) {
|
||||
Alpine.store('router').currentRoute = event.state.route;
|
||||
Alpine.store('router').routeParams = event.state.params || {};
|
||||
} else {
|
||||
const parsed = parseUrlPath(window.location.pathname);
|
||||
Alpine.store('router').currentRoute = parsed.route;
|
||||
Alpine.store('router').routeParams = parsed.params;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
window.SPA_ROUTES = SPA_ROUTES;
|
||||
window.parseUrlPath = parseUrlPath;
|
||||
</script>
|
||||
|
||||
<!-- SPA Scripts -->
|
||||
<script defer src="static/spa-router.js"></script>
|
||||
<script defer src="static/spa-home.js"></script>
|
||||
<script defer src="static/chat.js"></script>
|
||||
<script defer src="static/image.js"></script>
|
||||
<script defer src="static/tts.js"></script>
|
||||
<!-- Note: talk.js is NOT included here because it has global-scope DOM access that
|
||||
conflicts with the SPA architecture. The SPA talk view has its own inline JS. -->
|
||||
<script src="static/assets/pdf.min.js"></script>
|
||||
<script>
|
||||
// Initialize PDF.js worker
|
||||
if (typeof pdfjsLib !== 'undefined') {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'static/assets/pdf.worker.min.js';
|
||||
}
|
||||
|
||||
// Store gallery configs for header icon display and model info modal
|
||||
window.__galleryConfigs = {};
|
||||
{{ $allGalleryConfigs:=.GalleryConfig }}
|
||||
{{ range $modelName, $galleryConfig := $allGalleryConfigs }}
|
||||
window.__galleryConfigs["{{$modelName}}"] = {};
|
||||
{{ if $galleryConfig.Icon }}
|
||||
window.__galleryConfigs["{{$modelName}}"].Icon = "{{$galleryConfig.Icon}}";
|
||||
{{ end }}
|
||||
{{ if $galleryConfig.Description }}
|
||||
window.__galleryConfigs["{{$modelName}}"].Description = {{ printf "%q" $galleryConfig.Description }};
|
||||
{{ end }}
|
||||
{{ if $galleryConfig.URLs }}
|
||||
window.__galleryConfigs["{{$modelName}}"].URLs = [
|
||||
{{ range $idx, $url := $galleryConfig.URLs }}
|
||||
{{ if $idx }},{{ end }}{{ printf "%q" $url }}
|
||||
{{ end }}
|
||||
];
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</script>
|
||||
|
||||
<body class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">
|
||||
<div class="flex flex-col min-h-screen" x-data="{ mobileMenuOpen: false, settingsOpen: false, mobileSettingsOpen: false }">
|
||||
|
||||
{{template "views/partials/spa_navbar" .}}
|
||||
|
||||
<!-- SPA View Container -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
|
||||
<!-- Home View -->
|
||||
<div x-show="$store.router.currentRoute === 'home'" x-cloak>
|
||||
{{template "views/spa/home" .}}
|
||||
</div>
|
||||
|
||||
<!-- Chat View -->
|
||||
<div x-show="$store.router.currentRoute === 'chat'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/chat" .}}
|
||||
</div>
|
||||
|
||||
<!-- Text2Image View -->
|
||||
<div x-show="$store.router.currentRoute === 'text2image'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/text2image" .}}
|
||||
</div>
|
||||
|
||||
<!-- TTS View -->
|
||||
<div x-show="$store.router.currentRoute === 'tts'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/tts" .}}
|
||||
</div>
|
||||
|
||||
<!-- Talk View -->
|
||||
<div x-show="$store.router.currentRoute === 'talk'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/talk" .}}
|
||||
</div>
|
||||
|
||||
<!-- Manage View -->
|
||||
<div x-show="$store.router.currentRoute === 'manage'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/manage" .}}
|
||||
</div>
|
||||
|
||||
<!-- Browse View (Model Gallery) -->
|
||||
<div x-show="$store.router.currentRoute === 'browse'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/browse" .}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{template "views/partials/footer" .}}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Hide elements until Alpine.js initializes */
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
221
core/http/views/spa/browse.html
Normal file
221
core/http/views/spa/browse.html
Normal file
@@ -0,0 +1,221 @@
|
||||
<!-- Browse/Gallery View Content for SPA -->
|
||||
<!-- This is a simplified gallery view - for full functionality, use the /browse/ URL -->
|
||||
<div class="container mx-auto px-4 py-8 flex-grow" x-data="browseGallery()">
|
||||
|
||||
<!-- Hero Header -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
<i class="fas fa-images mr-2"></i>Model Gallery
|
||||
</h1>
|
||||
<p class="hero-subtitle">Browse and install AI models</p>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<div class="flex flex-wrap justify-center gap-3 mt-6">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input="filterModels()"
|
||||
placeholder="Search models..."
|
||||
class="input pl-10 py-2 w-64">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--color-text-secondary)]"></i>
|
||||
</div>
|
||||
|
||||
<select x-model="categoryFilter" @change="filterModels()" class="input py-2">
|
||||
<option value="">All Categories</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="image">Image Generation</option>
|
||||
<option value="audio">Audio</option>
|
||||
<option value="embedding">Embeddings</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Models Grid -->
|
||||
<div x-show="!loading" class="mt-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<template x-for="model in filteredModels" :key="model.name">
|
||||
<div class="card overflow-hidden hover:border-[var(--color-primary-border)] transition-colors">
|
||||
<!-- Model Header -->
|
||||
<div class="p-4 border-b border-[var(--color-border)]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-[var(--color-primary-light)] rounded-lg flex items-center justify-center mr-3">
|
||||
<template x-if="model.icon">
|
||||
<img :src="model.icon" :alt="model.name" class="w-8 h-8 rounded">
|
||||
</template>
|
||||
<template x-if="!model.icon">
|
||||
<i class="fas fa-brain text-[var(--color-primary)]"></i>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] truncate max-w-[150px]" x-text="model.name"></h3>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]" x-text="model.gallery?.name || 'Unknown'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="model.installed">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-300">
|
||||
<i class="fas fa-check mr-1"></i>Installed
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Info -->
|
||||
<div class="p-4">
|
||||
<p class="text-xs text-[var(--color-text-secondary)] line-clamp-2 mb-3" x-text="model.description || 'No description available'"></p>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
<template x-for="tag in (model.tags || []).slice(0, 3)" :key="tag">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)]" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<template x-if="model.installed">
|
||||
<button @click="$store.router.navigate('chat', { model: model.name })"
|
||||
class="flex-1 btn-primary text-xs py-1.5">
|
||||
<i class="fas fa-comments mr-1"></i>Use
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="!model.installed">
|
||||
<button @click="installModel(model)"
|
||||
:disabled="model.installing"
|
||||
:class="model.installing ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="flex-1 btn-primary text-xs py-1.5">
|
||||
<i class="fas fa-download mr-1"></i>
|
||||
<span x-text="model.installing ? 'Installing...' : 'Install'"></span>
|
||||
</button>
|
||||
</template>
|
||||
<a :href="`/browse/${model.gallery?.name || ''}/${model.name}`"
|
||||
class="btn-secondary text-xs py-1.5 px-2" title="View details">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && filteredModels.length === 0" class="text-center py-12 text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-search text-4xl mb-3 opacity-50"></i>
|
||||
<p>No models found</p>
|
||||
<p class="text-sm mt-2">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
|
||||
<!-- Link to Full Gallery -->
|
||||
<div class="mt-8 text-center">
|
||||
<a href="/browse/" class="btn-secondary">
|
||||
<i class="fas fa-external-link-alt mr-2"></i>
|
||||
View Full Model Gallery
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Browse gallery component
|
||||
function browseGallery() {
|
||||
return {
|
||||
loading: true,
|
||||
searchQuery: '',
|
||||
categoryFilter: '',
|
||||
models: [],
|
||||
filteredModels: [],
|
||||
|
||||
init() {
|
||||
this.loadModels();
|
||||
},
|
||||
|
||||
async loadModels() {
|
||||
try {
|
||||
// Fetch available models from gallery
|
||||
const response = await fetch('/models/available');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.models = data || [];
|
||||
this.filterModels();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading models:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filterModels() {
|
||||
let filtered = this.models;
|
||||
|
||||
// Search filter
|
||||
if (this.searchQuery.trim()) {
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(m =>
|
||||
(m.name && m.name.toLowerCase().includes(query)) ||
|
||||
(m.description && m.description.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (this.categoryFilter) {
|
||||
filtered = filtered.filter(m => {
|
||||
const tags = m.tags || [];
|
||||
const name = (m.name || '').toLowerCase();
|
||||
switch (this.categoryFilter) {
|
||||
case 'chat':
|
||||
return tags.includes('chat') || tags.includes('llm') || name.includes('chat');
|
||||
case 'image':
|
||||
return tags.includes('image') || tags.includes('diffusion') || name.includes('stable');
|
||||
case 'audio':
|
||||
return tags.includes('audio') || tags.includes('tts') || tags.includes('whisper');
|
||||
case 'embedding':
|
||||
return tags.includes('embedding') || name.includes('embed');
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.filteredModels = filtered.slice(0, 20); // Limit to first 20 for performance
|
||||
},
|
||||
|
||||
async installModel(model) {
|
||||
model.installing = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/models/apply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: model.gallery?.name + '@' + model.name
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Model installation started
|
||||
alert(`Installation of ${model.name} started. This may take a while.`);
|
||||
// Refresh after a delay
|
||||
setTimeout(() => this.loadModels(), 5000);
|
||||
} else {
|
||||
alert('Failed to start installation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error installing model:', error);
|
||||
alert('Error: ' + error.message);
|
||||
} finally {
|
||||
model.installing = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.browseGallery = browseGallery;
|
||||
</script>
|
||||
273
core/http/views/spa/chat.html
Normal file
273
core/http/views/spa/chat.html
Normal file
@@ -0,0 +1,273 @@
|
||||
<!-- Chat View Content for SPA -->
|
||||
<!-- This embeds the chat interface inline in the SPA -->
|
||||
<div class="flex flex-col flex-1 overflow-hidden" x-data="chatSPA()">
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Sidebar for chat list -->
|
||||
<aside class="hidden lg:flex w-64 flex-col bg-[var(--color-bg-secondary)] border-r border-[var(--color-bg-primary)]">
|
||||
<div class="p-3 border-b border-[var(--color-bg-primary)]">
|
||||
<button @click="createNewChatSPA()" class="w-full btn-primary text-sm py-2">
|
||||
<i class="fas fa-plus mr-2"></i>New Chat
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<template x-for="chat in $store.chat.chats" :key="chat.id">
|
||||
<div
|
||||
@click="switchChatSPA(chat.id)"
|
||||
:class="$store.chat.activeChatId === chat.id ? 'bg-[var(--color-primary-light)] border-[var(--color-primary-border)]' : 'hover:bg-[var(--color-bg-primary)] border-transparent'"
|
||||
class="p-2 rounded-lg cursor-pointer border transition-colors group relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="truncate text-sm text-[var(--color-text-primary)]" x-text="chat.name"></span>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click.stop="deleteChatSPA(chat.id)"
|
||||
class="p-1 text-red-400 hover:text-red-300 transition-colors"
|
||||
title="Delete chat">
|
||||
<i class="fas fa-trash text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 text-xs text-[var(--color-text-secondary)]">
|
||||
<span x-text="chat.model || 'No model'"></span>
|
||||
<span x-show="$store.chat.hasActiveRequest(chat.id)" class="flex items-center gap-1">
|
||||
<span class="animate-pulse w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Chat Content -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Chat Header -->
|
||||
<header class="flex items-center justify-between px-4 py-2 border-b border-[var(--color-bg-secondary)] bg-[var(--color-bg-primary)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="showMobileSidebar = !showMobileSidebar" class="lg:hidden p-2 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
x-model="currentModel"
|
||||
@change="updateChatModel()"
|
||||
class="input text-sm py-1.5 px-3">
|
||||
<option value="" disabled>Select model...</option>
|
||||
{{ range .ModelsConfig }}
|
||||
{{ $cfg := . }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_CHAT" }}
|
||||
<option value="{{$cfg.Name}}">{{$cfg.Name}}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range .ModelsWithoutConfig }}
|
||||
<option value="{{.}}">{{.}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="tokens-per-second" class="text-xs text-[var(--color-text-secondary)]">-</span>
|
||||
<span id="max-tokens-per-second-badge" class="hidden text-xs bg-green-500/20 text-green-300 px-2 py-0.5 rounded"></span>
|
||||
<div id="header-loading-indicator" class="hidden">
|
||||
<svg class="animate-spin h-4 w-4 text-[var(--color-primary)]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<button @click="clearChat()" class="p-2 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors" title="Clear chat">
|
||||
<i class="fas fa-eraser"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Messages Container -->
|
||||
<div id="chat" class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<template x-for="(message, index) in $store.chat.activeHistory" :key="index">
|
||||
<div :class="message.role === 'user' ? 'justify-end' : 'justify-start'" class="flex">
|
||||
<div :class="message.role === 'user' ? 'bg-[var(--color-primary)] text-white max-w-[80%]' : 'bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)] max-w-[90%]'"
|
||||
class="rounded-lg px-4 py-2">
|
||||
<!-- Thinking/Reasoning messages -->
|
||||
<template x-if="message.role === 'thinking' || message.role === 'reasoning'">
|
||||
<div class="text-xs">
|
||||
<button @click="message.expanded = !message.expanded" class="flex items-center gap-2 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
|
||||
<i :class="message.expanded ? 'fa-chevron-down' : 'fa-chevron-right'" class="fas text-xs"></i>
|
||||
<span>Thinking...</span>
|
||||
</button>
|
||||
<div x-show="message.expanded" x-html="message.html" class="mt-2 prose prose-sm prose-invert max-w-none"></div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Regular messages -->
|
||||
<template x-if="message.role !== 'thinking' && message.role !== 'reasoning'">
|
||||
<div x-html="message.html" class="prose prose-sm prose-invert max-w-none"></div>
|
||||
</template>
|
||||
<!-- Images -->
|
||||
<template x-if="message.image && message.image.length > 0">
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<template x-for="img in message.image" :key="img">
|
||||
<img :src="img" class="max-w-[200px] rounded-lg" alt="Attached image">
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div x-show="!$store.chat.activeHistory || $store.chat.activeHistory.length === 0" class="flex flex-col items-center justify-center h-full text-center text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-comments text-4xl mb-4 opacity-50"></i>
|
||||
<p>Start a conversation</p>
|
||||
<p class="text-sm mt-2">Select a model and send a message to begin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="border-t border-[var(--color-bg-secondary)] p-4 bg-[var(--color-bg-primary)]">
|
||||
<form id="prompt" @submit.prevent="submitPrompt($event)" class="relative">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1 relative">
|
||||
<textarea
|
||||
id="input"
|
||||
name="input"
|
||||
placeholder="Type a message..."
|
||||
class="input w-full resize-none py-3 pr-12"
|
||||
rows="1"
|
||||
@keydown.enter.prevent="if (!$event.shiftKey) submitPrompt($event)"
|
||||
@input="autoResize($event.target)"
|
||||
></textarea>
|
||||
<div class="absolute right-2 bottom-2 flex items-center gap-1">
|
||||
<button type="button" @click="document.getElementById('input_image').click()" class="p-1.5 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors" title="Attach image">
|
||||
<i class="fas fa-image"></i>
|
||||
</button>
|
||||
<button type="button" @click="document.getElementById('input_audio').click()" class="p-1.5 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors" title="Attach audio">
|
||||
<i class="fas fa-microphone"></i>
|
||||
</button>
|
||||
<button type="button" @click="document.getElementById('input_file').click()" class="p-1.5 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors" title="Attach file">
|
||||
<i class="fas fa-paperclip"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" id="send-button" class="btn-primary p-3">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
<button type="button" id="stop-button" @click="stopRequest()" class="btn-primary p-3 bg-red-500 hover:bg-red-600" style="display: none;">
|
||||
<i class="fas fa-stop"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Hidden file inputs -->
|
||||
<input type="file" id="input_image" multiple accept="image/*" class="hidden" @change="readInputImage">
|
||||
<input type="file" id="input_audio" multiple accept="audio/*" class="hidden" @change="readInputAudio">
|
||||
<input type="file" id="input_file" multiple accept=".txt,.md,.pdf" class="hidden" @change="readInputFile">
|
||||
</form>
|
||||
|
||||
<!-- System prompt form (hidden) -->
|
||||
<form id="system_prompt" @submit.prevent="submitSystemPrompt($event)" style="display: none;">
|
||||
<input type="text" id="systemPrompt" name="systemPrompt">
|
||||
</form>
|
||||
<input type="hidden" id="chat-model" value="{{.Model}}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Sidebar Overlay -->
|
||||
<div x-show="showMobileSidebar" @click="showMobileSidebar = false" class="lg:hidden fixed inset-0 bg-black/50 z-40"></div>
|
||||
<aside x-show="showMobileSidebar" class="lg:hidden fixed left-0 top-0 bottom-0 w-64 bg-[var(--color-bg-secondary)] z-50 transform transition-transform"
|
||||
:class="showMobileSidebar ? 'translate-x-0' : '-translate-x-full'">
|
||||
<div class="p-3 border-b border-[var(--color-bg-primary)] flex items-center justify-between">
|
||||
<span class="font-medium text-[var(--color-text-primary)]">Chats</span>
|
||||
<button @click="showMobileSidebar = false" class="p-2 text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<button @click="createNewChatSPA(); showMobileSidebar = false" class="w-full btn-primary text-sm py-2">
|
||||
<i class="fas fa-plus mr-2"></i>New Chat
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<template x-for="chat in $store.chat.chats" :key="chat.id">
|
||||
<div
|
||||
@click="switchChatSPA(chat.id); showMobileSidebar = false"
|
||||
:class="$store.chat.activeChatId === chat.id ? 'bg-[var(--color-primary-light)] border-[var(--color-primary-border)]' : 'hover:bg-[var(--color-bg-primary)] border-transparent'"
|
||||
class="p-2 rounded-lg cursor-pointer border transition-colors">
|
||||
<span class="truncate text-sm text-[var(--color-text-primary)]" x-text="chat.name"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Chat SPA component
|
||||
function chatSPA() {
|
||||
return {
|
||||
currentModel: '{{.Model}}',
|
||||
showMobileSidebar: false,
|
||||
|
||||
init() {
|
||||
// Initialize chat store if not already done
|
||||
this.$nextTick(() => {
|
||||
if (window.Alpine && Alpine.store('chat') && Alpine.store('chat').chats.length === 0) {
|
||||
Alpine.store('chat').createChat(this.currentModel, '', false);
|
||||
}
|
||||
// Update model from route params if available
|
||||
const routeParams = Alpine.store('router')?.routeParams;
|
||||
if (routeParams?.model) {
|
||||
this.currentModel = routeParams.model;
|
||||
const activeChat = Alpine.store('chat').activeChat();
|
||||
if (activeChat) {
|
||||
activeChat.model = this.currentModel;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateChatModel() {
|
||||
const activeChat = Alpine.store('chat').activeChat();
|
||||
if (activeChat) {
|
||||
activeChat.model = this.currentModel;
|
||||
if (typeof window.autoSaveChats === 'function') {
|
||||
window.autoSaveChats();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearChat() {
|
||||
if (confirm('Clear all messages in this chat?')) {
|
||||
Alpine.store('chat').clear();
|
||||
}
|
||||
},
|
||||
|
||||
autoResize(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions for chat in SPA context
|
||||
function createNewChatSPA() {
|
||||
const currentModel = document.getElementById('chat-model')?.value || '';
|
||||
if (window.createNewChat) {
|
||||
window.createNewChat(currentModel, '', false);
|
||||
}
|
||||
}
|
||||
|
||||
function switchChatSPA(chatId) {
|
||||
if (window.switchChat) {
|
||||
window.switchChat(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteChatSPA(chatId) {
|
||||
if (confirm('Delete this chat?')) {
|
||||
if (window.deleteChat) {
|
||||
window.deleteChat(chatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make component available globally
|
||||
window.chatSPA = chatSPA;
|
||||
</script>
|
||||
329
core/http/views/spa/home.html
Normal file
329
core/http/views/spa/home.html
Normal file
@@ -0,0 +1,329 @@
|
||||
<!-- Home View Content for SPA -->
|
||||
<!-- Main Content - ChatGPT-style minimal interface -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-3xl mx-auto">
|
||||
{{ if eq (len .ModelsConfig) 0 }}
|
||||
<!-- No Models - Wizard Guide -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h2 class="hero-title">
|
||||
No Models Installed
|
||||
</h2>
|
||||
<p class="hero-subtitle">
|
||||
Get started with LocalAI by installing your first model. Choose from our gallery, import your own, or use the API to download models.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Preview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="card card-animate">
|
||||
<div class="w-10 h-10 bg-[var(--color-primary-light)] rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-images text-[var(--color-primary)] text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-2">Model Gallery</h3>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Browse and install pre-configured models</p>
|
||||
</div>
|
||||
<div class="card card-animate">
|
||||
<div class="w-10 h-10 bg-[var(--color-accent-light)] rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-upload text-[var(--color-accent)] text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-2">Import Models</h3>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Upload your own model files</p>
|
||||
</div>
|
||||
<div class="card card-animate">
|
||||
<div class="w-10 h-10 bg-[var(--color-success-light)] rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-code text-[var(--color-success)] text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-2">API Download</h3>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Use the API to download models programmatically</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Instructions -->
|
||||
<div class="card mb-6 text-left">
|
||||
<h3 class="text-lg font-bold text-[var(--color-text-primary)] mb-4 flex items-center">
|
||||
<i class="fas fa-rocket text-[var(--color-accent)] mr-2"></i>
|
||||
How to Get Started
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-accent-light)] flex items-center justify-center mr-3 mt-0.5">
|
||||
<span class="text-[var(--color-accent)] font-bold text-sm">1</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-[var(--color-text-primary)] font-medium mb-2">Browse the Model Gallery</p>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm">Explore our curated collection of pre-configured models. Find models for chat, image generation, audio processing, and more.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-accent-light)] flex items-center justify-center mr-3 mt-0.5">
|
||||
<span class="text-[var(--color-accent)] font-bold text-sm">2</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-[var(--color-text-primary)] font-medium mb-2">Install a Model</p>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm">Click on a model from the gallery to install it, or use the import feature to upload your own model files.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-accent-light)] flex items-center justify-center mr-3 mt-0.5">
|
||||
<span class="text-[var(--color-accent)] font-bold text-sm">3</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-[var(--color-text-primary)] font-medium mb-2">Start Chatting</p>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm">Once installed, return to this page to start chatting with your model or use the API to interact programmatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-4 mb-8">
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse')" class="btn-primary">
|
||||
<i class="fas fa-images mr-2"></i>
|
||||
Browse Model Gallery
|
||||
</a>
|
||||
<a href="/import-model" class="btn-primary">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import Model
|
||||
</a>
|
||||
<a href="https://localai.io/basics/getting_started/" target="_blank" class="btn-secondary">
|
||||
<i class="fas fa-graduation-cap mr-2"></i>
|
||||
Getting Started
|
||||
<i class="fas fa-external-link-alt ml-2 text-sm"></i>
|
||||
</a>
|
||||
</div>
|
||||
{{ else }}
|
||||
<!-- Welcome Message / Hero Section -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<div class="mb-4 flex justify-center">
|
||||
<img src="static/logo.png" alt="LocalAI Logo" class="h-16 md:h-20">
|
||||
</div>
|
||||
<h1 class="hero-title">How can I help you today?</h1>
|
||||
<p class="hero-subtitle">Ask me anything, and I'll do my best to assist you.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Input Form -->
|
||||
<div class="mb-8" x-data="homeInputForm()">
|
||||
<!-- Model Selector with MCP Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Select Model</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<select
|
||||
x-model="selectedModel"
|
||||
@change="$nextTick(() => checkMCPAvailability())"
|
||||
class="input flex-1"
|
||||
required
|
||||
>
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model to chat with...</option>
|
||||
{{ range .ModelsConfig }}
|
||||
{{ $cfg := . }}
|
||||
{{ $hasMCP := or (ne $cfg.MCP.Servers "") (ne $cfg.MCP.Stdio "") }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_CHAT" }}
|
||||
<option value="{{$cfg.Name}}" data-has-mcp="{{if $hasMCP}}true{{else}}false{{end}}" class="bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<!-- Compact MCP Toggle - Show only if MCP is available for selected model -->
|
||||
<div
|
||||
x-show="mcpAvailable"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs rounded text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)] whitespace-nowrap">
|
||||
<i class="fa-solid fa-plug text-[var(--color-primary)] text-sm"></i>
|
||||
<span class="text-[var(--color-text-secondary)]">MCP</span>
|
||||
<label class="relative inline-flex items-center cursor-pointer ml-1">
|
||||
<input type="checkbox" id="spa_home_mcp_toggle" class="sr-only peer" x-model="mcpMode">
|
||||
<div class="w-9 h-5 bg-[var(--color-bg-primary)] peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-[var(--color-primary-border)] rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-[var(--color-bg-secondary)] after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Mode Notification - Compact tooltip style -->
|
||||
<div
|
||||
x-show="mcpMode && mcpAvailable"
|
||||
class="mt-2 p-2 bg-[var(--color-primary-light)] border border-[var(--color-primary-border)] rounded text-[var(--color-text-secondary)] text-xs">
|
||||
<div class="flex items-start space-x-2">
|
||||
<i class="fa-solid fa-info-circle text-[var(--color-primary)] mt-0.5 text-xs"></i>
|
||||
<p class="text-[var(--color-text-secondary)]">Non-streaming mode active. Responses may take longer to process.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Bar -->
|
||||
<form @submit.prevent="startChatSPA($event)" class="relative w-full">
|
||||
<!-- Attachment Tags - Show above input when files are attached -->
|
||||
<div x-show="attachedFiles.length > 0" class="mb-3 flex flex-wrap gap-2 items-center">
|
||||
<template x-for="(file, index) in attachedFiles" :key="index">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm bg-[var(--color-primary-light)] border border-[var(--color-primary-border)] text-[var(--color-text-primary)]">
|
||||
<i :class="file.type === 'image' ? 'fa-solid fa-image' : file.type === 'audio' ? 'fa-solid fa-microphone' : 'fa-solid fa-file'" class="text-[var(--color-primary)]"></i>
|
||||
<span x-text="file.name" class="max-w-[200px] truncate"></span>
|
||||
<button
|
||||
type="button"
|
||||
@click="attachedFiles.splice(index, 1); removeAttachedFile(file.type, file.name)"
|
||||
class="ml-1 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
title="Remove attachment"
|
||||
>
|
||||
<i class="fa-solid fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full">
|
||||
<textarea
|
||||
x-model="inputValue"
|
||||
:placeholder="currentPlaceholder"
|
||||
class="input p-3 pr-16 w-full resize-none border-0"
|
||||
required
|
||||
@keydown.shift="shiftPressed = true"
|
||||
@keyup.shift="shiftPressed = false"
|
||||
@keydown.enter.prevent="if (!shiftPressed && selectedModel && (inputValue.trim() || currentPlaceholder.trim())) { startChatSPA($event); }"
|
||||
@focus="handleFocus()"
|
||||
@blur="handleBlur()"
|
||||
@input="handleInput()"
|
||||
rows="2"
|
||||
></textarea>
|
||||
|
||||
<!-- Attachment Buttons -->
|
||||
<button
|
||||
type="button"
|
||||
@click="document.getElementById('spa_home_input_image').click()"
|
||||
class="fa-solid fa-image text-[var(--color-text-secondary)] absolute right-12 top-3 text-base p-1.5 hover:text-[var(--color-primary)] transition-colors duration-200"
|
||||
title="Attach images"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="document.getElementById('spa_home_input_audio').click()"
|
||||
class="fa-solid fa-microphone text-[var(--color-text-secondary)] absolute right-20 top-3 text-base p-1.5 hover:text-[var(--color-primary)] transition-colors duration-200"
|
||||
title="Attach an audio file"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="document.getElementById('spa_home_input_file').click()"
|
||||
class="fa-solid fa-file text-[var(--color-text-secondary)] absolute right-28 top-3 text-base p-1.5 hover:text-[var(--color-primary)] transition-colors duration-200"
|
||||
title="Upload text, markdown or PDF file"
|
||||
></button>
|
||||
|
||||
<!-- Send Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!selectedModel || (!inputValue.trim() && !currentPlaceholder.trim())"
|
||||
:class="!selectedModel || (!inputValue.trim() && !currentPlaceholder.trim()) ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="text-lg p-2 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors duration-200 absolute right-3 top-3"
|
||||
title="Send message (Enter)"
|
||||
>
|
||||
<i class="fa-solid fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Hidden File Inputs -->
|
||||
<input
|
||||
id="spa_home_input_image"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
@change="imageFiles = Array.from($event.target.files); handleFileSelection($event.target.files, 'image')"
|
||||
/>
|
||||
<input
|
||||
id="spa_home_input_audio"
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*"
|
||||
style="display: none;"
|
||||
@change="audioFiles = Array.from($event.target.files); handleFileSelection($event.target.files, 'audio')"
|
||||
/>
|
||||
<input
|
||||
id="spa_home_input_file"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".txt,.md,.pdf"
|
||||
style="display: none;"
|
||||
@change="textFiles = Array.from($event.target.files); handleFileSelection($event.target.files, 'file')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="flex flex-wrap justify-center gap-3 mb-8">
|
||||
<a href="#" @click.prevent="$store.router.navigate('manage')" class="btn-tertiary">
|
||||
<i class="fas fa-cog mr-2"></i>
|
||||
Installed Models and Backends
|
||||
</a>
|
||||
<a href="/import-model" class="btn-tertiary">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import Model
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse')" class="btn-tertiary">
|
||||
<i class="fas fa-images mr-2"></i>
|
||||
Browse Gallery
|
||||
</a>
|
||||
<a href="https://localai.io" target="_blank" class="btn-tertiary">
|
||||
<i class="fas fa-book mr-2"></i>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Memory Status Indicator (GPU or RAM) -->
|
||||
<div class="mb-4" x-data="resourceMonitor()" x-init="startPolling()">
|
||||
<template x-if="resourceData && resourceData.available">
|
||||
<div class="flex items-center justify-center gap-3 text-xs text-[var(--color-text-secondary)]">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20">
|
||||
<i :class="resourceData.type === 'gpu' ? 'fas fa-microchip' : 'fas fa-memory'"
|
||||
:class="resourceData.aggregate.usage_percent > 90 ? 'text-red-400' : resourceData.aggregate.usage_percent > 70 ? 'text-yellow-400' : 'text-green-400'"></i>
|
||||
<span class="text-[var(--color-text-secondary)]" x-text="resourceData.type === 'gpu' ? 'GPU' : 'RAM'"></span>
|
||||
<span class="font-mono"
|
||||
:class="resourceData.aggregate.usage_percent > 90 ? 'text-red-400' : resourceData.aggregate.usage_percent > 70 ? 'text-yellow-400' : 'text-green-400'"
|
||||
x-text="`${resourceData.aggregate.usage_percent.toFixed(0)}%`"></span>
|
||||
<div class="w-16 bg-[var(--color-bg-primary)] rounded-full h-1.5 overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-300"
|
||||
:class="resourceData.aggregate.usage_percent > 90 ? 'bg-red-500' : resourceData.aggregate.usage_percent > 70 ? 'bg-yellow-500' : 'bg-[var(--color-success)]'"
|
||||
:style="`width: ${resourceData.aggregate.usage_percent}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Model Status Summary - Subtle -->
|
||||
{{ $loadedModels := .LoadedModels }}
|
||||
<div class="mb-8 flex items-center justify-center gap-2 text-xs text-[var(--color-text-secondary)]"
|
||||
x-data="{ stoppingAll: false, stopAllModels() { window.stopAllModels(this); }, stopModel(name) { window.stopModel(name); }, getLoadedCount() { return document.querySelectorAll('[data-loaded-model]').length; } }"
|
||||
x-show="getLoadedCount() > 0"
|
||||
style="display: none;">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<i class="fas fa-circle text-green-500 text-[10px]"></i>
|
||||
<span x-text="`${getLoadedCount()} model(s) loaded`"></span>
|
||||
</span>
|
||||
<span class="text-[var(--color-primary)] opacity-40">•</span>
|
||||
{{ range .ModelsConfig }}
|
||||
{{ if index $loadedModels .Name }}
|
||||
<span class="inline-flex items-center gap-1 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors" data-loaded-model>
|
||||
<span class="truncate max-w-[100px]">{{.Name}}</span>
|
||||
<button
|
||||
@click="stopModel('{{.Name}}')"
|
||||
class="text-red-400/60 hover:text-red-400 transition-colors ml-0.5"
|
||||
title="Stop {{.Name}}"
|
||||
>
|
||||
<i class="fas fa-times text-[10px]"></i>
|
||||
</button>
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<span class="text-[var(--color-primary)] opacity-40">•</span>
|
||||
<button
|
||||
@click="stopAllModels()"
|
||||
:disabled="stoppingAll"
|
||||
:class="stoppingAll ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="text-red-400/60 hover:text-red-400 transition-colors text-xs"
|
||||
title="Stop all loaded models"
|
||||
>
|
||||
<span x-text="stoppingAll ? 'Stopping...' : 'Stop all'"></span>
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
322
core/http/views/spa/manage.html
Normal file
322
core/http/views/spa/manage.html
Normal file
@@ -0,0 +1,322 @@
|
||||
<!-- Manage View Content for SPA -->
|
||||
<div class="container mx-auto px-4 py-8 flex-grow" x-data="manageDashboard()">
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="fixed top-20 right-4 z-50 space-y-2" style="max-width: 400px;">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<div x-show="true"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
:class="notification.type === 'error' ? 'bg-red-500' : 'bg-[var(--color-success)]'"
|
||||
class="rounded-lg p-4 text-white flex items-start space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i :class="notification.type === 'error' ? 'fas fa-exclamation-circle' : 'fas fa-check-circle'" class="text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium break-words" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button @click="dismissNotification(notification.id)" class="flex-shrink-0 text-white hover:opacity-80 transition-opacity">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Hero Header -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
Model & Backend Management
|
||||
</h1>
|
||||
<p class="hero-subtitle">Manage your installed models and backends</p>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse')" class="btn-primary text-sm py-1.5 px-3">
|
||||
<i class="fas fa-images mr-1.5 text-[10px]"></i>
|
||||
<span>Model Gallery</span>
|
||||
</a>
|
||||
|
||||
<a href="/import-model" class="btn-primary text-sm py-1.5 px-3">
|
||||
<i class="fas fa-plus mr-1.5 text-[10px]"></i>
|
||||
<span>Import Model</span>
|
||||
</a>
|
||||
|
||||
<button @click="reloadModels()" class="btn-primary text-sm py-1.5 px-3">
|
||||
<i class="fas fa-sync-alt mr-1.5 text-[10px]"></i>
|
||||
<span>Update Models</span>
|
||||
</button>
|
||||
|
||||
<a href="/browse/backends" class="btn-secondary text-sm py-1.5 px-3">
|
||||
<i class="fas fa-cogs mr-1.5 text-[10px]"></i>
|
||||
<span>Backend Gallery</span>
|
||||
</a>
|
||||
|
||||
{{ if not .DisableRuntimeSettings }}
|
||||
<a href="/settings" class="btn-secondary text-sm py-1.5 px-3">
|
||||
<i class="fas fa-cog mr-1.5 text-[10px]"></i>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Info Section -->
|
||||
<div class="mt-8" x-data="resourceMonitor()" x-init="startPolling()">
|
||||
<template x-if="resourceData && resourceData.available">
|
||||
<div class="bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="h3 flex items-center">
|
||||
<i :class="resourceData.type === 'gpu' ? 'fas fa-microchip' : 'fas fa-memory'" class="mr-2 text-[var(--color-primary)] text-sm"></i>
|
||||
<span x-text="resourceData.type === 'gpu' ? 'GPU Status' : 'Memory Status'"></span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Aggregate Stats -->
|
||||
<div class="bg-[var(--color-bg-primary)] rounded p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-[var(--color-text-primary)]" x-text="resourceData.type === 'gpu' ? 'Total GPU Memory' : 'System RAM'"></span>
|
||||
<span class="text-xs font-mono"
|
||||
:class="resourceData.aggregate.usage_percent > 90 ? 'text-red-400' : resourceData.aggregate.usage_percent > 70 ? 'text-yellow-400' : 'text-green-400'"
|
||||
x-text="`${resourceData.aggregate.usage_percent.toFixed(1)}%`"></span>
|
||||
</div>
|
||||
<div class="w-full bg-[var(--color-bg-secondary)] rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-300"
|
||||
:class="resourceData.aggregate.usage_percent > 90 ? 'bg-red-500' : resourceData.aggregate.usage_percent > 70 ? 'bg-yellow-500' : 'bg-[var(--color-success)]'"
|
||||
:style="`width: ${resourceData.aggregate.usage_percent}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Installed Models Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
|
||||
<i class="fas fa-brain text-[var(--color-primary)] mr-2"></i>
|
||||
Installed Models
|
||||
</h2>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-[var(--color-bg-secondary)] border-b border-[var(--color-border)]">
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-[var(--color-text-secondary)] uppercase">Model</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-[var(--color-text-secondary)] uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-[var(--color-text-secondary)] uppercase">Backend</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-[var(--color-text-secondary)] uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--color-border)]">
|
||||
{{ $loadedModels := .LoadedModels }}
|
||||
{{ range .ModelsConfig }}
|
||||
<tr class="hover:bg-[var(--color-bg-secondary)]/50 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-[var(--color-text-primary)]">{{.Name}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{ if index $loadedModels .Name }}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-300">
|
||||
<i class="fas fa-circle text-[6px] mr-1.5"></i>Loaded
|
||||
</span>
|
||||
{{ else }}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-circle text-[6px] mr-1.5"></i>Idle
|
||||
</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs text-[var(--color-text-secondary)]">{{.Backend}}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
{{ $hasChat := false }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_CHAT" }}{{ $hasChat = true }}{{ end }}
|
||||
{{ end }}
|
||||
{{ if $hasChat }}
|
||||
<button @click="$store.router.navigate('chat', { model: '{{.Name}}' })"
|
||||
class="px-2 py-1 text-xs rounded bg-[var(--color-primary)] text-white hover:opacity-80 transition-opacity">
|
||||
<i class="fas fa-comments mr-1"></i>Chat
|
||||
</button>
|
||||
{{ end }}
|
||||
{{ if index $loadedModels .Name }}
|
||||
<button onclick="stopModelManage('{{.Name}}')"
|
||||
class="px-2 py-1 text-xs rounded bg-red-500/20 text-red-300 hover:bg-red-500/30 transition-colors">
|
||||
<i class="fas fa-stop mr-1"></i>Stop
|
||||
</button>
|
||||
{{ end }}
|
||||
<a href="/model-editor/{{.Name}}" class="px-2 py-1 text-xs rounded bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ range .Models }}
|
||||
<tr class="hover:bg-[var(--color-bg-secondary)]/50 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-[var(--color-text-primary)]">{{.}}</span>
|
||||
<span class="ml-2 text-xs text-[var(--color-text-secondary)]">(no config)</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-circle text-[6px] mr-1.5"></i>Idle
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs text-[var(--color-text-secondary)]">-</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button @click="$store.router.navigate('chat', { model: '{{.}}' })"
|
||||
class="px-2 py-1 text-xs rounded bg-[var(--color-primary)] text-white hover:opacity-80 transition-opacity">
|
||||
<i class="fas fa-comments mr-1"></i>Chat
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ if and (eq (len .ModelsConfig) 0) (eq (len .Models) 0) }}
|
||||
<div class="text-center py-8 text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-box-open text-4xl mb-3 opacity-50"></i>
|
||||
<p>No models installed yet</p>
|
||||
<p class="text-sm mt-2">
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse')" class="text-[var(--color-primary)] hover:underline">Browse the gallery</a> to get started
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed Backends Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
|
||||
<i class="fas fa-server text-[var(--color-accent)] mr-2"></i>
|
||||
Installed Backends
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{{ range .InstalledBackends }}
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-[var(--color-accent-light)] rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-cogs text-[var(--color-accent)]"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-[var(--color-text-primary)]">{{.Name}}</h3>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{{ if .IsSystem }}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-500/10 text-blue-300">
|
||||
<i class="fas fa-shield-alt text-[8px] mr-1"></i>System
|
||||
</span>
|
||||
{{ else }}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-success)]/10 text-green-300">
|
||||
<i class="fas fa-download text-[8px] mr-1"></i>User
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if .IsMeta }}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-accent-light)] text-[var(--color-accent)]">
|
||||
<i class="fas fa-layer-group text-[8px] mr-1"></i>Meta
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-300">
|
||||
Installed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="col-span-full text-center py-8 text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-plug text-4xl mb-3 opacity-50"></i>
|
||||
<p>No backends installed yet</p>
|
||||
<p class="text-sm mt-2">
|
||||
<a href="/browse/backends" class="text-[var(--color-primary)] hover:underline">Browse the backend gallery</a>
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Manage dashboard component
|
||||
function manageDashboard() {
|
||||
return {
|
||||
notifications: [],
|
||||
|
||||
init() {
|
||||
// Initialize
|
||||
},
|
||||
|
||||
addNotification(message, type = 'success') {
|
||||
const id = Date.now();
|
||||
this.notifications.push({ id, message, type });
|
||||
setTimeout(() => this.dismissNotification(id), 5000);
|
||||
},
|
||||
|
||||
dismissNotification(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
},
|
||||
|
||||
reloadModels() {
|
||||
fetch('/models/reload', { method: 'POST' })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
this.addNotification('Models reloaded successfully');
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
this.addNotification('Failed to reload models', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.addNotification('Error: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Stop model function
|
||||
async function stopModelManage(modelName) {
|
||||
if (!confirm(`Are you sure you want to stop "${modelName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ model: modelName })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping model:', error);
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
}
|
||||
|
||||
window.manageDashboard = manageDashboard;
|
||||
window.stopModelManage = stopModelManage;
|
||||
</script>
|
||||
229
core/http/views/spa/talk.html
Normal file
229
core/http/views/spa/talk.html
Normal file
@@ -0,0 +1,229 @@
|
||||
<!-- Talk View Content for SPA -->
|
||||
<div class="container mx-auto px-4 py-8 flex-grow">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
<i class="fas fa-comments mr-2"></i>Talk Interface
|
||||
</h1>
|
||||
<p class="hero-subtitle">Speak with your AI models using voice interaction</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Talk Interface -->
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="card overflow-hidden">
|
||||
<!-- Talk Interface Body -->
|
||||
<div class="p-6">
|
||||
<!-- Recording Status -->
|
||||
<div id="spa-recording" class="bg-red-500/10 border border-red-500/30 rounded-lg p-4 mb-4 flex items-center space-x-3" style="display: none;">
|
||||
<i class="fa-solid fa-microphone text-2xl text-red-400"></i>
|
||||
<span class="text-red-300 font-medium">Recording... press "Stop recording" to stop</span>
|
||||
</div>
|
||||
|
||||
<!-- Loader -->
|
||||
<div id="spa-talk-loader" class="my-4 flex justify-center" style="display: none;">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Status Text -->
|
||||
<div id="spa-statustext" class="my-4 p-3 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-primary)]" style="min-height: 3rem;">Press the record button to start recording.</div>
|
||||
|
||||
<!-- Note -->
|
||||
<div class="bg-[var(--color-primary-light)] border border-[var(--color-primary-border)] rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-[var(--color-primary)] mt-1 mr-3 flex-shrink-0"></i>
|
||||
<p class="text-[var(--color-text-secondary)]">
|
||||
<strong class="text-[var(--color-primary)]">Note:</strong> You need an LLM, an audio-transcription (whisper), and a TTS model installed for this to work. Select the appropriate models below and click 'Talk' to start recording.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Selectors -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<!-- LLM Model -->
|
||||
<div class="space-y-2">
|
||||
<label for="spa-modelSelect" class="flex items-center text-[var(--color-text-secondary)] font-medium">
|
||||
<i class="fas fa-brain text-[var(--color-primary)] mr-2"></i>LLM Model
|
||||
</label>
|
||||
<select id="spa-modelSelect" class="input w-full p-2.5">
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ range .ModelsConfig }}
|
||||
<option value="{{.Name}}" class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.Name}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Whisper Model -->
|
||||
<div class="space-y-2">
|
||||
<label for="spa-whisperModelSelect" class="flex items-center text-[var(--color-text-secondary)] font-medium">
|
||||
<i class="fas fa-ear-listen text-[var(--color-accent)] mr-2"></i>Whisper Model
|
||||
</label>
|
||||
<select id="spa-whisperModelSelect" class="input w-full p-2.5">
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ range .ModelsConfig }}
|
||||
<option value="{{.Name}}" class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.Name}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- TTS Model -->
|
||||
<div class="space-y-2">
|
||||
<label for="spa-ttsModelSelect" class="flex items-center text-[var(--color-text-secondary)] font-medium">
|
||||
<i class="fas fa-volume-high text-green-400 mr-2"></i>TTS Model
|
||||
</label>
|
||||
<select id="spa-ttsModelSelect" class="input w-full p-2.5">
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ range .ModelsConfig }}
|
||||
<option value="{{.Name}}" class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.Name}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex items-center justify-between mt-8">
|
||||
<button id="spa-recordButton" onclick="startTalkRecording()"
|
||||
class="inline-flex items-center bg-red-500 hover:bg-red-600 text-white font-semibold py-2 px-6 rounded-lg transition-colors">
|
||||
<i class="fas fa-microphone mr-2"></i>
|
||||
<span>Talk</span>
|
||||
</button>
|
||||
<button id="spa-stopRecordButton" onclick="stopTalkRecording()" style="display: none;"
|
||||
class="inline-flex items-center bg-gray-500 hover:bg-gray-600 text-white font-semibold py-2 px-6 rounded-lg transition-colors">
|
||||
<i class="fas fa-stop mr-2"></i>
|
||||
<span>Stop Recording</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audio Result -->
|
||||
<div id="spa-talk-result" class="mt-6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simplified Talk functions for SPA
|
||||
let talkMediaRecorder = null;
|
||||
let talkAudioChunks = [];
|
||||
|
||||
function startTalkRecording() {
|
||||
const statusText = document.getElementById('spa-statustext');
|
||||
const recording = document.getElementById('spa-recording');
|
||||
const recordButton = document.getElementById('spa-recordButton');
|
||||
const stopButton = document.getElementById('spa-stopRecordButton');
|
||||
|
||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
.then(stream => {
|
||||
talkMediaRecorder = new MediaRecorder(stream);
|
||||
talkAudioChunks = [];
|
||||
|
||||
talkMediaRecorder.ondataavailable = event => {
|
||||
talkAudioChunks.push(event.data);
|
||||
};
|
||||
|
||||
talkMediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(talkAudioChunks, { type: 'audio/wav' });
|
||||
processTalkAudio(audioBlob);
|
||||
};
|
||||
|
||||
talkMediaRecorder.start();
|
||||
recording.style.display = 'flex';
|
||||
recordButton.style.display = 'none';
|
||||
stopButton.style.display = 'inline-flex';
|
||||
statusText.textContent = 'Recording... Speak now.';
|
||||
})
|
||||
.catch(error => {
|
||||
statusText.textContent = 'Error accessing microphone: ' + error.message;
|
||||
});
|
||||
}
|
||||
|
||||
function stopTalkRecording() {
|
||||
const recording = document.getElementById('spa-recording');
|
||||
const recordButton = document.getElementById('spa-recordButton');
|
||||
const stopButton = document.getElementById('spa-stopRecordButton');
|
||||
|
||||
if (talkMediaRecorder && talkMediaRecorder.state !== 'inactive') {
|
||||
talkMediaRecorder.stop();
|
||||
talkMediaRecorder.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
recording.style.display = 'none';
|
||||
recordButton.style.display = 'inline-flex';
|
||||
stopButton.style.display = 'none';
|
||||
}
|
||||
|
||||
function processTalkAudio(audioBlob) {
|
||||
const statusText = document.getElementById('spa-statustext');
|
||||
const loader = document.getElementById('spa-talk-loader');
|
||||
const result = document.getElementById('spa-talk-result');
|
||||
const llmModel = document.getElementById('spa-modelSelect').value;
|
||||
const whisperModel = document.getElementById('spa-whisperModelSelect').value;
|
||||
const ttsModel = document.getElementById('spa-ttsModelSelect').value;
|
||||
|
||||
if (!llmModel || !whisperModel || !ttsModel) {
|
||||
statusText.textContent = 'Please select all three models (LLM, Whisper, TTS)';
|
||||
return;
|
||||
}
|
||||
|
||||
loader.style.display = 'flex';
|
||||
statusText.textContent = 'Processing...';
|
||||
|
||||
// Step 1: Transcribe audio
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioBlob, 'audio.wav');
|
||||
formData.append('model', whisperModel);
|
||||
|
||||
fetch('/v1/audio/transcriptions', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const transcription = data.text;
|
||||
statusText.textContent = 'You said: ' + transcription;
|
||||
|
||||
// Step 2: Send to LLM
|
||||
return fetch('/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: llmModel,
|
||||
messages: [{ role: 'user', content: transcription }]
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const reply = data.choices[0].message.content;
|
||||
statusText.textContent = 'AI: ' + reply;
|
||||
|
||||
// Step 3: Convert to speech
|
||||
return fetch('/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: ttsModel,
|
||||
input: reply
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
loader.style.display = 'none';
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
result.innerHTML = `
|
||||
<audio controls autoplay class="w-full">
|
||||
<source src="${audioUrl}" type="audio/wav">
|
||||
</audio>
|
||||
`;
|
||||
})
|
||||
.catch(error => {
|
||||
loader.style.display = 'none';
|
||||
statusText.textContent = 'Error: ' + error.message;
|
||||
});
|
||||
}
|
||||
|
||||
window.startTalkRecording = startTalkRecording;
|
||||
window.stopTalkRecording = stopTalkRecording;
|
||||
</script>
|
||||
155
core/http/views/spa/text2image.html
Normal file
155
core/http/views/spa/text2image.html
Normal file
@@ -0,0 +1,155 @@
|
||||
<!-- Text2Image View Content for SPA -->
|
||||
<div class="flex flex-col flex-1 overflow-hidden">
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Two Column Layout: Settings on Left, Preview on Right -->
|
||||
<div class="flex flex-col lg:flex-row flex-1 gap-4 p-4 overflow-hidden">
|
||||
<!-- Left Column: Generation Settings -->
|
||||
<div class="flex-shrink-0 lg:w-1/4 flex flex-col min-h-0">
|
||||
<div class="card p-3 space-y-3 overflow-y-auto flex-1">
|
||||
<!-- Model Selection -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide flex-shrink-0">Model</label>
|
||||
</div>
|
||||
<select id="image-model-select" class="input w-full p-1.5 text-xs" @change="document.getElementById('image-model').value = $event.target.value">
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ $model:=.Model}}
|
||||
{{ range .ModelsConfig }}
|
||||
{{ $cfg := . }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_IMAGE" }}
|
||||
<option value="{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range .ModelsWithoutConfig }}
|
||||
<option value="{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<input id="image-model" type="hidden" value="{{.Model}}">
|
||||
<form id="genimage" @submit.prevent="genImage($event)">
|
||||
<!-- Basic Settings -->
|
||||
<div class="space-y-2">
|
||||
<!-- Prompt -->
|
||||
<div class="space-y-1">
|
||||
<label for="image-input" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-magic mr-1.5 text-[var(--color-primary)]"></i>Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="image-input"
|
||||
name="input"
|
||||
placeholder="Describe the image you want to generate..."
|
||||
autocomplete="off"
|
||||
rows="3"
|
||||
class="input w-full p-1.5 text-xs resize-y"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Negative Prompt -->
|
||||
<div class="space-y-1">
|
||||
<label for="negative-prompt" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-ban mr-1.5 text-[var(--color-primary)]"></i>Negative Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="negative-prompt"
|
||||
name="negative-prompt"
|
||||
placeholder="Things to avoid in the image..."
|
||||
rows="2"
|
||||
class="input w-full p-1.5 text-xs resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Size Selection -->
|
||||
<div class="space-y-1">
|
||||
<label for="image-size" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-expand-arrows-alt mr-1.5 text-[var(--color-primary)]"></i>Image Size
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-1.5">
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="256x256">256×256</button>
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)] bg-[var(--color-primary)] text-white" data-size="512x512">512×512</button>
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="768x768">768×768</button>
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="1024x1024">1024×1024</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="image-size"
|
||||
value="512x512"
|
||||
placeholder="e.g., 256x256, 512x512, 1024x1024"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Number of Images -->
|
||||
<div class="space-y-1">
|
||||
<label for="image-count" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-images mr-1.5 text-[var(--color-primary)]"></i>Number of Images
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="image-count"
|
||||
name="n"
|
||||
min="1"
|
||||
max="4"
|
||||
value="1"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
id="generate-btn"
|
||||
class="w-full px-2 py-1.5 text-xs rounded text-[var(--color-bg-primary)] bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 transition-colors font-medium"
|
||||
>
|
||||
<i class="fas fa-magic mr-1.5"></i>Generate Image
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Image Preview -->
|
||||
<div class="flex-grow lg:w-3/4 flex flex-col min-h-0">
|
||||
<div class="relative flex-1 min-h-0 overflow-y-auto">
|
||||
<!-- Loading Animation -->
|
||||
<div id="loader" class="hidden absolute inset-0 flex items-center justify-center bg-[var(--color-bg-primary)]/80 rounded-xl z-10">
|
||||
<div class="text-center">
|
||||
<svg class="animate-spin h-10 w-10 text-[var(--color-primary)] mx-auto mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Generating image...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Placeholder when no images -->
|
||||
<div id="result-placeholder" class="min-h-[400px] flex items-center justify-center flex-shrink-0">
|
||||
<p class="text-xs text-[var(--color-text-secondary)] italic text-center">Your generated images will appear here</p>
|
||||
</div>
|
||||
<!-- Results container -->
|
||||
<div id="result" class="grid grid-cols-1 sm:grid-cols-2 gap-4 pb-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Size preset buttons for SPA
|
||||
document.querySelectorAll('.size-preset').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const size = this.getAttribute('data-size');
|
||||
document.getElementById('image-size').value = size;
|
||||
document.querySelectorAll('.size-preset').forEach(btn => {
|
||||
btn.classList.remove('bg-[var(--color-primary)]', 'text-white');
|
||||
});
|
||||
this.classList.add('bg-[var(--color-primary)]', 'text-white');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
138
core/http/views/spa/tts.html
Normal file
138
core/http/views/spa/tts.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!-- TTS View Content for SPA -->
|
||||
<div class="container mx-auto px-4 py-8 flex-grow">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
<i class="fas fa-volume-high mr-2"></i>Text to Speech
|
||||
</h1>
|
||||
<p class="hero-subtitle">Convert your text into natural-sounding speech</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS Interface -->
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="card overflow-hidden">
|
||||
<!-- Header with Model Selection -->
|
||||
<div class="border-b border-[var(--color-bg-secondary)] p-5">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<!-- Model Selection -->
|
||||
<div class="flex items-center">
|
||||
<label for="tts-model-select" class="mr-3 text-[var(--color-text-secondary)] font-medium">
|
||||
<i class="fas fa-microphone-lines text-[var(--color-accent)] mr-2"></i>Model:
|
||||
</label>
|
||||
<select id="tts-model-select" class="input p-2.5" @change="document.getElementById('tts-model').value = $event.target.value">
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ $model:=.Model}}
|
||||
{{ range .ModelsConfig }}
|
||||
{{ $cfg := . }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_TTS" }}
|
||||
<option value="{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range .ModelsWithoutConfig }}
|
||||
<option value="{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="p-6">
|
||||
<div class="bg-[var(--color-accent-light)] border border-[var(--color-accent-border)] rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-[var(--color-accent)] mt-1 mr-3 flex-shrink-0"></i>
|
||||
<p class="text-[var(--color-text-secondary)]">
|
||||
Enter your text below and submit to generate speech with the selected TTS model.
|
||||
The generated audio will appear below the input field.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input id="tts-model" type="hidden" value="{{.Model}}">
|
||||
<form id="tts" @submit.prevent="generateTTS($event)" class="mb-6">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="tts-input"
|
||||
name="input"
|
||||
placeholder="Enter text to convert to speech..."
|
||||
autocomplete="off"
|
||||
class="input w-full p-4 pl-4 pr-12"
|
||||
required
|
||||
/>
|
||||
<button type="submit" class="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--color-accent)] hover:text-[var(--color-primary)] transition icon-hover">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div class="flex justify-center my-6">
|
||||
<div id="tts-loader" class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-[var(--color-accent)]" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Results Area -->
|
||||
<div class="bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg p-4 min-h-[100px] flex items-center justify-center">
|
||||
<div id="tts-result" class="w-full text-center text-[var(--color-text-secondary)]">
|
||||
<p>Generated audio will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// TTS generation function for SPA
|
||||
function generateTTS(event) {
|
||||
if (event) event.preventDefault();
|
||||
|
||||
const input = document.getElementById('tts-input');
|
||||
const model = document.getElementById('tts-model')?.value;
|
||||
const loader = document.getElementById('tts-loader');
|
||||
const result = document.getElementById('tts-result');
|
||||
|
||||
if (!input?.value.trim() || !model) {
|
||||
alert('Please enter text and select a model');
|
||||
return;
|
||||
}
|
||||
|
||||
loader.style.display = 'block';
|
||||
result.innerHTML = '';
|
||||
|
||||
fetch('/tts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
input: input.value.trim()
|
||||
})
|
||||
})
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
loader.style.display = 'none';
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
result.innerHTML = `
|
||||
<audio controls class="w-full">
|
||||
<source src="${audioUrl}" type="audio/wav">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<a href="${audioUrl}" download="tts_output.wav" class="mt-3 inline-block btn-secondary text-sm">
|
||||
<i class="fas fa-download mr-2"></i>Download
|
||||
</a>
|
||||
`;
|
||||
})
|
||||
.catch(error => {
|
||||
loader.style.display = 'none';
|
||||
result.innerHTML = `<p class="text-red-400">Error generating speech: ${error.message}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
window.generateTTS = generateTTS;
|
||||
</script>
|
||||
@@ -28,19 +28,19 @@
|
||||
{{ $cfg := . }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_IMAGE" }}
|
||||
<option value="image/{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
|
||||
<option value="text2image/{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range .ModelsWithoutConfig }}
|
||||
<option value="image/{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.}}</option>
|
||||
<option value="text2image/{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<input id="image-model" type="hidden" value="{{.Model}}">
|
||||
<form id="genimage" action="image/{{.Model}}" method="get">
|
||||
<form id="genimage" action="text2image/{{.Model}}" method="get">
|
||||
<!-- Basic Settings -->
|
||||
<div class="space-y-2">
|
||||
<!-- Prompt -->
|
||||
@@ -326,4 +326,4 @@
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -1,315 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{template "views/partials/head" .}}
|
||||
<script defer src="static/video.js"></script>
|
||||
|
||||
<body class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] flex flex-col h-screen">
|
||||
<div class="flex flex-col flex-1 overflow-hidden">
|
||||
|
||||
{{template "views/partials/navbar" .}}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Two Column Layout: Settings on Left, Preview on Right -->
|
||||
<div class="flex flex-col lg:flex-row flex-1 gap-4 p-4 overflow-hidden">
|
||||
<!-- Left Column: Generation Settings -->
|
||||
<div class="flex-shrink-0 lg:w-1/4 flex flex-col min-h-0">
|
||||
<div class="card p-3 space-y-3 overflow-y-auto flex-1">
|
||||
<!-- Model Selection - Compact -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide flex-shrink-0">Model</label>
|
||||
</div>
|
||||
<select x-data="{ link : '' }" x-model="link" x-init="$watch('link', value => window.location = link)"
|
||||
id="model-select"
|
||||
class="input w-full p-1.5 text-xs"
|
||||
>
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ $model:=.Model}}
|
||||
{{ range .ModelsConfig }}
|
||||
{{ $cfg := . }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_VIDEO" }}
|
||||
<option value="video/{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range .ModelsWithoutConfig }}
|
||||
<option value="video/{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<input id="video-model" type="hidden" value="{{.Model}}">
|
||||
<form id="genvideo" action="video/{{.Model}}" method="get">
|
||||
<!-- Basic Settings -->
|
||||
<div class="space-y-2">
|
||||
<!-- Prompt -->
|
||||
<div class="space-y-1">
|
||||
<label for="input" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-magic mr-1.5 text-[var(--color-primary)]"></i>Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="input"
|
||||
name="input"
|
||||
placeholder="Describe the video you want to generate..."
|
||||
autocomplete="off"
|
||||
rows="3"
|
||||
class="input w-full p-1.5 text-xs resize-y"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Negative Prompt -->
|
||||
<div class="space-y-1">
|
||||
<label for="negative-prompt" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-ban mr-1.5 text-[var(--color-primary)]"></i>Negative Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="negative-prompt"
|
||||
name="negative-prompt"
|
||||
placeholder="Things to avoid in the video..."
|
||||
rows="2"
|
||||
class="input w-full p-1.5 text-xs resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Size Selection with Presets -->
|
||||
<div class="space-y-1">
|
||||
<label for="video-size" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-expand-arrows-alt mr-1.5 text-[var(--color-primary)]"></i>Video Size
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-1.5">
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="256x256">256×256</button>
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="512x512">512×512</button>
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="768x768">768×768</button>
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="1024x1024">1024×1024</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="video-size"
|
||||
value="512x512"
|
||||
placeholder="e.g., 256x256, 512x512, 1024x1024"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Video Duration / FPS / Frames -->
|
||||
<div class="space-y-1">
|
||||
<label for="video-seconds" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-clock mr-1.5 text-[var(--color-primary)]"></i>Duration (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="video-seconds"
|
||||
name="seconds"
|
||||
min="1"
|
||||
max="60"
|
||||
placeholder="Leave empty for default"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="video-fps" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-film mr-1.5 text-[var(--color-primary)]"></i>FPS
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="video-fps"
|
||||
name="fps"
|
||||
min="1"
|
||||
max="60"
|
||||
value="16"
|
||||
placeholder="Frames per second"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="video-frames" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-images mr-1.5 text-[var(--color-primary)]"></i>Number of Frames
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="video-frames"
|
||||
name="num_frames"
|
||||
min="1"
|
||||
max="500"
|
||||
placeholder="Leave empty for default"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Settings (Collapsible) -->
|
||||
<div class="space-y-2">
|
||||
<button type="button" id="advanced-toggle" class="w-full flex items-center justify-between px-2 py-1.5 text-xs rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors">
|
||||
<span><i class="fa-solid fa-sliders mr-1.5 text-[var(--color-primary)]"></i> Advanced Settings</span>
|
||||
<i class="fas fa-chevron-down text-[10px]" id="advanced-chevron"></i>
|
||||
</button>
|
||||
<div id="advanced-settings" class="hidden p-2 bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded pl-4 border-l-2 border-[var(--color-bg-secondary)] space-y-2">
|
||||
<!-- Steps -->
|
||||
<div class="space-y-1">
|
||||
<label for="video-steps" class="block text-xs text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-step-forward mr-1.5 text-[var(--color-primary)]"></i>Steps
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="video-steps"
|
||||
name="step"
|
||||
min="1"
|
||||
max="100"
|
||||
placeholder="Leave empty for default"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Seed -->
|
||||
<div class="space-y-1">
|
||||
<label for="video-seed" class="block text-xs text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-seedling mr-1.5 text-[var(--color-primary)]"></i>Seed
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="video-seed"
|
||||
name="seed"
|
||||
min="0"
|
||||
placeholder="Leave empty for random"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- CFG Scale -->
|
||||
<div class="space-y-1">
|
||||
<label for="video-cfg-scale" class="block text-xs text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-sliders-h mr-1.5 text-[var(--color-primary)]"></i>CFG Scale
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="video-cfg-scale"
|
||||
name="cfg_scale"
|
||||
min="0"
|
||||
max="20"
|
||||
step="0.1"
|
||||
placeholder="Leave empty for default"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Inputs (Collapsible) -->
|
||||
<div class="space-y-2">
|
||||
<button type="button" id="image-inputs-toggle" class="w-full flex items-center justify-between px-2 py-1.5 text-xs rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors">
|
||||
<span><i class="fa-solid fa-image mr-1.5 text-[var(--color-primary)]"></i> Image Inputs</span>
|
||||
<i class="fas fa-chevron-down text-[10px]" id="image-inputs-chevron"></i>
|
||||
</button>
|
||||
<div id="image-inputs-settings" class="hidden p-2 bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded pl-4 border-l-2 border-[var(--color-bg-secondary)] space-y-2">
|
||||
<!-- Start Image (img2video) -->
|
||||
<div class="space-y-1">
|
||||
<label for="start-image" class="block text-xs text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-play-circle mr-1.5 text-[var(--color-primary)]"></i>Start Image (img2video)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="start-image"
|
||||
name="start_image"
|
||||
accept="image/*"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- End Image -->
|
||||
<div class="space-y-1">
|
||||
<label for="end-image" class="block text-xs text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-stop-circle mr-1.5 text-[var(--color-primary)]"></i>End Image
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="end-image"
|
||||
name="end_image"
|
||||
accept="image/*"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
id="generate-btn"
|
||||
class="w-full px-2 py-1.5 text-xs rounded text-[var(--color-bg-primary)] bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 transition-colors font-medium"
|
||||
>
|
||||
<i class="fas fa-video mr-1.5"></i>Generate Video
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Video Preview -->
|
||||
<div class="flex-grow lg:w-3/4 flex flex-col min-h-0">
|
||||
<div class="relative flex-1 min-h-0 overflow-y-auto">
|
||||
<!-- Loading Animation -->
|
||||
<div id="loader" class="hidden absolute inset-0 flex items-center justify-center bg-[var(--color-bg-primary)]/80 rounded-xl z-10">
|
||||
<div class="text-center">
|
||||
<svg class="animate-spin h-10 w-10 text-[var(--color-primary)] mx-auto mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Generating video...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Placeholder when no videos -->
|
||||
<div id="result-placeholder" class="min-h-[400px] flex items-center justify-center flex-shrink-0">
|
||||
<p class="text-xs text-[var(--color-text-secondary)] italic text-center">Your generated videos will appear here</p>
|
||||
</div>
|
||||
<!-- Results container -->
|
||||
<div id="result" class="grid grid-cols-1 gap-4 pb-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Collapsible sections
|
||||
document.getElementById('advanced-toggle').addEventListener('click', function() {
|
||||
const settings = document.getElementById('advanced-settings');
|
||||
const chevron = document.getElementById('advanced-chevron');
|
||||
settings.classList.toggle('hidden');
|
||||
chevron.classList.toggle('fa-chevron-down');
|
||||
chevron.classList.toggle('fa-chevron-up');
|
||||
});
|
||||
|
||||
document.getElementById('image-inputs-toggle').addEventListener('click', function() {
|
||||
const settings = document.getElementById('image-inputs-settings');
|
||||
const chevron = document.getElementById('image-inputs-chevron');
|
||||
settings.classList.toggle('hidden');
|
||||
chevron.classList.toggle('fa-chevron-down');
|
||||
chevron.classList.toggle('fa-chevron-up');
|
||||
});
|
||||
|
||||
// Size preset buttons
|
||||
document.querySelectorAll('.size-preset').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const size = this.getAttribute('data-size');
|
||||
document.getElementById('video-size').value = size;
|
||||
// Update active state
|
||||
document.querySelectorAll('.size-preset').forEach(btn => {
|
||||
btn.classList.remove('bg-[var(--color-primary)]', 'text-white');
|
||||
});
|
||||
this.classList.add('bg-[var(--color-primary)]', 'text-white');
|
||||
});
|
||||
});
|
||||
|
||||
// Set initial active size preset
|
||||
document.querySelector('.size-preset[data-size="512x512"]').classList.add('bg-[var(--color-primary)]', 'text-white');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,306 +0,0 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Open Responses status constants
|
||||
const (
|
||||
ORStatusQueued = "queued"
|
||||
ORStatusInProgress = "in_progress"
|
||||
ORStatusCompleted = "completed"
|
||||
ORStatusFailed = "failed"
|
||||
ORStatusIncomplete = "incomplete"
|
||||
ORStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// OpenResponsesRequest represents a request to the Open Responses API
|
||||
// https://www.openresponses.org/specification
|
||||
type OpenResponsesRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input interface{} `json:"input"` // string or []ORItemParam
|
||||
Tools []ORFunctionTool `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto"|"required"|"none"|{type:"function",name:"..."}
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Truncation string `json:"truncation,omitempty"` // "auto"|"disabled"
|
||||
Instructions string `json:"instructions,omitempty"`
|
||||
Reasoning *ORReasoningParam `json:"reasoning,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
||||
|
||||
// Additional parameters from spec
|
||||
TextFormat interface{} `json:"text_format,omitempty"` // TextResponseFormat or JsonSchemaResponseFormatParam
|
||||
ServiceTier string `json:"service_tier,omitempty"` // "auto"|"default"|priority hint
|
||||
AllowedTools []string `json:"allowed_tools,omitempty"` // Restrict which tools can be invoked
|
||||
Store *bool `json:"store,omitempty"` // Whether to store the response
|
||||
Include []string `json:"include,omitempty"` // What to include in response
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` // Allow parallel tool calls
|
||||
PresencePenalty *float64 `json:"presence_penalty,omitempty"` // Presence penalty (-2.0 to 2.0)
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // Frequency penalty (-2.0 to 2.0)
|
||||
TopLogprobs *int `json:"top_logprobs,omitempty"` // Number of top logprobs to return
|
||||
Background *bool `json:"background,omitempty"` // Run request in background
|
||||
MaxToolCalls *int `json:"max_tool_calls,omitempty"` // Maximum number of tool calls
|
||||
|
||||
// OpenAI-compatible extensions (not in Open Responses spec)
|
||||
LogitBias map[string]float64 `json:"logit_bias,omitempty"` // Map of token IDs to bias values (-100 to 100)
|
||||
|
||||
// Internal fields (like OpenAIRequest)
|
||||
Context context.Context `json:"-"`
|
||||
Cancel context.CancelFunc `json:"-"`
|
||||
}
|
||||
|
||||
// ModelName implements the LocalAIRequest interface
|
||||
func (r *OpenResponsesRequest) ModelName(s *string) string {
|
||||
if s != nil {
|
||||
r.Model = *s
|
||||
}
|
||||
return r.Model
|
||||
}
|
||||
|
||||
// ORFunctionTool represents a function tool definition
|
||||
type ORFunctionTool struct {
|
||||
Type string `json:"type"` // always "function"
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters map[string]interface{} `json:"parameters,omitempty"`
|
||||
Strict bool `json:"strict"` // Always include in response
|
||||
}
|
||||
|
||||
// ORReasoningParam represents reasoning configuration
|
||||
type ORReasoningParam struct {
|
||||
Effort string `json:"effort,omitempty"` // "none"|"low"|"medium"|"high"|"xhigh"
|
||||
Summary string `json:"summary,omitempty"` // "auto"|"concise"|"detailed"
|
||||
}
|
||||
|
||||
// ORItemParam represents an input/output item (discriminated union by type)
|
||||
type ORItemParam struct {
|
||||
Type string `json:"type"` // message|function_call|function_call_output|reasoning|item_reference
|
||||
ID string `json:"id,omitempty"` // Present for all output items
|
||||
Status string `json:"status,omitempty"` // in_progress|completed|incomplete
|
||||
|
||||
// Message fields
|
||||
Role string `json:"role,omitempty"` // user|assistant|system|developer
|
||||
Content interface{} `json:"content,omitempty"` // string or []ORContentPart for messages
|
||||
|
||||
// Function call fields
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
|
||||
// Function call output fields
|
||||
Output interface{} `json:"output,omitempty"` // string or []ORContentPart
|
||||
|
||||
// Note: For item_reference type, use the ID field above to reference the item
|
||||
}
|
||||
|
||||
// ORContentPart represents a content block (discriminated union by type)
|
||||
// For output_text: type, text, annotations, logprobs are ALL REQUIRED per Open Responses spec
|
||||
type ORContentPart struct {
|
||||
Type string `json:"type"` // input_text|input_image|input_file|output_text|refusal
|
||||
Text string `json:"text"` // REQUIRED for output_text - must always be present (even if empty)
|
||||
Annotations []ORAnnotation `json:"annotations"` // REQUIRED for output_text - must always be present (use [])
|
||||
Logprobs []ORLogProb `json:"logprobs"` // REQUIRED for output_text - must always be present (use [])
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
FileURL string `json:"file_url,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
FileData string `json:"file_data,omitempty"`
|
||||
Refusal string `json:"refusal,omitempty"`
|
||||
Detail string `json:"detail,omitempty"` // low|high|auto for images
|
||||
}
|
||||
|
||||
// OROutputTextContentPart is an alias for ORContentPart used specifically for output_text
|
||||
type OROutputTextContentPart = ORContentPart
|
||||
|
||||
// ORItemField represents an output item (same structure as ORItemParam)
|
||||
type ORItemField = ORItemParam
|
||||
|
||||
// ORResponseResource represents the main response object
|
||||
type ORResponseResource struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"` // always "response"
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
CompletedAt *int64 `json:"completed_at"` // Required: present as number or null
|
||||
Status string `json:"status"` // in_progress|completed|failed|incomplete
|
||||
Model string `json:"model"`
|
||||
Output []ORItemField `json:"output"`
|
||||
Error *ORError `json:"error"` // Always present, null if no error
|
||||
IncompleteDetails *ORIncompleteDetails `json:"incomplete_details"` // Always present, null if complete
|
||||
PreviousResponseID *string `json:"previous_response_id"`
|
||||
Instructions *string `json:"instructions"`
|
||||
|
||||
// Tool-related fields
|
||||
Tools []ORFunctionTool `json:"tools"` // Always present, empty array if no tools
|
||||
ToolChoice interface{} `json:"tool_choice"`
|
||||
ParallelToolCalls bool `json:"parallel_tool_calls"`
|
||||
MaxToolCalls *int `json:"max_tool_calls"` // nullable
|
||||
|
||||
// Sampling parameters (always required)
|
||||
Temperature float64 `json:"temperature"`
|
||||
TopP float64 `json:"top_p"`
|
||||
PresencePenalty float64 `json:"presence_penalty"`
|
||||
FrequencyPenalty float64 `json:"frequency_penalty"`
|
||||
TopLogprobs int `json:"top_logprobs"` // Default to 0
|
||||
MaxOutputTokens *int `json:"max_output_tokens"`
|
||||
|
||||
// Text format configuration
|
||||
Text *ORTextConfig `json:"text"`
|
||||
|
||||
// Truncation and reasoning
|
||||
Truncation string `json:"truncation"`
|
||||
Reasoning *ORReasoning `json:"reasoning"` // nullable
|
||||
|
||||
// Usage statistics
|
||||
Usage *ORUsage `json:"usage"` // nullable
|
||||
|
||||
// Metadata and operational flags
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Store bool `json:"store"`
|
||||
Background bool `json:"background"`
|
||||
ServiceTier string `json:"service_tier"`
|
||||
|
||||
// Safety and caching
|
||||
SafetyIdentifier *string `json:"safety_identifier"` // nullable
|
||||
PromptCacheKey *string `json:"prompt_cache_key"` // nullable
|
||||
}
|
||||
|
||||
// ORTextConfig represents text format configuration
|
||||
type ORTextConfig struct {
|
||||
Format *ORTextFormat `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
// ORTextFormat represents the text format type
|
||||
type ORTextFormat struct {
|
||||
Type string `json:"type"` // "text" or "json_schema"
|
||||
}
|
||||
|
||||
// ORError represents an error in the response
|
||||
type ORError struct {
|
||||
Type string `json:"type"` // invalid_request|not_found|server_error|model_error|too_many_requests
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Param string `json:"param,omitempty"`
|
||||
}
|
||||
|
||||
// ORUsage represents token usage statistics
|
||||
type ORUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
InputTokensDetails *ORInputTokensDetails `json:"input_tokens_details"` // Always present
|
||||
OutputTokensDetails *OROutputTokensDetails `json:"output_tokens_details"` // Always present
|
||||
}
|
||||
|
||||
// ORInputTokensDetails represents input token breakdown
|
||||
type ORInputTokensDetails struct {
|
||||
CachedTokens int `json:"cached_tokens"` // Always include, even if 0
|
||||
}
|
||||
|
||||
// OROutputTokensDetails represents output token breakdown
|
||||
type OROutputTokensDetails struct {
|
||||
ReasoningTokens int `json:"reasoning_tokens"` // Always include, even if 0
|
||||
}
|
||||
|
||||
// ORReasoning represents reasoning configuration and metadata
|
||||
type ORReasoning struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// ORIncompleteDetails represents details about why a response was incomplete
|
||||
type ORIncompleteDetails struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ORStreamEvent represents a streaming event
|
||||
// Note: Fields like delta, text, logprobs should be set explicitly for events that require them
|
||||
// The sendSSEEvent function uses a custom serializer to handle conditional field inclusion
|
||||
type ORStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
SequenceNumber int `json:"sequence_number"`
|
||||
Response *ORResponseResource `json:"response,omitempty"`
|
||||
OutputIndex *int `json:"output_index,omitempty"`
|
||||
ContentIndex *int `json:"content_index,omitempty"`
|
||||
SummaryIndex *int `json:"summary_index,omitempty"`
|
||||
ItemID string `json:"item_id,omitempty"`
|
||||
Item *ORItemField `json:"item,omitempty"`
|
||||
Part *ORContentPart `json:"part,omitempty"`
|
||||
Delta *string `json:"delta,omitempty"` // Pointer to distinguish unset from empty
|
||||
Text *string `json:"text,omitempty"` // Pointer to distinguish unset from empty
|
||||
Arguments *string `json:"arguments,omitempty"` // Pointer to distinguish unset from empty
|
||||
Refusal string `json:"refusal,omitempty"`
|
||||
Error *ORErrorPayload `json:"error,omitempty"`
|
||||
Logprobs *[]ORLogProb `json:"logprobs,omitempty"` // Pointer to distinguish unset from empty
|
||||
Obfuscation string `json:"obfuscation,omitempty"`
|
||||
Annotation *ORAnnotation `json:"annotation,omitempty"`
|
||||
AnnotationIndex *int `json:"annotation_index,omitempty"`
|
||||
}
|
||||
|
||||
// ORErrorPayload represents an error payload in streaming events
|
||||
type ORErrorPayload struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// ORLogProb represents log probability information
|
||||
type ORLogProb struct {
|
||||
Token string `json:"token"`
|
||||
Logprob float64 `json:"logprob"`
|
||||
Bytes []int `json:"bytes"`
|
||||
TopLogprobs []ORTopLogProb `json:"top_logprobs,omitempty"`
|
||||
}
|
||||
|
||||
// ORTopLogProb represents a top log probability
|
||||
type ORTopLogProb struct {
|
||||
Token string `json:"token"`
|
||||
Logprob float64 `json:"logprob"`
|
||||
Bytes []int `json:"bytes"`
|
||||
}
|
||||
|
||||
// ORAnnotation represents an annotation (e.g., URL citation)
|
||||
type ORAnnotation struct {
|
||||
Type string `json:"type"` // url_citation
|
||||
StartIndex int `json:"start_index"`
|
||||
EndIndex int `json:"end_index"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// ORContentPartWithLogprobs creates an output_text content part with logprobs converted from OpenAI format
|
||||
func ORContentPartWithLogprobs(text string, logprobs *Logprobs) ORContentPart {
|
||||
orLogprobs := []ORLogProb{}
|
||||
|
||||
// Convert OpenAI-style logprobs to Open Responses format
|
||||
if logprobs != nil && len(logprobs.Content) > 0 {
|
||||
for _, lp := range logprobs.Content {
|
||||
// Convert top logprobs
|
||||
topLPs := []ORTopLogProb{}
|
||||
for _, tlp := range lp.TopLogprobs {
|
||||
topLPs = append(topLPs, ORTopLogProb{
|
||||
Token: tlp.Token,
|
||||
Logprob: tlp.Logprob,
|
||||
Bytes: tlp.Bytes,
|
||||
})
|
||||
}
|
||||
|
||||
orLogprobs = append(orLogprobs, ORLogProb{
|
||||
Token: lp.Token,
|
||||
Logprob: lp.Logprob,
|
||||
Bytes: lp.Bytes,
|
||||
TopLogprobs: topLPs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ORContentPart{
|
||||
Type: "output_text",
|
||||
Text: text,
|
||||
Annotations: []ORAnnotation{}, // REQUIRED - must always be present as array (empty if none)
|
||||
Logprobs: orLogprobs, // REQUIRED - must always be present as array (empty if none)
|
||||
}
|
||||
}
|
||||
@@ -164,57 +164,6 @@ curl http://localhost:8080/tts -H "Content-Type: application/json" -d '{
|
||||
}' | aplay
|
||||
```
|
||||
|
||||
### Pocket TTS
|
||||
|
||||
[Pocket TTS](https://github.com/kyutai-labs/pocket-tts) is a lightweight text-to-speech model designed to run efficiently on CPUs. It supports voice cloning through HuggingFace voice URLs or local audio files.
|
||||
|
||||
#### Setup
|
||||
|
||||
Install the `pocket-tts` model in the Model gallery or run `local-ai run models install pocket-tts`.
|
||||
|
||||
#### Usage
|
||||
|
||||
Use the tts endpoint by specifying the pocket-tts backend:
|
||||
|
||||
```
|
||||
curl http://localhost:8080/tts -H "Content-Type: application/json" -d '{
|
||||
"model": "pocket-tts",
|
||||
"input":"Hello world, this is a test."
|
||||
}' | aplay
|
||||
```
|
||||
|
||||
#### Voice cloning
|
||||
|
||||
Pocket TTS supports voice cloning through built-in voice names, HuggingFace URLs, or local audio files. You can configure a model with a specific voice:
|
||||
|
||||
```yaml
|
||||
name: pocket-tts
|
||||
backend: pocket-tts
|
||||
tts:
|
||||
voice: "azelma" # Built-in voice name
|
||||
# Or use HuggingFace URL: "hf://kyutai/tts-voices/alba-mackenna/casual.wav"
|
||||
# Or use local file path: "path/to/voice.wav"
|
||||
# Available built-in voices: alba, marius, javert, jean, fantine, cosette, eponine, azelma
|
||||
```
|
||||
|
||||
You can also pre-load a default voice for faster first generation:
|
||||
|
||||
```yaml
|
||||
name: pocket-tts
|
||||
backend: pocket-tts
|
||||
options:
|
||||
- "default_voice:azelma" # Pre-load this voice when model loads
|
||||
```
|
||||
|
||||
Then you can use the model:
|
||||
|
||||
```
|
||||
curl http://localhost:8080/tts -H "Content-Type: application/json" -d '{
|
||||
"model": "pocket-tts",
|
||||
"input":"Hello world, this is a test."
|
||||
}' | aplay
|
||||
```
|
||||
|
||||
### Vall-E-X
|
||||
|
||||
[VALL-E-X](https://github.com/Plachtaa/VALL-E-X) is an open source implementation of Microsoft's VALL-E X zero-shot TTS model.
|
||||
|
||||
@@ -20,7 +20,7 @@ Choose the installation method that best suits your needs:
|
||||
|
||||
1. **[Docker](docker/)** ⭐ **Recommended** - Works on all platforms, easiest setup
|
||||
2. **[macOS](macos/)** - Download and install the DMG application
|
||||
3. **[Linux](linux/)** - Install on Linux using binaries (install.sh script currently has issues - see [issue #8032](https://github.com/mudler/LocalAI/issues/8032))
|
||||
3. **[Linux](linux/)** - Install on Linux using the one-liner script or binaries
|
||||
4. **[Kubernetes](kubernetes/)** - Deploy LocalAI on Kubernetes clusters
|
||||
5. **[Build from Source](build/)** - Build LocalAI from source code
|
||||
|
||||
@@ -36,6 +36,6 @@ This will start LocalAI. The API will be available at `http://localhost:8080`. F
|
||||
|
||||
For other platforms:
|
||||
- **macOS**: Download the [DMG](macos/)
|
||||
- **Linux**: See the [Linux installation guide](linux/) for installation options. **Note:** The `install.sh` script is currently experiencing issues - see [issue #8032](https://github.com/mudler/LocalAI/issues/8032) for details.
|
||||
- **Linux**: Use the `curl https://localai.io/install.sh | sh` [one-liner](linux/)
|
||||
|
||||
For detailed instructions, see the [Docker installation guide](docker/).
|
||||
|
||||
@@ -6,11 +6,7 @@ url: '/installation/linux/'
|
||||
---
|
||||
|
||||
|
||||
## One-Line Installer
|
||||
|
||||
{{% notice warning %}}
|
||||
**The `install.sh` script is currently experiencing issues and may produce broken or misconfigured installations. Please use alternative installation methods (Docker or manual binary installation) until [issue #8032](https://github.com/mudler/LocalAI/issues/8032) is resolved.**
|
||||
{{% /notice %}}
|
||||
## One-Line Installer (Recommended)
|
||||
|
||||
The fastest way to install LocalAI on Linux is with the installation script:
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ For more information on VRAM management, see [VRAM and Memory Management]({{%rel
|
||||
| `--opaque-errors` | `false` | If true, all error responses are replaced with blank 500 errors. This is intended only for hardening against information leaks and is normally not recommended | `$LOCALAI_OPAQUE_ERRORS` |
|
||||
| `--use-subtle-key-comparison` | `false` | If true, API Key validation comparisons will be performed using constant-time comparisons rather than simple equality. This trades off performance on each request for resilience against timing attacks | `$LOCALAI_SUBTLE_KEY_COMPARISON` |
|
||||
| `--disable-api-key-requirement-for-http-get` | `false` | If true, a valid API key is not required to issue GET requests to portions of the web UI. This should only be enabled in secure testing environments | `$LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET` |
|
||||
| `--http-get-exempted-endpoints` | `^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/image/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$` | If `--disable-api-key-requirement-for-http-get` is overridden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review | `$LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS` |
|
||||
| `--http-get-exempted-endpoints` | `^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$` | If `--disable-api-key-requirement-for-http-get` is overridden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review | `$LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS` |
|
||||
|
||||
## P2P Flags
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ LocalAI will attempt to automatically load models which are not explicitly confi
|
||||
| [silero-vad](https://github.com/snakers4/silero-vad) with [Golang bindings](https://github.com/streamer45/silero-vad-go) | Silero VAD | no | Voice Activity Detection | no | no | CPU |
|
||||
| [neutts](https://github.com/neuphonic/neuttsair) | NeuTTSAir | no | Text-to-speech with voice cloning | no | no | CUDA 12/13, ROCm, CPU |
|
||||
| [vibevoice](https://github.com/microsoft/VibeVoice) | VibeVoice-Realtime | no | Real-time text-to-speech with voice cloning | no | no | CUDA 12/13, ROCm, Intel, CPU |
|
||||
| [pocket-tts](https://github.com/kyutai-labs/pocket-tts) | Pocket TTS | no | Lightweight CPU-based text-to-speech with voice cloning | no | no | CUDA 12/13, ROCm, Intel, CPU |
|
||||
| [mlx-audio](https://github.com/Blaizzy/mlx-audio) | MLX | no | Text-tospeech | no | no | Metal (Apple Silicon) |
|
||||
|
||||
## Image & Video Generation
|
||||
|
||||
@@ -478,16 +478,6 @@
|
||||
- filename: voices/streaming_model/en-Davis_man.pt
|
||||
uri: https://raw.githubusercontent.com/microsoft/VibeVoice/main/demo/voices/streaming_model/en-Davis_man.pt
|
||||
sha256: 67561d63bfa2153616e4c02fd967007c182593fc53738a6ad94bf5f84e8832ac
|
||||
- &pocket-tts
|
||||
url: "github:mudler/LocalAI/gallery/pocket-tts.yaml@master"
|
||||
icon: https://avatars.githubusercontent.com/u/6154722?s=200&v=4
|
||||
license: mit
|
||||
tags:
|
||||
- text-to-speech
|
||||
- TTS
|
||||
name: "pocket-tts"
|
||||
urls:
|
||||
- https://github.com/kyutai-labs/pocket-tts
|
||||
- &qwen3vl
|
||||
url: "github:mudler/LocalAI/gallery/qwen3.yaml@master"
|
||||
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/620760a26e3b7210c2ff1943/-s1gyJfvbE1RgO5iBeNOi.png
|
||||
@@ -1247,63 +1237,6 @@
|
||||
cuda: true
|
||||
pipeline_type: QwenImageEditPipeline
|
||||
enable_parameters: num_inference_steps,image
|
||||
- <x2
|
||||
name: "ltx-2"
|
||||
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
||||
urls:
|
||||
- https://huggingface.co/Lightricks/LTX-2
|
||||
license: ltx-2-community-license-agreement
|
||||
tags:
|
||||
- diffusers
|
||||
- gpu
|
||||
- image-to-video
|
||||
- video-generation
|
||||
- audio-video
|
||||
description: |
|
||||
**LTX-2** is a DiT-based audio-video foundation model designed to generate synchronized video and audio within a single model. It brings together the core building blocks of modern video generation, with open weights and a focus on practical, local execution.
|
||||
|
||||
**Key Features:**
|
||||
- **Joint Audio-Video Generation**: Generates synchronized video and audio in a single model
|
||||
- **Image-to-Video**: Converts static images into dynamic videos with matching audio
|
||||
- **High Quality**: Produces realistic video with natural motion and synchronized audio
|
||||
- **Open Weights**: Available under the LTX-2 Community License Agreement
|
||||
|
||||
**Model Details:**
|
||||
- **Model Type**: Diffusion-based audio-video foundation model
|
||||
- **Architecture**: DiT (Diffusion Transformer) based
|
||||
- **Developed by**: Lightricks
|
||||
- **Paper**: [LTX-2: Efficient Joint Audio-Visual Foundation Model](https://arxiv.org/abs/2601.03233)
|
||||
|
||||
**Usage Tips:**
|
||||
- Width & height settings must be divisible by 32
|
||||
- Frame count must be divisible by 8 + 1 (e.g., 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105, 113, 121)
|
||||
- Recommended settings: width=768, height=512, num_frames=121, frame_rate=24.0
|
||||
- For best results, use detailed prompts describing motion and scene dynamics
|
||||
|
||||
**Limitations:**
|
||||
- This model is not intended or able to provide factual information
|
||||
- Prompt following is heavily influenced by the prompting-style
|
||||
- When generating audio without speech, the audio may be of lower quality
|
||||
|
||||
**Citation:**
|
||||
```bibtex
|
||||
@article{hacohen2025ltx2,
|
||||
title={LTX-2: Efficient Joint Audio-Visual Foundation Model},
|
||||
author={HaCohen, Yoav and Brazowski, Benny and Chiprut, Nisan and others},
|
||||
journal={arXiv preprint arXiv:2601.03233},
|
||||
year={2025}
|
||||
}
|
||||
```
|
||||
overrides:
|
||||
backend: diffusers
|
||||
low_vram: true
|
||||
parameters:
|
||||
model: Lightricks/LTX-2
|
||||
diffusers:
|
||||
cuda: true
|
||||
pipeline_type: LTX2ImageToVideoPipeline
|
||||
options:
|
||||
- torch_dtype:bf16
|
||||
- &gptoss
|
||||
name: "gpt-oss-20b"
|
||||
url: "github:mudler/LocalAI/gallery/harmony.yaml@master"
|
||||
@@ -3822,41 +3755,6 @@
|
||||
- filename: boomerang-qwen3-4.9B.Q4_K_M.gguf
|
||||
sha256: 11e6c068351d104dee31dd63550e5e2fc9be70467c1cfc07a6f84030cb701537
|
||||
uri: huggingface://mradermacher/boomerang-qwen3-4.9B-GGUF/boomerang-qwen3-4.9B.Q4_K_M.gguf
|
||||
- !!merge <<: *qwen3
|
||||
name: "qwen3-coder-30b-a3b-instruct"
|
||||
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/620760a26e3b7210c2ff1943/-s1gyJfvbE1RgO5iBeNOi.png
|
||||
url: "github:mudler/LocalAI/gallery/qwen3.yaml@master"
|
||||
urls:
|
||||
- https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct
|
||||
- https://huggingface.co/unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF
|
||||
description: |
|
||||
Qwen3-Coder is available in multiple sizes. Today, we're excited to introduce Qwen3-Coder-30B-A3B-Instruct. This streamlined model maintains impressive performance and efficiency, featuring the following key enhancements:
|
||||
|
||||
- Significant Performance among open models on Agentic Coding, Agentic Browser-Use, and other foundational coding tasks.
|
||||
- Long-context Capabilities with native support for 256K tokens, extendable up to 1M tokens using Yarn, optimized for repository-scale understanding.
|
||||
- Agentic Coding supporting for most platform such as Qwen Code, CLINE, featuring a specially designed function call format.
|
||||
|
||||
|
||||
Model Overview:
|
||||
Qwen3-Coder-30B-A3B-Instruct has the following features:
|
||||
|
||||
- Type: Causal Language Models
|
||||
- Training Stage: Pretraining & Post-training
|
||||
- Number of Parameters: 30.5B in total and 3.3B activated
|
||||
- Number of Layers: 48
|
||||
- Number of Attention Heads (GQA): 32 for Q and 4 for KV
|
||||
- Number of Experts: 128
|
||||
- Number of Activated Experts: 8
|
||||
- Context Length: 262,144 natively.
|
||||
|
||||
NOTE: This model supports only non-thinking mode and does not generate <think></think> blocks in its output. Meanwhile, specifying enable_thinking=False is no longer required.
|
||||
overrides:
|
||||
parameters:
|
||||
model: Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf
|
||||
files:
|
||||
- filename: Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf
|
||||
sha256: fadc3e5f8d42bf7e894a785b05082e47daee4df26680389817e2093056f088ad
|
||||
uri: huggingface://unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf
|
||||
- &gemma3
|
||||
url: "github:mudler/LocalAI/gallery/gemma.yaml@master"
|
||||
name: "gemma-3-27b-it"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: localai
|
||||
|
||||
config_file: |-
|
||||
name: pocket-tts
|
||||
backend: pocket-tts
|
||||
description: |
|
||||
Pocket TTS is a lightweight text-to-speech model designed to run efficiently on CPUs.
|
||||
This model supports voice cloning through HuggingFace voice URLs or local audio files.
|
||||
|
||||
parameters:
|
||||
model: ""
|
||||
|
||||
# TTS configuration
|
||||
tts:
|
||||
# Voice selection - can be:
|
||||
# 1. Built-in voice name (e.g., "alba", "marius", "javert", "jean", "fantine", "cosette", "eponine", "azelma")
|
||||
# 2. HuggingFace URL (e.g., "hf://kyutai/tts-voices/alba-mackenna/casual.wav")
|
||||
# 3. Local file path (relative to model directory or absolute)
|
||||
# voice: "azelma"
|
||||
# Alternative: use audio_path to specify a voice file directly
|
||||
# audio_path: "hf://kyutai/tts-voices/alba-mackenna/casual.wav"
|
||||
|
||||
known_usecases:
|
||||
- tts
|
||||
|
||||
# Backend-specific options
|
||||
# These are passed as "key:value" strings to the backend
|
||||
options:
|
||||
# Default voice to pre-load (optional)
|
||||
# Can be a voice name or HuggingFace URL
|
||||
# If set, this voice will be loaded when the model loads for faster first generation
|
||||
- "default_voice:azelma"
|
||||
# - "default_voice:hf://kyutai/tts-voices/alba-mackenna/casual.wav"
|
||||
16
go.mod
16
go.mod
@@ -6,7 +6,7 @@ toolchain go1.24.5
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
fyne.io/fyne/v2 v2.7.2
|
||||
fyne.io/fyne/v2 v2.7.1
|
||||
github.com/Masterminds/sprig/v3 v3.3.0
|
||||
github.com/alecthomas/kong v1.13.0
|
||||
github.com/anthropics/anthropic-sdk-go v1.19.0
|
||||
@@ -20,7 +20,7 @@ require (
|
||||
github.com/gofrs/flock v0.13.0
|
||||
github.com/google/go-containerregistry v0.20.7
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gpustack/gguf-parser-go v0.23.1
|
||||
github.com/gpustack/gguf-parser-go v0.22.1
|
||||
github.com/hpcloud/tail v1.0.0
|
||||
github.com/ipfs/go-log v1.0.5
|
||||
github.com/jaypipes/ghw v0.21.2
|
||||
@@ -34,11 +34,11 @@ require (
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||
github.com/mudler/cogito v0.7.2
|
||||
github.com/mudler/edgevpn v0.31.1
|
||||
github.com/mudler/go-processmanager v0.1.0
|
||||
github.com/mudler/go-processmanager v0.0.0-20240820160718-8b802d3ecf82
|
||||
github.com/mudler/memory v0.0.0-20251216220809-d1256471a6c2
|
||||
github.com/mudler/xlog v0.0.5
|
||||
github.com/onsi/ginkgo/v2 v2.27.5
|
||||
github.com/onsi/gomega v1.39.0
|
||||
github.com/onsi/ginkgo/v2 v2.27.3
|
||||
github.com/onsi/gomega v1.38.3
|
||||
github.com/otiai10/copy v1.14.1
|
||||
github.com/otiai10/openaigo v1.7.0
|
||||
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
|
||||
@@ -59,6 +59,7 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.39.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
oras.land/oras-go/v2 v2.6.0
|
||||
@@ -73,11 +74,10 @@ require (
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.0 // indirect
|
||||
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
@@ -324,7 +324,7 @@ require (
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
|
||||
28
go.sum
28
go.sum
@@ -8,10 +8,10 @@ dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl
|
||||
dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU=
|
||||
dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4=
|
||||
dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU=
|
||||
fyne.io/fyne/v2 v2.7.2 h1:XiNpWkn0PzX43ZCjbb0QYGg1RCxVbugwfVgikWZBCMw=
|
||||
fyne.io/fyne/v2 v2.7.2/go.mod h1:PXbqY3mQmJV3J1NRUR2VbVgUUx3vgvhuFJxyjRK/4Ug=
|
||||
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
fyne.io/fyne/v2 v2.7.1 h1:ja7rNHWWEooha4XBIZNnPP8tVFwmTfwMJdpZmLxm2Zc=
|
||||
fyne.io/fyne/v2 v2.7.1/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE=
|
||||
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 h1:eA5/u2XRd8OUkoMqEv3IBlFYSruNlXD8bRHDiqm0VNI=
|
||||
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
@@ -292,8 +292,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gpustack/gguf-parser-go v0.23.1 h1:0U7DOrsi7ryx2L/dlMy+BSQ5bJV4AuMEIgGBs4RK46A=
|
||||
github.com/gpustack/gguf-parser-go v0.23.1/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0=
|
||||
github.com/gpustack/gguf-parser-go v0.22.1 h1:FRnEDWqT0Rcplr/R9ctCRSN2+3DhVsf6dnR5/i9JA4E=
|
||||
github.com/gpustack/gguf-parser-go v0.22.1/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0=
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
|
||||
@@ -513,8 +513,8 @@ github.com/mudler/edgevpn v0.31.1 h1:7qegiDWd0kAg6ljhNHxqvp8hbo/6BbzSdbb7/2WZfiY
|
||||
github.com/mudler/edgevpn v0.31.1/go.mod h1:ftV5B0nKFzm4R8vR80UYnCb2nf7lxCRgAALxUEEgCf8=
|
||||
github.com/mudler/go-piper v0.0.0-20241023091659-2494246fd9fc h1:RxwneJl1VgvikiX28EkpdAyL4yQVnJMrbquKospjHyA=
|
||||
github.com/mudler/go-piper v0.0.0-20241023091659-2494246fd9fc/go.mod h1:O7SwdSWMilAWhBZMK9N9Y/oBDyMMzshE3ju8Xkexwig=
|
||||
github.com/mudler/go-processmanager v0.1.0 h1:fcSKgF9U/a1Z7KofAFeZnke5YseadCI5GqL9oT0LS3E=
|
||||
github.com/mudler/go-processmanager v0.1.0/go.mod h1:h6kmHUZeafr+k5hRYpGLMzJFH4hItHffgpRo2QIkP+o=
|
||||
github.com/mudler/go-processmanager v0.0.0-20240820160718-8b802d3ecf82 h1:FVT07EI8njvsD4tC2Hw8Xhactp5AWhsQWD4oTeQuSAU=
|
||||
github.com/mudler/go-processmanager v0.0.0-20240820160718-8b802d3ecf82/go.mod h1:Urp7LG5jylKoDq0663qeBh0pINGcRl35nXdKx82PSoU=
|
||||
github.com/mudler/memory v0.0.0-20251216220809-d1256471a6c2 h1:+WHsL/j6EWOMUiMVIOJNKOwSKiQt/qDPc9fePCf87fA=
|
||||
github.com/mudler/memory v0.0.0-20251216220809-d1256471a6c2/go.mod h1:EA8Ashhd56o32qN7ouPKFSRUs/Z+LrRCF4v6R2Oarm8=
|
||||
github.com/mudler/water v0.0.0-20250808092830-dd90dcf09025 h1:WFLP5FHInarYGXi6B/Ze204x7Xy6q/I4nCZnWEyPHK0=
|
||||
@@ -561,10 +561,10 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
|
||||
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
|
||||
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
||||
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -978,8 +978,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo=
|
||||
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
|
||||
@@ -1492,7 +1492,7 @@ func ParseFunctionCall(llmresult string, functionConfig FunctionsConfig) []FuncC
|
||||
results := []FuncCallResults{}
|
||||
llmResults := []string{}
|
||||
|
||||
extractJSON := func(results []string) (result []FuncCallResults, e error) {
|
||||
returnResult := func(results []string) (result []FuncCallResults, e error) {
|
||||
// As we have to change the result before processing, we can't stream the answer token-by-token (yet?)
|
||||
result = make([]FuncCallResults, 0)
|
||||
|
||||
@@ -1593,7 +1593,7 @@ func ParseFunctionCall(llmresult string, functionConfig FunctionsConfig) []FuncC
|
||||
if len(llmResults) == 0 {
|
||||
llmResults = append(llmResults, llmresult)
|
||||
}
|
||||
results, _ = extractJSON(llmResults)
|
||||
results, _ = returnResult(llmResults)
|
||||
}
|
||||
|
||||
// Determine which XML format to use (if any)
|
||||
@@ -1632,16 +1632,8 @@ func ParseFunctionCall(llmresult string, functionConfig FunctionsConfig) []FuncC
|
||||
// But skip if JSONRegexMatch or ResponseRegex was used (they already extracted the content)
|
||||
xmlResults, err := ParseXML(llmresult, xmlFormat)
|
||||
if err == nil && len(xmlResults) > 0 {
|
||||
// Check if JSON is inside XML tags, if so, skip it
|
||||
for _, result := range xmlResults {
|
||||
jsonResults, _ := extractJSON([]string{result.Name})
|
||||
if len(jsonResults) > 0 {
|
||||
xlog.Debug("Found valid JSON inside XML tags, skipping XML parsing", "json_count", len(jsonResults))
|
||||
} else {
|
||||
xlog.Debug("Found additional XML tool calls alongside JSON", "xml_count", len(xmlResults))
|
||||
results = append(results, xmlResults...)
|
||||
}
|
||||
}
|
||||
xlog.Debug("Found additional XML tool calls alongside JSON", "xml_count", len(xmlResults))
|
||||
results = append(results, xmlResults...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -820,23 +820,6 @@ Final text`
|
||||
Expect(results[0].Name).To(Equal("first"))
|
||||
Expect(results[1].Name).To(Equal("second"))
|
||||
})
|
||||
|
||||
It("should not duplicate parse JSON inside tool_call tags", func() {
|
||||
// This test reproduces a bug where JSON inside <tool_call> tags
|
||||
// gets parsed twice: once as JSON (correctly) and once as XML (incorrectly)
|
||||
// The XML parser should not run when JSON parsing already found valid results
|
||||
input := `<tool_call>
|
||||
{"name": "get_current_weather", "arguments": {"location": "Beijing", "unit": "celsius"}}
|
||||
</tool_call>`
|
||||
|
||||
results := ParseFunctionCall(input, functionConfig)
|
||||
// Should only have 1 result, not 2 (one correct + one malformed)
|
||||
Expect(results).To(HaveLen(1), "Should not create duplicate entries when JSON is inside XML tags")
|
||||
Expect(results[0].Name).To(Equal("get_current_weather"))
|
||||
Expect(results[0].Arguments).To(Equal(`{"location":"Beijing","unit":"celsius"}`))
|
||||
// Verify the name is not the entire JSON object (which would indicate malformed XML parsing)
|
||||
Expect(results[0].Name).NotTo(ContainSubstring(`{"name"`), "Function name should not contain JSON object")
|
||||
})
|
||||
})
|
||||
|
||||
Context("Iterative Parser (ChatMsgParser)", func() {
|
||||
|
||||
@@ -148,10 +148,10 @@ package_cuda_libs() {
|
||||
done
|
||||
|
||||
# Copy CUDA target directory for runtime compilation support
|
||||
# if [ -d "/usr/local/cuda/targets" ]; then
|
||||
# mkdir -p "$TARGET_LIB_DIR/../cuda"
|
||||
# cp -arfL /usr/local/cuda/targets "$TARGET_LIB_DIR/../cuda/" 2>/dev/null || true
|
||||
# fi
|
||||
if [ -d "/usr/local/cuda/targets" ]; then
|
||||
mkdir -p "$TARGET_LIB_DIR/../cuda"
|
||||
cp -arfL /usr/local/cuda/targets "$TARGET_LIB_DIR/../cuda/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "CUDA libraries packaged successfully"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user