Spaces:
Running
Running
Commit Β·
b2f9b47
1
Parent(s): 97559c7
Initial deploy
Browse files- .gitignore +0 -0
- Dockerfile +78 -0
- nginx.conf +27 -0
- services/api/Dockerfile +48 -0
- services/api/main.py +557 -0
- services/api/requirements.txt +11 -0
- services/encoder/Dockerfile +78 -0
- services/encoder/main.py +255 -0
- services/encoder/requirements.txt +15 -0
- services/frontend/Dockerfile +62 -0
- services/frontend/index.html +12 -0
- services/frontend/nginx.conf +23 -0
- services/frontend/package-lock.json +1681 -0
- services/frontend/package.json +18 -0
- services/frontend/src/App.jsx +153 -0
- services/frontend/src/components/ResultGrid.jsx +76 -0
- services/frontend/src/components/SearchBar.jsx +55 -0
- services/frontend/src/components/StatsBar.jsx +1 -0
- services/frontend/src/components/VoiceButton.jsx +69 -0
- services/frontend/src/index.css +94 -0
- services/frontend/src/main.jsx +10 -0
- services/frontend/vite.config.js +21 -0
- start.sh +73 -0
- supervisord.conf +37 -0
.gitignore
ADDED
|
Binary file (122 Bytes). View file
|
|
|
Dockerfile
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HuggingFace Space β Unified Dockerfile
|
| 2 |
+
#
|
| 3 |
+
# Combines all 3 services (encoder + api + frontend) into one container.
|
| 4 |
+
# HuggingFace Spaces only exposes port 7860 and doesn't support docker-compose.
|
| 5 |
+
#
|
| 6 |
+
# Architecture inside this container:
|
| 7 |
+
# supervisord manages 3 processes:
|
| 8 |
+
# - encoder (uvicorn on port 8001) β ONNX CLIP inference
|
| 9 |
+
# - api (uvicorn on port 8000) β FAISS search + Whisper
|
| 10 |
+
# - nginx (on port 7860) β serves frontend + proxies /api β 8000
|
| 11 |
+
#
|
| 12 |
+
# On startup, start.sh downloads models + embeddings from HuggingFace Hub.
|
| 13 |
+
|
| 14 |
+
# ββ Stage 1: Build React frontend βββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
+
FROM node:18-alpine AS frontend-builder
|
| 16 |
+
|
| 17 |
+
WORKDIR /app/frontend
|
| 18 |
+
COPY services/frontend/package.json services/frontend/package-lock.json* ./
|
| 19 |
+
RUN npm ci
|
| 20 |
+
COPY services/frontend/ .
|
| 21 |
+
|
| 22 |
+
# API is proxied via nginx at /api on port 7860
|
| 23 |
+
ARG VITE_API_URL=""
|
| 24 |
+
ENV VITE_API_URL=$VITE_API_URL
|
| 25 |
+
RUN npm run build
|
| 26 |
+
|
| 27 |
+
# ββ Stage 2: Main runtime image ββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
FROM python:3.11-slim
|
| 29 |
+
|
| 30 |
+
# Install system dependencies
|
| 31 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 32 |
+
nginx \
|
| 33 |
+
supervisor \
|
| 34 |
+
ffmpeg \
|
| 35 |
+
git \
|
| 36 |
+
gcc \
|
| 37 |
+
g++ \
|
| 38 |
+
libgomp1 \
|
| 39 |
+
wget \
|
| 40 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 41 |
+
|
| 42 |
+
# ββ Install Python dependencies ββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
COPY services/encoder/requirements.txt /tmp/encoder-requirements.txt
|
| 44 |
+
COPY services/api/requirements.txt /tmp/api-requirements.txt
|
| 45 |
+
|
| 46 |
+
RUN pip install --no-cache-dir --default-timeout=1200 \
|
| 47 |
+
-r /tmp/encoder-requirements.txt \
|
| 48 |
+
-r /tmp/api-requirements.txt \
|
| 49 |
+
huggingface_hub
|
| 50 |
+
|
| 51 |
+
# ββ Copy application code ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
COPY services/encoder/main.py /app/encoder/main.py
|
| 53 |
+
COPY services/api/main.py /app/api/main.py
|
| 54 |
+
|
| 55 |
+
# ββ Copy compiled frontend βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
| 57 |
+
|
| 58 |
+
# ββ Nginx config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
COPY hf-space/nginx.conf /etc/nginx/conf.d/default.conf
|
| 60 |
+
RUN rm -f /etc/nginx/sites-enabled/default
|
| 61 |
+
|
| 62 |
+
# ββ Supervisord config βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
+
COPY hf-space/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
| 64 |
+
|
| 65 |
+
# ββ Startup script βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 66 |
+
COPY hf-space/start.sh /start.sh
|
| 67 |
+
RUN chmod +x /start.sh
|
| 68 |
+
|
| 69 |
+
# ββ Create required directories ββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
RUN mkdir -p /app/models /app/embeddings /app/images /app/data /var/log/supervisor
|
| 71 |
+
|
| 72 |
+
# HuggingFace Spaces runs as non-root user (uid 1000)
|
| 73 |
+
RUN chown -R 1000:1000 /app /var/log/supervisor /var/lib/nginx /var/log/nginx \
|
| 74 |
+
&& chmod -R 755 /app
|
| 75 |
+
|
| 76 |
+
EXPOSE 7860
|
| 77 |
+
|
| 78 |
+
CMD ["/start.sh"]
|
nginx.conf
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
server {
|
| 2 |
+
listen 7860;
|
| 3 |
+
|
| 4 |
+
# Serve React frontend
|
| 5 |
+
location / {
|
| 6 |
+
root /usr/share/nginx/html;
|
| 7 |
+
index index.html;
|
| 8 |
+
try_files $uri $uri/ /index.html;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
# Proxy /api β FastAPI on port 8000
|
| 12 |
+
# Frontend calls /api/search/text instead of http://localhost:8000/search/text
|
| 13 |
+
location /api/ {
|
| 14 |
+
proxy_pass http://127.0.0.1:8000/;
|
| 15 |
+
proxy_set_header Host $host;
|
| 16 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 17 |
+
proxy_read_timeout 60s;
|
| 18 |
+
client_max_body_size 20M;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# Proxy /images β static image files served by API
|
| 22 |
+
location /images/ {
|
| 23 |
+
proxy_pass http://127.0.0.1:8000/images/;
|
| 24 |
+
proxy_set_header Host $host;
|
| 25 |
+
proxy_read_timeout 30s;
|
| 26 |
+
}
|
| 27 |
+
}
|
services/api/Dockerfile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# services/api/Dockerfile
|
| 2 |
+
#
|
| 3 |
+
# WHY THIS IS SEPARATE FROM THE ENCODER:
|
| 4 |
+
# If they were one container:
|
| 5 |
+
# - Restart API β also restarts encoder β 3s model reload on every code change
|
| 6 |
+
# - Scale horizontally β each replica carries the 90MB model in RAM
|
| 7 |
+
# - One crash takes down both search logic AND inference
|
| 8 |
+
#
|
| 9 |
+
# Separate containers = independent restart, scale, update, and failure domains.
|
| 10 |
+
#
|
| 11 |
+
# THIS CONTAINER IS LIGHTER than the encoder:
|
| 12 |
+
# - No onnxruntime (that's the encoder's job)
|
| 13 |
+
# - Needs faiss-cpu, whisper, httpx (for calling encoder)
|
| 14 |
+
# - Target size: ~600MB
|
| 15 |
+
|
| 16 |
+
FROM python:3.11-slim
|
| 17 |
+
|
| 18 |
+
WORKDIR /app
|
| 19 |
+
|
| 20 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 21 |
+
ffmpeg \
|
| 22 |
+
git \
|
| 23 |
+
# ffmpeg is needed by Whisper to decode audio files (mp3, wav, webm, etc.)
|
| 24 |
+
# Without it, Whisper can only handle raw PCM.
|
| 25 |
+
# Size cost: ~80MB β worth it for voice search capability.
|
| 26 |
+
|
| 27 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 28 |
+
|
| 29 |
+
COPY requirements.txt .
|
| 30 |
+
RUN pip install --upgrade pip setuptools wheel
|
| 31 |
+
# RUN pip install --no-cache-dir -r requirements.txt
|
| 32 |
+
RUN pip install --no-cache-dir --no-build-isolation -r requirements.txt
|
| 33 |
+
COPY main.py .
|
| 34 |
+
|
| 35 |
+
# Create directories for runtime data
|
| 36 |
+
# embeddings/ and data/ are mounted as volumes β not baked in
|
| 37 |
+
RUN mkdir -p embeddings data images
|
| 38 |
+
|
| 39 |
+
EXPOSE 8000
|
| 40 |
+
|
| 41 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
| 42 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
| 43 |
+
|
| 44 |
+
# 2 workers for the API (it's I/O bound β waiting on encoder HTTP calls)
|
| 45 |
+
# I/O-bound services benefit from multiple workers because while one worker
|
| 46 |
+
# waits for the encoder response, another can handle a new request.
|
| 47 |
+
# The encoder is CPU-bound β multiple workers there would fight for CPU.
|
| 48 |
+
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
services/api/main.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/api/main.py
|
| 3 |
+
====================
|
| 4 |
+
WHY THIS IS A SEPARATE SERVICE FROM THE ENCODER:
|
| 5 |
+
This service handles:
|
| 6 |
+
- FAISS index (search logic)
|
| 7 |
+
- Whisper (voice transcription)
|
| 8 |
+
- Request routing
|
| 9 |
+
- Feedback storage
|
| 10 |
+
- Result reranking
|
| 11 |
+
|
| 12 |
+
The encoder handles:
|
| 13 |
+
- ONNX inference (heavy ML model)
|
| 14 |
+
|
| 15 |
+
Separation means: if FAISS crashes, encoder keeps running.
|
| 16 |
+
If encoder needs to be swapped for GPU, API logic doesn't change.
|
| 17 |
+
They communicate over HTTP on the internal Docker network.
|
| 18 |
+
|
| 19 |
+
WHISPER FOR VOICE SEARCH:
|
| 20 |
+
OpenAI Whisper is a speech-to-text model.
|
| 21 |
+
We use the "tiny" variant (39MB):
|
| 22 |
+
tiny: 39MB, ~2s for 5s audio, ~88% word accuracy
|
| 23 |
+
base: 74MB, ~3s for 5s audio, ~91% word accuracy
|
| 24 |
+
small: 244MB, ~6s for 5s audio, ~94% word accuracy
|
| 25 |
+
medium: 769MB, ~15s for 5s audio, ~96% word accuracy
|
| 26 |
+
large: 1.5GB, ~30s for 5s audio, ~98% word accuracy
|
| 27 |
+
|
| 28 |
+
We chose TINY because:
|
| 29 |
+
- Search queries are short (3-10 words), not medical transcription
|
| 30 |
+
- 88% accuracy on "dog running in park" is effectively 100%
|
| 31 |
+
- 2 seconds latency vs 30 seconds for large is massive UX difference
|
| 32 |
+
- 39MB vs 1.5GB β fits comfortably in our Docker container
|
| 33 |
+
|
| 34 |
+
TRADEOFF: If user has strong accent or says complex phrases,
|
| 35 |
+
tiny might mishear. For a demo/portfolio, fine. For production,
|
| 36 |
+
add an option to select model size.
|
| 37 |
+
|
| 38 |
+
THE RERANKER:
|
| 39 |
+
FAISS returns top-K results by vector distance.
|
| 40 |
+
Distance is a good but imperfect proxy for relevance.
|
| 41 |
+
The reranker applies additional signals:
|
| 42 |
+
1. Feedback boost: if user previously liked an image, boost similar ones
|
| 43 |
+
2. Diversity: don't return 10 photos from the same category
|
| 44 |
+
3. Recency: optionally boost recently added images
|
| 45 |
+
|
| 46 |
+
This is a LIGHTWEIGHT reranker β no neural network, just heuristics.
|
| 47 |
+
A full cross-encoder reranker (like BERT) would be more accurate but
|
| 48 |
+
adds 50-100ms latency. For search, perceived speed matters more than
|
| 49 |
+
marginal accuracy improvements.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
import os
|
| 53 |
+
import io
|
| 54 |
+
import pickle
|
| 55 |
+
import logging
|
| 56 |
+
import time
|
| 57 |
+
import sqlite3
|
| 58 |
+
from pathlib import Path
|
| 59 |
+
from typing import Optional
|
| 60 |
+
from contextlib import asynccontextmanager
|
| 61 |
+
|
| 62 |
+
import numpy as np
|
| 63 |
+
import faiss
|
| 64 |
+
import httpx
|
| 65 |
+
from fastapi import FastAPI, HTTPException, UploadFile, File
|
| 66 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 67 |
+
from fastapi.staticfiles import StaticFiles
|
| 68 |
+
from pydantic import BaseModel
|
| 69 |
+
|
| 70 |
+
# ββ Logging βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [api] %(message)s")
|
| 72 |
+
log = logging.getLogger(__name__)
|
| 73 |
+
|
| 74 |
+
# ββ Configuration from environment βββββββββββββββββββββββββββββββββββββββββββ
|
| 75 |
+
# Using env vars (not hardcoded) so Docker Compose can configure them
|
| 76 |
+
ENCODER_URL = os.getenv("ENCODER_URL", "http://encoder:8001")
|
| 77 |
+
EMBEDDINGS_DIR = os.getenv("EMBEDDINGS_DIR", "embeddings")
|
| 78 |
+
IMAGES_DIR = os.getenv("IMAGES_DIR", "images")
|
| 79 |
+
DB_PATH = os.getenv("DB_PATH", "data/search.db")
|
| 80 |
+
NPROBE = int(os.getenv("FAISS_NPROBE", "10"))
|
| 81 |
+
|
| 82 |
+
# ββ Global state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 83 |
+
faiss_index = None
|
| 84 |
+
metadata: list[dict] = []
|
| 85 |
+
whisper_model = None
|
| 86 |
+
db_conn: Optional[sqlite3.Connection] = None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ββ Lifespan (replaces @app.on_event, modern FastAPI pattern) βββββββββββββββββ
|
| 90 |
+
@asynccontextmanager
|
| 91 |
+
async def lifespan(app: FastAPI):
|
| 92 |
+
"""Load all resources on startup, clean up on shutdown."""
|
| 93 |
+
global faiss_index, metadata, whisper_model, db_conn
|
| 94 |
+
|
| 95 |
+
# Load FAISS index
|
| 96 |
+
index_path = os.path.join(EMBEDDINGS_DIR, "faiss.index")
|
| 97 |
+
meta_path = os.path.join(EMBEDDINGS_DIR, "metadata.pkl")
|
| 98 |
+
|
| 99 |
+
if Path(index_path).exists():
|
| 100 |
+
log.info(f"Loading FAISS index from {index_path}...")
|
| 101 |
+
faiss_index = faiss.read_index(index_path)
|
| 102 |
+
faiss_index.nprobe = NPROBE # set search-time parameter
|
| 103 |
+
log.info(f" Index loaded: {faiss_index.ntotal} vectors")
|
| 104 |
+
else:
|
| 105 |
+
log.warning(f"No FAISS index at {index_path}. Run ingest.py first.")
|
| 106 |
+
|
| 107 |
+
if Path(meta_path).exists():
|
| 108 |
+
with open(meta_path, "rb") as f:
|
| 109 |
+
metadata = pickle.load(f)
|
| 110 |
+
log.info(f" Metadata loaded: {len(metadata)} records")
|
| 111 |
+
|
| 112 |
+
# Load Whisper (lazy β only if installed)
|
| 113 |
+
try:
|
| 114 |
+
import whisper
|
| 115 |
+
log.info("Loading Whisper tiny model for voice search...")
|
| 116 |
+
whisper_model = whisper.load_model("tiny")
|
| 117 |
+
log.info(" Whisper ready.")
|
| 118 |
+
except ImportError:
|
| 119 |
+
log.warning("Whisper not installed. Voice search disabled. "
|
| 120 |
+
"Install with: pip install openai-whisper")
|
| 121 |
+
except Exception as e:
|
| 122 |
+
log.warning(f"Whisper load failed: {e}")
|
| 123 |
+
|
| 124 |
+
# Setup SQLite for feedback + query logging
|
| 125 |
+
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
| 126 |
+
db_conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
| 127 |
+
_init_db(db_conn)
|
| 128 |
+
log.info("Database ready.")
|
| 129 |
+
|
| 130 |
+
log.info("API service ready.")
|
| 131 |
+
yield # β app runs here
|
| 132 |
+
|
| 133 |
+
# Cleanup on shutdown
|
| 134 |
+
if db_conn:
|
| 135 |
+
db_conn.close()
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _init_db(conn: sqlite3.Connection):
|
| 139 |
+
"""Create tables if they don't exist."""
|
| 140 |
+
conn.executescript("""
|
| 141 |
+
CREATE TABLE IF NOT EXISTS queries (
|
| 142 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 143 |
+
query_text TEXT,
|
| 144 |
+
query_type TEXT, -- 'text', 'image', 'voice'
|
| 145 |
+
result_count INTEGER,
|
| 146 |
+
latency_ms REAL,
|
| 147 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 148 |
+
);
|
| 149 |
+
|
| 150 |
+
CREATE TABLE IF NOT EXISTS feedback (
|
| 151 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 152 |
+
image_path TEXT NOT NULL,
|
| 153 |
+
query_text TEXT,
|
| 154 |
+
vote INTEGER NOT NULL, -- +1 = thumbs up, -1 = thumbs down
|
| 155 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 156 |
+
);
|
| 157 |
+
""")
|
| 158 |
+
conn.commit()
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
app = FastAPI(
|
| 162 |
+
title="Visual Search API",
|
| 163 |
+
description="Semantic image search powered by CLIP + FAISS + Whisper",
|
| 164 |
+
version="1.0.0",
|
| 165 |
+
lifespan=lifespan,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
app.add_middleware(
|
| 169 |
+
CORSMiddleware,
|
| 170 |
+
allow_origins=["*"],
|
| 171 |
+
allow_methods=["*"],
|
| 172 |
+
allow_headers=["*"],
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# Serve image files statically
|
| 176 |
+
# This lets the React frontend load actual images
|
| 177 |
+
images_path = Path(IMAGES_DIR)
|
| 178 |
+
if images_path.exists():
|
| 179 |
+
app.mount("/images", StaticFiles(directory=str(images_path)), name="images")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ββ Pydantic schemas ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 183 |
+
class SearchResult(BaseModel):
|
| 184 |
+
path: str # relative path for frontend to construct URL
|
| 185 |
+
url: str # full URL to fetch the image
|
| 186 |
+
category: str
|
| 187 |
+
score: float # similarity score 0-1 (higher = more similar)
|
| 188 |
+
rank: int
|
| 189 |
+
|
| 190 |
+
class SearchResponse(BaseModel):
|
| 191 |
+
results: list[SearchResult]
|
| 192 |
+
query: str
|
| 193 |
+
query_type: str
|
| 194 |
+
total_found: int
|
| 195 |
+
latency_ms: float
|
| 196 |
+
encoder_latency_ms: float
|
| 197 |
+
|
| 198 |
+
class FeedbackRequest(BaseModel):
|
| 199 |
+
image_path: str
|
| 200 |
+
query: str
|
| 201 |
+
vote: int # +1 or -1
|
| 202 |
+
|
| 203 |
+
class StatsResponse(BaseModel):
|
| 204 |
+
total_images: int
|
| 205 |
+
total_queries: int
|
| 206 |
+
index_type: str
|
| 207 |
+
nprobe: int
|
| 208 |
+
whisper_available: bool
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ββ Core search logic βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 212 |
+
async def get_embedding_for_text(text: str) -> tuple[np.ndarray, float]:
|
| 213 |
+
"""Call encoder service to get text embedding."""
|
| 214 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 215 |
+
resp = await client.post(
|
| 216 |
+
f"{ENCODER_URL}/embed/text",
|
| 217 |
+
json={"text": text},
|
| 218 |
+
)
|
| 219 |
+
if resp.status_code != 200:
|
| 220 |
+
raise HTTPException(502, f"Encoder error: {resp.text}")
|
| 221 |
+
data = resp.json()
|
| 222 |
+
return np.array(data["embedding"], dtype=np.float32), data["latency_ms"]
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
async def get_embedding_for_image(image_bytes: bytes) -> tuple[np.ndarray, float]:
|
| 226 |
+
"""Call encoder service to get image embedding."""
|
| 227 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 228 |
+
resp = await client.post(
|
| 229 |
+
f"{ENCODER_URL}/embed/image/upload",
|
| 230 |
+
files={"file": ("image.jpg", image_bytes, "image/jpeg")},
|
| 231 |
+
)
|
| 232 |
+
if resp.status_code != 200:
|
| 233 |
+
raise HTTPException(502, f"Encoder error: {resp.text}")
|
| 234 |
+
data = resp.json()
|
| 235 |
+
return np.array(data["embedding"], dtype=np.float32), data["latency_ms"]
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def faiss_search(
|
| 239 |
+
query_embedding: np.ndarray,
|
| 240 |
+
k: int = 20,
|
| 241 |
+
) -> list[tuple[int, float]]:
|
| 242 |
+
"""
|
| 243 |
+
Search FAISS index.
|
| 244 |
+
Returns list of (metadata_index, distance) sorted by distance ascending.
|
| 245 |
+
|
| 246 |
+
WHY k=20 when user wants top-10:
|
| 247 |
+
We fetch 20 (2x) because the reranker may reorder them.
|
| 248 |
+
Fetching more candidates = reranker has more to work with.
|
| 249 |
+
This is called "over-fetching" β standard practice in two-stage retrieval.
|
| 250 |
+
"""
|
| 251 |
+
if faiss_index is None:
|
| 252 |
+
raise HTTPException(503, "FAISS index not loaded. Run ingest.py first.")
|
| 253 |
+
|
| 254 |
+
# FAISS expects shape [1, 512] for single query
|
| 255 |
+
query = query_embedding.reshape(1, -1)
|
| 256 |
+
|
| 257 |
+
# D = distances, I = indices into metadata list
|
| 258 |
+
D, I = faiss_index.search(query, k)
|
| 259 |
+
|
| 260 |
+
results = []
|
| 261 |
+
for dist, idx in zip(D[0], I[0]):
|
| 262 |
+
if idx == -1: # -1 means FAISS couldn't find enough results
|
| 263 |
+
continue
|
| 264 |
+
results.append((int(idx), float(dist)))
|
| 265 |
+
|
| 266 |
+
return results
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def rerank(
|
| 270 |
+
results: list[tuple[int, float]],
|
| 271 |
+
query: str,
|
| 272 |
+
top_k: int = 10,
|
| 273 |
+
) -> list[tuple[int, float]]:
|
| 274 |
+
"""
|
| 275 |
+
Apply feedback signals to reorder FAISS results.
|
| 276 |
+
|
| 277 |
+
WHAT RERANKING DOES:
|
| 278 |
+
FAISS gives us [img1, img2, img3...] ordered by vector distance.
|
| 279 |
+
But vector distance doesn't know:
|
| 280 |
+
- Which images a USER has liked before
|
| 281 |
+
- Whether we're showing too many similar images (diversity)
|
| 282 |
+
|
| 283 |
+
The reranker adjusts scores based on this context.
|
| 284 |
+
|
| 285 |
+
FEEDBACK BOOST:
|
| 286 |
+
If user previously gave thumbs up to an image similar to the query,
|
| 287 |
+
we boost its score slightly. Not a lot β we don't want to overfit
|
| 288 |
+
to one user's preferences, but enough to personalize.
|
| 289 |
+
|
| 290 |
+
DIVERSITY PENALTY:
|
| 291 |
+
If we already have 3 images from the same category in top results,
|
| 292 |
+
the 4th one gets a small penalty. Prevents showing 10 dog photos
|
| 293 |
+
when searching "animals".
|
| 294 |
+
|
| 295 |
+
WHY NOT A NEURAL RERANKER:
|
| 296 |
+
Cross-encoder models (BERT-based) can rerank with 95%+ accuracy
|
| 297 |
+
but add 50-200ms latency per result set.
|
| 298 |
+
Our lightweight heuristic adds <1ms.
|
| 299 |
+
For a portfolio project, the heuristic is the right call.
|
| 300 |
+
For a production search engine serving 10k QPS, neural reranking
|
| 301 |
+
on a GPU is the right call.
|
| 302 |
+
"""
|
| 303 |
+
if db_conn is None or not results:
|
| 304 |
+
return results[:top_k]
|
| 305 |
+
|
| 306 |
+
# Get feedback data for these image paths
|
| 307 |
+
relevant_paths = [metadata[idx]["path"] for idx, _ in results if idx < len(metadata)]
|
| 308 |
+
placeholders = ",".join(["?"] * len(relevant_paths))
|
| 309 |
+
cursor = db_conn.execute(
|
| 310 |
+
f"SELECT image_path, SUM(vote) as score FROM feedback "
|
| 311 |
+
f"WHERE image_path IN ({placeholders}) GROUP BY image_path",
|
| 312 |
+
relevant_paths,
|
| 313 |
+
)
|
| 314 |
+
feedback_scores = {row[0]: row[1] for row in cursor.fetchall()}
|
| 315 |
+
|
| 316 |
+
# Diversity tracking
|
| 317 |
+
category_counts: dict[str, int] = {}
|
| 318 |
+
|
| 319 |
+
adjusted = []
|
| 320 |
+
for idx, dist in results:
|
| 321 |
+
if idx >= len(metadata):
|
| 322 |
+
continue
|
| 323 |
+
record = metadata[idx]
|
| 324 |
+
path = record["path"]
|
| 325 |
+
category = record.get("category", "unknown")
|
| 326 |
+
|
| 327 |
+
# Convert L2 distance to similarity score [0, 1]
|
| 328 |
+
# L2 distance 0 = identical, grows as vectors diverge
|
| 329 |
+
# We convert: similarity = 1 / (1 + distance)
|
| 330 |
+
similarity = 1.0 / (1.0 + dist)
|
| 331 |
+
|
| 332 |
+
# Apply feedback boost
|
| 333 |
+
user_vote = feedback_scores.get(path, 0)
|
| 334 |
+
if user_vote > 0:
|
| 335 |
+
similarity *= 1.15 # 15% boost for liked images
|
| 336 |
+
elif user_vote < 0:
|
| 337 |
+
similarity *= 0.70 # 30% penalty for disliked images
|
| 338 |
+
|
| 339 |
+
# Apply diversity penalty
|
| 340 |
+
count_in_category = category_counts.get(category, 0)
|
| 341 |
+
if count_in_category >= 3:
|
| 342 |
+
similarity *= 0.90 # 10% penalty if category is already represented
|
| 343 |
+
|
| 344 |
+
category_counts[category] = count_in_category + 1
|
| 345 |
+
adjusted.append((idx, similarity))
|
| 346 |
+
|
| 347 |
+
# Sort by adjusted similarity descending
|
| 348 |
+
adjusted.sort(key=lambda x: x[1], reverse=True)
|
| 349 |
+
return adjusted[:top_k]
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def build_response(
|
| 353 |
+
ranked: list[tuple[int, float]],
|
| 354 |
+
query: str,
|
| 355 |
+
query_type: str,
|
| 356 |
+
encoder_latency: float,
|
| 357 |
+
total_latency: float,
|
| 358 |
+
) -> SearchResponse:
|
| 359 |
+
"""Build the final response from ranked results."""
|
| 360 |
+
results = []
|
| 361 |
+
for rank, (idx, score) in enumerate(ranked):
|
| 362 |
+
if idx >= len(metadata):
|
| 363 |
+
continue
|
| 364 |
+
record = metadata[idx]
|
| 365 |
+
path = record["path"]
|
| 366 |
+
# Convert local filesystem path to URL the frontend can use
|
| 367 |
+
# Docker volume mounts images at /images/ route
|
| 368 |
+
relative = path.replace("\\", "/")
|
| 369 |
+
# Extract everything after 'images/'
|
| 370 |
+
parts = relative.split("images/")
|
| 371 |
+
url_path = parts[-1] if len(parts) > 1 else os.path.basename(path)
|
| 372 |
+
|
| 373 |
+
results.append(SearchResult(
|
| 374 |
+
path=path,
|
| 375 |
+
url=f"/images/{url_path}",
|
| 376 |
+
category=record.get("category", "unknown"),
|
| 377 |
+
score=round(min(score, 1.0), 4),
|
| 378 |
+
rank=rank + 1,
|
| 379 |
+
))
|
| 380 |
+
|
| 381 |
+
return SearchResponse(
|
| 382 |
+
results=results,
|
| 383 |
+
query=query,
|
| 384 |
+
query_type=query_type,
|
| 385 |
+
total_found=len(results),
|
| 386 |
+
latency_ms=round(total_latency, 1),
|
| 387 |
+
encoder_latency_ms=round(encoder_latency, 1),
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def log_query(query: str, query_type: str, result_count: int, latency_ms: float):
|
| 392 |
+
"""Store query in SQLite for analytics."""
|
| 393 |
+
if db_conn:
|
| 394 |
+
try:
|
| 395 |
+
db_conn.execute(
|
| 396 |
+
"INSERT INTO queries (query_text, query_type, result_count, latency_ms) VALUES (?,?,?,?)",
|
| 397 |
+
(query, query_type, result_count, latency_ms),
|
| 398 |
+
)
|
| 399 |
+
db_conn.commit()
|
| 400 |
+
except Exception as e:
|
| 401 |
+
log.warning(f"Failed to log query: {e}")
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
# ββ API Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 405 |
+
|
| 406 |
+
@app.get("/health")
|
| 407 |
+
async def health():
|
| 408 |
+
return {
|
| 409 |
+
"status": "ok",
|
| 410 |
+
"index_loaded": faiss_index is not None,
|
| 411 |
+
"image_count": faiss_index.ntotal if faiss_index else 0,
|
| 412 |
+
"whisper_available": whisper_model is not None,
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
@app.get("/stats", response_model=StatsResponse)
|
| 417 |
+
async def stats():
|
| 418 |
+
total_queries = 0
|
| 419 |
+
if db_conn:
|
| 420 |
+
row = db_conn.execute("SELECT COUNT(*) FROM queries").fetchone()
|
| 421 |
+
total_queries = row[0] if row else 0
|
| 422 |
+
|
| 423 |
+
index_type = "none"
|
| 424 |
+
if faiss_index:
|
| 425 |
+
index_type = type(faiss_index).__name__
|
| 426 |
+
|
| 427 |
+
return StatsResponse(
|
| 428 |
+
total_images=faiss_index.ntotal if faiss_index else 0,
|
| 429 |
+
total_queries=total_queries,
|
| 430 |
+
index_type=index_type,
|
| 431 |
+
nprobe=NPROBE,
|
| 432 |
+
whisper_available=whisper_model is not None,
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
@app.get("/search/text")
|
| 437 |
+
async def search_text(q: str, k: int = 10):
|
| 438 |
+
"""
|
| 439 |
+
Text β image search.
|
| 440 |
+
User types "dog running in park" β returns top-k matching images.
|
| 441 |
+
"""
|
| 442 |
+
if not q.strip():
|
| 443 |
+
raise HTTPException(400, "Query cannot be empty")
|
| 444 |
+
|
| 445 |
+
t0 = time.perf_counter()
|
| 446 |
+
embedding, encoder_ms = await get_embedding_for_text(q)
|
| 447 |
+
raw_results = faiss_search(embedding, k=k * 2)
|
| 448 |
+
ranked = rerank(raw_results, q, top_k=k)
|
| 449 |
+
latency = (time.perf_counter() - t0) * 1000
|
| 450 |
+
|
| 451 |
+
log_query(q, "text", len(ranked), latency)
|
| 452 |
+
return build_response(ranked, q, "text", encoder_ms, latency)
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
@app.post("/search/image")
|
| 456 |
+
async def search_image(file: UploadFile = File(...), k: int = 10):
|
| 457 |
+
"""
|
| 458 |
+
Image β similar image search (reverse image search).
|
| 459 |
+
User uploads a photo β returns visually similar images.
|
| 460 |
+
"""
|
| 461 |
+
t0 = time.perf_counter()
|
| 462 |
+
contents = await file.read()
|
| 463 |
+
embedding, encoder_ms = await get_embedding_for_image(contents)
|
| 464 |
+
raw_results = faiss_search(embedding, k=k * 2)
|
| 465 |
+
ranked = rerank(raw_results, "image_query", top_k=k)
|
| 466 |
+
latency = (time.perf_counter() - t0) * 1000
|
| 467 |
+
|
| 468 |
+
log_query("image_upload", "image", len(ranked), latency)
|
| 469 |
+
return build_response(ranked, "image_upload", "image", encoder_ms, latency)
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
@app.post("/search/voice")
|
| 473 |
+
async def search_voice(file: UploadFile = File(...), k: int = 10):
|
| 474 |
+
"""
|
| 475 |
+
Voice β image search.
|
| 476 |
+
User speaks "show me photos of mountains at sunset"
|
| 477 |
+
β Whisper transcribes β CLIP searches β returns images.
|
| 478 |
+
|
| 479 |
+
Flow:
|
| 480 |
+
Audio file β Whisper tiny β transcribed text β same as /search/text
|
| 481 |
+
"""
|
| 482 |
+
if whisper_model is None:
|
| 483 |
+
raise HTTPException(503, "Voice search not available. Whisper not installed.")
|
| 484 |
+
|
| 485 |
+
t0 = time.perf_counter()
|
| 486 |
+
|
| 487 |
+
# Save audio to temp file (Whisper needs a file path, not bytes)
|
| 488 |
+
import tempfile
|
| 489 |
+
audio_bytes = await file.read()
|
| 490 |
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
| 491 |
+
tmp.write(audio_bytes)
|
| 492 |
+
tmp_path = tmp.name
|
| 493 |
+
|
| 494 |
+
try:
|
| 495 |
+
# Whisper transcription
|
| 496 |
+
# fp16=False because we're on CPU (FP16 is GPU-only)
|
| 497 |
+
t_whisper = time.perf_counter()
|
| 498 |
+
result = whisper_model.transcribe(tmp_path, fp16=False, language="en")
|
| 499 |
+
whisper_ms = (time.perf_counter() - t_whisper) * 1000
|
| 500 |
+
transcription = result["text"].strip()
|
| 501 |
+
log.info(f"Voice transcription ({whisper_ms:.0f}ms): '{transcription}'")
|
| 502 |
+
finally:
|
| 503 |
+
os.unlink(tmp_path) # clean up temp file
|
| 504 |
+
|
| 505 |
+
if not transcription:
|
| 506 |
+
raise HTTPException(400, "Could not transcribe audio")
|
| 507 |
+
|
| 508 |
+
# Now treat it exactly like a text search
|
| 509 |
+
embedding, encoder_ms = await get_embedding_for_text(transcription)
|
| 510 |
+
raw_results = faiss_search(embedding, k=k * 2)
|
| 511 |
+
ranked = rerank(raw_results, transcription, top_k=k)
|
| 512 |
+
latency = (time.perf_counter() - t0) * 1000
|
| 513 |
+
|
| 514 |
+
log_query(transcription, "voice", len(ranked), latency)
|
| 515 |
+
|
| 516 |
+
response = build_response(ranked, transcription, "voice", encoder_ms, latency)
|
| 517 |
+
# Add transcription to response so frontend can show "I heard: ..."
|
| 518 |
+
return {**response.dict(), "transcription": transcription, "whisper_ms": round(whisper_ms, 1)}
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
@app.post("/feedback")
|
| 522 |
+
async def submit_feedback(req: FeedbackRequest):
|
| 523 |
+
"""
|
| 524 |
+
Store user feedback (thumbs up/down) for a search result.
|
| 525 |
+
Used by the reranker to personalize future results.
|
| 526 |
+
"""
|
| 527 |
+
if req.vote not in (-1, 1):
|
| 528 |
+
raise HTTPException(400, "vote must be +1 or -1")
|
| 529 |
+
if db_conn:
|
| 530 |
+
db_conn.execute(
|
| 531 |
+
"INSERT INTO feedback (image_path, query_text, vote) VALUES (?,?,?)",
|
| 532 |
+
(req.image_path, req.query, req.vote),
|
| 533 |
+
)
|
| 534 |
+
db_conn.commit()
|
| 535 |
+
return {"status": "ok"}
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
@app.get("/queries/recent")
|
| 539 |
+
async def recent_queries(limit: int = 20):
|
| 540 |
+
"""Return recent search queries for analytics."""
|
| 541 |
+
if db_conn is None:
|
| 542 |
+
return {"queries": []}
|
| 543 |
+
rows = db_conn.execute(
|
| 544 |
+
"SELECT query_text, query_type, result_count, latency_ms, timestamp "
|
| 545 |
+
"FROM queries ORDER BY timestamp DESC LIMIT ?",
|
| 546 |
+
(limit,),
|
| 547 |
+
).fetchall()
|
| 548 |
+
return {"queries": [
|
| 549 |
+
{"query": r[0], "type": r[1], "results": r[2],
|
| 550 |
+
"latency_ms": r[3], "timestamp": r[4]}
|
| 551 |
+
for r in rows
|
| 552 |
+
]}
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
if __name__ == "__main__":
|
| 556 |
+
import uvicorn
|
| 557 |
+
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
|
services/api/requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.109.0
|
| 2 |
+
uvicorn[standard]==0.27.0
|
| 3 |
+
faiss-cpu==1.7.4
|
| 4 |
+
numpy==1.26.4
|
| 5 |
+
httpx==0.26.0
|
| 6 |
+
# openai-whisper==20231117
|
| 7 |
+
git+https://github.com/openai/whisper.git
|
| 8 |
+
pydantic==2.5.3
|
| 9 |
+
python-multipart==0.0.7
|
| 10 |
+
aiofiles==23.2.1
|
| 11 |
+
python-multipart
|
services/encoder/Dockerfile
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# services/encoder/Dockerfile
|
| 2 |
+
#
|
| 3 |
+
# WHY python:3.11-slim AND NOT python:3.11:
|
| 4 |
+
# The full python:3.11 image is ~900MB β it includes compilers, dev headers,
|
| 5 |
+
# documentation, and dozens of tools you never need at runtime.
|
| 6 |
+
# python:3.11-slim is ~130MB β just the runtime.
|
| 7 |
+
#
|
| 8 |
+
# We do need build tools temporarily to compile some Python packages
|
| 9 |
+
# (onnxruntime has C extensions). We install them, build, then remove them.
|
| 10 |
+
# This is called a multi-stage awareness pattern β using build deps only
|
| 11 |
+
# when needed.
|
| 12 |
+
#
|
| 13 |
+
# FINAL IMAGE SIZE TARGET: ~800MB
|
| 14 |
+
# - python:3.11-slim base: 130MB
|
| 15 |
+
# - onnxruntime: ~400MB (it's big β ONNX runtime is a full inference engine)
|
| 16 |
+
# - clip + torchvision: ~150MB (we need torchvision for preprocessing)
|
| 17 |
+
# - Our code: <1MB
|
| 18 |
+
# - ONNX model files: ~90MB (mounted as volume, not baked in)
|
| 19 |
+
#
|
| 20 |
+
# WHY NOT ALPINE (even smaller base):
|
| 21 |
+
# Alpine uses musl libc instead of glibc.
|
| 22 |
+
# onnxruntime ships pre-compiled wheels built against glibc.
|
| 23 |
+
# Using Alpine would require compiling onnxruntime from source: 2+ hours.
|
| 24 |
+
# Not worth it for a <100MB size difference.
|
| 25 |
+
|
| 26 |
+
FROM python:3.11-slim
|
| 27 |
+
|
| 28 |
+
WORKDIR /app
|
| 29 |
+
|
| 30 |
+
# Install system deps needed to build Python packages with C extensions
|
| 31 |
+
# --no-install-recommends: only install exactly what's listed, not suggested packages
|
| 32 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 33 |
+
git \
|
| 34 |
+
gcc \
|
| 35 |
+
g++ \
|
| 36 |
+
libgomp1 \
|
| 37 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 38 |
+
# rm -rf /var/lib/apt/lists/ removes the package cache after install
|
| 39 |
+
# Every RUN command creates a Docker layer. The cleanup MUST be in the same
|
| 40 |
+
# RUN to actually reduce layer size. If you put it in a separate RUN,
|
| 41 |
+
# the cache files are already baked into the previous layer.
|
| 42 |
+
|
| 43 |
+
COPY requirements.txt .
|
| 44 |
+
RUN pip install --default-timeout=1200 --no-cache-dir -r requirements.txt
|
| 45 |
+
# --no-cache-dir: don't store pip's download cache in the image
|
| 46 |
+
# pip normally caches to speed up repeated installs, but in Docker
|
| 47 |
+
# we build once and run many times β cache just wastes space.
|
| 48 |
+
|
| 49 |
+
# Copy only the application code (not scripts, not embeddings)
|
| 50 |
+
COPY main.py .
|
| 51 |
+
|
| 52 |
+
# Create directory for model files
|
| 53 |
+
# Actual models are mounted via Docker volume β NOT baked into the image.
|
| 54 |
+
# WHY NOT BAKE IN:
|
| 55 |
+
# If models are in the image, every model update requires rebuilding the image.
|
| 56 |
+
# With volumes, you can update models without touching Docker.
|
| 57 |
+
# Also: 90MB model in image = 90MB transferred on every docker pull.
|
| 58 |
+
RUN mkdir -p models
|
| 59 |
+
|
| 60 |
+
# Port 8001: encoder service (internal, not exposed to host directly)
|
| 61 |
+
EXPOSE 8001
|
| 62 |
+
|
| 63 |
+
# HEALTHCHECK: Docker polls this to know if the service is ready.
|
| 64 |
+
# --interval: check every 30s
|
| 65 |
+
# --timeout: fail if no response in 10s
|
| 66 |
+
# --start-period: wait 60s before starting checks (model loading takes time)
|
| 67 |
+
# --retries: mark unhealthy after 3 consecutive failures
|
| 68 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
| 69 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')"
|
| 70 |
+
|
| 71 |
+
# Run with uvicorn (ASGI server for FastAPI)
|
| 72 |
+
# --host 0.0.0.0: listen on all interfaces (needed inside Docker)
|
| 73 |
+
# --port 8001: encoder port
|
| 74 |
+
# --workers 1: ONE worker. Why?
|
| 75 |
+
# ONNX sessions are NOT safely forkable.
|
| 76 |
+
# Multiple workers would each load the 90MB model into RAM.
|
| 77 |
+
# For CPU-bound inference, multiple workers don't help β use async instead.
|
| 78 |
+
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001", "--workers", "1"]
|
services/encoder/main.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/encoder/main.py
|
| 3 |
+
========================
|
| 4 |
+
WHY A SEPARATE ENCODER SERVICE:
|
| 5 |
+
The encoder (ONNX CLIP model) is the heaviest component:
|
| 6 |
+
- ~90MB model file to load into RAM
|
| 7 |
+
- Startup time: ~3 seconds to initialize ONNX Runtime session
|
| 8 |
+
- CPU-intensive: uses all cores during inference
|
| 9 |
+
|
| 10 |
+
If we put this inside the API service:
|
| 11 |
+
1. Every API restart also restarts the encoder (3s downtime)
|
| 12 |
+
2. Can't scale encoder independently (what if we add GPU later?)
|
| 13 |
+
3. API crashes take down inference capability
|
| 14 |
+
4. Can't swap the model without touching search logic
|
| 15 |
+
|
| 16 |
+
As a SEPARATE SERVICE:
|
| 17 |
+
- Encoder loads once, stays up
|
| 18 |
+
- API restarts don't kill it
|
| 19 |
+
- Swap ONNX β TensorRT (GPU) by changing ONE service
|
| 20 |
+
- Can run on a different machine if needed
|
| 21 |
+
|
| 22 |
+
The communication cost: one HTTP call per search query (~1ms on localhost)
|
| 23 |
+
That's a fine tradeoff for the decoupling benefits.
|
| 24 |
+
|
| 25 |
+
WHY FASTAPI OVER FLASK:
|
| 26 |
+
Flask: synchronous, one request at a time per worker
|
| 27 |
+
FastAPI: async, handles multiple concurrent requests with one worker
|
| 28 |
+
|
| 29 |
+
For an encoder service that does CPU-bound inference:
|
| 30 |
+
- Both are fine for single requests
|
| 31 |
+
- FastAPI's automatic OpenAPI docs at /docs is useful for debugging
|
| 32 |
+
- Pydantic validation catches malformed inputs before they hit inference
|
| 33 |
+
- Type hints make the code self-documenting
|
| 34 |
+
- FastAPI is what real ML serving frameworks (Ray Serve, BentoML) use
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
import os
|
| 38 |
+
import io
|
| 39 |
+
import base64
|
| 40 |
+
import time
|
| 41 |
+
import logging
|
| 42 |
+
import numpy as np
|
| 43 |
+
import onnxruntime as ort
|
| 44 |
+
from pathlib import Path
|
| 45 |
+
from typing import Optional
|
| 46 |
+
|
| 47 |
+
from fastapi import FastAPI, HTTPException, UploadFile, File
|
| 48 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 49 |
+
from pydantic import BaseModel
|
| 50 |
+
import clip
|
| 51 |
+
from PIL import Image
|
| 52 |
+
from torchvision import transforms
|
| 53 |
+
|
| 54 |
+
# ββ Logging setup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [encoder] %(message)s")
|
| 56 |
+
log = logging.getLogger(__name__)
|
| 57 |
+
|
| 58 |
+
# ββ CLIP image preprocessing βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
# Replicated from CLIP source β we don't need all of PyTorch, just this transform
|
| 60 |
+
PREPROCESS = transforms.Compose([
|
| 61 |
+
transforms.Resize(224, interpolation=transforms.InterpolationMode.BICUBIC),
|
| 62 |
+
transforms.CenterCrop(224),
|
| 63 |
+
transforms.ToTensor(),
|
| 64 |
+
transforms.Normalize(
|
| 65 |
+
mean=[0.48145466, 0.4578275, 0.40821073],
|
| 66 |
+
std=[0.26862954, 0.26130258, 0.27577711],
|
| 67 |
+
),
|
| 68 |
+
])
|
| 69 |
+
|
| 70 |
+
# ββ App setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
app = FastAPI(
|
| 72 |
+
title="Visual Search Encoder",
|
| 73 |
+
description="ONNX INT8 CLIP encoder β converts images and text to 512-dim vectors",
|
| 74 |
+
version="1.0.0",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
app.add_middleware(
|
| 78 |
+
CORSMiddleware,
|
| 79 |
+
allow_origins=["*"], # tighter in production
|
| 80 |
+
allow_methods=["*"],
|
| 81 |
+
allow_headers=["*"],
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# ββ Global encoder state ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
# WHY GLOBAL STATE (not dependency injection):
|
| 86 |
+
# ONNX InferenceSession is NOT thread-safe to CREATE, but IS thread-safe to RUN.
|
| 87 |
+
# We create it once at startup and share it.
|
| 88 |
+
# FastAPI's @app.on_event("startup") runs before any requests are served.
|
| 89 |
+
|
| 90 |
+
vision_session: Optional[ort.InferenceSession] = None
|
| 91 |
+
text_session: Optional[ort.InferenceSession] = None
|
| 92 |
+
vision_input_name: str = ""
|
| 93 |
+
text_input_name: str = ""
|
| 94 |
+
startup_time: float = 0.0
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.on_event("startup")
|
| 98 |
+
async def load_models():
|
| 99 |
+
global vision_session, text_session, vision_input_name, text_input_name, startup_time
|
| 100 |
+
|
| 101 |
+
models_dir = os.getenv("MODELS_DIR", "models")
|
| 102 |
+
# vision_path = os.path.join(models_dir, "clip_vision_int8.onnx")
|
| 103 |
+
vision_path = os.path.join(models_dir, "clip_vision_fp32.onnx")
|
| 104 |
+
text_path = os.path.join(models_dir, "clip_text_int8.onnx")
|
| 105 |
+
# text_path = os.path.join(models_dir, "clip_text_int8.onnx")
|
| 106 |
+
|
| 107 |
+
# Session options: tune threading
|
| 108 |
+
# intra_op = parallelism within a single operation (e.g. matrix multiply)
|
| 109 |
+
# inter_op = parallelism between operations in the graph
|
| 110 |
+
# For inference-only with small batches: max intra, min inter
|
| 111 |
+
opts = ort.SessionOptions()
|
| 112 |
+
opts.intra_op_num_threads = os.cpu_count()
|
| 113 |
+
opts.inter_op_num_threads = 1
|
| 114 |
+
opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
| 115 |
+
|
| 116 |
+
providers = ["CPUExecutionProvider"]
|
| 117 |
+
if "CUDAExecutionProvider" in ort.get_available_providers():
|
| 118 |
+
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
| 119 |
+
log.info("CUDA GPU available β using GPU for inference")
|
| 120 |
+
else:
|
| 121 |
+
log.info("No CUDA GPU found β using CPU with INT8 optimizations")
|
| 122 |
+
|
| 123 |
+
t0 = time.perf_counter()
|
| 124 |
+
|
| 125 |
+
if Path(vision_path).exists():
|
| 126 |
+
vision_session = ort.InferenceSession(vision_path, opts, providers=providers)
|
| 127 |
+
vision_input_name = vision_session.get_inputs()[0].name
|
| 128 |
+
log.info(f"Vision encoder loaded: {vision_path}")
|
| 129 |
+
else:
|
| 130 |
+
log.warning(f"Vision model not found at {vision_path}. Run export_to_onnx.py first.")
|
| 131 |
+
|
| 132 |
+
if Path(text_path).exists():
|
| 133 |
+
text_session = ort.InferenceSession(text_path, opts, providers=providers)
|
| 134 |
+
text_input_name = text_session.get_inputs()[0].name
|
| 135 |
+
log.info(f"Text encoder loaded: {text_path}")
|
| 136 |
+
else:
|
| 137 |
+
log.warning(f"Text model not found at {text_path}. Run export_to_onnx.py first.")
|
| 138 |
+
|
| 139 |
+
startup_time = time.perf_counter() - t0
|
| 140 |
+
log.info(f"Encoder service ready in {startup_time:.2f}s")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ββ Pydantic models (request/response schemas) ββββββββββββββββββββββββββββββββ
|
| 144 |
+
class TextEmbedRequest(BaseModel):
|
| 145 |
+
text: str
|
| 146 |
+
|
| 147 |
+
class EmbeddingResponse(BaseModel):
|
| 148 |
+
embedding: list[float]
|
| 149 |
+
latency_ms: float
|
| 150 |
+
|
| 151 |
+
class HealthResponse(BaseModel):
|
| 152 |
+
status: str
|
| 153 |
+
vision_loaded: bool
|
| 154 |
+
text_loaded: bool
|
| 155 |
+
startup_time_s: float
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ββ Helper functions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 159 |
+
def l2_normalize(v: np.ndarray) -> np.ndarray:
|
| 160 |
+
"""L2 normalize a vector. Makes cosine similarity == dot product."""
|
| 161 |
+
norm = np.linalg.norm(v)
|
| 162 |
+
return v / max(norm, 1e-8)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def embed_image_array(arr: np.ndarray) -> tuple[list[float], float]:
|
| 166 |
+
"""Run vision encoder on a preprocessed image array."""
|
| 167 |
+
t0 = time.perf_counter()
|
| 168 |
+
output = vision_session.run(None, {vision_input_name: arr})
|
| 169 |
+
emb = l2_normalize(output[0][0])
|
| 170 |
+
latency_ms = (time.perf_counter() - t0) * 1000
|
| 171 |
+
return emb.tolist(), latency_ms
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ββ Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 175 |
+
|
| 176 |
+
@app.get("/health", response_model=HealthResponse)
|
| 177 |
+
async def health():
|
| 178 |
+
"""Docker health check + status."""
|
| 179 |
+
return HealthResponse(
|
| 180 |
+
status="ok",
|
| 181 |
+
vision_loaded=vision_session is not None,
|
| 182 |
+
text_loaded=text_session is not None,
|
| 183 |
+
startup_time_s=round(startup_time, 2),
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@app.post("/embed/text", response_model=EmbeddingResponse)
|
| 188 |
+
async def embed_text(req: TextEmbedRequest):
|
| 189 |
+
"""
|
| 190 |
+
Convert text query β 512-dim CLIP embedding.
|
| 191 |
+
Called by the API service on every text search.
|
| 192 |
+
"""
|
| 193 |
+
if text_session is None:
|
| 194 |
+
raise HTTPException(503, "Text encoder not loaded")
|
| 195 |
+
if not req.text.strip():
|
| 196 |
+
raise HTTPException(400, "Text cannot be empty")
|
| 197 |
+
|
| 198 |
+
t0 = time.perf_counter()
|
| 199 |
+
|
| 200 |
+
# Tokenize: convert text string β integer token IDs
|
| 201 |
+
# CLIP uses a BPE tokenizer with max length 77
|
| 202 |
+
# We still need the clip library for tokenization (it's tiny, no PyTorch needed at runtime)
|
| 203 |
+
import clip as clip_tokenizer
|
| 204 |
+
tokens = clip_tokenizer.tokenize([req.text]).numpy() # shape: [1, 77]
|
| 205 |
+
|
| 206 |
+
output = text_session.run(None, {text_input_name: tokens})
|
| 207 |
+
emb = l2_normalize(output[0][0])
|
| 208 |
+
|
| 209 |
+
latency_ms = (time.perf_counter() - t0) * 1000
|
| 210 |
+
return EmbeddingResponse(embedding=emb.tolist(), latency_ms=round(latency_ms, 2))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@app.post("/embed/image/upload", response_model=EmbeddingResponse)
|
| 214 |
+
async def embed_image_upload(file: UploadFile = File(...)):
|
| 215 |
+
"""
|
| 216 |
+
Convert uploaded image β 512-dim CLIP embedding.
|
| 217 |
+
Used for reverse image search (search by image instead of text).
|
| 218 |
+
"""
|
| 219 |
+
if vision_session is None:
|
| 220 |
+
raise HTTPException(503, "Vision encoder not loaded")
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
contents = await file.read()
|
| 224 |
+
img = Image.open(io.BytesIO(contents)).convert("RGB")
|
| 225 |
+
except Exception as e:
|
| 226 |
+
raise HTTPException(400, f"Invalid image: {e}")
|
| 227 |
+
|
| 228 |
+
tensor = PREPROCESS(img).unsqueeze(0).numpy()
|
| 229 |
+
emb, latency_ms = embed_image_array(tensor)
|
| 230 |
+
return EmbeddingResponse(embedding=emb, latency_ms=round(latency_ms, 2))
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
@app.post("/embed/image/base64", response_model=EmbeddingResponse)
|
| 234 |
+
async def embed_image_base64(data: dict):
|
| 235 |
+
"""
|
| 236 |
+
Convert base64-encoded image β embedding.
|
| 237 |
+
Alternative to file upload for frontend that already has base64 data.
|
| 238 |
+
"""
|
| 239 |
+
if vision_session is None:
|
| 240 |
+
raise HTTPException(503, "Vision encoder not loaded")
|
| 241 |
+
|
| 242 |
+
try:
|
| 243 |
+
img_data = base64.b64decode(data["image"])
|
| 244 |
+
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
| 245 |
+
except Exception as e:
|
| 246 |
+
raise HTTPException(400, f"Invalid base64 image: {e}")
|
| 247 |
+
|
| 248 |
+
tensor = PREPROCESS(img).unsqueeze(0).numpy()
|
| 249 |
+
emb, latency_ms = embed_image_array(tensor)
|
| 250 |
+
return EmbeddingResponse(embedding=emb, latency_ms=round(latency_ms, 2))
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
if __name__ == "__main__":
|
| 254 |
+
import uvicorn
|
| 255 |
+
uvicorn.run(app, host="0.0.0.0", port=8001, log_level="info")
|
services/encoder/requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.109.0
|
| 2 |
+
uvicorn[standard]==0.27.0
|
| 3 |
+
onnxruntime==1.19.2
|
| 4 |
+
numpy==1.26.4
|
| 5 |
+
Pillow==10.2.0
|
| 6 |
+
# torchvision==0.17.0
|
| 7 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 8 |
+
torch==2.2.0+cpu
|
| 9 |
+
torchvision==0.17.0+cpu
|
| 10 |
+
# clip is needed only for tokenization at runtime (text encoder)
|
| 11 |
+
# We get it from the OpenAI repo
|
| 12 |
+
git+https://github.com/openai/CLIP.git
|
| 13 |
+
httpx==0.26.0
|
| 14 |
+
pydantic==2.5.3
|
| 15 |
+
python-multipart==0.0.7
|
services/frontend/Dockerfile
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# services/frontend/Dockerfile
|
| 2 |
+
#
|
| 3 |
+
# MULTI-STAGE BUILD β the most important Docker pattern to understand.
|
| 4 |
+
#
|
| 5 |
+
# STAGE 1 (builder): Install Node.js, install dependencies, compile React β static HTML/JS/CSS
|
| 6 |
+
# STAGE 2 (runtime): Copy ONLY the compiled output into a tiny Nginx image
|
| 7 |
+
#
|
| 8 |
+
# WHY TWO STAGES:
|
| 9 |
+
# node:18-alpine (with node_modules) = ~400MB
|
| 10 |
+
# nginx:alpine serving static files = ~25MB
|
| 11 |
+
#
|
| 12 |
+
# React is a build-time framework. "npm run build" compiles your JSX into
|
| 13 |
+
# plain JavaScript that any browser can run. Once compiled, you don't need
|
| 14 |
+
# Node.js anymore β just a web server to serve the static files.
|
| 15 |
+
#
|
| 16 |
+
# Without multi-stage: 400MB image with Node.js sitting idle
|
| 17 |
+
# With multi-stage: 25MB image, same result
|
| 18 |
+
#
|
| 19 |
+
# This is the standard production pattern for any React/Vue/Angular app.
|
| 20 |
+
|
| 21 |
+
# ββ Stage 1: Build ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
FROM node:18-alpine AS builder
|
| 23 |
+
|
| 24 |
+
WORKDIR /app
|
| 25 |
+
|
| 26 |
+
# Copy package files first, THEN source code.
|
| 27 |
+
# WHY: Docker caches each layer. If you copy everything at once,
|
| 28 |
+
# any file change (even a .jsx edit) invalidates the npm install cache.
|
| 29 |
+
# By copying package.json first, npm install is only re-run when
|
| 30 |
+
# dependencies actually change β not on every code edit.
|
| 31 |
+
# This saves minutes on rebuilds.
|
| 32 |
+
COPY package.json package-lock.json* ./
|
| 33 |
+
RUN npm ci
|
| 34 |
+
# npm ci = "clean install" β uses exact versions from package-lock.json
|
| 35 |
+
# Faster and more deterministic than npm install for CI/Docker builds
|
| 36 |
+
|
| 37 |
+
COPY . .
|
| 38 |
+
|
| 39 |
+
# Build the React app
|
| 40 |
+
# Vite compiles JSX β JS, bundles, minifies, tree-shakes
|
| 41 |
+
# Output goes to /app/dist/
|
| 42 |
+
ARG VITE_API_URL=
|
| 43 |
+
ENV VITE_API_URL=$VITE_API_URL
|
| 44 |
+
RUN npm run build
|
| 45 |
+
|
| 46 |
+
# ββ Stage 2: Serve ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
FROM nginx:alpine
|
| 48 |
+
|
| 49 |
+
# Copy compiled static files from builder stage
|
| 50 |
+
COPY --from=builder /app/dist /usr/share/nginx/html
|
| 51 |
+
|
| 52 |
+
# Custom Nginx config to handle React Router (SPA routing)
|
| 53 |
+
# Without this, refreshing on /search returns 404 because Nginx
|
| 54 |
+
# doesn't know React handles routing β it looks for a /search folder.
|
| 55 |
+
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
| 56 |
+
|
| 57 |
+
EXPOSE 3000
|
| 58 |
+
|
| 59 |
+
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
| 60 |
+
CMD wget -qO- http://localhost:3000/ || exit 1
|
| 61 |
+
|
| 62 |
+
CMD ["nginx", "-g", "daemon off;"]
|
services/frontend/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Visual Search</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<div id="root"></div>
|
| 10 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 11 |
+
</body>
|
| 12 |
+
</html>
|
services/frontend/nginx.conf
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
server {
|
| 2 |
+
listen 3000;
|
| 3 |
+
|
| 4 |
+
root /usr/share/nginx/html;
|
| 5 |
+
index index.html;
|
| 6 |
+
|
| 7 |
+
# SPA fallback: any route that doesn't match a file gets index.html
|
| 8 |
+
# React Router handles the rest client-side
|
| 9 |
+
location / {
|
| 10 |
+
try_files $uri $uri/ /index.html;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
# Cache static assets aggressively (they have content hashes in filenames)
|
| 14 |
+
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
| 15 |
+
expires 1y;
|
| 16 |
+
add_header Cache-Control "public, immutable";
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
# Don't cache index.html (so new deploys are picked up immediately)
|
| 20 |
+
location = /index.html {
|
| 21 |
+
add_header Cache-Control "no-cache";
|
| 22 |
+
}
|
| 23 |
+
}
|
services/frontend/package-lock.json
ADDED
|
@@ -0,0 +1,1681 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "visual-search-frontend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "visual-search-frontend",
|
| 9 |
+
"version": "1.0.0",
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"react": "^18.2.0",
|
| 12 |
+
"react-dom": "^18.2.0"
|
| 13 |
+
},
|
| 14 |
+
"devDependencies": {
|
| 15 |
+
"@vitejs/plugin-react": "^4.2.0",
|
| 16 |
+
"vite": "^5.0.0"
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
"node_modules/@babel/code-frame": {
|
| 20 |
+
"version": "7.29.0",
|
| 21 |
+
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
| 22 |
+
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
| 23 |
+
"dev": true,
|
| 24 |
+
"license": "MIT",
|
| 25 |
+
"dependencies": {
|
| 26 |
+
"@babel/helper-validator-identifier": "^7.28.5",
|
| 27 |
+
"js-tokens": "^4.0.0",
|
| 28 |
+
"picocolors": "^1.1.1"
|
| 29 |
+
},
|
| 30 |
+
"engines": {
|
| 31 |
+
"node": ">=6.9.0"
|
| 32 |
+
}
|
| 33 |
+
},
|
| 34 |
+
"node_modules/@babel/compat-data": {
|
| 35 |
+
"version": "7.29.0",
|
| 36 |
+
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
| 37 |
+
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
| 38 |
+
"dev": true,
|
| 39 |
+
"license": "MIT",
|
| 40 |
+
"engines": {
|
| 41 |
+
"node": ">=6.9.0"
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
"node_modules/@babel/core": {
|
| 45 |
+
"version": "7.29.0",
|
| 46 |
+
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
| 47 |
+
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 48 |
+
"dev": true,
|
| 49 |
+
"license": "MIT",
|
| 50 |
+
"peer": true,
|
| 51 |
+
"dependencies": {
|
| 52 |
+
"@babel/code-frame": "^7.29.0",
|
| 53 |
+
"@babel/generator": "^7.29.0",
|
| 54 |
+
"@babel/helper-compilation-targets": "^7.28.6",
|
| 55 |
+
"@babel/helper-module-transforms": "^7.28.6",
|
| 56 |
+
"@babel/helpers": "^7.28.6",
|
| 57 |
+
"@babel/parser": "^7.29.0",
|
| 58 |
+
"@babel/template": "^7.28.6",
|
| 59 |
+
"@babel/traverse": "^7.29.0",
|
| 60 |
+
"@babel/types": "^7.29.0",
|
| 61 |
+
"@jridgewell/remapping": "^2.3.5",
|
| 62 |
+
"convert-source-map": "^2.0.0",
|
| 63 |
+
"debug": "^4.1.0",
|
| 64 |
+
"gensync": "^1.0.0-beta.2",
|
| 65 |
+
"json5": "^2.2.3",
|
| 66 |
+
"semver": "^6.3.1"
|
| 67 |
+
},
|
| 68 |
+
"engines": {
|
| 69 |
+
"node": ">=6.9.0"
|
| 70 |
+
},
|
| 71 |
+
"funding": {
|
| 72 |
+
"type": "opencollective",
|
| 73 |
+
"url": "https://opencollective.com/babel"
|
| 74 |
+
}
|
| 75 |
+
},
|
| 76 |
+
"node_modules/@babel/generator": {
|
| 77 |
+
"version": "7.29.1",
|
| 78 |
+
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
| 79 |
+
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
| 80 |
+
"dev": true,
|
| 81 |
+
"license": "MIT",
|
| 82 |
+
"dependencies": {
|
| 83 |
+
"@babel/parser": "^7.29.0",
|
| 84 |
+
"@babel/types": "^7.29.0",
|
| 85 |
+
"@jridgewell/gen-mapping": "^0.3.12",
|
| 86 |
+
"@jridgewell/trace-mapping": "^0.3.28",
|
| 87 |
+
"jsesc": "^3.0.2"
|
| 88 |
+
},
|
| 89 |
+
"engines": {
|
| 90 |
+
"node": ">=6.9.0"
|
| 91 |
+
}
|
| 92 |
+
},
|
| 93 |
+
"node_modules/@babel/helper-compilation-targets": {
|
| 94 |
+
"version": "7.28.6",
|
| 95 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
| 96 |
+
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
| 97 |
+
"dev": true,
|
| 98 |
+
"license": "MIT",
|
| 99 |
+
"dependencies": {
|
| 100 |
+
"@babel/compat-data": "^7.28.6",
|
| 101 |
+
"@babel/helper-validator-option": "^7.27.1",
|
| 102 |
+
"browserslist": "^4.24.0",
|
| 103 |
+
"lru-cache": "^5.1.1",
|
| 104 |
+
"semver": "^6.3.1"
|
| 105 |
+
},
|
| 106 |
+
"engines": {
|
| 107 |
+
"node": ">=6.9.0"
|
| 108 |
+
}
|
| 109 |
+
},
|
| 110 |
+
"node_modules/@babel/helper-globals": {
|
| 111 |
+
"version": "7.28.0",
|
| 112 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
| 113 |
+
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
| 114 |
+
"dev": true,
|
| 115 |
+
"license": "MIT",
|
| 116 |
+
"engines": {
|
| 117 |
+
"node": ">=6.9.0"
|
| 118 |
+
}
|
| 119 |
+
},
|
| 120 |
+
"node_modules/@babel/helper-module-imports": {
|
| 121 |
+
"version": "7.28.6",
|
| 122 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
| 123 |
+
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
| 124 |
+
"dev": true,
|
| 125 |
+
"license": "MIT",
|
| 126 |
+
"dependencies": {
|
| 127 |
+
"@babel/traverse": "^7.28.6",
|
| 128 |
+
"@babel/types": "^7.28.6"
|
| 129 |
+
},
|
| 130 |
+
"engines": {
|
| 131 |
+
"node": ">=6.9.0"
|
| 132 |
+
}
|
| 133 |
+
},
|
| 134 |
+
"node_modules/@babel/helper-module-transforms": {
|
| 135 |
+
"version": "7.28.6",
|
| 136 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
| 137 |
+
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
| 138 |
+
"dev": true,
|
| 139 |
+
"license": "MIT",
|
| 140 |
+
"dependencies": {
|
| 141 |
+
"@babel/helper-module-imports": "^7.28.6",
|
| 142 |
+
"@babel/helper-validator-identifier": "^7.28.5",
|
| 143 |
+
"@babel/traverse": "^7.28.6"
|
| 144 |
+
},
|
| 145 |
+
"engines": {
|
| 146 |
+
"node": ">=6.9.0"
|
| 147 |
+
},
|
| 148 |
+
"peerDependencies": {
|
| 149 |
+
"@babel/core": "^7.0.0"
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
"node_modules/@babel/helper-plugin-utils": {
|
| 153 |
+
"version": "7.28.6",
|
| 154 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
| 155 |
+
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
| 156 |
+
"dev": true,
|
| 157 |
+
"license": "MIT",
|
| 158 |
+
"engines": {
|
| 159 |
+
"node": ">=6.9.0"
|
| 160 |
+
}
|
| 161 |
+
},
|
| 162 |
+
"node_modules/@babel/helper-string-parser": {
|
| 163 |
+
"version": "7.27.1",
|
| 164 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
| 165 |
+
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
| 166 |
+
"dev": true,
|
| 167 |
+
"license": "MIT",
|
| 168 |
+
"engines": {
|
| 169 |
+
"node": ">=6.9.0"
|
| 170 |
+
}
|
| 171 |
+
},
|
| 172 |
+
"node_modules/@babel/helper-validator-identifier": {
|
| 173 |
+
"version": "7.28.5",
|
| 174 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
| 175 |
+
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
| 176 |
+
"dev": true,
|
| 177 |
+
"license": "MIT",
|
| 178 |
+
"engines": {
|
| 179 |
+
"node": ">=6.9.0"
|
| 180 |
+
}
|
| 181 |
+
},
|
| 182 |
+
"node_modules/@babel/helper-validator-option": {
|
| 183 |
+
"version": "7.27.1",
|
| 184 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
| 185 |
+
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
| 186 |
+
"dev": true,
|
| 187 |
+
"license": "MIT",
|
| 188 |
+
"engines": {
|
| 189 |
+
"node": ">=6.9.0"
|
| 190 |
+
}
|
| 191 |
+
},
|
| 192 |
+
"node_modules/@babel/helpers": {
|
| 193 |
+
"version": "7.29.2",
|
| 194 |
+
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
| 195 |
+
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
| 196 |
+
"dev": true,
|
| 197 |
+
"license": "MIT",
|
| 198 |
+
"dependencies": {
|
| 199 |
+
"@babel/template": "^7.28.6",
|
| 200 |
+
"@babel/types": "^7.29.0"
|
| 201 |
+
},
|
| 202 |
+
"engines": {
|
| 203 |
+
"node": ">=6.9.0"
|
| 204 |
+
}
|
| 205 |
+
},
|
| 206 |
+
"node_modules/@babel/parser": {
|
| 207 |
+
"version": "7.29.2",
|
| 208 |
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
| 209 |
+
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
| 210 |
+
"dev": true,
|
| 211 |
+
"license": "MIT",
|
| 212 |
+
"dependencies": {
|
| 213 |
+
"@babel/types": "^7.29.0"
|
| 214 |
+
},
|
| 215 |
+
"bin": {
|
| 216 |
+
"parser": "bin/babel-parser.js"
|
| 217 |
+
},
|
| 218 |
+
"engines": {
|
| 219 |
+
"node": ">=6.0.0"
|
| 220 |
+
}
|
| 221 |
+
},
|
| 222 |
+
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
| 223 |
+
"version": "7.27.1",
|
| 224 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
| 225 |
+
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
| 226 |
+
"dev": true,
|
| 227 |
+
"license": "MIT",
|
| 228 |
+
"dependencies": {
|
| 229 |
+
"@babel/helper-plugin-utils": "^7.27.1"
|
| 230 |
+
},
|
| 231 |
+
"engines": {
|
| 232 |
+
"node": ">=6.9.0"
|
| 233 |
+
},
|
| 234 |
+
"peerDependencies": {
|
| 235 |
+
"@babel/core": "^7.0.0-0"
|
| 236 |
+
}
|
| 237 |
+
},
|
| 238 |
+
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
| 239 |
+
"version": "7.27.1",
|
| 240 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
| 241 |
+
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
| 242 |
+
"dev": true,
|
| 243 |
+
"license": "MIT",
|
| 244 |
+
"dependencies": {
|
| 245 |
+
"@babel/helper-plugin-utils": "^7.27.1"
|
| 246 |
+
},
|
| 247 |
+
"engines": {
|
| 248 |
+
"node": ">=6.9.0"
|
| 249 |
+
},
|
| 250 |
+
"peerDependencies": {
|
| 251 |
+
"@babel/core": "^7.0.0-0"
|
| 252 |
+
}
|
| 253 |
+
},
|
| 254 |
+
"node_modules/@babel/template": {
|
| 255 |
+
"version": "7.28.6",
|
| 256 |
+
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
| 257 |
+
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
| 258 |
+
"dev": true,
|
| 259 |
+
"license": "MIT",
|
| 260 |
+
"dependencies": {
|
| 261 |
+
"@babel/code-frame": "^7.28.6",
|
| 262 |
+
"@babel/parser": "^7.28.6",
|
| 263 |
+
"@babel/types": "^7.28.6"
|
| 264 |
+
},
|
| 265 |
+
"engines": {
|
| 266 |
+
"node": ">=6.9.0"
|
| 267 |
+
}
|
| 268 |
+
},
|
| 269 |
+
"node_modules/@babel/traverse": {
|
| 270 |
+
"version": "7.29.0",
|
| 271 |
+
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
| 272 |
+
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
| 273 |
+
"dev": true,
|
| 274 |
+
"license": "MIT",
|
| 275 |
+
"dependencies": {
|
| 276 |
+
"@babel/code-frame": "^7.29.0",
|
| 277 |
+
"@babel/generator": "^7.29.0",
|
| 278 |
+
"@babel/helper-globals": "^7.28.0",
|
| 279 |
+
"@babel/parser": "^7.29.0",
|
| 280 |
+
"@babel/template": "^7.28.6",
|
| 281 |
+
"@babel/types": "^7.29.0",
|
| 282 |
+
"debug": "^4.3.1"
|
| 283 |
+
},
|
| 284 |
+
"engines": {
|
| 285 |
+
"node": ">=6.9.0"
|
| 286 |
+
}
|
| 287 |
+
},
|
| 288 |
+
"node_modules/@babel/types": {
|
| 289 |
+
"version": "7.29.0",
|
| 290 |
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
| 291 |
+
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
| 292 |
+
"dev": true,
|
| 293 |
+
"license": "MIT",
|
| 294 |
+
"dependencies": {
|
| 295 |
+
"@babel/helper-string-parser": "^7.27.1",
|
| 296 |
+
"@babel/helper-validator-identifier": "^7.28.5"
|
| 297 |
+
},
|
| 298 |
+
"engines": {
|
| 299 |
+
"node": ">=6.9.0"
|
| 300 |
+
}
|
| 301 |
+
},
|
| 302 |
+
"node_modules/@esbuild/aix-ppc64": {
|
| 303 |
+
"version": "0.21.5",
|
| 304 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
| 305 |
+
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
| 306 |
+
"cpu": [
|
| 307 |
+
"ppc64"
|
| 308 |
+
],
|
| 309 |
+
"dev": true,
|
| 310 |
+
"license": "MIT",
|
| 311 |
+
"optional": true,
|
| 312 |
+
"os": [
|
| 313 |
+
"aix"
|
| 314 |
+
],
|
| 315 |
+
"engines": {
|
| 316 |
+
"node": ">=12"
|
| 317 |
+
}
|
| 318 |
+
},
|
| 319 |
+
"node_modules/@esbuild/android-arm": {
|
| 320 |
+
"version": "0.21.5",
|
| 321 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
| 322 |
+
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
| 323 |
+
"cpu": [
|
| 324 |
+
"arm"
|
| 325 |
+
],
|
| 326 |
+
"dev": true,
|
| 327 |
+
"license": "MIT",
|
| 328 |
+
"optional": true,
|
| 329 |
+
"os": [
|
| 330 |
+
"android"
|
| 331 |
+
],
|
| 332 |
+
"engines": {
|
| 333 |
+
"node": ">=12"
|
| 334 |
+
}
|
| 335 |
+
},
|
| 336 |
+
"node_modules/@esbuild/android-arm64": {
|
| 337 |
+
"version": "0.21.5",
|
| 338 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
| 339 |
+
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
| 340 |
+
"cpu": [
|
| 341 |
+
"arm64"
|
| 342 |
+
],
|
| 343 |
+
"dev": true,
|
| 344 |
+
"license": "MIT",
|
| 345 |
+
"optional": true,
|
| 346 |
+
"os": [
|
| 347 |
+
"android"
|
| 348 |
+
],
|
| 349 |
+
"engines": {
|
| 350 |
+
"node": ">=12"
|
| 351 |
+
}
|
| 352 |
+
},
|
| 353 |
+
"node_modules/@esbuild/android-x64": {
|
| 354 |
+
"version": "0.21.5",
|
| 355 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
| 356 |
+
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
| 357 |
+
"cpu": [
|
| 358 |
+
"x64"
|
| 359 |
+
],
|
| 360 |
+
"dev": true,
|
| 361 |
+
"license": "MIT",
|
| 362 |
+
"optional": true,
|
| 363 |
+
"os": [
|
| 364 |
+
"android"
|
| 365 |
+
],
|
| 366 |
+
"engines": {
|
| 367 |
+
"node": ">=12"
|
| 368 |
+
}
|
| 369 |
+
},
|
| 370 |
+
"node_modules/@esbuild/darwin-arm64": {
|
| 371 |
+
"version": "0.21.5",
|
| 372 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
| 373 |
+
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
| 374 |
+
"cpu": [
|
| 375 |
+
"arm64"
|
| 376 |
+
],
|
| 377 |
+
"dev": true,
|
| 378 |
+
"license": "MIT",
|
| 379 |
+
"optional": true,
|
| 380 |
+
"os": [
|
| 381 |
+
"darwin"
|
| 382 |
+
],
|
| 383 |
+
"engines": {
|
| 384 |
+
"node": ">=12"
|
| 385 |
+
}
|
| 386 |
+
},
|
| 387 |
+
"node_modules/@esbuild/darwin-x64": {
|
| 388 |
+
"version": "0.21.5",
|
| 389 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
| 390 |
+
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
| 391 |
+
"cpu": [
|
| 392 |
+
"x64"
|
| 393 |
+
],
|
| 394 |
+
"dev": true,
|
| 395 |
+
"license": "MIT",
|
| 396 |
+
"optional": true,
|
| 397 |
+
"os": [
|
| 398 |
+
"darwin"
|
| 399 |
+
],
|
| 400 |
+
"engines": {
|
| 401 |
+
"node": ">=12"
|
| 402 |
+
}
|
| 403 |
+
},
|
| 404 |
+
"node_modules/@esbuild/freebsd-arm64": {
|
| 405 |
+
"version": "0.21.5",
|
| 406 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
| 407 |
+
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
| 408 |
+
"cpu": [
|
| 409 |
+
"arm64"
|
| 410 |
+
],
|
| 411 |
+
"dev": true,
|
| 412 |
+
"license": "MIT",
|
| 413 |
+
"optional": true,
|
| 414 |
+
"os": [
|
| 415 |
+
"freebsd"
|
| 416 |
+
],
|
| 417 |
+
"engines": {
|
| 418 |
+
"node": ">=12"
|
| 419 |
+
}
|
| 420 |
+
},
|
| 421 |
+
"node_modules/@esbuild/freebsd-x64": {
|
| 422 |
+
"version": "0.21.5",
|
| 423 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
| 424 |
+
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
| 425 |
+
"cpu": [
|
| 426 |
+
"x64"
|
| 427 |
+
],
|
| 428 |
+
"dev": true,
|
| 429 |
+
"license": "MIT",
|
| 430 |
+
"optional": true,
|
| 431 |
+
"os": [
|
| 432 |
+
"freebsd"
|
| 433 |
+
],
|
| 434 |
+
"engines": {
|
| 435 |
+
"node": ">=12"
|
| 436 |
+
}
|
| 437 |
+
},
|
| 438 |
+
"node_modules/@esbuild/linux-arm": {
|
| 439 |
+
"version": "0.21.5",
|
| 440 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
| 441 |
+
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
| 442 |
+
"cpu": [
|
| 443 |
+
"arm"
|
| 444 |
+
],
|
| 445 |
+
"dev": true,
|
| 446 |
+
"license": "MIT",
|
| 447 |
+
"optional": true,
|
| 448 |
+
"os": [
|
| 449 |
+
"linux"
|
| 450 |
+
],
|
| 451 |
+
"engines": {
|
| 452 |
+
"node": ">=12"
|
| 453 |
+
}
|
| 454 |
+
},
|
| 455 |
+
"node_modules/@esbuild/linux-arm64": {
|
| 456 |
+
"version": "0.21.5",
|
| 457 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
| 458 |
+
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
| 459 |
+
"cpu": [
|
| 460 |
+
"arm64"
|
| 461 |
+
],
|
| 462 |
+
"dev": true,
|
| 463 |
+
"license": "MIT",
|
| 464 |
+
"optional": true,
|
| 465 |
+
"os": [
|
| 466 |
+
"linux"
|
| 467 |
+
],
|
| 468 |
+
"engines": {
|
| 469 |
+
"node": ">=12"
|
| 470 |
+
}
|
| 471 |
+
},
|
| 472 |
+
"node_modules/@esbuild/linux-ia32": {
|
| 473 |
+
"version": "0.21.5",
|
| 474 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
| 475 |
+
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
| 476 |
+
"cpu": [
|
| 477 |
+
"ia32"
|
| 478 |
+
],
|
| 479 |
+
"dev": true,
|
| 480 |
+
"license": "MIT",
|
| 481 |
+
"optional": true,
|
| 482 |
+
"os": [
|
| 483 |
+
"linux"
|
| 484 |
+
],
|
| 485 |
+
"engines": {
|
| 486 |
+
"node": ">=12"
|
| 487 |
+
}
|
| 488 |
+
},
|
| 489 |
+
"node_modules/@esbuild/linux-loong64": {
|
| 490 |
+
"version": "0.21.5",
|
| 491 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
| 492 |
+
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
| 493 |
+
"cpu": [
|
| 494 |
+
"loong64"
|
| 495 |
+
],
|
| 496 |
+
"dev": true,
|
| 497 |
+
"license": "MIT",
|
| 498 |
+
"optional": true,
|
| 499 |
+
"os": [
|
| 500 |
+
"linux"
|
| 501 |
+
],
|
| 502 |
+
"engines": {
|
| 503 |
+
"node": ">=12"
|
| 504 |
+
}
|
| 505 |
+
},
|
| 506 |
+
"node_modules/@esbuild/linux-mips64el": {
|
| 507 |
+
"version": "0.21.5",
|
| 508 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
| 509 |
+
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
| 510 |
+
"cpu": [
|
| 511 |
+
"mips64el"
|
| 512 |
+
],
|
| 513 |
+
"dev": true,
|
| 514 |
+
"license": "MIT",
|
| 515 |
+
"optional": true,
|
| 516 |
+
"os": [
|
| 517 |
+
"linux"
|
| 518 |
+
],
|
| 519 |
+
"engines": {
|
| 520 |
+
"node": ">=12"
|
| 521 |
+
}
|
| 522 |
+
},
|
| 523 |
+
"node_modules/@esbuild/linux-ppc64": {
|
| 524 |
+
"version": "0.21.5",
|
| 525 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
| 526 |
+
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
| 527 |
+
"cpu": [
|
| 528 |
+
"ppc64"
|
| 529 |
+
],
|
| 530 |
+
"dev": true,
|
| 531 |
+
"license": "MIT",
|
| 532 |
+
"optional": true,
|
| 533 |
+
"os": [
|
| 534 |
+
"linux"
|
| 535 |
+
],
|
| 536 |
+
"engines": {
|
| 537 |
+
"node": ">=12"
|
| 538 |
+
}
|
| 539 |
+
},
|
| 540 |
+
"node_modules/@esbuild/linux-riscv64": {
|
| 541 |
+
"version": "0.21.5",
|
| 542 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
| 543 |
+
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
| 544 |
+
"cpu": [
|
| 545 |
+
"riscv64"
|
| 546 |
+
],
|
| 547 |
+
"dev": true,
|
| 548 |
+
"license": "MIT",
|
| 549 |
+
"optional": true,
|
| 550 |
+
"os": [
|
| 551 |
+
"linux"
|
| 552 |
+
],
|
| 553 |
+
"engines": {
|
| 554 |
+
"node": ">=12"
|
| 555 |
+
}
|
| 556 |
+
},
|
| 557 |
+
"node_modules/@esbuild/linux-s390x": {
|
| 558 |
+
"version": "0.21.5",
|
| 559 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
| 560 |
+
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
| 561 |
+
"cpu": [
|
| 562 |
+
"s390x"
|
| 563 |
+
],
|
| 564 |
+
"dev": true,
|
| 565 |
+
"license": "MIT",
|
| 566 |
+
"optional": true,
|
| 567 |
+
"os": [
|
| 568 |
+
"linux"
|
| 569 |
+
],
|
| 570 |
+
"engines": {
|
| 571 |
+
"node": ">=12"
|
| 572 |
+
}
|
| 573 |
+
},
|
| 574 |
+
"node_modules/@esbuild/linux-x64": {
|
| 575 |
+
"version": "0.21.5",
|
| 576 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
| 577 |
+
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
| 578 |
+
"cpu": [
|
| 579 |
+
"x64"
|
| 580 |
+
],
|
| 581 |
+
"dev": true,
|
| 582 |
+
"license": "MIT",
|
| 583 |
+
"optional": true,
|
| 584 |
+
"os": [
|
| 585 |
+
"linux"
|
| 586 |
+
],
|
| 587 |
+
"engines": {
|
| 588 |
+
"node": ">=12"
|
| 589 |
+
}
|
| 590 |
+
},
|
| 591 |
+
"node_modules/@esbuild/netbsd-x64": {
|
| 592 |
+
"version": "0.21.5",
|
| 593 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
| 594 |
+
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
| 595 |
+
"cpu": [
|
| 596 |
+
"x64"
|
| 597 |
+
],
|
| 598 |
+
"dev": true,
|
| 599 |
+
"license": "MIT",
|
| 600 |
+
"optional": true,
|
| 601 |
+
"os": [
|
| 602 |
+
"netbsd"
|
| 603 |
+
],
|
| 604 |
+
"engines": {
|
| 605 |
+
"node": ">=12"
|
| 606 |
+
}
|
| 607 |
+
},
|
| 608 |
+
"node_modules/@esbuild/openbsd-x64": {
|
| 609 |
+
"version": "0.21.5",
|
| 610 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
| 611 |
+
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
| 612 |
+
"cpu": [
|
| 613 |
+
"x64"
|
| 614 |
+
],
|
| 615 |
+
"dev": true,
|
| 616 |
+
"license": "MIT",
|
| 617 |
+
"optional": true,
|
| 618 |
+
"os": [
|
| 619 |
+
"openbsd"
|
| 620 |
+
],
|
| 621 |
+
"engines": {
|
| 622 |
+
"node": ">=12"
|
| 623 |
+
}
|
| 624 |
+
},
|
| 625 |
+
"node_modules/@esbuild/sunos-x64": {
|
| 626 |
+
"version": "0.21.5",
|
| 627 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
| 628 |
+
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
| 629 |
+
"cpu": [
|
| 630 |
+
"x64"
|
| 631 |
+
],
|
| 632 |
+
"dev": true,
|
| 633 |
+
"license": "MIT",
|
| 634 |
+
"optional": true,
|
| 635 |
+
"os": [
|
| 636 |
+
"sunos"
|
| 637 |
+
],
|
| 638 |
+
"engines": {
|
| 639 |
+
"node": ">=12"
|
| 640 |
+
}
|
| 641 |
+
},
|
| 642 |
+
"node_modules/@esbuild/win32-arm64": {
|
| 643 |
+
"version": "0.21.5",
|
| 644 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
| 645 |
+
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
| 646 |
+
"cpu": [
|
| 647 |
+
"arm64"
|
| 648 |
+
],
|
| 649 |
+
"dev": true,
|
| 650 |
+
"license": "MIT",
|
| 651 |
+
"optional": true,
|
| 652 |
+
"os": [
|
| 653 |
+
"win32"
|
| 654 |
+
],
|
| 655 |
+
"engines": {
|
| 656 |
+
"node": ">=12"
|
| 657 |
+
}
|
| 658 |
+
},
|
| 659 |
+
"node_modules/@esbuild/win32-ia32": {
|
| 660 |
+
"version": "0.21.5",
|
| 661 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
| 662 |
+
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
| 663 |
+
"cpu": [
|
| 664 |
+
"ia32"
|
| 665 |
+
],
|
| 666 |
+
"dev": true,
|
| 667 |
+
"license": "MIT",
|
| 668 |
+
"optional": true,
|
| 669 |
+
"os": [
|
| 670 |
+
"win32"
|
| 671 |
+
],
|
| 672 |
+
"engines": {
|
| 673 |
+
"node": ">=12"
|
| 674 |
+
}
|
| 675 |
+
},
|
| 676 |
+
"node_modules/@esbuild/win32-x64": {
|
| 677 |
+
"version": "0.21.5",
|
| 678 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
| 679 |
+
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
| 680 |
+
"cpu": [
|
| 681 |
+
"x64"
|
| 682 |
+
],
|
| 683 |
+
"dev": true,
|
| 684 |
+
"license": "MIT",
|
| 685 |
+
"optional": true,
|
| 686 |
+
"os": [
|
| 687 |
+
"win32"
|
| 688 |
+
],
|
| 689 |
+
"engines": {
|
| 690 |
+
"node": ">=12"
|
| 691 |
+
}
|
| 692 |
+
},
|
| 693 |
+
"node_modules/@jridgewell/gen-mapping": {
|
| 694 |
+
"version": "0.3.13",
|
| 695 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
| 696 |
+
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
| 697 |
+
"dev": true,
|
| 698 |
+
"license": "MIT",
|
| 699 |
+
"dependencies": {
|
| 700 |
+
"@jridgewell/sourcemap-codec": "^1.5.0",
|
| 701 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 702 |
+
}
|
| 703 |
+
},
|
| 704 |
+
"node_modules/@jridgewell/remapping": {
|
| 705 |
+
"version": "2.3.5",
|
| 706 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
| 707 |
+
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
| 708 |
+
"dev": true,
|
| 709 |
+
"license": "MIT",
|
| 710 |
+
"dependencies": {
|
| 711 |
+
"@jridgewell/gen-mapping": "^0.3.5",
|
| 712 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 713 |
+
}
|
| 714 |
+
},
|
| 715 |
+
"node_modules/@jridgewell/resolve-uri": {
|
| 716 |
+
"version": "3.1.2",
|
| 717 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
| 718 |
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
| 719 |
+
"dev": true,
|
| 720 |
+
"license": "MIT",
|
| 721 |
+
"engines": {
|
| 722 |
+
"node": ">=6.0.0"
|
| 723 |
+
}
|
| 724 |
+
},
|
| 725 |
+
"node_modules/@jridgewell/sourcemap-codec": {
|
| 726 |
+
"version": "1.5.5",
|
| 727 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
| 728 |
+
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
| 729 |
+
"dev": true,
|
| 730 |
+
"license": "MIT"
|
| 731 |
+
},
|
| 732 |
+
"node_modules/@jridgewell/trace-mapping": {
|
| 733 |
+
"version": "0.3.31",
|
| 734 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
| 735 |
+
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
| 736 |
+
"dev": true,
|
| 737 |
+
"license": "MIT",
|
| 738 |
+
"dependencies": {
|
| 739 |
+
"@jridgewell/resolve-uri": "^3.1.0",
|
| 740 |
+
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 741 |
+
}
|
| 742 |
+
},
|
| 743 |
+
"node_modules/@rolldown/pluginutils": {
|
| 744 |
+
"version": "1.0.0-beta.27",
|
| 745 |
+
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
| 746 |
+
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
| 747 |
+
"dev": true,
|
| 748 |
+
"license": "MIT"
|
| 749 |
+
},
|
| 750 |
+
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 751 |
+
"version": "4.60.1",
|
| 752 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
| 753 |
+
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
| 754 |
+
"cpu": [
|
| 755 |
+
"arm"
|
| 756 |
+
],
|
| 757 |
+
"dev": true,
|
| 758 |
+
"license": "MIT",
|
| 759 |
+
"optional": true,
|
| 760 |
+
"os": [
|
| 761 |
+
"android"
|
| 762 |
+
]
|
| 763 |
+
},
|
| 764 |
+
"node_modules/@rollup/rollup-android-arm64": {
|
| 765 |
+
"version": "4.60.1",
|
| 766 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
| 767 |
+
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
| 768 |
+
"cpu": [
|
| 769 |
+
"arm64"
|
| 770 |
+
],
|
| 771 |
+
"dev": true,
|
| 772 |
+
"license": "MIT",
|
| 773 |
+
"optional": true,
|
| 774 |
+
"os": [
|
| 775 |
+
"android"
|
| 776 |
+
]
|
| 777 |
+
},
|
| 778 |
+
"node_modules/@rollup/rollup-darwin-arm64": {
|
| 779 |
+
"version": "4.60.1",
|
| 780 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
| 781 |
+
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
| 782 |
+
"cpu": [
|
| 783 |
+
"arm64"
|
| 784 |
+
],
|
| 785 |
+
"dev": true,
|
| 786 |
+
"license": "MIT",
|
| 787 |
+
"optional": true,
|
| 788 |
+
"os": [
|
| 789 |
+
"darwin"
|
| 790 |
+
]
|
| 791 |
+
},
|
| 792 |
+
"node_modules/@rollup/rollup-darwin-x64": {
|
| 793 |
+
"version": "4.60.1",
|
| 794 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
| 795 |
+
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
| 796 |
+
"cpu": [
|
| 797 |
+
"x64"
|
| 798 |
+
],
|
| 799 |
+
"dev": true,
|
| 800 |
+
"license": "MIT",
|
| 801 |
+
"optional": true,
|
| 802 |
+
"os": [
|
| 803 |
+
"darwin"
|
| 804 |
+
]
|
| 805 |
+
},
|
| 806 |
+
"node_modules/@rollup/rollup-freebsd-arm64": {
|
| 807 |
+
"version": "4.60.1",
|
| 808 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
| 809 |
+
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
| 810 |
+
"cpu": [
|
| 811 |
+
"arm64"
|
| 812 |
+
],
|
| 813 |
+
"dev": true,
|
| 814 |
+
"license": "MIT",
|
| 815 |
+
"optional": true,
|
| 816 |
+
"os": [
|
| 817 |
+
"freebsd"
|
| 818 |
+
]
|
| 819 |
+
},
|
| 820 |
+
"node_modules/@rollup/rollup-freebsd-x64": {
|
| 821 |
+
"version": "4.60.1",
|
| 822 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
| 823 |
+
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
| 824 |
+
"cpu": [
|
| 825 |
+
"x64"
|
| 826 |
+
],
|
| 827 |
+
"dev": true,
|
| 828 |
+
"license": "MIT",
|
| 829 |
+
"optional": true,
|
| 830 |
+
"os": [
|
| 831 |
+
"freebsd"
|
| 832 |
+
]
|
| 833 |
+
},
|
| 834 |
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
| 835 |
+
"version": "4.60.1",
|
| 836 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
| 837 |
+
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
| 838 |
+
"cpu": [
|
| 839 |
+
"arm"
|
| 840 |
+
],
|
| 841 |
+
"dev": true,
|
| 842 |
+
"license": "MIT",
|
| 843 |
+
"optional": true,
|
| 844 |
+
"os": [
|
| 845 |
+
"linux"
|
| 846 |
+
]
|
| 847 |
+
},
|
| 848 |
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
| 849 |
+
"version": "4.60.1",
|
| 850 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
| 851 |
+
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
| 852 |
+
"cpu": [
|
| 853 |
+
"arm"
|
| 854 |
+
],
|
| 855 |
+
"dev": true,
|
| 856 |
+
"license": "MIT",
|
| 857 |
+
"optional": true,
|
| 858 |
+
"os": [
|
| 859 |
+
"linux"
|
| 860 |
+
]
|
| 861 |
+
},
|
| 862 |
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
| 863 |
+
"version": "4.60.1",
|
| 864 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
| 865 |
+
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
| 866 |
+
"cpu": [
|
| 867 |
+
"arm64"
|
| 868 |
+
],
|
| 869 |
+
"dev": true,
|
| 870 |
+
"license": "MIT",
|
| 871 |
+
"optional": true,
|
| 872 |
+
"os": [
|
| 873 |
+
"linux"
|
| 874 |
+
]
|
| 875 |
+
},
|
| 876 |
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
| 877 |
+
"version": "4.60.1",
|
| 878 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
| 879 |
+
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
| 880 |
+
"cpu": [
|
| 881 |
+
"arm64"
|
| 882 |
+
],
|
| 883 |
+
"dev": true,
|
| 884 |
+
"license": "MIT",
|
| 885 |
+
"optional": true,
|
| 886 |
+
"os": [
|
| 887 |
+
"linux"
|
| 888 |
+
]
|
| 889 |
+
},
|
| 890 |
+
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
| 891 |
+
"version": "4.60.1",
|
| 892 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
| 893 |
+
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
| 894 |
+
"cpu": [
|
| 895 |
+
"loong64"
|
| 896 |
+
],
|
| 897 |
+
"dev": true,
|
| 898 |
+
"license": "MIT",
|
| 899 |
+
"optional": true,
|
| 900 |
+
"os": [
|
| 901 |
+
"linux"
|
| 902 |
+
]
|
| 903 |
+
},
|
| 904 |
+
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
| 905 |
+
"version": "4.60.1",
|
| 906 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
| 907 |
+
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
| 908 |
+
"cpu": [
|
| 909 |
+
"loong64"
|
| 910 |
+
],
|
| 911 |
+
"dev": true,
|
| 912 |
+
"license": "MIT",
|
| 913 |
+
"optional": true,
|
| 914 |
+
"os": [
|
| 915 |
+
"linux"
|
| 916 |
+
]
|
| 917 |
+
},
|
| 918 |
+
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
| 919 |
+
"version": "4.60.1",
|
| 920 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
| 921 |
+
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
| 922 |
+
"cpu": [
|
| 923 |
+
"ppc64"
|
| 924 |
+
],
|
| 925 |
+
"dev": true,
|
| 926 |
+
"license": "MIT",
|
| 927 |
+
"optional": true,
|
| 928 |
+
"os": [
|
| 929 |
+
"linux"
|
| 930 |
+
]
|
| 931 |
+
},
|
| 932 |
+
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
| 933 |
+
"version": "4.60.1",
|
| 934 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
| 935 |
+
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
| 936 |
+
"cpu": [
|
| 937 |
+
"ppc64"
|
| 938 |
+
],
|
| 939 |
+
"dev": true,
|
| 940 |
+
"license": "MIT",
|
| 941 |
+
"optional": true,
|
| 942 |
+
"os": [
|
| 943 |
+
"linux"
|
| 944 |
+
]
|
| 945 |
+
},
|
| 946 |
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
| 947 |
+
"version": "4.60.1",
|
| 948 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
| 949 |
+
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
| 950 |
+
"cpu": [
|
| 951 |
+
"riscv64"
|
| 952 |
+
],
|
| 953 |
+
"dev": true,
|
| 954 |
+
"license": "MIT",
|
| 955 |
+
"optional": true,
|
| 956 |
+
"os": [
|
| 957 |
+
"linux"
|
| 958 |
+
]
|
| 959 |
+
},
|
| 960 |
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
| 961 |
+
"version": "4.60.1",
|
| 962 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
| 963 |
+
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
| 964 |
+
"cpu": [
|
| 965 |
+
"riscv64"
|
| 966 |
+
],
|
| 967 |
+
"dev": true,
|
| 968 |
+
"license": "MIT",
|
| 969 |
+
"optional": true,
|
| 970 |
+
"os": [
|
| 971 |
+
"linux"
|
| 972 |
+
]
|
| 973 |
+
},
|
| 974 |
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
| 975 |
+
"version": "4.60.1",
|
| 976 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
| 977 |
+
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
| 978 |
+
"cpu": [
|
| 979 |
+
"s390x"
|
| 980 |
+
],
|
| 981 |
+
"dev": true,
|
| 982 |
+
"license": "MIT",
|
| 983 |
+
"optional": true,
|
| 984 |
+
"os": [
|
| 985 |
+
"linux"
|
| 986 |
+
]
|
| 987 |
+
},
|
| 988 |
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
| 989 |
+
"version": "4.60.1",
|
| 990 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
| 991 |
+
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
| 992 |
+
"cpu": [
|
| 993 |
+
"x64"
|
| 994 |
+
],
|
| 995 |
+
"dev": true,
|
| 996 |
+
"license": "MIT",
|
| 997 |
+
"optional": true,
|
| 998 |
+
"os": [
|
| 999 |
+
"linux"
|
| 1000 |
+
]
|
| 1001 |
+
},
|
| 1002 |
+
"node_modules/@rollup/rollup-linux-x64-musl": {
|
| 1003 |
+
"version": "4.60.1",
|
| 1004 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
| 1005 |
+
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
| 1006 |
+
"cpu": [
|
| 1007 |
+
"x64"
|
| 1008 |
+
],
|
| 1009 |
+
"dev": true,
|
| 1010 |
+
"license": "MIT",
|
| 1011 |
+
"optional": true,
|
| 1012 |
+
"os": [
|
| 1013 |
+
"linux"
|
| 1014 |
+
]
|
| 1015 |
+
},
|
| 1016 |
+
"node_modules/@rollup/rollup-openbsd-x64": {
|
| 1017 |
+
"version": "4.60.1",
|
| 1018 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
| 1019 |
+
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
| 1020 |
+
"cpu": [
|
| 1021 |
+
"x64"
|
| 1022 |
+
],
|
| 1023 |
+
"dev": true,
|
| 1024 |
+
"license": "MIT",
|
| 1025 |
+
"optional": true,
|
| 1026 |
+
"os": [
|
| 1027 |
+
"openbsd"
|
| 1028 |
+
]
|
| 1029 |
+
},
|
| 1030 |
+
"node_modules/@rollup/rollup-openharmony-arm64": {
|
| 1031 |
+
"version": "4.60.1",
|
| 1032 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
| 1033 |
+
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
| 1034 |
+
"cpu": [
|
| 1035 |
+
"arm64"
|
| 1036 |
+
],
|
| 1037 |
+
"dev": true,
|
| 1038 |
+
"license": "MIT",
|
| 1039 |
+
"optional": true,
|
| 1040 |
+
"os": [
|
| 1041 |
+
"openharmony"
|
| 1042 |
+
]
|
| 1043 |
+
},
|
| 1044 |
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
| 1045 |
+
"version": "4.60.1",
|
| 1046 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
| 1047 |
+
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
| 1048 |
+
"cpu": [
|
| 1049 |
+
"arm64"
|
| 1050 |
+
],
|
| 1051 |
+
"dev": true,
|
| 1052 |
+
"license": "MIT",
|
| 1053 |
+
"optional": true,
|
| 1054 |
+
"os": [
|
| 1055 |
+
"win32"
|
| 1056 |
+
]
|
| 1057 |
+
},
|
| 1058 |
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
| 1059 |
+
"version": "4.60.1",
|
| 1060 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
| 1061 |
+
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
| 1062 |
+
"cpu": [
|
| 1063 |
+
"ia32"
|
| 1064 |
+
],
|
| 1065 |
+
"dev": true,
|
| 1066 |
+
"license": "MIT",
|
| 1067 |
+
"optional": true,
|
| 1068 |
+
"os": [
|
| 1069 |
+
"win32"
|
| 1070 |
+
]
|
| 1071 |
+
},
|
| 1072 |
+
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
| 1073 |
+
"version": "4.60.1",
|
| 1074 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
| 1075 |
+
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
| 1076 |
+
"cpu": [
|
| 1077 |
+
"x64"
|
| 1078 |
+
],
|
| 1079 |
+
"dev": true,
|
| 1080 |
+
"license": "MIT",
|
| 1081 |
+
"optional": true,
|
| 1082 |
+
"os": [
|
| 1083 |
+
"win32"
|
| 1084 |
+
]
|
| 1085 |
+
},
|
| 1086 |
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
| 1087 |
+
"version": "4.60.1",
|
| 1088 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
| 1089 |
+
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
| 1090 |
+
"cpu": [
|
| 1091 |
+
"x64"
|
| 1092 |
+
],
|
| 1093 |
+
"dev": true,
|
| 1094 |
+
"license": "MIT",
|
| 1095 |
+
"optional": true,
|
| 1096 |
+
"os": [
|
| 1097 |
+
"win32"
|
| 1098 |
+
]
|
| 1099 |
+
},
|
| 1100 |
+
"node_modules/@types/babel__core": {
|
| 1101 |
+
"version": "7.20.5",
|
| 1102 |
+
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
| 1103 |
+
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
| 1104 |
+
"dev": true,
|
| 1105 |
+
"license": "MIT",
|
| 1106 |
+
"dependencies": {
|
| 1107 |
+
"@babel/parser": "^7.20.7",
|
| 1108 |
+
"@babel/types": "^7.20.7",
|
| 1109 |
+
"@types/babel__generator": "*",
|
| 1110 |
+
"@types/babel__template": "*",
|
| 1111 |
+
"@types/babel__traverse": "*"
|
| 1112 |
+
}
|
| 1113 |
+
},
|
| 1114 |
+
"node_modules/@types/babel__generator": {
|
| 1115 |
+
"version": "7.27.0",
|
| 1116 |
+
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
| 1117 |
+
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
| 1118 |
+
"dev": true,
|
| 1119 |
+
"license": "MIT",
|
| 1120 |
+
"dependencies": {
|
| 1121 |
+
"@babel/types": "^7.0.0"
|
| 1122 |
+
}
|
| 1123 |
+
},
|
| 1124 |
+
"node_modules/@types/babel__template": {
|
| 1125 |
+
"version": "7.4.4",
|
| 1126 |
+
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
| 1127 |
+
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
| 1128 |
+
"dev": true,
|
| 1129 |
+
"license": "MIT",
|
| 1130 |
+
"dependencies": {
|
| 1131 |
+
"@babel/parser": "^7.1.0",
|
| 1132 |
+
"@babel/types": "^7.0.0"
|
| 1133 |
+
}
|
| 1134 |
+
},
|
| 1135 |
+
"node_modules/@types/babel__traverse": {
|
| 1136 |
+
"version": "7.28.0",
|
| 1137 |
+
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
| 1138 |
+
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
| 1139 |
+
"dev": true,
|
| 1140 |
+
"license": "MIT",
|
| 1141 |
+
"dependencies": {
|
| 1142 |
+
"@babel/types": "^7.28.2"
|
| 1143 |
+
}
|
| 1144 |
+
},
|
| 1145 |
+
"node_modules/@types/estree": {
|
| 1146 |
+
"version": "1.0.8",
|
| 1147 |
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
| 1148 |
+
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
| 1149 |
+
"dev": true,
|
| 1150 |
+
"license": "MIT"
|
| 1151 |
+
},
|
| 1152 |
+
"node_modules/@vitejs/plugin-react": {
|
| 1153 |
+
"version": "4.7.0",
|
| 1154 |
+
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
| 1155 |
+
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
| 1156 |
+
"dev": true,
|
| 1157 |
+
"license": "MIT",
|
| 1158 |
+
"dependencies": {
|
| 1159 |
+
"@babel/core": "^7.28.0",
|
| 1160 |
+
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
| 1161 |
+
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
| 1162 |
+
"@rolldown/pluginutils": "1.0.0-beta.27",
|
| 1163 |
+
"@types/babel__core": "^7.20.5",
|
| 1164 |
+
"react-refresh": "^0.17.0"
|
| 1165 |
+
},
|
| 1166 |
+
"engines": {
|
| 1167 |
+
"node": "^14.18.0 || >=16.0.0"
|
| 1168 |
+
},
|
| 1169 |
+
"peerDependencies": {
|
| 1170 |
+
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
| 1171 |
+
}
|
| 1172 |
+
},
|
| 1173 |
+
"node_modules/baseline-browser-mapping": {
|
| 1174 |
+
"version": "2.10.16",
|
| 1175 |
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
|
| 1176 |
+
"integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==",
|
| 1177 |
+
"dev": true,
|
| 1178 |
+
"license": "Apache-2.0",
|
| 1179 |
+
"bin": {
|
| 1180 |
+
"baseline-browser-mapping": "dist/cli.cjs"
|
| 1181 |
+
},
|
| 1182 |
+
"engines": {
|
| 1183 |
+
"node": ">=6.0.0"
|
| 1184 |
+
}
|
| 1185 |
+
},
|
| 1186 |
+
"node_modules/browserslist": {
|
| 1187 |
+
"version": "4.28.2",
|
| 1188 |
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
| 1189 |
+
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
| 1190 |
+
"dev": true,
|
| 1191 |
+
"funding": [
|
| 1192 |
+
{
|
| 1193 |
+
"type": "opencollective",
|
| 1194 |
+
"url": "https://opencollective.com/browserslist"
|
| 1195 |
+
},
|
| 1196 |
+
{
|
| 1197 |
+
"type": "tidelift",
|
| 1198 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1199 |
+
},
|
| 1200 |
+
{
|
| 1201 |
+
"type": "github",
|
| 1202 |
+
"url": "https://github.com/sponsors/ai"
|
| 1203 |
+
}
|
| 1204 |
+
],
|
| 1205 |
+
"license": "MIT",
|
| 1206 |
+
"peer": true,
|
| 1207 |
+
"dependencies": {
|
| 1208 |
+
"baseline-browser-mapping": "^2.10.12",
|
| 1209 |
+
"caniuse-lite": "^1.0.30001782",
|
| 1210 |
+
"electron-to-chromium": "^1.5.328",
|
| 1211 |
+
"node-releases": "^2.0.36",
|
| 1212 |
+
"update-browserslist-db": "^1.2.3"
|
| 1213 |
+
},
|
| 1214 |
+
"bin": {
|
| 1215 |
+
"browserslist": "cli.js"
|
| 1216 |
+
},
|
| 1217 |
+
"engines": {
|
| 1218 |
+
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
| 1219 |
+
}
|
| 1220 |
+
},
|
| 1221 |
+
"node_modules/caniuse-lite": {
|
| 1222 |
+
"version": "1.0.30001787",
|
| 1223 |
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
|
| 1224 |
+
"integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==",
|
| 1225 |
+
"dev": true,
|
| 1226 |
+
"funding": [
|
| 1227 |
+
{
|
| 1228 |
+
"type": "opencollective",
|
| 1229 |
+
"url": "https://opencollective.com/browserslist"
|
| 1230 |
+
},
|
| 1231 |
+
{
|
| 1232 |
+
"type": "tidelift",
|
| 1233 |
+
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
| 1234 |
+
},
|
| 1235 |
+
{
|
| 1236 |
+
"type": "github",
|
| 1237 |
+
"url": "https://github.com/sponsors/ai"
|
| 1238 |
+
}
|
| 1239 |
+
],
|
| 1240 |
+
"license": "CC-BY-4.0"
|
| 1241 |
+
},
|
| 1242 |
+
"node_modules/convert-source-map": {
|
| 1243 |
+
"version": "2.0.0",
|
| 1244 |
+
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
| 1245 |
+
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
| 1246 |
+
"dev": true,
|
| 1247 |
+
"license": "MIT"
|
| 1248 |
+
},
|
| 1249 |
+
"node_modules/debug": {
|
| 1250 |
+
"version": "4.4.3",
|
| 1251 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
| 1252 |
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
| 1253 |
+
"dev": true,
|
| 1254 |
+
"license": "MIT",
|
| 1255 |
+
"dependencies": {
|
| 1256 |
+
"ms": "^2.1.3"
|
| 1257 |
+
},
|
| 1258 |
+
"engines": {
|
| 1259 |
+
"node": ">=6.0"
|
| 1260 |
+
},
|
| 1261 |
+
"peerDependenciesMeta": {
|
| 1262 |
+
"supports-color": {
|
| 1263 |
+
"optional": true
|
| 1264 |
+
}
|
| 1265 |
+
}
|
| 1266 |
+
},
|
| 1267 |
+
"node_modules/electron-to-chromium": {
|
| 1268 |
+
"version": "1.5.334",
|
| 1269 |
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz",
|
| 1270 |
+
"integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
|
| 1271 |
+
"dev": true,
|
| 1272 |
+
"license": "ISC"
|
| 1273 |
+
},
|
| 1274 |
+
"node_modules/esbuild": {
|
| 1275 |
+
"version": "0.21.5",
|
| 1276 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
| 1277 |
+
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
| 1278 |
+
"dev": true,
|
| 1279 |
+
"hasInstallScript": true,
|
| 1280 |
+
"license": "MIT",
|
| 1281 |
+
"bin": {
|
| 1282 |
+
"esbuild": "bin/esbuild"
|
| 1283 |
+
},
|
| 1284 |
+
"engines": {
|
| 1285 |
+
"node": ">=12"
|
| 1286 |
+
},
|
| 1287 |
+
"optionalDependencies": {
|
| 1288 |
+
"@esbuild/aix-ppc64": "0.21.5",
|
| 1289 |
+
"@esbuild/android-arm": "0.21.5",
|
| 1290 |
+
"@esbuild/android-arm64": "0.21.5",
|
| 1291 |
+
"@esbuild/android-x64": "0.21.5",
|
| 1292 |
+
"@esbuild/darwin-arm64": "0.21.5",
|
| 1293 |
+
"@esbuild/darwin-x64": "0.21.5",
|
| 1294 |
+
"@esbuild/freebsd-arm64": "0.21.5",
|
| 1295 |
+
"@esbuild/freebsd-x64": "0.21.5",
|
| 1296 |
+
"@esbuild/linux-arm": "0.21.5",
|
| 1297 |
+
"@esbuild/linux-arm64": "0.21.5",
|
| 1298 |
+
"@esbuild/linux-ia32": "0.21.5",
|
| 1299 |
+
"@esbuild/linux-loong64": "0.21.5",
|
| 1300 |
+
"@esbuild/linux-mips64el": "0.21.5",
|
| 1301 |
+
"@esbuild/linux-ppc64": "0.21.5",
|
| 1302 |
+
"@esbuild/linux-riscv64": "0.21.5",
|
| 1303 |
+
"@esbuild/linux-s390x": "0.21.5",
|
| 1304 |
+
"@esbuild/linux-x64": "0.21.5",
|
| 1305 |
+
"@esbuild/netbsd-x64": "0.21.5",
|
| 1306 |
+
"@esbuild/openbsd-x64": "0.21.5",
|
| 1307 |
+
"@esbuild/sunos-x64": "0.21.5",
|
| 1308 |
+
"@esbuild/win32-arm64": "0.21.5",
|
| 1309 |
+
"@esbuild/win32-ia32": "0.21.5",
|
| 1310 |
+
"@esbuild/win32-x64": "0.21.5"
|
| 1311 |
+
}
|
| 1312 |
+
},
|
| 1313 |
+
"node_modules/escalade": {
|
| 1314 |
+
"version": "3.2.0",
|
| 1315 |
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
| 1316 |
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
| 1317 |
+
"dev": true,
|
| 1318 |
+
"license": "MIT",
|
| 1319 |
+
"engines": {
|
| 1320 |
+
"node": ">=6"
|
| 1321 |
+
}
|
| 1322 |
+
},
|
| 1323 |
+
"node_modules/fsevents": {
|
| 1324 |
+
"version": "2.3.3",
|
| 1325 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 1326 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 1327 |
+
"dev": true,
|
| 1328 |
+
"hasInstallScript": true,
|
| 1329 |
+
"license": "MIT",
|
| 1330 |
+
"optional": true,
|
| 1331 |
+
"os": [
|
| 1332 |
+
"darwin"
|
| 1333 |
+
],
|
| 1334 |
+
"engines": {
|
| 1335 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1336 |
+
}
|
| 1337 |
+
},
|
| 1338 |
+
"node_modules/gensync": {
|
| 1339 |
+
"version": "1.0.0-beta.2",
|
| 1340 |
+
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
| 1341 |
+
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
| 1342 |
+
"dev": true,
|
| 1343 |
+
"license": "MIT",
|
| 1344 |
+
"engines": {
|
| 1345 |
+
"node": ">=6.9.0"
|
| 1346 |
+
}
|
| 1347 |
+
},
|
| 1348 |
+
"node_modules/js-tokens": {
|
| 1349 |
+
"version": "4.0.0",
|
| 1350 |
+
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
| 1351 |
+
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
| 1352 |
+
"license": "MIT"
|
| 1353 |
+
},
|
| 1354 |
+
"node_modules/jsesc": {
|
| 1355 |
+
"version": "3.1.0",
|
| 1356 |
+
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
| 1357 |
+
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
| 1358 |
+
"dev": true,
|
| 1359 |
+
"license": "MIT",
|
| 1360 |
+
"bin": {
|
| 1361 |
+
"jsesc": "bin/jsesc"
|
| 1362 |
+
},
|
| 1363 |
+
"engines": {
|
| 1364 |
+
"node": ">=6"
|
| 1365 |
+
}
|
| 1366 |
+
},
|
| 1367 |
+
"node_modules/json5": {
|
| 1368 |
+
"version": "2.2.3",
|
| 1369 |
+
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
| 1370 |
+
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
| 1371 |
+
"dev": true,
|
| 1372 |
+
"license": "MIT",
|
| 1373 |
+
"bin": {
|
| 1374 |
+
"json5": "lib/cli.js"
|
| 1375 |
+
},
|
| 1376 |
+
"engines": {
|
| 1377 |
+
"node": ">=6"
|
| 1378 |
+
}
|
| 1379 |
+
},
|
| 1380 |
+
"node_modules/loose-envify": {
|
| 1381 |
+
"version": "1.4.0",
|
| 1382 |
+
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
| 1383 |
+
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
| 1384 |
+
"license": "MIT",
|
| 1385 |
+
"dependencies": {
|
| 1386 |
+
"js-tokens": "^3.0.0 || ^4.0.0"
|
| 1387 |
+
},
|
| 1388 |
+
"bin": {
|
| 1389 |
+
"loose-envify": "cli.js"
|
| 1390 |
+
}
|
| 1391 |
+
},
|
| 1392 |
+
"node_modules/lru-cache": {
|
| 1393 |
+
"version": "5.1.1",
|
| 1394 |
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
| 1395 |
+
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
| 1396 |
+
"dev": true,
|
| 1397 |
+
"license": "ISC",
|
| 1398 |
+
"dependencies": {
|
| 1399 |
+
"yallist": "^3.0.2"
|
| 1400 |
+
}
|
| 1401 |
+
},
|
| 1402 |
+
"node_modules/ms": {
|
| 1403 |
+
"version": "2.1.3",
|
| 1404 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 1405 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 1406 |
+
"dev": true,
|
| 1407 |
+
"license": "MIT"
|
| 1408 |
+
},
|
| 1409 |
+
"node_modules/nanoid": {
|
| 1410 |
+
"version": "3.3.11",
|
| 1411 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
| 1412 |
+
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
| 1413 |
+
"dev": true,
|
| 1414 |
+
"funding": [
|
| 1415 |
+
{
|
| 1416 |
+
"type": "github",
|
| 1417 |
+
"url": "https://github.com/sponsors/ai"
|
| 1418 |
+
}
|
| 1419 |
+
],
|
| 1420 |
+
"license": "MIT",
|
| 1421 |
+
"bin": {
|
| 1422 |
+
"nanoid": "bin/nanoid.cjs"
|
| 1423 |
+
},
|
| 1424 |
+
"engines": {
|
| 1425 |
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
| 1426 |
+
}
|
| 1427 |
+
},
|
| 1428 |
+
"node_modules/node-releases": {
|
| 1429 |
+
"version": "2.0.37",
|
| 1430 |
+
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
| 1431 |
+
"integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
|
| 1432 |
+
"dev": true,
|
| 1433 |
+
"license": "MIT"
|
| 1434 |
+
},
|
| 1435 |
+
"node_modules/picocolors": {
|
| 1436 |
+
"version": "1.1.1",
|
| 1437 |
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
| 1438 |
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
| 1439 |
+
"dev": true,
|
| 1440 |
+
"license": "ISC"
|
| 1441 |
+
},
|
| 1442 |
+
"node_modules/postcss": {
|
| 1443 |
+
"version": "8.5.9",
|
| 1444 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
| 1445 |
+
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
|
| 1446 |
+
"dev": true,
|
| 1447 |
+
"funding": [
|
| 1448 |
+
{
|
| 1449 |
+
"type": "opencollective",
|
| 1450 |
+
"url": "https://opencollective.com/postcss/"
|
| 1451 |
+
},
|
| 1452 |
+
{
|
| 1453 |
+
"type": "tidelift",
|
| 1454 |
+
"url": "https://tidelift.com/funding/github/npm/postcss"
|
| 1455 |
+
},
|
| 1456 |
+
{
|
| 1457 |
+
"type": "github",
|
| 1458 |
+
"url": "https://github.com/sponsors/ai"
|
| 1459 |
+
}
|
| 1460 |
+
],
|
| 1461 |
+
"license": "MIT",
|
| 1462 |
+
"dependencies": {
|
| 1463 |
+
"nanoid": "^3.3.11",
|
| 1464 |
+
"picocolors": "^1.1.1",
|
| 1465 |
+
"source-map-js": "^1.2.1"
|
| 1466 |
+
},
|
| 1467 |
+
"engines": {
|
| 1468 |
+
"node": "^10 || ^12 || >=14"
|
| 1469 |
+
}
|
| 1470 |
+
},
|
| 1471 |
+
"node_modules/react": {
|
| 1472 |
+
"version": "18.3.1",
|
| 1473 |
+
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
| 1474 |
+
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
| 1475 |
+
"license": "MIT",
|
| 1476 |
+
"peer": true,
|
| 1477 |
+
"dependencies": {
|
| 1478 |
+
"loose-envify": "^1.1.0"
|
| 1479 |
+
},
|
| 1480 |
+
"engines": {
|
| 1481 |
+
"node": ">=0.10.0"
|
| 1482 |
+
}
|
| 1483 |
+
},
|
| 1484 |
+
"node_modules/react-dom": {
|
| 1485 |
+
"version": "18.3.1",
|
| 1486 |
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
| 1487 |
+
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
| 1488 |
+
"license": "MIT",
|
| 1489 |
+
"dependencies": {
|
| 1490 |
+
"loose-envify": "^1.1.0",
|
| 1491 |
+
"scheduler": "^0.23.2"
|
| 1492 |
+
},
|
| 1493 |
+
"peerDependencies": {
|
| 1494 |
+
"react": "^18.3.1"
|
| 1495 |
+
}
|
| 1496 |
+
},
|
| 1497 |
+
"node_modules/react-refresh": {
|
| 1498 |
+
"version": "0.17.0",
|
| 1499 |
+
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
| 1500 |
+
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
| 1501 |
+
"dev": true,
|
| 1502 |
+
"license": "MIT",
|
| 1503 |
+
"engines": {
|
| 1504 |
+
"node": ">=0.10.0"
|
| 1505 |
+
}
|
| 1506 |
+
},
|
| 1507 |
+
"node_modules/rollup": {
|
| 1508 |
+
"version": "4.60.1",
|
| 1509 |
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
| 1510 |
+
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
| 1511 |
+
"dev": true,
|
| 1512 |
+
"license": "MIT",
|
| 1513 |
+
"dependencies": {
|
| 1514 |
+
"@types/estree": "1.0.8"
|
| 1515 |
+
},
|
| 1516 |
+
"bin": {
|
| 1517 |
+
"rollup": "dist/bin/rollup"
|
| 1518 |
+
},
|
| 1519 |
+
"engines": {
|
| 1520 |
+
"node": ">=18.0.0",
|
| 1521 |
+
"npm": ">=8.0.0"
|
| 1522 |
+
},
|
| 1523 |
+
"optionalDependencies": {
|
| 1524 |
+
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
| 1525 |
+
"@rollup/rollup-android-arm64": "4.60.1",
|
| 1526 |
+
"@rollup/rollup-darwin-arm64": "4.60.1",
|
| 1527 |
+
"@rollup/rollup-darwin-x64": "4.60.1",
|
| 1528 |
+
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
| 1529 |
+
"@rollup/rollup-freebsd-x64": "4.60.1",
|
| 1530 |
+
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
| 1531 |
+
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
| 1532 |
+
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
| 1533 |
+
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
| 1534 |
+
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
| 1535 |
+
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
| 1536 |
+
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
| 1537 |
+
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
| 1538 |
+
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
| 1539 |
+
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
| 1540 |
+
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
| 1541 |
+
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
| 1542 |
+
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
| 1543 |
+
"@rollup/rollup-openbsd-x64": "4.60.1",
|
| 1544 |
+
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
| 1545 |
+
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
| 1546 |
+
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
| 1547 |
+
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
| 1548 |
+
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
| 1549 |
+
"fsevents": "~2.3.2"
|
| 1550 |
+
}
|
| 1551 |
+
},
|
| 1552 |
+
"node_modules/scheduler": {
|
| 1553 |
+
"version": "0.23.2",
|
| 1554 |
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
| 1555 |
+
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
| 1556 |
+
"license": "MIT",
|
| 1557 |
+
"dependencies": {
|
| 1558 |
+
"loose-envify": "^1.1.0"
|
| 1559 |
+
}
|
| 1560 |
+
},
|
| 1561 |
+
"node_modules/semver": {
|
| 1562 |
+
"version": "6.3.1",
|
| 1563 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
| 1564 |
+
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
| 1565 |
+
"dev": true,
|
| 1566 |
+
"license": "ISC",
|
| 1567 |
+
"bin": {
|
| 1568 |
+
"semver": "bin/semver.js"
|
| 1569 |
+
}
|
| 1570 |
+
},
|
| 1571 |
+
"node_modules/source-map-js": {
|
| 1572 |
+
"version": "1.2.1",
|
| 1573 |
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
| 1574 |
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
| 1575 |
+
"dev": true,
|
| 1576 |
+
"license": "BSD-3-Clause",
|
| 1577 |
+
"engines": {
|
| 1578 |
+
"node": ">=0.10.0"
|
| 1579 |
+
}
|
| 1580 |
+
},
|
| 1581 |
+
"node_modules/update-browserslist-db": {
|
| 1582 |
+
"version": "1.2.3",
|
| 1583 |
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
| 1584 |
+
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
| 1585 |
+
"dev": true,
|
| 1586 |
+
"funding": [
|
| 1587 |
+
{
|
| 1588 |
+
"type": "opencollective",
|
| 1589 |
+
"url": "https://opencollective.com/browserslist"
|
| 1590 |
+
},
|
| 1591 |
+
{
|
| 1592 |
+
"type": "tidelift",
|
| 1593 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1594 |
+
},
|
| 1595 |
+
{
|
| 1596 |
+
"type": "github",
|
| 1597 |
+
"url": "https://github.com/sponsors/ai"
|
| 1598 |
+
}
|
| 1599 |
+
],
|
| 1600 |
+
"license": "MIT",
|
| 1601 |
+
"dependencies": {
|
| 1602 |
+
"escalade": "^3.2.0",
|
| 1603 |
+
"picocolors": "^1.1.1"
|
| 1604 |
+
},
|
| 1605 |
+
"bin": {
|
| 1606 |
+
"update-browserslist-db": "cli.js"
|
| 1607 |
+
},
|
| 1608 |
+
"peerDependencies": {
|
| 1609 |
+
"browserslist": ">= 4.21.0"
|
| 1610 |
+
}
|
| 1611 |
+
},
|
| 1612 |
+
"node_modules/vite": {
|
| 1613 |
+
"version": "5.4.21",
|
| 1614 |
+
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
| 1615 |
+
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 1616 |
+
"dev": true,
|
| 1617 |
+
"license": "MIT",
|
| 1618 |
+
"peer": true,
|
| 1619 |
+
"dependencies": {
|
| 1620 |
+
"esbuild": "^0.21.3",
|
| 1621 |
+
"postcss": "^8.4.43",
|
| 1622 |
+
"rollup": "^4.20.0"
|
| 1623 |
+
},
|
| 1624 |
+
"bin": {
|
| 1625 |
+
"vite": "bin/vite.js"
|
| 1626 |
+
},
|
| 1627 |
+
"engines": {
|
| 1628 |
+
"node": "^18.0.0 || >=20.0.0"
|
| 1629 |
+
},
|
| 1630 |
+
"funding": {
|
| 1631 |
+
"url": "https://github.com/vitejs/vite?sponsor=1"
|
| 1632 |
+
},
|
| 1633 |
+
"optionalDependencies": {
|
| 1634 |
+
"fsevents": "~2.3.3"
|
| 1635 |
+
},
|
| 1636 |
+
"peerDependencies": {
|
| 1637 |
+
"@types/node": "^18.0.0 || >=20.0.0",
|
| 1638 |
+
"less": "*",
|
| 1639 |
+
"lightningcss": "^1.21.0",
|
| 1640 |
+
"sass": "*",
|
| 1641 |
+
"sass-embedded": "*",
|
| 1642 |
+
"stylus": "*",
|
| 1643 |
+
"sugarss": "*",
|
| 1644 |
+
"terser": "^5.4.0"
|
| 1645 |
+
},
|
| 1646 |
+
"peerDependenciesMeta": {
|
| 1647 |
+
"@types/node": {
|
| 1648 |
+
"optional": true
|
| 1649 |
+
},
|
| 1650 |
+
"less": {
|
| 1651 |
+
"optional": true
|
| 1652 |
+
},
|
| 1653 |
+
"lightningcss": {
|
| 1654 |
+
"optional": true
|
| 1655 |
+
},
|
| 1656 |
+
"sass": {
|
| 1657 |
+
"optional": true
|
| 1658 |
+
},
|
| 1659 |
+
"sass-embedded": {
|
| 1660 |
+
"optional": true
|
| 1661 |
+
},
|
| 1662 |
+
"stylus": {
|
| 1663 |
+
"optional": true
|
| 1664 |
+
},
|
| 1665 |
+
"sugarss": {
|
| 1666 |
+
"optional": true
|
| 1667 |
+
},
|
| 1668 |
+
"terser": {
|
| 1669 |
+
"optional": true
|
| 1670 |
+
}
|
| 1671 |
+
}
|
| 1672 |
+
},
|
| 1673 |
+
"node_modules/yallist": {
|
| 1674 |
+
"version": "3.1.1",
|
| 1675 |
+
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
| 1676 |
+
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
| 1677 |
+
"dev": true,
|
| 1678 |
+
"license": "ISC"
|
| 1679 |
+
}
|
| 1680 |
+
}
|
| 1681 |
+
}
|
services/frontend/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "visual-search-frontend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"type": "module",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "vite",
|
| 7 |
+
"build": "vite build",
|
| 8 |
+
"preview": "vite preview"
|
| 9 |
+
},
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"react": "^18.2.0",
|
| 12 |
+
"react-dom": "^18.2.0"
|
| 13 |
+
},
|
| 14 |
+
"devDependencies": {
|
| 15 |
+
"@vitejs/plugin-react": "^4.2.0",
|
| 16 |
+
"vite": "^5.0.0"
|
| 17 |
+
}
|
| 18 |
+
}
|
services/frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useCallback } from "react"
|
| 2 |
+
import SearchBar from "./components/SearchBar"
|
| 3 |
+
import ResultGrid from "./components/ResultGrid"
|
| 4 |
+
import StatsBar from "./components/StatsBar"
|
| 5 |
+
import VoiceButton from "./components/VoiceButton"
|
| 6 |
+
|
| 7 |
+
const API = import.meta.env.VITE_API_URL || "http://localhost:8000"
|
| 8 |
+
|
| 9 |
+
export default function App() {
|
| 10 |
+
const [results, setResults] = useState([])
|
| 11 |
+
const [query, setQuery] = useState("")
|
| 12 |
+
const [queryType, setQueryType] = useState("")
|
| 13 |
+
const [transcription, setTranscription] = useState("")
|
| 14 |
+
const [loading, setLoading] = useState(false)
|
| 15 |
+
const [error, setError] = useState("")
|
| 16 |
+
const [latency, setLatency] = useState(null)
|
| 17 |
+
const [stats, setStats] = useState(null)
|
| 18 |
+
|
| 19 |
+
// ββ Search handlers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
|
| 21 |
+
const searchText = useCallback(async (text) => {
|
| 22 |
+
if (!text.trim()) return
|
| 23 |
+
setLoading(true)
|
| 24 |
+
setError("")
|
| 25 |
+
setTranscription("")
|
| 26 |
+
try {
|
| 27 |
+
const res = await fetch(`${API}/search/text?q=${encodeURIComponent(text)}&k=12`)
|
| 28 |
+
if (!res.ok) throw new Error(await res.text())
|
| 29 |
+
const data = await res.json()
|
| 30 |
+
setResults(data.results)
|
| 31 |
+
setQuery(data.query)
|
| 32 |
+
setQueryType("text")
|
| 33 |
+
setLatency({ total: data.latency_ms, encoder: data.encoder_latency_ms })
|
| 34 |
+
} catch (e) {
|
| 35 |
+
setError(e.message)
|
| 36 |
+
} finally {
|
| 37 |
+
setLoading(false)
|
| 38 |
+
}
|
| 39 |
+
}, [])
|
| 40 |
+
|
| 41 |
+
const searchImage = useCallback(async (file) => {
|
| 42 |
+
setLoading(true)
|
| 43 |
+
setError("")
|
| 44 |
+
setTranscription("")
|
| 45 |
+
const form = new FormData()
|
| 46 |
+
form.append("file", file)
|
| 47 |
+
try {
|
| 48 |
+
const res = await fetch(`${API}/search/image?k=12`, { method: "POST", body: form })
|
| 49 |
+
if (!res.ok) throw new Error(await res.text())
|
| 50 |
+
const data = await res.json()
|
| 51 |
+
setResults(data.results)
|
| 52 |
+
setQuery("uploaded image")
|
| 53 |
+
setQueryType("image")
|
| 54 |
+
setLatency({ total: data.latency_ms, encoder: data.encoder_latency_ms })
|
| 55 |
+
} catch (e) {
|
| 56 |
+
setError(e.message)
|
| 57 |
+
} finally {
|
| 58 |
+
setLoading(false)
|
| 59 |
+
}
|
| 60 |
+
}, [])
|
| 61 |
+
|
| 62 |
+
const searchVoice = useCallback(async (audioBlob) => {
|
| 63 |
+
setLoading(true)
|
| 64 |
+
setError("")
|
| 65 |
+
const form = new FormData()
|
| 66 |
+
form.append("file", audioBlob, "voice.wav")
|
| 67 |
+
try {
|
| 68 |
+
const res = await fetch(`${API}/search/voice?k=12`, { method: "POST", body: form })
|
| 69 |
+
if (!res.ok) throw new Error(await res.text())
|
| 70 |
+
const data = await res.json()
|
| 71 |
+
setResults(data.results)
|
| 72 |
+
setQuery(data.query)
|
| 73 |
+
setQueryType("voice")
|
| 74 |
+
setTranscription(data.transcription || "")
|
| 75 |
+
setLatency({ total: data.latency_ms, encoder: data.encoder_latency_ms, whisper: data.whisper_ms })
|
| 76 |
+
} catch (e) {
|
| 77 |
+
setError(e.message)
|
| 78 |
+
} finally {
|
| 79 |
+
setLoading(false)
|
| 80 |
+
}
|
| 81 |
+
}, [])
|
| 82 |
+
|
| 83 |
+
const submitFeedback = useCallback(async (imagePath, vote) => {
|
| 84 |
+
try {
|
| 85 |
+
await fetch(`${API}/feedback`, {
|
| 86 |
+
method: "POST",
|
| 87 |
+
headers: { "Content-Type": "application/json" },
|
| 88 |
+
body: JSON.stringify({ image_path: imagePath, query, vote }),
|
| 89 |
+
})
|
| 90 |
+
} catch (e) {
|
| 91 |
+
console.warn("Feedback failed:", e)
|
| 92 |
+
}
|
| 93 |
+
}, [query])
|
| 94 |
+
|
| 95 |
+
return (
|
| 96 |
+
<div className="app">
|
| 97 |
+
<header className="header">
|
| 98 |
+
<h1 className="logo">Visual Search</h1>
|
| 99 |
+
<p className="tagline">Search images by text, voice, or image β powered by CLIP + FAISS</p>
|
| 100 |
+
</header>
|
| 101 |
+
|
| 102 |
+
<main className="main">
|
| 103 |
+
<div className="search-area">
|
| 104 |
+
<SearchBar onSearch={searchText} onImageUpload={searchImage} loading={loading} />
|
| 105 |
+
<VoiceButton onResult={searchVoice} loading={loading} />
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{transcription && (
|
| 109 |
+
<div className="transcription">
|
| 110 |
+
<span className="transcription-label">Heard:</span> "{transcription}"
|
| 111 |
+
</div>
|
| 112 |
+
)}
|
| 113 |
+
|
| 114 |
+
{latency && (
|
| 115 |
+
<StatsBar
|
| 116 |
+
latency={latency}
|
| 117 |
+
resultCount={results.length}
|
| 118 |
+
queryType={queryType}
|
| 119 |
+
/>
|
| 120 |
+
)}
|
| 121 |
+
|
| 122 |
+
{error && <div className="error">{error}</div>}
|
| 123 |
+
|
| 124 |
+
{loading && (
|
| 125 |
+
<div className="loading">
|
| 126 |
+
<div className="spinner" />
|
| 127 |
+
<span>Searching{queryType === "voice" ? " (transcribing...)" : ""}...</span>
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
|
| 131 |
+
{!loading && results.length > 0 && (
|
| 132 |
+
<ResultGrid results={results} onFeedback={submitFeedback} apiBase={API} />
|
| 133 |
+
)}
|
| 134 |
+
|
| 135 |
+
{!loading && results.length === 0 && !error && query && (
|
| 136 |
+
<div className="empty">No results found for "{query}"</div>
|
| 137 |
+
)}
|
| 138 |
+
|
| 139 |
+
{!query && !loading && (
|
| 140 |
+
<div className="hero-hint">
|
| 141 |
+
<div className="hint-grid">
|
| 142 |
+
{["dog running in rain", "mountain sunset", "busy city market", "rocket launch"].map(q => (
|
| 143 |
+
<button key={q} className="hint-chip" onClick={() => searchText(q)}>
|
| 144 |
+
{q}
|
| 145 |
+
</button>
|
| 146 |
+
))}
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
)}
|
| 150 |
+
</main>
|
| 151 |
+
</div>
|
| 152 |
+
)
|
| 153 |
+
}
|
services/frontend/src/components/ResultGrid.jsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react"
|
| 2 |
+
|
| 3 |
+
export function ResultGrid({ results, onFeedback, apiBase }) {
|
| 4 |
+
const [votes, setVotes] = useState({})
|
| 5 |
+
|
| 6 |
+
const vote = (path, v) => {
|
| 7 |
+
setVotes(prev => ({ ...prev, [path]: v }))
|
| 8 |
+
onFeedback(path, v)
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
return (
|
| 12 |
+
<div className="result-grid">
|
| 13 |
+
{results.map((r) => (
|
| 14 |
+
<div key={r.path} className="result-card">
|
| 15 |
+
<div className="result-img-wrap">
|
| 16 |
+
<img
|
| 17 |
+
src={`${apiBase}${r.url}`}
|
| 18 |
+
alt={r.category}
|
| 19 |
+
className="result-img"
|
| 20 |
+
loading="lazy"
|
| 21 |
+
onError={e => { e.target.style.display = "none" }}
|
| 22 |
+
/>
|
| 23 |
+
</div>
|
| 24 |
+
<div className="result-meta">
|
| 25 |
+
<span className="result-category">{r.category.replace(/_/g, " ")}</span>
|
| 26 |
+
<span className="result-score">{(r.score * 100).toFixed(1)}%</span>
|
| 27 |
+
</div>
|
| 28 |
+
<div className="result-actions">
|
| 29 |
+
<button
|
| 30 |
+
className={`vote-btn ${votes[r.path] === 1 ? "voted-up" : ""}`}
|
| 31 |
+
onClick={() => vote(r.path, 1)}
|
| 32 |
+
title="Relevant"
|
| 33 |
+
>+</button>
|
| 34 |
+
<button
|
| 35 |
+
className={`vote-btn ${votes[r.path] === -1 ? "voted-down" : ""}`}
|
| 36 |
+
onClick={() => vote(r.path, -1)}
|
| 37 |
+
title="Not relevant"
|
| 38 |
+
>β</button>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
))}
|
| 42 |
+
</div>
|
| 43 |
+
)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export default ResultGrid
|
| 47 |
+
|
| 48 |
+
export function StatsBar({ latency, resultCount, queryType }) {
|
| 49 |
+
const icons = { text: "T", image: "I", voice: "V" }
|
| 50 |
+
return (
|
| 51 |
+
<div className="stats-bar">
|
| 52 |
+
<span className="stat">
|
| 53 |
+
<span className="stat-label">type</span>
|
| 54 |
+
<span className="stat-value">{icons[queryType] || "?"} {queryType}</span>
|
| 55 |
+
</span>
|
| 56 |
+
<span className="stat">
|
| 57 |
+
<span className="stat-label">results</span>
|
| 58 |
+
<span className="stat-value">{resultCount}</span>
|
| 59 |
+
</span>
|
| 60 |
+
<span className="stat">
|
| 61 |
+
<span className="stat-label">total</span>
|
| 62 |
+
<span className="stat-value">{latency.total?.toFixed(0)}ms</span>
|
| 63 |
+
</span>
|
| 64 |
+
<span className="stat">
|
| 65 |
+
<span className="stat-label">encoder</span>
|
| 66 |
+
<span className="stat-value">{latency.encoder?.toFixed(0)}ms</span>
|
| 67 |
+
</span>
|
| 68 |
+
{latency.whisper && (
|
| 69 |
+
<span className="stat">
|
| 70 |
+
<span className="stat-label">whisper</span>
|
| 71 |
+
<span className="stat-value">{latency.whisper?.toFixed(0)}ms</span>
|
| 72 |
+
</span>
|
| 73 |
+
)}
|
| 74 |
+
</div>
|
| 75 |
+
)
|
| 76 |
+
}
|
services/frontend/src/components/SearchBar.jsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef } from "react"
|
| 2 |
+
|
| 3 |
+
export default function SearchBar({ onSearch, onImageUpload, loading }) {
|
| 4 |
+
const [text, setText] = useState("")
|
| 5 |
+
const fileRef = useRef()
|
| 6 |
+
|
| 7 |
+
const handleSubmit = (e) => {
|
| 8 |
+
e.preventDefault()
|
| 9 |
+
if (text.trim()) onSearch(text.trim())
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const handleFile = (e) => {
|
| 13 |
+
const file = e.target.files?.[0]
|
| 14 |
+
if (file) onImageUpload(file)
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const handleDrop = (e) => {
|
| 18 |
+
e.preventDefault()
|
| 19 |
+
const file = e.dataTransfer.files?.[0]
|
| 20 |
+
if (file && file.type.startsWith("image/")) onImageUpload(file)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<form className="search-bar" onSubmit={handleSubmit}
|
| 25 |
+
onDragOver={e => e.preventDefault()} onDrop={handleDrop}>
|
| 26 |
+
<input
|
| 27 |
+
className="search-input"
|
| 28 |
+
type="text"
|
| 29 |
+
placeholder="Search images... (or drag & drop an image)"
|
| 30 |
+
value={text}
|
| 31 |
+
onChange={e => setText(e.target.value)}
|
| 32 |
+
disabled={loading}
|
| 33 |
+
/>
|
| 34 |
+
<button type="submit" className="search-btn" disabled={loading || !text.trim()}>
|
| 35 |
+
Search
|
| 36 |
+
</button>
|
| 37 |
+
<button
|
| 38 |
+
type="button"
|
| 39 |
+
className="upload-btn"
|
| 40 |
+
onClick={() => fileRef.current?.click()}
|
| 41 |
+
disabled={loading}
|
| 42 |
+
title="Search by image"
|
| 43 |
+
>
|
| 44 |
+
Upload
|
| 45 |
+
</button>
|
| 46 |
+
<input
|
| 47 |
+
ref={fileRef}
|
| 48 |
+
type="file"
|
| 49 |
+
accept="image/*"
|
| 50 |
+
style={{ display: "none" }}
|
| 51 |
+
onChange={handleFile}
|
| 52 |
+
/>
|
| 53 |
+
</form>
|
| 54 |
+
)
|
| 55 |
+
}
|
services/frontend/src/components/StatsBar.jsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export { StatsBar as default } from "./ResultGrid"
|
services/frontend/src/components/VoiceButton.jsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef } from "react"
|
| 2 |
+
|
| 3 |
+
/*
|
| 4 |
+
WHY THIS COMPONENT EXISTS:
|
| 5 |
+
The Web MediaRecorder API lets us record audio from the microphone.
|
| 6 |
+
We record while button is held down, stop on release.
|
| 7 |
+
Send the recorded blob as WAV to /search/voice.
|
| 8 |
+
|
| 9 |
+
Browser compatibility note:
|
| 10 |
+
- Chrome: records as webm/opus by default
|
| 11 |
+
- Safari: records as mp4/aac
|
| 12 |
+
Whisper handles both formats natively so we don't need to convert.
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
export default function VoiceButton({ onResult, loading }) {
|
| 16 |
+
const [recording, setRecording] = useState(false)
|
| 17 |
+
const [supported, setSupported] = useState(!!navigator.mediaDevices?.getUserMedia)
|
| 18 |
+
const mediaRef = useRef(null)
|
| 19 |
+
const chunksRef = useRef([])
|
| 20 |
+
|
| 21 |
+
const startRecording = async () => {
|
| 22 |
+
if (recording || loading) return
|
| 23 |
+
try {
|
| 24 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
| 25 |
+
const recorder = new MediaRecorder(stream)
|
| 26 |
+
chunksRef.current = []
|
| 27 |
+
|
| 28 |
+
recorder.ondataavailable = e => {
|
| 29 |
+
if (e.data.size > 0) chunksRef.current.push(e.data)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
recorder.onstop = () => {
|
| 33 |
+
const blob = new Blob(chunksRef.current, { type: "audio/wav" })
|
| 34 |
+
stream.getTracks().forEach(t => t.stop())
|
| 35 |
+
onResult(blob)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
recorder.start()
|
| 39 |
+
mediaRef.current = recorder
|
| 40 |
+
setRecording(true)
|
| 41 |
+
} catch (e) {
|
| 42 |
+
console.error("Mic access denied:", e)
|
| 43 |
+
setSupported(false)
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const stopRecording = () => {
|
| 48 |
+
if (mediaRef.current && recording) {
|
| 49 |
+
mediaRef.current.stop()
|
| 50 |
+
setRecording(false)
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
if (!supported) return null
|
| 55 |
+
|
| 56 |
+
return (
|
| 57 |
+
<button
|
| 58 |
+
className={`voice-btn ${recording ? "recording" : ""}`}
|
| 59 |
+
onMouseDown={startRecording}
|
| 60 |
+
onMouseUp={stopRecording}
|
| 61 |
+
onTouchStart={startRecording}
|
| 62 |
+
onTouchEnd={stopRecording}
|
| 63 |
+
disabled={loading && !recording}
|
| 64 |
+
title="Hold to record voice search"
|
| 65 |
+
>
|
| 66 |
+
{recording ? "β Release to search" : "Hold to speak"}
|
| 67 |
+
</button>
|
| 68 |
+
)
|
| 69 |
+
}
|
services/frontend/src/index.css
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--bg: #0f0f0f;
|
| 5 |
+
--surface: #1a1a1a;
|
| 6 |
+
--border: #2a2a2a;
|
| 7 |
+
--text: #e8e8e8;
|
| 8 |
+
--muted: #888;
|
| 9 |
+
--accent: #6366f1;
|
| 10 |
+
--accent-hover: #818cf8;
|
| 11 |
+
--danger: #ef4444;
|
| 12 |
+
--success: #22c55e;
|
| 13 |
+
--radius: 8px;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; font-size: 15px; line-height: 1.5; }
|
| 17 |
+
|
| 18 |
+
.app { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
|
| 19 |
+
|
| 20 |
+
.header { text-align: center; margin-bottom: 32px; }
|
| 21 |
+
.logo { font-size: 28px; font-weight: 600; letter-spacing: -0.5px; }
|
| 22 |
+
.tagline { color: var(--muted); font-size: 14px; margin-top: 6px; }
|
| 23 |
+
|
| 24 |
+
.search-area { display: flex; gap: 10px; flex-wrap: wrap; }
|
| 25 |
+
|
| 26 |
+
.search-bar { display: flex; flex: 1; gap: 8px; min-width: 300px; }
|
| 27 |
+
.search-input {
|
| 28 |
+
flex: 1; background: var(--surface); border: 1px solid var(--border);
|
| 29 |
+
color: var(--text); border-radius: var(--radius); padding: 10px 14px;
|
| 30 |
+
font-size: 15px; outline: none; transition: border-color .15s;
|
| 31 |
+
}
|
| 32 |
+
.search-input:focus { border-color: var(--accent); }
|
| 33 |
+
.search-input::placeholder { color: var(--muted); }
|
| 34 |
+
|
| 35 |
+
button { cursor: pointer; border: none; border-radius: var(--radius); font-size: 14px; font-weight: 500; transition: all .15s; }
|
| 36 |
+
button:disabled { opacity: .4; cursor: not-allowed; }
|
| 37 |
+
|
| 38 |
+
.search-btn { background: var(--accent); color: #fff; padding: 10px 20px; }
|
| 39 |
+
.search-btn:hover:not(:disabled) { background: var(--accent-hover); }
|
| 40 |
+
|
| 41 |
+
.upload-btn { background: var(--surface); color: var(--text); border: 1px solid var(--border); padding: 10px 16px; }
|
| 42 |
+
.upload-btn:hover:not(:disabled) { border-color: var(--accent); }
|
| 43 |
+
|
| 44 |
+
.voice-btn {
|
| 45 |
+
background: var(--surface); color: var(--text); border: 1px solid var(--border);
|
| 46 |
+
padding: 10px 18px; white-space: nowrap;
|
| 47 |
+
}
|
| 48 |
+
.voice-btn:hover:not(:disabled) { border-color: var(--accent); }
|
| 49 |
+
.voice-btn.recording { background: #450a0a; border-color: var(--danger); color: var(--danger); animation: pulse 1s ease-in-out infinite; }
|
| 50 |
+
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .6 } }
|
| 51 |
+
|
| 52 |
+
.transcription { margin-top: 12px; font-size: 14px; color: var(--muted); }
|
| 53 |
+
.transcription-label { font-weight: 500; color: var(--text); }
|
| 54 |
+
|
| 55 |
+
.stats-bar { display: flex; gap: 20px; margin-top: 16px; padding: 10px 14px; background: var(--surface); border-radius: var(--radius); border: 1px solid var(--border); flex-wrap: wrap; }
|
| 56 |
+
.stat { display: flex; flex-direction: column; gap: 2px; }
|
| 57 |
+
.stat-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
| 58 |
+
.stat-value { font-size: 14px; font-weight: 500; }
|
| 59 |
+
|
| 60 |
+
.error { margin-top: 16px; padding: 12px 16px; background: #450a0a; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); font-size: 14px; }
|
| 61 |
+
|
| 62 |
+
.loading { display: flex; align-items: center; gap: 12px; margin-top: 40px; color: var(--muted); font-size: 15px; }
|
| 63 |
+
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; }
|
| 64 |
+
@keyframes spin { to { transform: rotate(360deg) } }
|
| 65 |
+
|
| 66 |
+
.empty { text-align: center; color: var(--muted); margin-top: 60px; font-size: 15px; }
|
| 67 |
+
|
| 68 |
+
.hero-hint { text-align: center; margin-top: 60px; }
|
| 69 |
+
.hint-grid { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 16px; }
|
| 70 |
+
.hint-chip { background: var(--surface); color: var(--muted); border: 1px solid var(--border); padding: 8px 16px; border-radius: 20px; font-size: 13px; }
|
| 71 |
+
.hint-chip:hover { border-color: var(--accent); color: var(--text); }
|
| 72 |
+
|
| 73 |
+
.result-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; margin-top: 24px; }
|
| 74 |
+
|
| 75 |
+
.result-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; transition: border-color .15s, transform .15s; }
|
| 76 |
+
.result-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
| 77 |
+
|
| 78 |
+
.result-img-wrap { aspect-ratio: 4/3; overflow: hidden; background: #111; }
|
| 79 |
+
.result-img { width: 100%; height: 100%; object-fit: cover; }
|
| 80 |
+
|
| 81 |
+
.result-meta { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px; }
|
| 82 |
+
.result-category { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 70%; }
|
| 83 |
+
.result-score { font-size: 12px; font-weight: 600; color: var(--accent); }
|
| 84 |
+
|
| 85 |
+
.result-actions { display: flex; gap: 6px; padding: 0 10px 10px; }
|
| 86 |
+
.vote-btn { flex: 1; background: transparent; border: 1px solid var(--border); color: var(--muted); padding: 4px 8px; font-size: 16px; border-radius: 6px; }
|
| 87 |
+
.vote-btn:hover { border-color: var(--text); color: var(--text); }
|
| 88 |
+
.vote-btn.voted-up { background: #14532d; border-color: var(--success); color: var(--success); }
|
| 89 |
+
.vote-btn.voted-down { background: #450a0a; border-color: var(--danger); color: var(--danger); }
|
| 90 |
+
|
| 91 |
+
@media (max-width: 600px) {
|
| 92 |
+
.search-area { flex-direction: column; }
|
| 93 |
+
.result-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
| 94 |
+
}
|
services/frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react"
|
| 2 |
+
import ReactDOM from "react-dom/client"
|
| 3 |
+
import App from "./App"
|
| 4 |
+
import "./index.css"
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById("root")).render(
|
| 7 |
+
<React.StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</React.StrictMode>
|
| 10 |
+
)
|
services/frontend/vite.config.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite"
|
| 2 |
+
import react from "@vitejs/plugin-react"
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
server: {
|
| 7 |
+
port: 3000,
|
| 8 |
+
proxy: {
|
| 9 |
+
// In dev mode, proxy /search/* and /images/* to the API
|
| 10 |
+
// This avoids CORS issues when running outside Docker
|
| 11 |
+
"/search": "http://localhost:8000",
|
| 12 |
+
"/feedback": "http://localhost:8000",
|
| 13 |
+
"/images": "http://localhost:8000",
|
| 14 |
+
"/health": "http://localhost:8000",
|
| 15 |
+
"/stats": "http://localhost:8000",
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
build: {
|
| 19 |
+
outDir: "dist",
|
| 20 |
+
},
|
| 21 |
+
})
|
start.sh
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
echo "=========================================="
|
| 5 |
+
echo " Visual Search β HuggingFace Space"
|
| 6 |
+
echo "=========================================="
|
| 7 |
+
|
| 8 |
+
HF_USER="darshvit20"
|
| 9 |
+
MODEL_REPO="${HF_USER}/visual-search-clip"
|
| 10 |
+
DATASET_REPO="${HF_USER}/visual-search-dataset"
|
| 11 |
+
|
| 12 |
+
# ββ Download ONNX models if not already present ββββββββββββββββββββββββββββ
|
| 13 |
+
if [ ! -f "/app/models/clip_vision_int8.onnx" ]; then
|
| 14 |
+
echo "[1/3] Downloading ONNX models from HuggingFace..."
|
| 15 |
+
python3 -c "
|
| 16 |
+
from huggingface_hub import hf_hub_download, list_repo_files
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
repo = '${MODEL_REPO}'
|
| 20 |
+
dest = '/app/models'
|
| 21 |
+
os.makedirs(dest, exist_ok=True)
|
| 22 |
+
|
| 23 |
+
for f in list_repo_files(repo, repo_type='model'):
|
| 24 |
+
print(f' Downloading {f}...')
|
| 25 |
+
hf_hub_download(repo_id=repo, filename=f, repo_type='model', local_dir=dest)
|
| 26 |
+
|
| 27 |
+
print(' Models ready.')
|
| 28 |
+
"
|
| 29 |
+
else
|
| 30 |
+
echo "[1/3] Models already present, skipping download."
|
| 31 |
+
fi
|
| 32 |
+
|
| 33 |
+
# ββ Download embeddings if not already present βββββββββββββββββββββββββββββ
|
| 34 |
+
if [ ! -f "/app/embeddings/faiss.index" ]; then
|
| 35 |
+
echo "[2/3] Downloading embeddings from HuggingFace..."
|
| 36 |
+
python3 -c "
|
| 37 |
+
from huggingface_hub import hf_hub_download, list_repo_files
|
| 38 |
+
import os
|
| 39 |
+
|
| 40 |
+
repo = '${DATASET_REPO}'
|
| 41 |
+
dest = '/app/embeddings'
|
| 42 |
+
os.makedirs(dest, exist_ok=True)
|
| 43 |
+
|
| 44 |
+
for f in list_repo_files(repo, repo_type='dataset'):
|
| 45 |
+
if f.startswith('embeddings/') or f in ['faiss.index', 'metadata.pkl']:
|
| 46 |
+
print(f' Downloading {f}...')
|
| 47 |
+
hf_hub_download(repo_id=repo, filename=f, repo_type='dataset', local_dir=dest)
|
| 48 |
+
|
| 49 |
+
print(' Embeddings ready.')
|
| 50 |
+
"
|
| 51 |
+
else
|
| 52 |
+
echo "[2/3] Embeddings already present, skipping download."
|
| 53 |
+
fi
|
| 54 |
+
|
| 55 |
+
# ββ Wait for encoder to be healthy before starting API βββββββββββββββββββββ
|
| 56 |
+
echo "[3/3] Starting services..."
|
| 57 |
+
|
| 58 |
+
# Start supervisord in background temporarily to boot encoder first
|
| 59 |
+
supervisord -c /etc/supervisor/conf.d/supervisord.conf &
|
| 60 |
+
SUPER_PID=$!
|
| 61 |
+
|
| 62 |
+
# Wait for encoder to be ready (max 60s)
|
| 63 |
+
echo " Waiting for encoder to load ONNX model..."
|
| 64 |
+
for i in $(seq 1 30); do
|
| 65 |
+
if python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')" 2>/dev/null; then
|
| 66 |
+
echo " Encoder ready!"
|
| 67 |
+
break
|
| 68 |
+
fi
|
| 69 |
+
sleep 2
|
| 70 |
+
done
|
| 71 |
+
|
| 72 |
+
# Hand off to supervisord (foreground)
|
| 73 |
+
wait $SUPER_PID
|
supervisord.conf
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[supervisord]
|
| 2 |
+
nodaemon=true
|
| 3 |
+
logfile=/var/log/supervisor/supervisord.log
|
| 4 |
+
pidfile=/var/run/supervisord.pid
|
| 5 |
+
user=root
|
| 6 |
+
|
| 7 |
+
[program:encoder]
|
| 8 |
+
command=python -m uvicorn main:app --host 0.0.0.0 --port 8001 --workers 1
|
| 9 |
+
directory=/app/encoder
|
| 10 |
+
environment=MODELS_DIR="/app/models",OMP_NUM_THREADS="2"
|
| 11 |
+
autostart=true
|
| 12 |
+
autorestart=true
|
| 13 |
+
startretries=3
|
| 14 |
+
stdout_logfile=/var/log/supervisor/encoder.log
|
| 15 |
+
stderr_logfile=/var/log/supervisor/encoder.log
|
| 16 |
+
priority=1
|
| 17 |
+
|
| 18 |
+
[program:api]
|
| 19 |
+
command=python -m uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1
|
| 20 |
+
directory=/app/api
|
| 21 |
+
environment=ENCODER_URL="http://127.0.0.1:8001",EMBEDDINGS_DIR="/app/embeddings",IMAGES_DIR="/app/images",DB_PATH="/app/data/search.db",FAISS_NPROBE="10"
|
| 22 |
+
autostart=true
|
| 23 |
+
autorestart=true
|
| 24 |
+
startretries=3
|
| 25 |
+
stdout_logfile=/var/log/supervisor/api.log
|
| 26 |
+
stderr_logfile=/var/log/supervisor/api.log
|
| 27 |
+
; Wait for encoder to be ready before starting API
|
| 28 |
+
; supervisord doesn't have depends_on, so we handle this in start.sh
|
| 29 |
+
priority=2
|
| 30 |
+
|
| 31 |
+
[program:nginx]
|
| 32 |
+
command=nginx -g "daemon off;"
|
| 33 |
+
autostart=true
|
| 34 |
+
autorestart=true
|
| 35 |
+
stdout_logfile=/var/log/supervisor/nginx.log
|
| 36 |
+
stderr_logfile=/var/log/supervisor/nginx.log
|
| 37 |
+
priority=3
|