darshvit20 commited on
Commit
b2f9b47
Β·
1 Parent(s): 97559c7

Initial deploy

Browse files
.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