diff --git a/.env.example b/.env.example index bd8ed40..616e3f7 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,8 @@ # ----------------------------------------------------------------- # 1. Core Application & Database # ----------------------------------------------------------------- -# Port on the host machine to expose the application (default: 3000) -PORT=3000 +# Port on the host machine to expose the application (default: 5050) +PORT=5050 # MongoDB Connection String (pre-configured with replica set for transactions) DATABASE_URL="mongodb://mongo:27017/instagram?replicaSet=rs0&directConnection=true" @@ -28,7 +28,30 @@ CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= # ----------------------------------------------------------------- -# 3. Logging & Telemetry (Optional) +# 3. AI Vector Search - Qdrant (Optional, Beta - OFF by default) +# ----------------------------------------------------------------- +# Leave these commented out and search stays disabled: the app shows a +# "not enabled" notice on the Search page and never downloads any models. +# +# To turn it on you need a running Qdrant service. With docker compose: +# docker compose -f docker-compose.yml -f docker-compose.search.yml up -d +# then uncomment QDRANT_URL below. +# +# Setting QDRANT_URL is the switch — nothing else enables or disables search. +# See docs/features/ai-vector-search.md +# +# QDRANT_URL="http://qdrant:6333" +# +# Host port for the Qdrant API & Dashboard (default: 6335, loopback only). +# Dashboard UI: http://localhost:6335/dashboard +# QDRANT_PORT=6335 +# +# Required if you enable Qdrant's own auth. The app refuses to send this over +# plaintext HTTP to a non-local host. +# QDRANT_API_KEY= + +# ----------------------------------------------------------------- +# 4. Logging & Telemetry (Optional) # ----------------------------------------------------------------- # Log level: fatal | error | warn | info | debug | trace (default: info) LOG_LEVEL=info diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6d4434..0efa0fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,24 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Node.js 20 + - name: Set up Node.js 24 uses: actions/setup-node@v7 with: - node-version: 20 - cache: npm + node-version: 24 + + # node_modules is ~1.7 GB across 59k files, dominated by onnxruntime + # (342 MB) and tfjs (277 MB). Caching the tree itself skips npm ci + # outright; setup-node's `cache: npm` only skipped the download, leaving + # the extraction — the actual cost — to run on every single job. + - name: Restore node_modules + id: node-modules + uses: actions/cache@v4 + with: + path: node_modules + key: node-modules-${{ runner.os }}-node24-${{ hashFiles('package-lock.json') }} - name: Install dependencies + if: steps.node-modules.outputs.cache-hit != 'true' run: npm ci --ignore-scripts - name: Generate Prisma Client diff --git a/.github/workflows/pr-beta.yml b/.github/workflows/pr-beta.yml index b4a11e5..a6da63f 100644 --- a/.github/workflows/pr-beta.yml +++ b/.github/workflows/pr-beta.yml @@ -23,11 +23,12 @@ jobs: with: fetch-depth: 0 - - name: Set up Node.js 20 + - name: Set up Node.js 24 uses: actions/setup-node@v7 with: - node-version: 20 - cache: npm + node-version: 24 + # No npm install in this workflow — only the version scripts run here, + # and they use node builtins. - name: Calculate Beta Version id: semver @@ -36,16 +37,9 @@ jobs: GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} run: node .github/scripts/calculate-version.mjs --mode=beta - - name: Install dependencies - run: npm ci --ignore-scripts - - - name: Generate Prisma Client - run: npx prisma generate - - - name: Verify Lint & TypeScript - run: | - npm run lint - npx tsc --noEmit + # Lint, typecheck and build verification run in ci.yml on this same + # pull_request trigger — repeating them here meant a third full npm ci + # (~1.7 GB) per PR for no extra signal. - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -71,8 +65,13 @@ jobs: ${{ env.REGISTRY }}/${{ steps.semver.outputs.image_name }}:${{ steps.semver.outputs.tag }} build-args: | APP_VERSION=${{ steps.semver.outputs.version }} - cache-from: type=gha - cache-to: type=gha,mode=max + # Separate scopes: sharing the default one meant beta and release + # exports kept overwriting each other's manifest. Beta also reads the + # release scope so a PR starts warm from the last master build. + cache-from: | + type=gha,scope=beta + type=gha,scope=release + cache-to: type=gha,mode=max,scope=beta - name: Beta Release Summary run: | @@ -84,7 +83,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY echo "# Pull and run this beta container locally:" >> $GITHUB_STEP_SUMMARY - echo "docker run -d -p 3000:3000 --name test-beta \\" >> $GITHUB_STEP_SUMMARY + echo "docker run -d -p 5050:3000 --name test-beta \\" >> $GITHUB_STEP_SUMMARY echo " -e DATABASE_URL=\"mongodb://...\" \\" >> $GITHUB_STEP_SUMMARY echo " ${{ env.REGISTRY }}/${{ steps.semver.outputs.image_name }}:beta-pr-${{ github.event.pull_request.number }}" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a515cc0..846c536 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,11 +49,12 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Node.js 20 + - name: Set up Node.js 24 uses: actions/setup-node@v7 with: - node-version: 20 - cache: npm + node-version: 24 + # No npm install in this workflow — only the version scripts run here, + # and they use node builtins. - name: Calculate Release Version id: semver @@ -65,19 +66,9 @@ jobs: IS_TAG_PUSH: ${{ startsWith(github.ref, 'refs/tags/v') }} run: node .github/scripts/calculate-version.mjs --mode=release - - name: Install dependencies - if: steps.semver.outputs.should_release == 'true' - run: npm ci --ignore-scripts - - - name: Generate Prisma Client - if: steps.semver.outputs.should_release == 'true' - run: npx prisma generate - - - name: Verify Lint & TypeScript - if: steps.semver.outputs.should_release == 'true' - run: | - npm run lint - npx tsc --noEmit + # Lint and typecheck already ran in ci.yml for this commit, and the Docker + # build below runs `npm run build` — a broken tree cannot publish an image. + # Installing here just to re-run them cost a full ~1.7 GB npm ci. - name: Update package.json Version if: steps.semver.outputs.should_release == 'true' && steps.semver.outputs.is_tag_push == 'false' @@ -122,8 +113,8 @@ jobs: ${{ env.REGISTRY }}/${{ steps.semver.outputs.image_name }}:${{ github.sha }} build-args: | APP_VERSION=${{ steps.semver.outputs.version }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=release + cache-to: type=gha,mode=max,scope=release - name: Create GitHub Release with Auto-Changelog if: steps.semver.outputs.should_release == 'true' && steps.semver.outputs.dry_run == 'false' diff --git a/Dockerfile b/Dockerfile index 9063ee3..ab9ac31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # ── Builder ─────────────────────────────────────────────────── -FROM node:20-bookworm-slim AS builder +FROM node:24-bookworm-slim AS builder WORKDIR /app # OpenSSL is required by the Prisma query engine @@ -12,9 +12,19 @@ COPY package.json package-lock.json ./ # --ignore-scripts skips native builds and the postinstall prisma generate (run explicitly below). RUN npm ci --ignore-scripts +# onnxruntime-node ships prebuilt binaries for win32/darwin/linux in one tarball. +# Only linux is reachable from this image, and the other two (~159 MB) otherwise +# ride through npm prune, the runner COPY, and the registry layer cache. +RUN rm -rf node_modules/onnxruntime-node/bin/napi-v*/win32 node_modules/onnxruntime-node/bin/napi-v*/darwin + COPY prisma ./prisma RUN npx prisma generate +# CLIP and face-recognition weights are deliberately NOT baked in. Vector search +# is an optional beta add-on, so shipping ~600 MB of weights to every install +# would tax the majority that never enables it. They are downloaded on the first +# index run instead, into the model_cache volume from docker-compose.search.yml. + COPY . . ARG APP_VERSION=1.0.1 @@ -25,7 +35,7 @@ RUN npm run build RUN npm prune --omit=dev # ── Runner ──────────────────────────────────────────────────── -FROM node:20-bookworm-slim AS runner +FROM node:24-bookworm-slim AS runner WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates curl \ @@ -51,6 +61,13 @@ COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma +# Transformers.js writes downloaded weights here. The path must exist and be +# owned by nextjs in the image: Docker seeds a fresh named volume from the image +# directory, so without this the search add-on's model_cache volume would come +# up root-owned and the non-root app could not write to it. +RUN mkdir -p /app/node_modules/@huggingface/transformers/.cache \ + && chown -R nextjs:nodejs /app/node_modules/@huggingface/transformers/.cache + USER nextjs EXPOSE 3000 diff --git a/README.md b/README.md index e7bc19e..1514e82 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ services: pull_policy: always restart: unless-stopped ports: - - "3000:3000" + - "5050:3000" environment: - DATABASE_URL=mongodb://mongo:27017/instagram?replicaSet=rs0&directConnection=true depends_on: @@ -90,7 +90,18 @@ volumes: docker compose up -d ``` -🎉 Open **`http://localhost:3000`** in your browser and complete the 60-second onboarding wizard! +🎉 Open **`http://localhost:5050`** in your browser and complete the 60-second onboarding wizard! + +> **Optional: AI Vector Search (Beta).** Semantic prompt, image and face search is +> **not enabled by default** — it adds a Qdrant service and downloads ~600 MB of +> model weights on first index. Everything else works fine without it. To opt in, +> grab [`docker-compose.search.yml`](docker-compose.search.yml) and run: +> +> ```bash +> docker compose -f docker-compose.yml -f docker-compose.search.yml up -d +> ``` +> +> Full setup and trade-offs: [AI Vector Search](docs/features/ai-vector-search.md). --- @@ -129,6 +140,7 @@ This project follows an automated semantic CI/CD versioning lifecycle directly c | Feature | Description | | :--- | :--- | +| 🔍 **Multimodal Vector Search** *(Beta, optional)* | Natural-language prompt search (CLIP), visual similarity image search, and facial recognition search with Qdrant. **Off by default** — [how to enable](docs/features/ai-vector-search.md). | | 🧙‍♂️ **Interactive Onboarding** | Built-in setup wizard with real-time Instagram cookie testing and avatar preview. | | 👥 **Multi-Profile Support** | Manage multiple Instagram accounts with separate sessions and isolated bookmarks. | | 📈 **Account Timelines** | Automatically records username changes, bio updates, verification changes, and lost accounts. | @@ -161,7 +173,9 @@ When running via Docker Compose, **zero environment variables are required**. Op | Variable | Default | Description | | :--- | :--- | :--- | -| `PORT` | `3000` | Port exposed on host | +| `PORT` | `5050` | Port exposed on host for web app | +| `QDRANT_URL` | _(unset)_ | **Optional.** Setting this switches on the beta vector search. Unset = disabled | +| `QDRANT_PORT` | `6335` | Host port for the Qdrant API & Dashboard, only used with search enabled | | `DATABASE_URL` | `mongodb://mongo:27017/instagram?replicaSet=rs0&directConnection=true` | MongoDB connection string (replica set enabled) | | `CLOUDINARY_CLOUD_NAME` | `""` | Optional Cloudinary cloud name for permanent media | | `CLOUDINARY_API_KEY` | `""` | Optional Cloudinary API key | diff --git a/coolify-compose.yml b/coolify-compose.yml index b448d54..b1487bb 100644 --- a/coolify-compose.yml +++ b/coolify-compose.yml @@ -10,11 +10,15 @@ services: restart: unless-stopped expose: - "3000" + ports: + - "${PORT:-5050}:3000" environment: - DATABASE_URL=mongodb://mongo:27017/instagram?replicaSet=rs0&directConnection=true - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 + # AI Vector Search is an optional beta add-on, off by default. + # To enable it, see docs/features/ai-vector-search.md # Optional Cloudinary CDN Configuration - CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME:-} - CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY:-} diff --git a/docker-compose.search.yml b/docker-compose.search.yml new file mode 100644 index 0000000..1b0c5f0 --- /dev/null +++ b/docker-compose.search.yml @@ -0,0 +1,55 @@ +# Optional add-on: AI Vector Search (Beta) +# +# Search is NOT part of the default deployment. It adds a Qdrant service and +# makes the app download ~600 MB of CLIP and face-recognition model weights on +# the first index run, which most archives do not need. +# +# Enable it by layering this file over the base compose: +# docker compose -f docker-compose.yml -f docker-compose.search.yml up -d +# +# Disable it again by dropping the second -f and recreating: +# docker compose -f docker-compose.yml up -d --remove-orphans +# +# See docs/features/ai-vector-search.md + +services: + app: + environment: + # Presence of QDRANT_URL is what switches the feature on. + - QDRANT_URL=http://qdrant:6333 + - QDRANT_API_KEY=${QDRANT_API_KEY:-} + - QDRANT_PORT=${QDRANT_PORT:-6335} + volumes: + # Model weights are downloaded once and cached here, so restarts and + # image upgrades do not re-fetch them. + - model_cache:/app/node_modules/@huggingface/transformers/.cache + depends_on: + qdrant: + condition: service_healthy + + qdrant: + image: qdrant/qdrant:v1.13.4 + container_name: instagram_saved_posts_qdrant + restart: unless-stopped + ports: + # Bound to loopback: the API has no authentication unless you set + # QDRANT__SERVICE__API_KEY, so it must never be published on 0.0.0.0. + - "127.0.0.1:${QDRANT_PORT:-6335}:6333" + volumes: + - qdrant_data:/qdrant/storage + ulimits: + nofile: + soft: 65535 + hard: 65535 + healthcheck: + test: ["CMD-SHELL", "bash -c ': >/dev/tcp/127.0.0.1/6333' || exit 1"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 2s + +volumes: + qdrant_data: + name: instagram_saved_posts_qdrant_data + model_cache: + name: instagram_saved_posts_model_cache diff --git a/docker-compose.yml b/docker-compose.yml index 665c99f..74a2562 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,12 +10,14 @@ services: container_name: instagram_saved_posts_app restart: unless-stopped ports: - - "${PORT:-3000}:3000" + - "${PORT:-5050}:3000" environment: - DATABASE_URL=mongodb://mongo:27017/instagram?replicaSet=rs0&directConnection=true - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 + # AI Vector Search is an optional beta add-on and is off by default. + # To enable: docker compose -f docker-compose.yml -f docker-compose.search.yml up -d # Optional: Cloudinary for permanent media CDN - CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME:-} - CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY:-} diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md index 5cf1825..aac1f75 100644 --- a/docs/deployment/coolify.md +++ b/docs/deployment/coolify.md @@ -29,11 +29,15 @@ services: restart: unless-stopped expose: - "3000" + ports: + - "${PORT:-5050}:3000" environment: - DATABASE_URL=mongodb://mongo:27017/instagram?replicaSet=rs0&directConnection=true - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 + # AI Vector Search is an optional beta add-on, off by default. + # To enable it, see ../features/ai-vector-search.md - CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME:-} - CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY:-} - CLOUDINARY_API_SECRET=${CLOUDINARY_API_SECRET:-} @@ -73,4 +77,8 @@ volumes: 2. Set the destination port to `3000`. ### Step 4: Deploy +> Want semantic image and face search? It is an optional beta add-on that is off +> by default — see [AI Vector Search](../features/ai-vector-search.md) for the +> extra service and environment variable to add here. + Click **Deploy**. Coolify will orchestrate the containers, provision Traefik routing, issue Let's Encrypt certificates, and make your app accessible securely over HTTPS! diff --git a/docs/deployment/docker-compose.md b/docs/deployment/docker-compose.md index 87f32da..d04f087 100644 --- a/docs/deployment/docker-compose.md +++ b/docs/deployment/docker-compose.md @@ -39,14 +39,27 @@ curl -fsSL https://raw.githubusercontent.com/gitnasr/Instagram-Saved-Posts/maste docker compose up -d ``` +> **Optional: AI Vector Search (Beta).** Semantic image and face search is not +> part of the default stack — it needs an extra Qdrant service and downloads +> ~600 MB of model weights on first index. To include it, layer the add-on file: +> +> ```bash +> docker compose -f docker-compose.yml -f docker-compose.search.yml up -d +> ``` +> +> See [AI Vector Search](../features/ai-vector-search.md). + ### 4. Verify Running Containers ```bash docker compose ps ``` You should see: -- `instagram_saved_posts_app`: Next.js frontend and scraper engine (port 3000). +- `instagram_saved_posts_app`: Next.js frontend and scraper engine (port 5050, accessible at `http://localhost:5050`). - `instagram_saved_posts_mongo`: MongoDB database instance (healthy). +Plus, only if you launched with the search add-on: +- `instagram_saved_posts_qdrant`: Qdrant vector database (port 6335, Dashboard at `http://localhost:6335/dashboard`). + > [!NOTE] > The compose configuration uses `pull_policy: always` for the `app` service. This ensures `docker compose up -d` always pulls the latest image update from GitHub Container Registry (GHCR) when tracking `:latest` or `:beta`. @@ -61,6 +74,10 @@ To check volume details: docker volume inspect instagram_saved_posts_mongo_data ``` +With the search add-on enabled there are two more: `instagram_saved_posts_qdrant_data` +holds the vectors and `instagram_saved_posts_model_cache` holds the downloaded model +weights. Both are derived data — deleting them costs a reindex, not your archive. + --- ## 🔄 Automatic Updates with Watchtower diff --git a/docs/deployment/dokploy.md b/docs/deployment/dokploy.md index d2606cf..d5363c4 100644 --- a/docs/deployment/dokploy.md +++ b/docs/deployment/dokploy.md @@ -28,13 +28,14 @@ services: pull_policy: always restart: unless-stopped ports: - - "3000:3000" + - "5050:3000" environment: - DATABASE_URL=mongodb://mongo:27017/instagram?replicaSet=rs0&directConnection=true - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 - # Optional: Cloudinary credentials + # AI Vector Search is an optional beta add-on, off by default. + # To enable it, see ../features/ai-vector-search.md - CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME} - CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY} - CLOUDINARY_API_SECRET=${CLOUDINARY_API_SECRET} @@ -72,12 +73,16 @@ volumes: ### Step 3: Configure Domain & SSL 1. In Dokploy, open the **Domains** tab for the `app` service. 2. Add your custom domain (e.g. `instagram.yourdomain.com`). -3. Select Port `3000`. +3. Select Port `5050`. 4. Enable **HTTPS (Let's Encrypt)**. ### Step 4: Deploy Click **Deploy** at the top right. Dokploy will pull the container images, verify MongoDB health, and start the application automatically! +> Want semantic image and face search? It is an optional beta add-on that is off +> by default — see [AI Vector Search](../features/ai-vector-search.md) for the +> extra service and environment variable to add here. + --- ## 🔒 Optional: Dokploy Environment Variables diff --git a/docs/deployment/reverse-proxy-and-sso.md b/docs/deployment/reverse-proxy-and-sso.md index cc1d4ad..e83f8d1 100644 --- a/docs/deployment/reverse-proxy-and-sso.md +++ b/docs/deployment/reverse-proxy-and-sso.md @@ -53,7 +53,7 @@ server { ssl_certificate_key /path/to/privkey.pem; location / { - proxy_pass http://127.0.0.1:3000; + proxy_pass http://127.0.0.1:5050; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -72,5 +72,5 @@ server { If using Cloudflare Tunnels: 1. In Cloudflare Zero Trust -> **Access** -> **Tunnels**, create a tunnel. -2. Add a Public Hostname pointing to `http://localhost:3000` (or `http://app:3000` if on docker network). +2. Add a Public Hostname pointing to `http://localhost:5050` (or `http://app:3000` if on docker network). 3. Optional: Add Cloudflare Access Applications for email PIN or Google authentication. diff --git a/docs/features/ai-vector-search.md b/docs/features/ai-vector-search.md index 0765ec7..a635733 100644 --- a/docs/features/ai-vector-search.md +++ b/docs/features/ai-vector-search.md @@ -1,38 +1,145 @@ --- -title: "In-Browser & Qdrant AI Vector Search (Coming Soon)" -description: "Roadmap for in-browser Transformer.js, self-hosted Qdrant vector indexing, and facial recognition." +title: "AI Vector Search (Beta)" +description: "Optional CLIP-based semantic image search, facial recognition, and Qdrant vector indexing. Off by default." --- -# In-Browser & Qdrant AI Vector Search +# AI Vector Search (Beta) -> **Status: Coming Soon / In-Browser Active Development** -> -> We are actively developing client-side in-browser inference using Transformer.js (WebGPU) and optional self-hosted Qdrant vector backend integration. +> **This feature is optional and switched off by default.** +> A standard install has no Qdrant service and no `QDRANT_URL`, the Search page +> shows a "not enabled" notice, and no model weights are ever downloaded. -Saved Posts Tracker is designing privacy-first deep learning models for visual understanding and facial identification without requiring expensive third-party AI APIs. +Once enabled, Saved Posts Tracker indexes every saved post's image (and detected +faces) into [Qdrant](https://qdrant.tech) for semantic search — no third-party AI +APIs, self-hosted. -## 1. Multimodal CLIP Embeddings (In-Browser & Qdrant) +## Should you enable it? -- **Model**: HuggingFace CLIP (`Xenova/clip-vit-base-patch32`) executed locally in-browser via `@huggingface/transformers` or on backend. -- **Dimensionality**: 512 floating-point vectors. -- **Query Mechanism**: Both image pixels and text descriptions are projected into the same latent embedding space. -- **Vector Storage**: Client-side vector index with optional [Qdrant](https://qdrant.tech) backend for large-scale self-hosted instances. +It is worth it if you want to find posts by describing them, by uploading a +similar image, or by a person's face. It costs: -### Example Natural Language Queries: +| | | +|---|---| +| **Extra service** | One Qdrant container plus a storage volume | +| **First-run download** | ~600 MB of CLIP and face-recognition model weights | +| **Memory** | Roughly 1–2 GB more while indexing | +| **Indexing time** | Every saved image is embedded once; large archives take a while | + +If you only want the archive, the scraper and the dashboard, skip it. Everything +else works exactly the same without it. + +## Enabling it + +Search turns on when the app can see a `QDRANT_URL`. That is the only switch — +there is no separate feature flag. + +### Docker Compose + +A second compose file layers the Qdrant service and the env var onto the base +stack, so nothing needs editing: + +```bash +docker compose -f docker-compose.yml -f docker-compose.search.yml up -d +``` + +To turn it back off, drop the second file and recreate: + +```bash +docker compose -f docker-compose.yml up -d --remove-orphans +``` + +Your posts are untouched either way — vectors are derived data and are rebuilt +by reindexing. + +### Dokploy / Coolify + +Those templates are pasted as a single file, so add the pieces by hand. In the +`app` service `environment:` block: + +```yaml + - QDRANT_URL=http://qdrant:6333 +``` + +Give the app a volume for the downloaded model weights, so they survive +restarts and image upgrades: + +```yaml + volumes: + - model_cache:/app/node_modules/@huggingface/transformers/.cache +``` + +Add `qdrant` to the app's `depends_on:`: + +```yaml + depends_on: + mongo: + condition: service_healthy + qdrant: + condition: service_healthy +``` + +Then add the service and its volume: + +```yaml + qdrant: + image: qdrant/qdrant:v1.13.4 + restart: unless-stopped + ports: + # Loopback only — see the security note below. + - "127.0.0.1:6335:6333" + volumes: + - qdrant_data:/qdrant/storage + ulimits: + nofile: + soft: 65535 + hard: 65535 + healthcheck: + test: ["CMD-SHELL", "bash -c ': >/dev/tcp/127.0.0.1/6333' || exit 1"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 2s + +volumes: + qdrant_data: + model_cache: +``` + +### After enabling + +Open **Search** and press **Index Archive Now** (or `POST /api/search/reindex`). +The first run downloads the model weights, so it is slower than later ones. + +> **Security:** Qdrant ships with **no authentication**. Publishing its port on +> `0.0.0.0` exposes every vector and payload — and lets anyone delete your +> collections. Keep the `127.0.0.1:` prefix, or set +> `QDRANT__SERVICE__API_KEY` and put it behind your reverse proxy. The app +> refuses to send `QDRANT_API_KEY` over plaintext HTTP to a non-local host. + +## 1. Multimodal CLIP Embeddings + +- **Model**: HuggingFace CLIP (`Xenova/clip-vit-base-patch16`), run server-side via `@huggingface/transformers` at full precision. +- **Dimensionality**: 512-d vectors, cosine distance. +- **Query mechanism**: post images and text queries are both projected into the same latent space, so a plain-language query retrieves visually matching posts (`/search`, "text prompt" tab). Captions and creator usernames are also matched lexically and merged in via Reciprocal Rank Fusion. +- **Storage**: `post_images` Qdrant collection, one point per post thumbnail / carousel slide. + +### Example natural language queries: - `"Moody neon cyberpunk street"` - `"Warm wooden Scandinavian interior"` - `"Minimalist typography posters with Swiss grid"` -## 2. In-Browser Face Detection & Facial Descriptors +## 2. Face Detection & Facial Descriptors -- **Model**: `@vladmandic/face-api` running on `@tensorflow/tfjs` in-browser backend. -- **Dimensionality**: 128-dimensional facial descriptor vectors. -- **Clustering**: Automatically detects faces in saved images, extracts biometric embeddings, and allows filtering all posts containing matching individuals with zero biometric data leakage. +- **Model**: `@vladmandic/face-api` on `@tensorflow/tfjs`, run server-side during indexing. +- **Dimensionality**: 128-d facial descriptor vectors, Euclidean distance. +- **Storage**: `post_faces` Qdrant collection — lets you filter all posts containing a matching face. -## Reindexing Vectors CLI +## Reindexing Vectors -When vector indexing is activated, you will be able to run: +After changing the embedding model, or to index newly saved posts, run: ```bash -npm run reindex:vectors +npm run reindex:vectors -- --all ``` + +or trigger it per-profile from the UI, or via `POST /api/search/reindex`. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 9aa7088..a6e3418 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -57,4 +57,4 @@ npm install npm run dev ``` -Open [http://localhost:3000](http://localhost:3000) in your web browser. +Open [http://localhost:3000](http://localhost:3000) in your web browser (or [http://localhost:5050](http://localhost:5050) if running via Docker Compose). diff --git a/docs/index.md b/docs/index.md index f2d5b21..db70ada 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,7 +38,7 @@ Saved Posts Tracker addresses every one of these problems with a unified, self-h - **Resumable Checkpoint Scraper**: Uses `checkpointMaxId` to safely pause and resume scraping across rate limits and network interruptions without duplicates. - **Append-Only Event Timelines**: Detects and logs username renames (`from @old to @new`), account deletions (`lost`), recoveries, and privacy flips. - **Cloudinary CDN Mirroring**: Automatically syncs photos, multi-slide carousels, and video clips to permanent cloud storage. -- **In-Browser & Qdrant Vector AI (Coming Soon)**: 512-dimensional CLIP multimodal search and TensorFlow.js in-browser biometric face detection. +- **Qdrant Vector AI (Beta, optional)**: 512-dimensional CLIP multimodal search and face-descriptor matching. Off by default — see [AI Vector Search](features/ai-vector-search.md) to enable it. ## Next Steps @@ -59,7 +59,7 @@ Saved Posts Tracker addresses every one of these problems with a unified, self-h ### ✨ Features & Integrations - [Cloudinary Permanent CDN](features/cloudinary-cdn.md): Permanent media hosting to protect against expiring Instagram CDN links. -- [AI Vector Search & Face Recognition](features/ai-vector-search.md): Multimodal CLIP search and biometric face clustering. +- [AI Vector Search & Face Recognition](features/ai-vector-search.md) *(Beta, optional — off by default)*: Multimodal CLIP search and biometric face clustering. ### 🚢 Deployment Guides - [Docker Compose](deployment/docker-compose.md): Standalone server, home lab, and VPS deployment. diff --git a/dokploy-compose.yml b/dokploy-compose.yml index aba15f1..917d679 100644 --- a/dokploy-compose.yml +++ b/dokploy-compose.yml @@ -9,12 +9,14 @@ services: pull_policy: always restart: unless-stopped ports: - - "3000:3000" + - "5050:3000" environment: - DATABASE_URL=mongodb://mongo:27017/instagram?replicaSet=rs0&directConnection=true - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 + # AI Vector Search is an optional beta add-on, off by default. + # To enable it, see docs/features/ai-vector-search.md # Optional: Cloudinary credentials for permanent media CDN - CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME} - CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY} diff --git a/install.ps1 b/install.ps1 index 2c5d89a..f0e6603 100644 --- a/install.ps1 +++ b/install.ps1 @@ -54,15 +54,22 @@ Write-Host "==> Pulling container images and starting services..." -ForegroundCo docker compose pull docker compose up -d +# Resolve effective published ports +$resolvedAppPort = (docker compose port app 3000 2>$null) -replace '.*:' +$appPort = if ($resolvedAppPort) { $resolvedAppPort.Trim() } else { "5050" } +$resolvedQdrantPort = (docker compose port qdrant 6333 2>$null) -replace '.*:' +$qdrantPort = if ($resolvedQdrantPort) { $resolvedQdrantPort.Trim() } else { "6335" } + Write-Host "`n======================================================" -ForegroundColor Green Write-Host " 🎉 InstaSave Tracker is successfully installed!" -ForegroundColor Green Write-Host "======================================================`n" -ForegroundColor Green Write-Host "Access your instance at:" -Write-Host " 👉 Local: http://localhost:3000" -ForegroundColor Cyan +Write-Host " 👉 Web App: http://localhost:$appPort" -ForegroundColor Cyan +Write-Host " 👉 Qdrant Dashboard: http://localhost:$qdrantPort/dashboard" -ForegroundColor Cyan Write-Host "`nNext steps:" -Write-Host " 1. Open http://localhost:3000 in your browser." +Write-Host " 1. Open http://localhost:$appPort in your browser." Write-Host " 2. Complete the quick onboarding wizard." Write-Host " 3. Start archiving and exploring your saved posts!`n" diff --git a/install.sh b/install.sh index 97707f6..b8a9573 100644 --- a/install.sh +++ b/install.sh @@ -78,16 +78,20 @@ ${DOCKER_COMPOSE} up -d # Completion Banner HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost") -PORT="3000" +RESOLVED_APP_PORT=$(${DOCKER_COMPOSE} port app 3000 2>/dev/null | awk -F: '{print $NF}') +PORT="${RESOLVED_APP_PORT:-5050}" +RESOLVED_QDRANT_PORT=$(${DOCKER_COMPOSE} port qdrant 6333 2>/dev/null | awk -F: '{print $NF}') +QDRANT_PORT="${RESOLVED_QDRANT_PORT:-6335}" echo -e "\n${GREEN}${BOLD}======================================================${NC}" echo -e "${GREEN}${BOLD} 🎉 InstaSave Tracker is successfully installed!${NC}" echo -e "${GREEN}${BOLD}======================================================${NC}\n" echo -e "Access your instance at:" -echo -e " 👉 Local: ${CYAN}${BOLD}http://localhost:${PORT}${NC}" +echo -e " 👉 Web App: ${CYAN}${BOLD}http://localhost:${PORT}${NC}" if [ "$HOST_IP" != "localhost" ]; then -echo -e " 👉 Network: ${CYAN}${BOLD}http://${HOST_IP}:${PORT}${NC}" +echo -e " 👉 Network Web App: ${CYAN}${BOLD}http://${HOST_IP}:${PORT}${NC}" fi +echo -e " 👉 Qdrant Dashboard: ${CYAN}${BOLD}http://localhost:${QDRANT_PORT}/dashboard${NC}" echo -e "\nNext steps:" echo -e " 1. Open ${CYAN}http://localhost:${PORT}${NC} in your browser." echo -e " 2. Complete the quick 60-second onboarding wizard." diff --git a/knip.json b/knip.json index b3d9ed4..1cea984 100644 --- a/knip.json +++ b/knip.json @@ -3,5 +3,5 @@ "entry": ["src/app/**/*.{ts,tsx}", "src/instrumentation*.ts"], "project": ["src/**/*.{ts,tsx}"], "ignore": ["src/components/ui/**"], - "ignoreDependencies": ["tw-animate-css", "shadcn", "tailwindcss", "postcss"] + "ignoreDependencies": ["tw-animate-css", "shadcn", "tailwindcss", "postcss", "@tensorflow/tfjs-backend-wasm"] } diff --git a/next.config.ts b/next.config.ts index 4758186..51debb7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,12 @@ const nextConfig: NextConfig = { "@prisma/client", ".prisma/client", "pino", + "onnxruntime-node", + "@huggingface/transformers", + "sharp", + "@vladmandic/face-api", + "@tensorflow/tfjs", + "@tensorflow/tfjs-backend-wasm", ], }; diff --git a/package-lock.json b/package-lock.json index ecf1795..1e4e764 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,14 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@huggingface/transformers": "^4.2.0", "@prisma/client": "^6.19.3", + "@qdrant/js-client-rest": "^1.19.0", "@tanstack/react-query": "^5.102.8", "@tanstack/react-query-devtools": "^5.102.8", + "@tensorflow/tfjs": "^4.22.0", + "@tensorflow/tfjs-backend-wasm": "^4.22.0", + "@vladmandic/face-api": "^1.7.15", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "cloudinary": "^2.11.0", @@ -26,6 +31,7 @@ "react": "19.2.8", "react-dom": "19.2.8", "recharts": "^2.15.4", + "sharp": "^0.35.4", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0" }, @@ -40,6 +46,7 @@ "prisma": "^6.12.0", "shadcn": "^4.19.0", "tailwindcss": "^4", + "tsx": "^4.23.13", "tw-animate-css": "^1.4.0", "typescript": "^5" } @@ -744,261 +751,1291 @@ "tslib": "^2.4.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "*" + "node": ">=18" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", - "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.2", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "*" + "node": ">=18" } }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=18" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@floating-ui/core": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", - "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.12" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@floating-ui/dom": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", - "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", - "license": "MIT", + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", "dependencies": { - "@floating-ui/core": "^1.8.0", - "@floating-ui/utils": "^0.2.12" + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", + "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/tokenizers": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", + "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "license": "Apache-2.0" + }, + "node_modules/@huggingface/transformers": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", + "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", + "sharp": "^0.34.5" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", - "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", - "license": "MIT", + "node_modules/@huggingface/transformers/node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "@floating-ui/dom": "^1.8.0" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", - "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "license": "MIT" - }, - "node_modules/@hono/node-server": { - "version": "1.19.17", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", - "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependencies": { - "hono": "^4" + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/@humanfs/core": { @@ -1058,7 +2095,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -2739,6 +3775,89 @@ "@prisma/debug": "6.19.3" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@qdrant/js-client-rest": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@qdrant/js-client-rest/-/js-client-rest-1.19.0.tgz", + "integrity": "sha512-1+QLUHsWp+WV4PE35FLnH2ckxotWrQEqi/F3t4goF3cCThR0ZxLVtOC4OoOi/E1iyj/iIYBdbuACWMuQ15NAnA==", + "license": "Apache-2.0", + "dependencies": { + "@qdrant/openapi-typescript-fetch": "1.2.6", + "undici": "7.29.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "typescript": ">=4.7" + } + }, + "node_modules/@qdrant/openapi-typescript-fetch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@qdrant/openapi-typescript-fetch/-/openapi-typescript-fetch-1.2.6.tgz", + "integrity": "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", @@ -4684,6 +5803,163 @@ "react": "^18 || ^19" } }, + "node_modules/@tensorflow/tfjs": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-4.22.0.tgz", + "integrity": "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg==", + "license": "Apache-2.0", + "dependencies": { + "@tensorflow/tfjs-backend-cpu": "4.22.0", + "@tensorflow/tfjs-backend-webgl": "4.22.0", + "@tensorflow/tfjs-converter": "4.22.0", + "@tensorflow/tfjs-core": "4.22.0", + "@tensorflow/tfjs-data": "4.22.0", + "@tensorflow/tfjs-layers": "4.22.0", + "argparse": "^1.0.10", + "chalk": "^4.1.0", + "core-js": "3.29.1", + "regenerator-runtime": "^0.13.5", + "yargs": "^16.0.3" + }, + "bin": { + "tfjs-custom-module": "dist/tools/custom_module/cli.js" + } + }, + "node_modules/@tensorflow/tfjs-backend-cpu": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz", + "integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==", + "license": "Apache-2.0", + "dependencies": { + "@types/seedrandom": "^2.4.28", + "seedrandom": "^3.0.5" + }, + "engines": { + "yarn": ">= 1.3.2" + }, + "peerDependencies": { + "@tensorflow/tfjs-core": "4.22.0" + } + }, + "node_modules/@tensorflow/tfjs-backend-wasm": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-wasm/-/tfjs-backend-wasm-4.22.0.tgz", + "integrity": "sha512-/IYhReRIp4jg/wYW0OwbbJZG8ON87mbz0PgkiP3CdcACRSvUN0h8rvC0O3YcDtkTQtFWF/tcXq/KlVDyV49wmA==", + "license": "Apache-2.0", + "dependencies": { + "@tensorflow/tfjs-backend-cpu": "4.22.0", + "@types/emscripten": "~0.0.34" + }, + "peerDependencies": { + "@tensorflow/tfjs-core": "4.22.0" + } + }, + "node_modules/@tensorflow/tfjs-backend-webgl": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz", + "integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==", + "license": "Apache-2.0", + "dependencies": { + "@tensorflow/tfjs-backend-cpu": "4.22.0", + "@types/offscreencanvas": "~2019.3.0", + "@types/seedrandom": "^2.4.28", + "seedrandom": "^3.0.5" + }, + "engines": { + "yarn": ">= 1.3.2" + }, + "peerDependencies": { + "@tensorflow/tfjs-core": "4.22.0" + } + }, + "node_modules/@tensorflow/tfjs-converter": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz", + "integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@tensorflow/tfjs-core": "4.22.0" + } + }, + "node_modules/@tensorflow/tfjs-core": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz", + "integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==", + "license": "Apache-2.0", + "dependencies": { + "@types/long": "^4.0.1", + "@types/offscreencanvas": "~2019.7.0", + "@types/seedrandom": "^2.4.28", + "@webgpu/types": "0.1.38", + "long": "4.0.0", + "node-fetch": "~2.6.1", + "seedrandom": "^3.0.5" + }, + "engines": { + "yarn": ">= 1.3.2" + } + }, + "node_modules/@tensorflow/tfjs-core/node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@tensorflow/tfjs-data": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-data/-/tfjs-data-4.22.0.tgz", + "integrity": "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w==", + "license": "Apache-2.0", + "dependencies": { + "@types/node-fetch": "^2.1.2", + "node-fetch": "~2.6.1", + "string_decoder": "^1.3.0" + }, + "peerDependencies": { + "@tensorflow/tfjs-core": "4.22.0", + "seedrandom": "^3.0.5" + } + }, + "node_modules/@tensorflow/tfjs-layers": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-layers/-/tfjs-layers-4.22.0.tgz", + "integrity": "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA==", + "license": "Apache-2.0 AND MIT", + "peerDependencies": { + "@tensorflow/tfjs-core": "4.22.0" + } + }, + "node_modules/@tensorflow/tfjs/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@tensorflow/tfjs/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@tensorflow/tfjs/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -4800,6 +6076,12 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-0.0.34.tgz", + "integrity": "sha512-QSb9ojDincskc+uKMI0KXp8e1NALFINCrMlp8VGKGcTSxeEyRTTKyjWw75NYrCZHUsVEEEpr1tYHpbtaC++/sQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4821,16 +6103,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.4.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.3.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz", + "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -4851,6 +6154,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/seedrandom": { + "version": "2.4.34", + "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.34.tgz", + "integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -5285,6 +6594,21 @@ "win32" ] }, + "node_modules/@vladmandic/face-api": { + "version": "1.7.15", + "resolved": "https://registry.npmjs.org/@vladmandic/face-api/-/face-api-1.7.15.tgz", + "integrity": "sha512-WDMmK3CfNLo8jylWqMoQgf4nIst3M0fzx1dnac96wv/dvMTN4DxC/Pq1DGtduDk1lktCamQ3MIDXFnvrdHTXDw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz", + "integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==", + "license": "BSD-3-Clause" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5349,6 +6673,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -5425,7 +6758,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5805,6 +7137,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -6098,6 +7437,58 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cloudinary": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.11.0.tgz", @@ -6130,7 +7521,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6143,7 +7533,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -6233,6 +7622,17 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.29.1.tgz", + "integrity": "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -6603,7 +8003,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -6634,7 +8033,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -6685,12 +8083,17 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -7034,11 +8437,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7055,7 +8505,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -8225,6 +9674,12 @@ "node": ">=16" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", @@ -8336,6 +9791,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8403,6 +9873,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", @@ -8554,6 +10033,35 @@ "node": ">=10.13.0" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -8571,7 +10079,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -8603,6 +10110,12 @@ "dev": true, "license": "ISC" }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8620,7 +10133,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8630,7 +10142,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -9064,6 +10575,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -9550,6 +11070,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -10078,6 +11604,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -10119,6 +11651,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10419,6 +11963,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -10514,7 +12078,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10675,6 +12238,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.26.0-dev.20260416-b7804b056c", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", + "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", + "license": "MIT" + }, "node_modules/open": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", @@ -11044,6 +12656,12 @@ "pathe": "^2.0.3" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11213,6 +12831,35 @@ "react-is": "^16.13.1" } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -11650,6 +13297,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -11671,6 +13324,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -11753,6 +13415,23 @@ "node": ">=0.10.0" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -11845,6 +13524,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -11902,6 +13601,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -11912,6 +13617,12 @@ "semver": "bin/semver.js" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -11966,6 +13677,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -12150,7 +13876,6 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -12200,7 +13925,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -12419,6 +14143,12 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -12463,6 +14193,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -12698,7 +14437,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -12856,6 +14594,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -12912,6 +14656,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -12935,6 +14698,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -13077,7 +14852,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13120,7 +14894,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -13130,7 +14903,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -13347,6 +15119,22 @@ "node": "20 || >=22" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13462,6 +15250,64 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -13486,6 +15332,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -13509,6 +15364,74 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index b431da2..66bb367 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,22 @@ "lint": "eslint", "prisma:generate": "prisma generate", "postinstall": "prisma generate", - "sync:docs": "node scripts/sync-docs.mjs" + "sync:docs": "node scripts/sync-docs.mjs", + "reindex:vectors": "tsx --env-file-if-exists=.env scripts/reindex-vectors.ts", + "test:vectors": "tsx scripts/test-vector-search.ts" + }, + "engines": { + "node": ">=22" }, "dependencies": { + "@huggingface/transformers": "^4.2.0", "@prisma/client": "^6.19.3", + "@qdrant/js-client-rest": "^1.19.0", "@tanstack/react-query": "^5.102.8", "@tanstack/react-query-devtools": "^5.102.8", + "@tensorflow/tfjs": "^4.22.0", + "@tensorflow/tfjs-backend-wasm": "^4.22.0", + "@vladmandic/face-api": "^1.7.15", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "cloudinary": "^2.11.0", @@ -52,6 +62,7 @@ "react": "19.2.8", "react-dom": "19.2.8", "recharts": "^2.15.4", + "sharp": "^0.35.4", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0" }, @@ -66,6 +77,7 @@ "prisma": "^6.12.0", "shadcn": "^4.19.0", "tailwindcss": "^4", + "tsx": "^4.23.13", "tw-animate-css": "^1.4.0", "typescript": "^5" } diff --git a/scripts/reindex-vectors.ts b/scripts/reindex-vectors.ts new file mode 100644 index 0000000..699bb47 --- /dev/null +++ b/scripts/reindex-vectors.ts @@ -0,0 +1,38 @@ +// Env comes from `tsx --env-file-if-exists=.env` (see the reindex:vectors script). +import { PrismaClient } from "@prisma/client"; +import { runVectorIndex, getCurrentIndexState } from "../src/lib/vector/index-posts"; + +function getArg(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + return idx !== -1 ? process.argv[idx + 1] : undefined; +} + +async function main() { + const prisma = new PrismaClient(); + const profileArg = getArg("profile"); + const all = process.argv.includes("--all"); + + if (!profileArg && !all) { + console.error("Usage: npm run reindex:vectors -- --profile OR --all"); + process.exit(1); + } + + const profiles = all + ? await prisma.profile.findMany({ select: { id: true, name: true } }) + : [{ id: profileArg!, name: profileArg! }]; + + for (const profile of profiles) { + console.log(`\n[reindex-vectors] Indexing profile ${profile.name} (${profile.id})...`); + await runVectorIndex(profile.id); + const state = getCurrentIndexState(profile.id); + console.log("[reindex-vectors] Done:", state); + if (state?.status === "failed" || (state?.failedItems ?? 0) > 0) process.exitCode = 1; + } + + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error("[reindex-vectors] Failed:", err); + process.exit(1); +}); diff --git a/scripts/test-vector-search.ts b/scripts/test-vector-search.ts new file mode 100644 index 0000000..c098a9d --- /dev/null +++ b/scripts/test-vector-search.ts @@ -0,0 +1,71 @@ +/** + * Self-check for the pure vector-search logic. Run: npm run test:vectors + * Covers the parts that silently return wrong results if they break — + * point ids (re-index would duplicate instead of upsert), score calibration, + * and the higher-is-better normalisation shared by all three search modes. + */ +import assert from "assert"; +import { describeVectorParamsMismatch, pointId } from "../src/lib/vector/qdrant-client"; +import { bestHitPerPost, calibrate } from "../src/lib/vector/search-api"; + +// pointId must be a stable UUIDv5 — these vectors were verified against the +// `uuid` package's v5() before that dependency was dropped. +assert.equal(pointId("p", "1", "thumbnail", 0), "bdf94c6f-29ce-5542-870a-9eda13efc726"); +assert.equal(pointId("p", "1", "carousel", 3, 2), "37c303b0-0e07-5438-a850-27c6443f5fce"); +assert.equal(pointId("p", "1", "carousel", 3, 0), pointId("p", "1", "carousel", 3, 0)); +assert.notEqual(pointId("p", "1", "carousel", 3, 0), pointId("p", "1", "carousel", 3, 1)); +assert.notEqual(pointId("p", "1", "carousel", 3), pointId("p", "1", "carousel", 3, 3)); + +// calibrate clamps at both ends and is monotonically increasing in between. +assert.equal(calibrate(0.1, 0.2, 0.4, 0.45, 0.95), 0.45); +assert.equal(calibrate(0.9, 0.2, 0.4, 0.45, 0.95), 0.95); +assert.equal(calibrate(0.3, 0.2, 0.4, 0.45, 0.95), 0.7); +assert.ok(calibrate(0.35, 0.2, 0.4, 0.45, 0.95) > calibrate(0.25, 0.2, 0.4, 0.45, 0.95)); + +const hit = (postPk: string, score: number, carouselPosition?: number) => ({ + score, + payload: { postPk, carouselPosition, imageUrl: `${postPk}-${carouselPosition ?? 0}.jpg` }, +}); + +// Cosine (higher is better): keeps the best slide per post, drops sub-floor noise. +const cosine = bestHitPerPost( + [hit("a", 0.31, 0), hit("a", 0.45, 2), hit("b", 0.1, 0), hit("c", 0.28, 1)], + (s) => (s >= 0.26 ? s : null) +); +assert.deepEqual([...cosine.keys()].sort(), ["a", "c"]); +assert.equal(cosine.get("a")!.quality, 0.45); +assert.equal(cosine.get("a")!.carouselPosition, 2); +assert.equal(cosine.get("a")!.imageUrl, "a-2.jpg"); + +// Euclid (lower is better): the closest face must win, and anything past the +// identity threshold must be dropped — an inverted comparison here would rank +// strangers above the actual person. +const THRESHOLD = 0.62; +const faces = bestHitPerPost( + [hit("a", 0.55), hit("a", 0.2), hit("b", 0.71)], + (d) => (d <= THRESHOLD ? THRESHOLD - d : null) +); +assert.deepEqual([...faces.keys()], ["a"]); +assert.equal(Number(faces.get("a")!.quality.toFixed(2)), 0.42); // from distance 0.2, the closer match + +// Hits without a usable postPk payload are ignored rather than crashing. +assert.equal(bestHitPerPost([{ score: 0.9, payload: null }], (s) => s).size, 0); + +// A collection left over from a different embedding model must be reported, not +// silently written to — 768 is SigLIP2-base, 512 is the CLIP ViT-B/16 we use. +const imageSpec = { name: "post_images", size: 512, distance: "Cosine" as const }; +assert.equal(describeVectorParamsMismatch(imageSpec, { size: 512, distance: "Cosine" }), null); +assert.match( + describeVectorParamsMismatch(imageSpec, { size: 768, distance: "Cosine" }) ?? "", + /size 768 .*writes size 512/ +); +// Right dimension, wrong metric still scores everything wrong. +assert.match( + describeVectorParamsMismatch(imageSpec, { size: 512, distance: "Euclid" }) ?? "", + /delete the collection/ +); +// Named-vector or unreadable configs are a mismatch, not a pass. +assert.ok(describeVectorParamsMismatch(imageSpec, undefined)); +assert.ok(describeVectorParamsMismatch(imageSpec, {})); + +console.log("vector search self-check: all assertions passed"); diff --git a/src/app/(dashboard)/search/page.tsx b/src/app/(dashboard)/search/page.tsx new file mode 100644 index 0000000..0771173 --- /dev/null +++ b/src/app/(dashboard)/search/page.tsx @@ -0,0 +1,792 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Header } from "@/components/layout/header"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Progress } from "@/components/ui/progress"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { PostCard } from "@/components/posts/post-card"; +import { PostDetailDialog } from "@/components/posts/post-detail-dialog"; +import { + useSearchByText, + useSearchByImage, + useSearchByFace, + useVectorIndexStatus, + useReindexVectors, + type VectorSearchError, +} from "@/hooks/use-vector-search"; +import { + Search as SearchIcon, + ImageIcon, + ScanFace, + Upload, + Sparkles, + RefreshCw, + Database, + X, + AlertCircle, + ExternalLink, + BarChart3, + Clock, + Calendar, + Layers, + Activity, + CheckCircle2, +} from "lucide-react"; +import { VECTOR_SEARCH_DOCS_URL } from "@/lib/constants"; +import type { Post, VectorSearchHit } from "@/types"; + +type SearchMode = "text" | "image" | "face"; + +const EXAMPLE_PROMPTS = [ + "Minimalist architecture", + "Golden hour sunset", + "Vintage car aesthetics", + "Coffee and workspace", + "Dark moody portrait", + "Tokyo street photography", +]; + +export default function SearchPage() { + const [mode, setMode] = useState("text"); + const modeRef = useRef(mode); + const searchTokenRef = useRef(0); + + useEffect(() => { + modeRef.current = mode; + }, [mode]); + + const [textQuery, setTextQuery] = useState(""); + const [previewUrl, setPreviewUrl] = useState(null); + + // Revoke object URLs on preview change and component unmount + useEffect(() => { + return () => { + if (previewUrl && previewUrl.startsWith("blob:")) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [previewUrl]); + const [results, setResults] = useState(null); + const [activeQueryLabel, setActiveQueryLabel] = useState(null); + const [selectedPost, setSelectedPost] = useState(null); + const [showIndexModal, setShowIndexModal] = useState(false); + const [showStatsModal, setShowStatsModal] = useState(false); + const [searchErrorMessage, setSearchErrorMessage] = useState(null); + const fileInputRef = useRef(null); + + const searchByText = useSearchByText(); + const searchByImage = useSearchByImage(); + const searchByFace = useSearchByFace(); + const { data: indexStatusData, isLoading: isLoadingStatus } = useVectorIndexStatus(); + const reindexMutation = useReindexVectors(); + + const isPending = + mode === "text" + ? searchByText.isPending + : mode === "image" + ? searchByImage.isPending + : searchByFace.isPending; + + const handleSearchError = (err: VectorSearchError | Error) => { + const isIndexNeeded = "needsIndexing" in err ? err.needsIndexing : false; + if (isIndexNeeded) { + setShowIndexModal(true); + setSearchErrorMessage("Vector index has not been built yet. Please run the indexer first."); + } else { + setSearchErrorMessage(err.message); + toast.error(err.message); + } + }; + + const handleModeChange = (newMode: SearchMode) => { + modeRef.current = newMode; + searchTokenRef.current += 1; + searchByText.reset(); + searchByImage.reset(); + searchByFace.reset(); + setMode(newMode); + setResults(null); + setPreviewUrl(null); + setActiveQueryLabel(null); + setSearchErrorMessage(null); + }; + + const triggerTextSearch = (query: string) => { + const trimmed = query.trim(); + if (!trimmed) return; + + const token = ++searchTokenRef.current; + const requestMode: SearchMode = "text"; + + setResults(null); + setSearchErrorMessage(null); + setActiveQueryLabel(`"${trimmed}"`); + + searchByText.mutate(trimmed, { + onSuccess: (hits) => { + if (token !== searchTokenRef.current || modeRef.current !== requestMode) return; + setResults(hits); + }, + onError: (err) => { + if (token !== searchTokenRef.current || modeRef.current !== requestMode) return; + handleSearchError(err); + }, + }); + }; + + const handleTextSubmit = (e?: React.FormEvent) => { + if (e) e.preventDefault(); + triggerTextSearch(textQuery); + }; + + const handleFile = (file: File) => { + const token = ++searchTokenRef.current; + const requestMode = modeRef.current; + const objectUrl = URL.createObjectURL(file); + + setPreviewUrl(objectUrl); + setResults(null); + setSearchErrorMessage(null); + setActiveQueryLabel(file.name); + + // Ignore a response whose search was superseded by a newer one or a mode switch. + const isStale = () => token !== searchTokenRef.current || modeRef.current !== requestMode; + const mutation = requestMode === "image" ? searchByImage : searchByFace; + mutation.mutate(file, { + onSuccess: (hits) => { + if (!isStale()) setResults(hits); + }, + onError: (err) => { + if (!isStale()) handleSearchError(err); + }, + }); + }; + + const handleReindex = () => { + setShowIndexModal(false); + reindexMutation.mutate(undefined, { + onSuccess: () => toast.success("Vector indexing initiated in background."), + onError: (err) => toast.error(err.message), + }); + }; + + const currentIndex = indexStatusData?.current; + const isIndexRunning = currentIndex?.status === "running"; + const indexProgressPct = + currentIndex && currentIndex.totalItems > 0 + ? Math.round((currentIndex.indexedItems / currentIndex.totalItems) * 100) + : 0; + + const liveness = indexStatusData?.liveness; + const stats = indexStatusData?.stats; + const dashboardUrl = liveness?.dashboardUrl || null; + // Search is opt-in: no QDRANT_URL means the feature was never switched on, + // which is a normal deployment rather than a fault. + const searchEnabled = indexStatusData?.configured ?? false; + const lastRunFailed = + searchEnabled && !isLoadingStatus && !isIndexRunning && stats?.status === "failed"; + const hasNeverIndexed = + searchEnabled && + !isLoadingStatus && + !isIndexRunning && + !lastRunFailed && + (!stats || stats.indexedItems === 0); + + return ( +
+
+
+ {/* Qdrant Liveness Badge */} + {liveness && ( +
+
+ + {liveness.status === "healthy" + ? `Qdrant (${liveness.latencyMs}ms)` + : liveness.status === "degraded" + ? "Qdrant (Empty)" + : "Qdrant Offline"} + +
+ )} + + {/* Statistics Button */} + + + {/* Qdrant Dashboard Link */} + {dashboardUrl && ( + + )} + + {/* Reindex Button */} + +
+
+ + {/* Feature switched off — nothing below this point can do anything useful. */} + {!isLoadingStatus && !searchEnabled && ( + +
+ +
+
+

Vector Search is not enabled

+

+ Search is an optional beta feature. It needs a Qdrant service and the{" "} + QDRANT_URL{" "} + environment variable — neither ships in the default deployment, because + the embedding models it downloads are a heavy addition most archives + do not need. +

+
+ +
+ )} + + {/* Failed Index Run Callout */} + {lastRunFailed && ( + +
+ +
+

Last index run failed

+

+ {stats?.lastError || "Unknown error"} +

+
+
+ +
+ )} + + {/* Unindexed Archive Warning Callout */} + {hasNeverIndexed && ( + +
+ +
+

Archive Vector Index Not Found

+

+ Your saved posts must be embedded into Qdrant before search queries can find matches. +

+
+
+ +
+ )} + + {/* Search Error Callout Banner */} + {searchErrorMessage && ( + +
+ + {searchErrorMessage} +
+ +
+ )} + + {/* Index progress banner */} + {isIndexRunning && currentIndex && ( + +
+ + + Indexing archive in progress... + + + {currentIndex.indexedItems} / {currentIndex.totalItems} items ({indexProgressPct}%) + +
+ +
+ Faces detected: {currentIndex.facesIndexed} + {currentIndex.failedItems > 0 && ( + Failures: {currentIndex.failedItems} + )} +
+
+ )} + + {/* Search Input Controls */} + {searchEnabled && ( + + handleModeChange(v as SearchMode)} + > + {/* Full labels overflow a phone viewport, so the qualifier drops below sm. */} + + + + Prompt Search + + + + Visual Similarity + + + + Face Recognition + + + + {/* Tab 1: Free-text prompt search */} + +
+
+ + setTextQuery(e.target.value)} + className="pl-9 pr-8 h-10 rounded-[6px] bg-surface-2 border-hairline text-sm" + /> + {textQuery && ( + + )} +
+ +
+ +
+ Suggestions: + {EXAMPLE_PROMPTS.map((prompt) => ( + + ))} +
+
+ + {/* Tab 2 & 3: Text-free search (image upload & face recognition) */} + {(mode === "image" || mode === "face") && ( + + { + const file = e.target.files?.[0]; + if (file) handleFile(file); + e.target.value = ""; + }} + /> + + + + )} +
+
+ )} + + {/* Loading Skeleton */} + {isPending && ( +
+
+
+ {Array.from({ length: 12 }).map((_, i) => ( + + ))} +
+
+ )} + + {/* Search Results */} + {!isPending && results && ( +
+
+

+ {results.length === 0 ? ( + No matching posts found {activeQueryLabel && `for ${activeQueryLabel}`}. + ) : ( + + Found {results.length} matching post + {results.length === 1 ? "" : "s"} {activeQueryLabel && `for ${activeQueryLabel}`}. + + )} +

+
+ + {results.length > 0 && ( +
+ {results.map((hit) => { + const pct = Math.round(hit.score * 100); + return ( +
+ setSelectedPost(hit.post)} + thumbnailOverride={hit.matchedImageUrl} + matchedSlideIndex={hit.matchedSlideIndex} + /> +
+ = 85 + ? "text-amber-400" + : pct >= 70 + ? "text-emerald-400" + : "text-zinc-300" + } + > + {pct}% + + {hit.matchType && ( + + {hit.matchType === "hybrid" + ? "Hybrid" + : hit.matchType === "caption" + ? "Caption" + : hit.matchType === "account" + ? "Author" + : hit.matchType === "face" + ? "Face" + : "Visual"} + + )} +
+
+ ); + })} +
+ )} +
+ )} + + {/* Vector Index Required Popup / Modal */} + + + +
+ + Vector Index Required +
+ + Your saved posts have not been indexed into the vector database yet. Vector search + requires generating CLIP and face embeddings for your saved posts before searches can be run. + +
+ +
+
+ Required Collections: + post_images, post_faces +
+
+ Current Status: + Not Indexed +
+
+ + + + + +
+
+ + {/* Vector Search Statistics Modal */} + + + +
+ + Vector Search Statistics +
+ + Telemetry, index coverage, and Qdrant cluster statistics across your profiles. + +
+ + {isLoadingStatus ? ( +
+ + + +
+ ) : ( +
+ {/* Profile Stats Card */} +
+
+ + + Active Profile Index + + + {stats?.status === "failed" ? ( + Failed + ) : stats?.status === "completed" ? ( + + Completed + + ) : stats?.status === "running" ? ( + Running + ) : ( + Not Indexed + )} + +
+ +
+
+ + Last Run: + + + {stats?.lastRunAt + ? new Date(stats.lastRunAt).toLocaleString() + : "Never"} + +
+ +
+ + Post Cutoff: + + + {stats?.cutoffPostDate + ? new Date(stats.cutoffPostDate).toLocaleDateString() + : "None"} + +
+ +
+ + Indexed Items: + + + {stats?.indexedItems ?? 0} / {stats?.totalItems ?? 0} + +
+ +
+ + Faces Found: + + {stats?.facesIndexed ?? 0} +
+
+ + {stats?.lastError || (stats?.failedItems ?? 0) > 0 ? ( +
+ {(stats?.failedItems ?? 0) > 0 && `Failed Items: ${stats?.failedItems} — `} + {stats?.lastError || "Unknown error"} +
+ ) : null} +
+ + {/* Cluster & Global Stats Card */} +
+
+ + + Qdrant Database & Cluster + + + {liveness?.status ?? "unknown"} + +
+ +
+
+ Image Vectors: +

+ {(liveness?.collections.post_images.pointsCount ?? 0).toLocaleString()} +

+
+
+ Face Vectors: +

+ {(liveness?.collections.post_faces.pointsCount ?? 0).toLocaleString()} +

+
+
+ Cluster Latency: +

{liveness?.latencyMs ?? 0} ms

+
+
+
+
+ )} + + + {dashboardUrl ? ( + + ) :
} + + + +
+ + { + if (!open) setSelectedPost(null); + }} + /> +
+ ); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index f7e2ea3..b65e52f 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,5 +1,10 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { + checkQdrantLiveness, + getQdrantConfig, + isQdrantConfigured, +} from "@/lib/vector/qdrant-client"; export const dynamic = "force-dynamic"; @@ -12,15 +17,31 @@ export async function GET() { mongoConnected = false; } - const status = mongoConnected ? "healthy" : "degraded"; + // Vector search is opt-in. With QDRANT_URL unset the feature is simply off, + // which is a valid deployment — not a degraded one. Reporting it as degraded + // would fail the container HEALTHCHECK for every install that skipped search. + const searchEnabled = isQdrantConfigured(getQdrantConfig()); + const qdrantLiveness = searchEnabled + ? await checkQdrantLiveness().catch(() => ({ + status: "disconnected" as const, + latencyMs: 0, + })) + : null; + + const isHealthy = + mongoConnected && (!searchEnabled || qdrantLiveness?.status === "healthy"); + const status = isHealthy ? "healthy" : mongoConnected ? "degraded" : "unhealthy"; return NextResponse.json( { status, mongo: mongoConnected ? "connected" : "disconnected", + vectorService: qdrantLiveness + ? { status: qdrantLiveness.status, latencyMs: qdrantLiveness.latencyMs } + : { status: "disabled" }, uptime: process.uptime(), timestamp: new Date().toISOString(), }, - { status: mongoConnected ? 200 : 503 } + { status: isHealthy ? 200 : 503 } ); } diff --git a/src/app/api/search/by-face/route.ts b/src/app/api/search/by-face/route.ts new file mode 100644 index 0000000..2162a41 --- /dev/null +++ b/src/app/api/search/by-face/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getActiveProfile, noActiveProfileResponse } from "@/lib/active-profile"; +import { detectFacesFromBuffer } from "@/lib/vector/face-embedding"; +import { COLLECTIONS, searchByVector } from "@/lib/vector/qdrant-client"; +import { + RESULT_LIMIT, + bestHitPerPost, + calibrate, + qdrantNotConfiguredResponse, + readUploadedImage, + searchErrorResponse, +} from "@/lib/vector/search-api"; +import type { VectorSearchHit } from "@/types"; + +// Standard FaceNet same-person identity boundary. +const FACE_DISTANCE_THRESHOLD = 0.62; +const MAX_SEARCH_FACES = 5; + +export async function POST(request: NextRequest) { + const profile = await getActiveProfile(); + if (!profile) return noActiveProfileResponse(); + + const notConfigured = qdrantNotConfiguredResponse(); + if (notConfigured) return notConfigured; + + const upload = await readUploadedImage(request); + if ("error" in upload) return upload.error; + + try { + const queryFaces = await detectFacesFromBuffer(upload.buffer); + if (queryFaces.length === 0) { + return NextResponse.json( + { error: "No face detected in the uploaded image.", results: [] }, + { status: 400 } + ); + } + if (queryFaces.length > MAX_SEARCH_FACES) { + return NextResponse.json( + { + error: `Too many faces detected (${queryFaces.length}). Please upload an image with at most ${MAX_SEARCH_FACES} faces.`, + results: [], + }, + { status: 400 } + ); + } + + // Every face in the query photo is searched; hits merge by post below, + // regardless of which query face matched. + const hitLists = await Promise.all( + queryFaces.map((descriptor) => + searchByVector(COLLECTIONS.POST_FACES, descriptor, profile.id, RESULT_LIMIT) + ) + ); + + // The faces collection uses Euclid, so Qdrant's score IS the distance — + // lower is better. Convert to closeness so higher is better everywhere else. + const best = bestHitPerPost(hitLists.flat(), (distance) => + distance <= FACE_DISTANCE_THRESHOLD ? FACE_DISTANCE_THRESHOLD - distance : null + ); + + const sorted = [...best.entries()] + .sort((a, b) => b[1].quality - a[1].quality) + .slice(0, RESULT_LIMIT); + + const posts = await prisma.post.findMany({ + where: { profileId: profile.id, pk: { in: sorted.map(([pk]) => pk) } }, + }); + const postByPk = new Map(posts.map((p) => [p.pk, p])); + + const results: VectorSearchHit[] = []; + for (const [pk, hit] of sorted) { + const post = postByPk.get(pk); + if (!post) continue; + results.push({ + post, + score: calibrate(hit.quality, 0, FACE_DISTANCE_THRESHOLD - 0.15, 0.5, 0.99), + matchType: "face", + matchedSlideIndex: hit.carouselPosition, + matchedImageUrl: hit.imageUrl, + }); + } + + return NextResponse.json({ results }); + } catch (err: unknown) { + return searchErrorResponse(err); + } +} diff --git a/src/app/api/search/by-image/route.ts b/src/app/api/search/by-image/route.ts new file mode 100644 index 0000000..af03a60 --- /dev/null +++ b/src/app/api/search/by-image/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getActiveProfile, noActiveProfileResponse } from "@/lib/active-profile"; +import { embedImageFromBuffer } from "@/lib/vector/image-embedding"; +import { COLLECTIONS, searchByVector } from "@/lib/vector/qdrant-client"; +import { + RESULT_LIMIT, + bestHitPerPost, + calibrate, + qdrantNotConfiguredResponse, + readUploadedImage, + searchErrorResponse, +} from "@/lib/vector/search-api"; +import type { VectorSearchHit } from "@/types"; + +const NOISE_FLOOR = 0.26; +// Drop anything far below the best match for this query — image-to-image +// similarity has a sharp elbow, and everything past it is unrelated. +const ELBOW_RATIO = 0.65; + +export async function POST(request: NextRequest) { + const profile = await getActiveProfile(); + if (!profile) return noActiveProfileResponse(); + + const notConfigured = qdrantNotConfiguredResponse(); + if (notConfigured) return notConfigured; + + const upload = await readUploadedImage(request); + if ("error" in upload) return upload.error; + + try { + const vector = await embedImageFromBuffer(upload.buffer); + const hits = await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); + + const sorted = [...bestHitPerPost(hits, (s) => (s >= NOISE_FLOOR ? s : null)).entries()].sort( + (a, b) => b[1].quality - a[1].quality + ); + const topScore = sorted[0]?.[1].quality ?? 0; + const kept = sorted.filter(([, h]) => h.quality >= topScore * ELBOW_RATIO); + + const posts = await prisma.post.findMany({ + where: { profileId: profile.id, pk: { in: kept.map(([pk]) => pk) } }, + }); + const postByPk = new Map(posts.map((p) => [p.pk, p])); + + const results: VectorSearchHit[] = []; + for (const [pk, hit] of kept) { + const post = postByPk.get(pk); + if (!post) continue; + results.push({ + post, + score: calibrate(hit.quality, NOISE_FLOOR, 0.81, 0.45, 0.99), + matchType: "visual", + matchedSlideIndex: hit.carouselPosition, + matchedImageUrl: hit.imageUrl, + }); + } + + return NextResponse.json({ results }); + } catch (err: unknown) { + return searchErrorResponse(err); + } +} diff --git a/src/app/api/search/by-text/route.ts b/src/app/api/search/by-text/route.ts new file mode 100644 index 0000000..a81eefa --- /dev/null +++ b/src/app/api/search/by-text/route.ts @@ -0,0 +1,214 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getActiveProfile, noActiveProfileResponse } from "@/lib/active-profile"; +import { embedText } from "@/lib/vector/text-embedding"; +import { COLLECTIONS, searchByVector } from "@/lib/vector/qdrant-client"; +import { + RESULT_LIMIT, + bestHitPerPost, + calibrate, + qdrantNotConfiguredResponse, + searchErrorResponse, +} from "@/lib/vector/search-api"; +import type { VectorSearchHit } from "@/types"; + +const MAX_QUERY_LENGTH = 1000; + +// Reciprocal Rank Fusion tuning. Text matches outweigh visual ones because an +// exact caption/author hit is a stronger signal than a CLIP similarity. +const RRF_K = 60; +const WEIGHT_VECTOR = 1.0; +const WEIGHT_TEXT = 1.3; + +// Below this raw cosine score a vector hit is noise. +const VECTOR_NOISE_FLOOR = 0.2; +// A visual-only candidate (no text match) must also clear an absolute floor and +// a fraction of this query's best visual score. +const VISUAL_ELBOW_FLOOR = 0.22; +const VISUAL_ELBOW_RATIO = 0.65; + +type MatchType = NonNullable; + +/** Lexical search over captions and creator accounts, best matches first. */ +async function textSearch(profileId: string, rawQuery: string) { + const terms = rawQuery + .split(/\s+/) + .map((t) => t.replace(/^[#@]/, "").trim()) + .filter((t) => t.length >= 2); + + const matchingAccounts = await prisma.account.findMany({ + where: { + profileId, + OR: [ + { username: { contains: rawQuery, mode: "insensitive" } }, + { fullName: { contains: rawQuery, mode: "insensitive" } }, + ], + }, + select: { pk: true }, + take: 30, + }); + const accountPks = matchingAccounts.map((a) => a.pk); + + const conditions: import("@prisma/client").Prisma.PostWhereInput[] = [ + { captionText: { contains: rawQuery, mode: "insensitive" } }, + ]; + if (accountPks.length > 0) conditions.push({ accountPk: { in: accountPks } }); + if (terms.length > 1) { + conditions.push({ + AND: terms.map((term) => ({ + captionText: { contains: term, mode: "insensitive" as const }, + })), + }); + } + + const posts = await prisma.post.findMany({ + where: { profileId, OR: conditions }, + select: { pk: true, captionText: true, accountPk: true }, + take: RESULT_LIMIT, + }); + + const lowerQuery = rawQuery.toLowerCase(); + return posts + .map((p) => { + const isAccount = accountPks.includes(p.accountPk); + // Whole-query caption hit beats an author hit, which beats a term-only hit. + const priority = p.captionText?.toLowerCase().includes(lowerQuery) ? 1 : isAccount ? 2 : 3; + return { pk: p.pk, priority, isAccount }; + }) + .sort((a, b) => a.priority - b.priority); +} + +export async function POST(request: NextRequest) { + const profile = await getActiveProfile(); + if (!profile) return noActiveProfileResponse(); + + const notConfigured = qdrantNotConfiguredResponse(); + if (notConfigured) return notConfigured; + + let query: unknown; + try { + query = (await request.json())?.query; + } catch { + return NextResponse.json( + { error: "Invalid JSON body. Expected { query: string }." }, + { status: 400 } + ); + } + + if (typeof query !== "string" || !query.trim()) { + return NextResponse.json({ error: "Query cannot be empty." }, { status: 400 }); + } + if (query.length > MAX_QUERY_LENGTH) { + return NextResponse.json( + { error: `Query exceeds maximum length of ${MAX_QUERY_LENGTH} characters.` }, + { status: 413 } + ); + } + + const rawQuery = query.trim(); + + try { + // Vector failure is tolerated as long as the lexical side found something. + const vectorPromise = embedText(rawQuery) + .then((v) => searchByVector(COLLECTIONS.POST_IMAGES, v, profile.id, RESULT_LIMIT)) + .catch((e: unknown) => e); + + const [vectorResult, textMatches] = await Promise.all([ + vectorPromise, + textSearch(profile.id, rawQuery), + ]); + + if (!Array.isArray(vectorResult) && textMatches.length === 0) { + throw vectorResult; + } + const vectorHits = Array.isArray(vectorResult) ? vectorResult : []; + + const bestVisual = bestHitPerPost(vectorHits, (s) => (s >= VECTOR_NOISE_FLOOR ? s : null)); + const rankedVisual = [...bestVisual.entries()].sort((a, b) => b[1].quality - a[1].quality); + const visualRank = new Map(rankedVisual.map(([pk], i) => [pk, i + 1])); + const topVisualScore = rankedVisual[0]?.[1].quality ?? 0; + + const textRank = new Map( + textMatches.map((m, i) => [ + m.pk, + { rank: i + 1, matchType: (m.isAccount ? "account" : "caption") as MatchType }, + ]) + ); + + interface Candidate { + pk: string; + rrfScore: number; + score: number; + matchType: MatchType; + matchedSlideIndex?: number; + matchedImageUrl?: string; + } + const candidates: Candidate[] = []; + + for (const pk of new Set([...visualRank.keys(), ...textRank.keys()])) { + const vRank = visualRank.get(pk); + const tInfo = textRank.get(pk); + const vMatch = bestVisual.get(pk); + + // Visual-only candidates below the elbow are noise, not results. + if (!tInfo && vMatch) { + if ( + vMatch.quality < VISUAL_ELBOW_FLOOR || + vMatch.quality < topVisualScore * VISUAL_ELBOW_RATIO + ) { + continue; + } + } + + const rrfScore = + (vRank !== undefined ? WEIGHT_VECTOR / (RRF_K + vRank) : 0) + + (tInfo !== undefined ? WEIGHT_TEXT / (RRF_K + tInfo.rank) : 0); + + let matchType: MatchType = "visual"; + let score = 0.5; + if (vRank !== undefined && tInfo !== undefined) { + // Matching on both signals is the strongest evidence we have. + matchType = "hybrid"; + score = calibrate(vMatch?.quality ?? 0.25, VECTOR_NOISE_FLOOR, 0.42, 0.88, 0.99); + } else if (tInfo !== undefined) { + matchType = tInfo.matchType; + score = tInfo.rank <= 3 ? 0.92 : 0.85; + } else if (vMatch !== undefined) { + score = calibrate(vMatch.quality, VECTOR_NOISE_FLOOR, 0.4, 0.45, 0.95); + } + + candidates.push({ + pk, + rrfScore, + score, + matchType, + matchedSlideIndex: vMatch?.carouselPosition, + matchedImageUrl: vMatch?.imageUrl, + }); + } + + const top = candidates.sort((a, b) => b.rrfScore - a.rrfScore).slice(0, RESULT_LIMIT); + + const posts = await prisma.post.findMany({ + where: { profileId: profile.id, pk: { in: top.map((c) => c.pk) } }, + }); + const postByPk = new Map(posts.map((p) => [p.pk, p])); + + const results: VectorSearchHit[] = []; + for (const c of top) { + const post = postByPk.get(c.pk); + if (!post) continue; + results.push({ + post, + score: c.score, + matchType: c.matchType, + matchedSlideIndex: c.matchedSlideIndex, + matchedImageUrl: c.matchedImageUrl, + }); + } + + return NextResponse.json({ results }); + } catch (err: unknown) { + return searchErrorResponse(err); + } +} diff --git a/src/app/api/search/reindex/route.ts b/src/app/api/search/reindex/route.ts new file mode 100644 index 0000000..fe97d99 --- /dev/null +++ b/src/app/api/search/reindex/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { runVectorIndex, getCurrentIndexState } from "@/lib/vector/index-posts"; +import { + getQdrantConfig, + isQdrantConfigured, + checkQdrantLiveness, +} from "@/lib/vector/qdrant-client"; +import { getProfileVectorStats } from "@/lib/vector/stats"; +import { getActiveProfile, noActiveProfileResponse } from "@/lib/active-profile"; + +export async function POST() { + const profile = await getActiveProfile(); + if (!profile) return noActiveProfileResponse(); + + if (!isQdrantConfigured(getQdrantConfig())) { + return NextResponse.json( + { error: "Vector search is not configured. Set QDRANT_URL env var." }, + { status: 400 } + ); + } + if (getCurrentIndexState(profile.id)?.status === "running") { + return NextResponse.json( + { error: "A vector index run is already in progress for this profile." }, + { status: 409 } + ); + } + + try { + // Fire and forget — index run happens in background + runVectorIndex(profile.id).catch((err) => { + // Error is captured in the profile's index state and persisted stats + console.error(`[reindex] Background index failed for profile ${profile.id}:`, err); + }); + return NextResponse.json({ status: "started" }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function GET() { + const profile = await getActiveProfile(); + if (!profile) return noActiveProfileResponse(); + + const current = getCurrentIndexState(profile.id); + const configured = isQdrantConfigured(getQdrantConfig()); + const [stats, liveness] = await Promise.all([ + getProfileVectorStats(profile.id), + checkQdrantLiveness(profile.id), + ]); + + return NextResponse.json({ + current, + configured, + stats, + liveness, + }); +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index ff82b78..67d234f 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -6,6 +6,7 @@ import { LayoutDashboard, Users, Play, + Search, Settings, } from "lucide-react"; import { ThemeToggle } from "@/components/layout/theme-toggle"; @@ -16,6 +17,7 @@ const navItems = [ { href: "/", label: "Overview", icon: LayoutDashboard }, { href: "/accounts", label: "Accounts", icon: Users }, { href: "/scrape", label: "Scrape", icon: Play }, + { href: "/search", label: "Search", icon: Search }, { href: "/settings", label: "Settings", icon: Settings }, ]; @@ -75,7 +77,7 @@ export function Sidebar() { {/* Mobile Bottom Bar */} -