From 116494bea7016a87368b6cd300dde6b79a0f37df Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Thu, 3 Sep 2026 12:46:49 +0300 Subject: [PATCH 01/13] feat(search): restore vector search with Qdrant and add free-text/text-free modes --- .env.example | 10 +- Dockerfile | 4 + coolify-compose.yml | 18 + docker-compose.yml | 22 + dokploy-compose.yml | 18 + next.config.ts | 6 + package-lock.json | 2345 +++++++++++++++++++++++--- package.json | 14 +- scripts/reindex-vectors.ts | 60 + scripts/warm-models.ts | 26 + src/app/(dashboard)/search/page.tsx | 357 ++++ src/app/api/search/by-face/route.ts | 78 + src/app/api/search/by-image/route.ts | 60 + src/app/api/search/by-text/route.ts | 67 + src/app/api/search/reindex/route.ts | 35 + src/components/layout/sidebar.tsx | 4 +- src/hooks/use-vector-search.ts | 83 + src/lib/vector/face-embedding.ts | 84 + src/lib/vector/image-embedding.ts | 48 + src/lib/vector/index-posts.ts | 172 ++ src/lib/vector/qdrant-client.ts | 145 ++ src/lib/vector/text-embedding.ts | 41 + src/types/index.ts | 16 + 23 files changed, 3509 insertions(+), 204 deletions(-) create mode 100644 scripts/reindex-vectors.ts create mode 100644 scripts/warm-models.ts create mode 100644 src/app/(dashboard)/search/page.tsx create mode 100644 src/app/api/search/by-face/route.ts create mode 100644 src/app/api/search/by-image/route.ts create mode 100644 src/app/api/search/by-text/route.ts create mode 100644 src/app/api/search/reindex/route.ts create mode 100644 src/hooks/use-vector-search.ts create mode 100644 src/lib/vector/face-embedding.ts create mode 100644 src/lib/vector/image-embedding.ts create mode 100644 src/lib/vector/index-posts.ts create mode 100644 src/lib/vector/qdrant-client.ts create mode 100644 src/lib/vector/text-embedding.ts diff --git a/.env.example b/.env.example index bd8ed40..12b88e3 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,15 @@ CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= # ----------------------------------------------------------------- -# 3. Logging & Telemetry (Optional) +# 3. Vector Database - Qdrant (Optional for custom/external setup) +# ----------------------------------------------------------------- +# Pre-configured in docker-compose (http://qdrant:6333). +# For local dev or external Qdrant instances: +QDRANT_URL="http://localhost:6333" +# QDRANT_API_KEY= + +# ----------------------------------------------------------------- +# 4. Logging & Telemetry (Optional) # ----------------------------------------------------------------- # Log level: fatal | error | warn | info | debug | trace (default: info) LOG_LEVEL=info diff --git a/Dockerfile b/Dockerfile index 9063ee3..6db7298 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,10 @@ RUN npm ci --ignore-scripts COPY prisma ./prisma RUN npx prisma generate +# Bake CLIP weights into the image (cached layer) instead of downloading at runtime +COPY scripts/warm-models.ts ./scripts/warm-models.ts +RUN npx tsx scripts/warm-models.ts + COPY . . ARG APP_VERSION=1.0.1 diff --git a/coolify-compose.yml b/coolify-compose.yml index b448d54..be45274 100644 --- a/coolify-compose.yml +++ b/coolify-compose.yml @@ -15,6 +15,9 @@ services: - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 + # Vector search (Qdrant) + - QDRANT_URL=http://qdrant:6333 + - QDRANT_API_KEY=${QDRANT_API_KEY:-} # Optional Cloudinary CDN Configuration - CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME:-} - CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY:-} @@ -22,6 +25,8 @@ services: depends_on: mongo: condition: service_healthy + qdrant: + condition: service_healthy mongo: image: mongo:7.0 @@ -46,5 +51,18 @@ services: retries: 10 start_period: 2s + qdrant: + image: qdrant/qdrant:v1.13.4 + restart: unless-stopped + volumes: + - qdrant_data:/qdrant/storage + 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: mongo_data: + qdrant_data: diff --git a/docker-compose.yml b/docker-compose.yml index 665c99f..6e43621 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,9 @@ services: - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 + # Vector search (Qdrant) + - QDRANT_URL=http://qdrant:6333 + - QDRANT_API_KEY=${QDRANT_API_KEY:-} # Optional: Cloudinary for permanent media CDN - CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME:-} - CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY:-} @@ -25,6 +28,8 @@ services: depends_on: mongo: condition: service_healthy + qdrant: + condition: service_healthy mongo: image: mongo:7.0 @@ -50,6 +55,23 @@ services: retries: 10 start_period: 2s + qdrant: + image: qdrant/qdrant:v1.13.4 + container_name: instagram_saved_posts_qdrant + restart: unless-stopped + ports: + - "${QDRANT_PORT:-6333}:6333" + volumes: + - qdrant_data:/qdrant/storage + 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: mongo_data: name: instagram_saved_posts_mongo_data + qdrant_data: + name: instagram_saved_posts_qdrant_data diff --git a/dokploy-compose.yml b/dokploy-compose.yml index aba15f1..a755525 100644 --- a/dokploy-compose.yml +++ b/dokploy-compose.yml @@ -15,6 +15,9 @@ services: - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 + # Vector search (Qdrant) + - QDRANT_URL=http://qdrant:6333 + - QDRANT_API_KEY=${QDRANT_API_KEY} # Optional: Cloudinary credentials for permanent media CDN - CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME} - CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY} @@ -22,6 +25,8 @@ services: depends_on: mongo: condition: service_healthy + qdrant: + condition: service_healthy mongo: image: mongo:7.0 @@ -46,5 +51,18 @@ services: retries: 10 start_period: 2s + qdrant: + image: qdrant/qdrant:v1.13.4 + restart: unless-stopped + volumes: + - qdrant_data:/qdrant/storage + 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: mongo_data: + qdrant_data: 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 c07f9d7..bbbdad4 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,20 +31,24 @@ "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" + "tailwind-merge": "^3.6.0", + "uuid": "^14.0.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^26", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", "knip": "^6.33.0", "prisma": "^6.12.0", "shadcn": "^4.19.0", "tailwindcss": "^4", + "tsx": "^4.23.13", "tw-animate-css": "^1.4.0", "typescript": "^5" } @@ -744,261 +753,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": "Apache-2.0", + "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/core": "^0.17.0", - "levn": "^0.4.1" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "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/@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-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": { - "@floating-ui/utils": "^0.2.12" + "@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/dom": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", - "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "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/core": "^1.8.0", - "@floating-ui/utils": "^0.2.12" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "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/@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/dom": "^1.8.0" + "brace-expansion": "^1.1.7" }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "engines": { + "node": "*" } }, - "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/@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/@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", + "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": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, "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 +2097,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 +3777,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 +5805,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 +6078,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 +6105,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 +6156,19 @@ "@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/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "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 +6603,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 +6682,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 +6767,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 +7146,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 +7446,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 +7530,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 +7542,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 +7631,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 +8012,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 +8042,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 +8092,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 +8446,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 +8514,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 +9683,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 +9800,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 +9882,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", @@ -8547,11 +10035,40 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "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.13.0" + "node": ">=10" } }, "node_modules/globals": { @@ -8571,7 +10088,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 +10119,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 +10142,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 +10151,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 +10584,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 +11079,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 +11613,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 +11660,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 +11972,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 +12087,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 +12247,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 +12665,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 +12840,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 +13306,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 +13333,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 +13424,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 +13533,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 +13610,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 +13626,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 +13686,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 +13885,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 +13934,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 +14152,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 +14202,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 +14446,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 +14603,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 +14665,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 +14707,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 +14861,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 +14903,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 +14912,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": { @@ -13295,6 +15076,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -13347,6 +15141,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 +15272,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 +15354,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 +15386,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 3a60e77..e55b115 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,18 @@ "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 scripts/reindex-vectors.ts" }, "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,20 +58,24 @@ "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" + "tailwind-merge": "^3.6.0", + "uuid": "^14.0.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^26", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", "knip": "^6.33.0", "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..63bff36 --- /dev/null +++ b/scripts/reindex-vectors.ts @@ -0,0 +1,60 @@ +import fs from "fs"; +import path from "path"; +import { PrismaClient } from "@prisma/client"; + +// Minimal .env loader without external dependencies +function loadDotEnv() { + const envPath = path.join(process.cwd(), ".env"); + if (!fs.existsSync(envPath)) return; + for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { + const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/); + if (!m) continue; + const key = m[1]; + let val = m[2]; + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + if (process.env[key] === undefined) process.env[key] = val; + } +} + +function getArg(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + return idx !== -1 ? process.argv[idx + 1] : undefined; +} + +loadDotEnv(); + +async function main() { + const { runVectorIndex, getCurrentIndexState } = await import("../src/lib/vector/index-posts"); + + 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); + } + + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error("[reindex-vectors] Failed:", err); + process.exit(1); +}); diff --git a/scripts/warm-models.ts b/scripts/warm-models.ts new file mode 100644 index 0000000..f20bd29 --- /dev/null +++ b/scripts/warm-models.ts @@ -0,0 +1,26 @@ +/** + * Docker build-time only: downloads the CLIP model weights (vision & text) into + * the Transformers.js cache so the image ships with them baked in instead + * of fetching from huggingface.co on first request. + */ +import { pipeline, AutoTokenizer, CLIPTextModelWithProjection } from "@huggingface/transformers"; + +async function main() { + console.log("[warm-models] Warming CLIP vision model..."); + await pipeline("image-feature-extraction", "Xenova/clip-vit-base-patch32", { + dtype: "q8", + }); + + console.log("[warm-models] Warming CLIP text tokenizer & projection model..."); + await AutoTokenizer.from_pretrained("Xenova/clip-vit-base-patch32"); + await CLIPTextModelWithProjection.from_pretrained("Xenova/clip-vit-base-patch32", { + dtype: "q8", + }); + + console.log("[warm-models] All CLIP weights cached successfully."); +} + +main().catch((err) => { + console.error("[warm-models] Failed:", err); + process.exit(1); +}); diff --git a/src/app/(dashboard)/search/page.tsx b/src/app/(dashboard)/search/page.tsx new file mode 100644 index 0000000..3563e34 --- /dev/null +++ b/src/app/(dashboard)/search/page.tsx @@ -0,0 +1,357 @@ +"use client"; + +import { 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 { PostCard } from "@/components/posts/post-card"; +import { PostDetailDialog } from "@/components/posts/post-detail-dialog"; +import { + useSearchByText, + useSearchByImage, + useSearchByFace, + useVectorIndexStatus, + useReindexVectors, +} from "@/hooks/use-vector-search"; +import { + Search as SearchIcon, + ImageIcon, + ScanFace, + Upload, + Sparkles, + RefreshCw, + Database, + X, + AlertCircle, +} from "lucide-react"; +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 [textQuery, setTextQuery] = useState(""); + const [previewUrl, setPreviewUrl] = useState(null); + const [results, setResults] = useState(null); + const [activeQueryLabel, setActiveQueryLabel] = useState(null); + const [selectedPost, setSelectedPost] = 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 = + searchByText.isPending || searchByImage.isPending || searchByFace.isPending; + + const handleTextSubmit = (e?: React.FormEvent) => { + if (e) e.preventDefault(); + const query = textQuery.trim(); + if (!query) return; + + setResults(null); + setActiveQueryLabel(`"${query}"`); + searchByText.mutate(query, { + onSuccess: (hits) => setResults(hits), + onError: (err) => toast.error(err.message), + }); + }; + + const handleFile = (file: File) => { + const objectUrl = URL.createObjectURL(file); + setPreviewUrl(objectUrl); + setResults(null); + setActiveQueryLabel(file.name); + + if (mode === "image") { + searchByImage.mutate(file, { + onSuccess: (hits) => setResults(hits), + onError: (err) => toast.error(err.message), + }); + } else { + searchByFace.mutate(file, { + onSuccess: (hits) => setResults(hits), + onError: (err) => toast.error(err.message), + }); + } + }; + + const handleReindex = () => { + 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; + + return ( +
+
+ +
+ + {/* Database connection warning */} + {indexStatusData && !indexStatusData.configured && ( +
+ +
+ Qdrant is not connected. Ensure + Qdrant is running in Docker Compose and QDRANT_URL is set. +
+
+ )} + + {/* 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 */} + + { + setMode(v as SearchMode); + setResults(null); + setPreviewUrl(null); + setActiveQueryLabel(null); + }} + > + + + + 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 = ""; + }} + /> + +
fileInputRef.current?.click()} + className="group flex flex-col items-center justify-center gap-3 rounded-[8px] border-2 border-dashed border-hairline bg-surface-2/40 p-8 text-center transition-all cursor-pointer hover:border-amber-500/60 hover:bg-surface-2/70" + > + {previewUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Query preview + + Click to choose a different photo + +
+ ) : ( + <> +
+ +
+
+

+ {mode === "image" + ? "Upload a photo to find visually similar posts" + : "Upload a portrait to identify matching people"} +

+

+ {mode === "image" + ? "Drag & drop or click to browse (PNG, JPG, WebP)" + : "Matches facial structure and geometry across all archive posts"} +

+
+ + )} +
+
+ )} +
+
+ + {/* 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)} /> +
+ = 80 ? "text-amber-400" : "text-zinc-300"}> + {pct}% + + match +
+
+ ); + })} +
+ )} +
+ )} + + { + if (!open) setSelectedPost(null); + }} + /> +
+ ); +} 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..2d4df61 --- /dev/null +++ b/src/app/api/search/by-face/route.ts @@ -0,0 +1,78 @@ +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, + getQdrantConfig, + isQdrantConfigured, + searchByVector, +} from "@/lib/vector/qdrant-client"; +import type { VectorSearchHit } from "@/types"; + +const RESULT_LIMIT = 60; + +export async function POST(request: NextRequest) { + 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 } + ); + } + + const formData = await request.formData(); + const file = formData.get("image"); + if (!(file instanceof Blob)) { + return NextResponse.json( + { error: "Missing 'image' file in form data." }, + { status: 400 } + ); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const queryFaces = await detectFacesFromBuffer(buffer); + if (queryFaces.length === 0) { + return NextResponse.json( + { error: "No face detected in the uploaded image.", results: [] }, + { status: 200 } + ); + } + + // Multiple faces in the query photo (e.g. a group shot) are all searched; + // hits are merged by post below regardless of which query face matched. + const hitLists = await Promise.all( + queryFaces.map((face) => + searchByVector(COLLECTIONS.POST_FACES, face.descriptor, profile.id, RESULT_LIMIT) + ) + ); + + const bestByPk = new Map(); + for (const hits of hitLists) { + for (const hit of hits) { + const pk = hit.payload?.postPk; + if (typeof pk !== "string") continue; + const prev = bestByPk.get(pk); + if (prev === undefined || hit.score > prev.score) { + bestByPk.set(pk, { + score: hit.score, + bbox: hit.payload?.bbox as VectorSearchHit["bbox"], + }); + } + } + } + + const posts = await prisma.post.findMany({ + where: { profileId: profile.id, pk: { in: [...bestByPk.keys()] } }, + }); + const postByPk = new Map(posts.map((p) => [p.pk, p])); + + const results: VectorSearchHit[] = [...bestByPk.entries()] + .filter(([pk]) => postByPk.has(pk)) + .map(([pk, { score, bbox }]) => ({ post: postByPk.get(pk)!, score, bbox })) + .sort((a, b) => b.score - a.score); + + return NextResponse.json({ results }); +} 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..5146cdc --- /dev/null +++ b/src/app/api/search/by-image/route.ts @@ -0,0 +1,60 @@ +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, + getQdrantConfig, + isQdrantConfigured, + searchByVector, +} from "@/lib/vector/qdrant-client"; +import type { VectorSearchHit } from "@/types"; + +const RESULT_LIMIT = 60; + +export async function POST(request: NextRequest) { + 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 } + ); + } + + const formData = await request.formData(); + const file = formData.get("image"); + if (!(file instanceof Blob)) { + return NextResponse.json( + { error: "Missing 'image' file in form data." }, + { status: 400 } + ); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const vector = await embedImageFromBuffer(buffer); + const hits = await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); + + // A post can match via its thumbnail and/or multiple carousel slides — + // keep only the best-scoring hit per post. + const bestScoreByPk = new Map(); + for (const hit of hits) { + const pk = hit.payload?.postPk; + if (typeof pk !== "string") continue; + const prev = bestScoreByPk.get(pk); + if (prev === undefined || hit.score > prev) bestScoreByPk.set(pk, hit.score); + } + + const posts = await prisma.post.findMany({ + where: { profileId: profile.id, pk: { in: [...bestScoreByPk.keys()] } }, + }); + const postByPk = new Map(posts.map((p) => [p.pk, p])); + + const results: VectorSearchHit[] = [...bestScoreByPk.entries()] + .map(([pk, score]) => ({ post: postByPk.get(pk), score })) + .filter((r): r is VectorSearchHit => !!r.post) + .sort((a, b) => b.score - a.score); + + return NextResponse.json({ results }); +} 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..0b18684 --- /dev/null +++ b/src/app/api/search/by-text/route.ts @@ -0,0 +1,67 @@ +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, + getQdrantConfig, + isQdrantConfigured, + searchByVector, +} from "@/lib/vector/qdrant-client"; +import type { VectorSearchHit } from "@/types"; + +const RESULT_LIMIT = 60; + +export async function POST(request: NextRequest) { + 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 } + ); + } + + let query: string | undefined; + try { + const body = await request.json(); + query = body.query; + } catch { + return NextResponse.json( + { error: "Invalid JSON body. Expected { query: string }." }, + { status: 400 } + ); + } + + if (!query || typeof query !== "string" || !query.trim()) { + return NextResponse.json( + { error: "Query cannot be empty." }, + { status: 400 } + ); + } + + const vector = await embedText(query.trim()); + const hits = await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); + + // Keep highest similarity score per post + const bestScoreByPk = new Map(); + for (const hit of hits) { + const pk = hit.payload?.postPk; + if (typeof pk !== "string") continue; + const prev = bestScoreByPk.get(pk); + if (prev === undefined || hit.score > prev) bestScoreByPk.set(pk, hit.score); + } + + const posts = await prisma.post.findMany({ + where: { profileId: profile.id, pk: { in: [...bestScoreByPk.keys()] } }, + }); + const postByPk = new Map(posts.map((p) => [p.pk, p])); + + const results: VectorSearchHit[] = [...bestScoreByPk.entries()] + .map(([pk, score]) => ({ post: postByPk.get(pk), score })) + .filter((r): r is VectorSearchHit => !!r.post) + .sort((a, b) => b.score - a.score); + + return NextResponse.json({ results }); +} diff --git a/src/app/api/search/reindex/route.ts b/src/app/api/search/reindex/route.ts new file mode 100644 index 0000000..e9a59d2 --- /dev/null +++ b/src/app/api/search/reindex/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { runVectorIndex, getCurrentIndexState } from "@/lib/vector/index-posts"; +import { getQdrantConfig, isQdrantConfigured } from "@/lib/vector/qdrant-client"; +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 } + ); + } + try { + // Fire and forget — index run happens in background + runVectorIndex(profile.id).catch(() => { + // Error is captured in the profile's index state + }); + 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()); + return NextResponse.json({ current, configured }); +} 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 */} -
)} + {/* 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. + +
+ + {isLoadingStats ? ( +
+ + + +
+ ) : ( +
+ {/* Profile Stats Card */} +
+
+ + + Active Profile Index + + + {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?.failedItems && stats.failedItems > 0 ? ( +
+ Failed Items: {stats.failedItems} ({stats.lastError || "Unknown error"}) +
+ ) : null} +
+ + {/* Cluster & Global Stats Card */} +
+
+ + + Qdrant Database & Cluster + + + {vectorStatsData?.totalProfilesIndexed ?? 0} Profiles Indexed + +
+ +
+
+ Image Vectors: +

+ {vectorStatsData?.qdrant?.profilePoints?.images?.toLocaleString() ?? 0} +

+
+
+ Face Vectors: +

+ {vectorStatsData?.qdrant?.profilePoints?.faces?.toLocaleString() ?? 0} +

+
+
+ Cluster Latency: +

{liveness?.latencyMs ?? 0} ms

+
+
+ Storage Backend: +

Qdrant RocksDB

+
+
+
+
+ )} + + + + + +
+
+ ({ + status: "disconnected" as const, + latencyMs: 0, + })); + + const isHealthy = mongoConnected && qdrantLiveness.status !== "disconnected"; + const status = isHealthy ? "healthy" : mongoConnected ? "degraded" : "unhealthy"; return NextResponse.json( { status, mongo: mongoConnected ? "connected" : "disconnected", + vectorService: { + status: qdrantLiveness.status, + latencyMs: qdrantLiveness.latencyMs, + }, uptime: process.uptime(), timestamp: new Date().toISOString(), }, diff --git a/src/app/api/search/by-face/route.ts b/src/app/api/search/by-face/route.ts index 2d4df61..2a1a935 100644 --- a/src/app/api/search/by-face/route.ts +++ b/src/app/api/search/by-face/route.ts @@ -7,6 +7,7 @@ import { getQdrantConfig, isQdrantConfigured, searchByVector, + VectorIndexNotBuiltError, } from "@/lib/vector/qdrant-client"; import type { VectorSearchHit } from "@/types"; @@ -18,7 +19,7 @@ export async function POST(request: NextRequest) { if (!isQdrantConfigured(getQdrantConfig())) { return NextResponse.json( - { error: "Vector search is not configured. Set QDRANT_URL env var." }, + { error: "Vector search is not configured. Set QDRANT_URL environment variable.", needsIndexing: false }, { status: 400 } ); } @@ -32,47 +33,73 @@ export async function POST(request: NextRequest) { ); } - const buffer = Buffer.from(await file.arrayBuffer()); - const queryFaces = await detectFacesFromBuffer(buffer); - if (queryFaces.length === 0) { - return NextResponse.json( - { error: "No face detected in the uploaded image.", results: [] }, - { status: 200 } - ); - } + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const queryFaces = await detectFacesFromBuffer(buffer); + if (queryFaces.length === 0) { + return NextResponse.json( + { error: "No face detected in the uploaded image.", results: [] }, + { status: 200 } + ); + } - // Multiple faces in the query photo (e.g. a group shot) are all searched; - // hits are merged by post below regardless of which query face matched. - const hitLists = await Promise.all( - queryFaces.map((face) => - searchByVector(COLLECTIONS.POST_FACES, face.descriptor, profile.id, RESULT_LIMIT) - ) - ); + // Multiple faces in the query photo (e.g. a group shot) are all searched; + // hits are merged by post below regardless of which query face matched. + const hitLists = await Promise.all( + queryFaces.map((face) => + searchByVector(COLLECTIONS.POST_FACES, face.descriptor, profile.id, RESULT_LIMIT) + ) + ); - const bestByPk = new Map(); - for (const hits of hitLists) { - for (const hit of hits) { - const pk = hit.payload?.postPk; - if (typeof pk !== "string") continue; - const prev = bestByPk.get(pk); - if (prev === undefined || hit.score > prev.score) { - bestByPk.set(pk, { - score: hit.score, - bbox: hit.payload?.bbox as VectorSearchHit["bbox"], - }); + const bestByPk = new Map(); + for (const hits of hitLists) { + for (const hit of hits) { + const pk = hit.payload?.postPk; + if (typeof pk !== "string") continue; + const prev = bestByPk.get(pk); + if (prev === undefined || hit.score > prev.score) { + bestByPk.set(pk, { + score: hit.score, + bbox: hit.payload?.bbox as VectorSearchHit["bbox"], + }); + } } } - } - const posts = await prisma.post.findMany({ - where: { profileId: profile.id, pk: { in: [...bestByPk.keys()] } }, - }); - const postByPk = new Map(posts.map((p) => [p.pk, p])); + const posts = await prisma.post.findMany({ + where: { profileId: profile.id, pk: { in: [...bestByPk.keys()] } }, + }); + const postByPk = new Map(posts.map((p) => [p.pk, p])); - const results: VectorSearchHit[] = [...bestByPk.entries()] - .filter(([pk]) => postByPk.has(pk)) - .map(([pk, { score, bbox }]) => ({ post: postByPk.get(pk)!, score, bbox })) - .sort((a, b) => b.score - a.score); + const results: VectorSearchHit[] = [...bestByPk.entries()] + .filter(([pk]) => postByPk.has(pk)) + .map(([pk, { score, bbox }]) => ({ post: postByPk.get(pk)!, score, bbox })) + .sort((a, b) => b.score - a.score); - return NextResponse.json({ results }); + return NextResponse.json({ results }); + } catch (err: unknown) { + if (err instanceof VectorIndexNotBuiltError) { + return NextResponse.json( + { + error: "Vector index has not been built yet. Please index your saved posts first.", + needsIndexing: true, + }, + { status: 400 } + ); + } + const message = err instanceof Error ? err.message : String(err); + if (message.includes("doesn't exist") || message.includes("Not found: Collection")) { + return NextResponse.json( + { + error: "Vector index collection not found. Please run the indexer first.", + needsIndexing: true, + }, + { status: 400 } + ); + } + return NextResponse.json( + { error: `Vector search error: ${message}`, needsIndexing: false }, + { status: 500 } + ); + } } diff --git a/src/app/api/search/by-image/route.ts b/src/app/api/search/by-image/route.ts index 5146cdc..8efc19e 100644 --- a/src/app/api/search/by-image/route.ts +++ b/src/app/api/search/by-image/route.ts @@ -7,6 +7,7 @@ import { getQdrantConfig, isQdrantConfigured, searchByVector, + VectorIndexNotBuiltError, } from "@/lib/vector/qdrant-client"; import type { VectorSearchHit } from "@/types"; @@ -18,7 +19,7 @@ export async function POST(request: NextRequest) { if (!isQdrantConfigured(getQdrantConfig())) { return NextResponse.json( - { error: "Vector search is not configured. Set QDRANT_URL env var." }, + { error: "Vector search is not configured. Set QDRANT_URL environment variable.", needsIndexing: false }, { status: 400 } ); } @@ -32,29 +33,54 @@ export async function POST(request: NextRequest) { ); } - const buffer = Buffer.from(await file.arrayBuffer()); - const vector = await embedImageFromBuffer(buffer); - const hits = await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); - - // A post can match via its thumbnail and/or multiple carousel slides — - // keep only the best-scoring hit per post. - const bestScoreByPk = new Map(); - for (const hit of hits) { - const pk = hit.payload?.postPk; - if (typeof pk !== "string") continue; - const prev = bestScoreByPk.get(pk); - if (prev === undefined || hit.score > prev) bestScoreByPk.set(pk, hit.score); - } + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const vector = await embedImageFromBuffer(buffer); + const hits = await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); + + // Keep only the best-scoring hit per post + const bestScoreByPk = new Map(); + for (const hit of hits) { + const pk = hit.payload?.postPk; + if (typeof pk !== "string") continue; + const prev = bestScoreByPk.get(pk); + if (prev === undefined || hit.score > prev) bestScoreByPk.set(pk, hit.score); + } - const posts = await prisma.post.findMany({ - where: { profileId: profile.id, pk: { in: [...bestScoreByPk.keys()] } }, - }); - const postByPk = new Map(posts.map((p) => [p.pk, p])); + const posts = await prisma.post.findMany({ + where: { profileId: profile.id, pk: { in: [...bestScoreByPk.keys()] } }, + }); + const postByPk = new Map(posts.map((p) => [p.pk, p])); - const results: VectorSearchHit[] = [...bestScoreByPk.entries()] - .map(([pk, score]) => ({ post: postByPk.get(pk), score })) - .filter((r): r is VectorSearchHit => !!r.post) - .sort((a, b) => b.score - a.score); + const results: VectorSearchHit[] = [...bestScoreByPk.entries()] + .map(([pk, score]) => ({ post: postByPk.get(pk), score })) + .filter((r): r is VectorSearchHit => !!r.post) + .sort((a, b) => b.score - a.score); - return NextResponse.json({ results }); + return NextResponse.json({ results }); + } catch (err: unknown) { + if (err instanceof VectorIndexNotBuiltError) { + return NextResponse.json( + { + error: "Vector index has not been built yet. Please index your saved posts first.", + needsIndexing: true, + }, + { status: 400 } + ); + } + const message = err instanceof Error ? err.message : String(err); + if (message.includes("doesn't exist") || message.includes("Not found: Collection")) { + return NextResponse.json( + { + error: "Vector index collection not found. Please run the indexer first.", + needsIndexing: true, + }, + { status: 400 } + ); + } + return NextResponse.json( + { error: `Vector search error: ${message}`, needsIndexing: false }, + { status: 500 } + ); + } } diff --git a/src/app/api/search/by-text/route.ts b/src/app/api/search/by-text/route.ts index 0b18684..eef5c81 100644 --- a/src/app/api/search/by-text/route.ts +++ b/src/app/api/search/by-text/route.ts @@ -7,6 +7,7 @@ import { getQdrantConfig, isQdrantConfigured, searchByVector, + VectorIndexNotBuiltError, } from "@/lib/vector/qdrant-client"; import type { VectorSearchHit } from "@/types"; @@ -18,7 +19,10 @@ export async function POST(request: NextRequest) { if (!isQdrantConfigured(getQdrantConfig())) { return NextResponse.json( - { error: "Vector search is not configured. Set QDRANT_URL env var." }, + { + error: "Vector search is not configured. Set QDRANT_URL environment variable.", + needsIndexing: false, + }, { status: 400 } ); } @@ -41,27 +45,53 @@ export async function POST(request: NextRequest) { ); } - const vector = await embedText(query.trim()); - const hits = await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); + try { + const vector = await embedText(query.trim()); + const hits = await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); - // Keep highest similarity score per post - const bestScoreByPk = new Map(); - for (const hit of hits) { - const pk = hit.payload?.postPk; - if (typeof pk !== "string") continue; - const prev = bestScoreByPk.get(pk); - if (prev === undefined || hit.score > prev) bestScoreByPk.set(pk, hit.score); - } + // Keep highest similarity score per post + const bestScoreByPk = new Map(); + for (const hit of hits) { + const pk = hit.payload?.postPk; + if (typeof pk !== "string") continue; + const prev = bestScoreByPk.get(pk); + if (prev === undefined || hit.score > prev) bestScoreByPk.set(pk, hit.score); + } - const posts = await prisma.post.findMany({ - where: { profileId: profile.id, pk: { in: [...bestScoreByPk.keys()] } }, - }); - const postByPk = new Map(posts.map((p) => [p.pk, p])); + const posts = await prisma.post.findMany({ + where: { profileId: profile.id, pk: { in: [...bestScoreByPk.keys()] } }, + }); + const postByPk = new Map(posts.map((p) => [p.pk, p])); - const results: VectorSearchHit[] = [...bestScoreByPk.entries()] - .map(([pk, score]) => ({ post: postByPk.get(pk), score })) - .filter((r): r is VectorSearchHit => !!r.post) - .sort((a, b) => b.score - a.score); + const results: VectorSearchHit[] = [...bestScoreByPk.entries()] + .map(([pk, score]) => ({ post: postByPk.get(pk), score })) + .filter((r): r is VectorSearchHit => !!r.post) + .sort((a, b) => b.score - a.score); - return NextResponse.json({ results }); + return NextResponse.json({ results }); + } catch (err: unknown) { + if (err instanceof VectorIndexNotBuiltError) { + return NextResponse.json( + { + error: "Vector index has not been built yet. Please index your saved posts first.", + needsIndexing: true, + }, + { status: 400 } + ); + } + const message = err instanceof Error ? err.message : String(err); + if (message.includes("doesn't exist") || message.includes("Not found: Collection")) { + return NextResponse.json( + { + error: "Vector index collection not found. Please run the indexer first.", + needsIndexing: true, + }, + { status: 400 } + ); + } + return NextResponse.json( + { error: `Vector search error: ${message}`, needsIndexing: false }, + { status: 500 } + ); + } } diff --git a/src/app/api/search/liveness/route.ts b/src/app/api/search/liveness/route.ts new file mode 100644 index 0000000..e7f4dde --- /dev/null +++ b/src/app/api/search/liveness/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { getActiveProfile } from "@/lib/active-profile"; +import { checkQdrantLiveness } from "@/lib/vector/qdrant-client"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const profile = await getActiveProfile(); + const liveness = await checkQdrantLiveness(profile?.id); + + const httpStatus = liveness.status === "disconnected" ? 503 : 200; + return NextResponse.json(liveness, { status: httpStatus }); +} diff --git a/src/app/api/search/reindex/route.ts b/src/app/api/search/reindex/route.ts index e9a59d2..3071bce 100644 --- a/src/app/api/search/reindex/route.ts +++ b/src/app/api/search/reindex/route.ts @@ -1,6 +1,11 @@ import { NextResponse } from "next/server"; import { runVectorIndex, getCurrentIndexState } from "@/lib/vector/index-posts"; -import { getQdrantConfig, isQdrantConfigured } from "@/lib/vector/qdrant-client"; +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() { @@ -16,7 +21,7 @@ export async function POST() { try { // Fire and forget — index run happens in background runVectorIndex(profile.id).catch(() => { - // Error is captured in the profile's index state + // Error is captured in the profile's index state and persisted stats }); return NextResponse.json({ status: "started" }); } catch (error) { @@ -31,5 +36,15 @@ export async function GET() { const current = getCurrentIndexState(profile.id); const configured = isQdrantConfigured(getQdrantConfig()); - return NextResponse.json({ current, configured }); + const [stats, liveness] = await Promise.all([ + getProfileVectorStats(profile.id), + checkQdrantLiveness(profile.id), + ]); + + return NextResponse.json({ + current, + configured, + stats, + liveness, + }); } diff --git a/src/app/api/search/stats/route.ts b/src/app/api/search/stats/route.ts new file mode 100644 index 0000000..fbdb956 --- /dev/null +++ b/src/app/api/search/stats/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { getActiveProfile, noActiveProfileResponse } from "@/lib/active-profile"; +import { + getProfileVectorStats, + getAllProfilesVectorStats, +} from "@/lib/vector/stats"; +import { + COLLECTIONS, + countCollectionPoints, + getQdrantDashboardUrl, + isQdrantConfigured, + getQdrantConfig, +} from "@/lib/vector/qdrant-client"; + +export async function GET() { + const profile = await getActiveProfile(); + if (!profile) return noActiveProfileResponse(); + + const isConfigured = isQdrantConfigured(getQdrantConfig()); + + const [activeProfileStats, allProfilesStats, imagesPoints, facesPoints] = await Promise.all([ + getProfileVectorStats(profile.id), + getAllProfilesVectorStats(), + isConfigured ? countCollectionPoints(COLLECTIONS.POST_IMAGES, profile.id) : 0, + isConfigured ? countCollectionPoints(COLLECTIONS.POST_FACES, profile.id) : 0, + ]); + + return NextResponse.json({ + activeProfile: activeProfileStats, + allProfiles: allProfilesStats, + totalProfilesIndexed: allProfilesStats.filter((p) => p.indexedItems > 0).length, + qdrant: { + configured: isConfigured, + dashboardUrl: getQdrantDashboardUrl(), + profilePoints: { + images: imagesPoints, + faces: facesPoints, + total: imagesPoints + facesPoints, + }, + }, + }); +} diff --git a/src/hooks/use-vector-search.ts b/src/hooks/use-vector-search.ts index 9cc9fc9..76e6498 100644 --- a/src/hooks/use-vector-search.ts +++ b/src/hooks/use-vector-search.ts @@ -2,25 +2,56 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { VectorSearchHit, VectorIndexProgress } from "@/types"; +import type { VectorIndexStats } from "@/lib/vector/stats"; +import type { QdrantLivenessResult } from "@/lib/vector/qdrant-client"; + +export class VectorSearchError extends Error { + needsIndexing: boolean; + constructor(message: string, needsIndexing = false) { + super(message); + this.name = "VectorSearchError"; + this.needsIndexing = needsIndexing; + } +} interface SearchResponse { - results: VectorSearchHit[]; + results?: VectorSearchHit[]; error?: string; + needsIndexing?: boolean; } -interface ReindexStatusResponse { +export interface ReindexStatusResponse { current: VectorIndexProgress | null; configured: boolean; + stats: VectorIndexStats | null; + liveness: QdrantLivenessResult | null; error?: string; } +export interface VectorStatsResponse { + activeProfile: VectorIndexStats | null; + allProfiles: VectorIndexStats[]; + totalProfilesIndexed: number; + qdrant: { + configured: boolean; + dashboardUrl: string; + profilePoints: { + images: number; + faces: number; + total: number; + }; + }; +} + async function postImage(url: string, file: File): Promise { const formData = new FormData(); formData.append("image", file); const res = await fetch(url, { method: "POST", body: formData }); - const body: SearchResponse = await res.json(); - if (!res.ok) throw new Error(body.error ?? "Search failed"); - return body.results; + const body: SearchResponse = await res.json().catch(() => ({})); + if (!res.ok) { + throw new VectorSearchError(body.error ?? `Search failed (${res.status})`, !!body.needsIndexing); + } + return body.results ?? []; } async function postText(query: string): Promise { @@ -29,25 +60,27 @@ async function postText(query: string): Promise { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }), }); - const body: SearchResponse = await res.json(); - if (!res.ok) throw new Error(body.error ?? "Search failed"); - return body.results; + const body: SearchResponse = await res.json().catch(() => ({})); + if (!res.ok) { + throw new VectorSearchError(body.error ?? `Search failed (${res.status})`, !!body.needsIndexing); + } + return body.results ?? []; } export function useSearchByText() { - return useMutation({ + return useMutation({ mutationFn: (query: string) => postText(query), }); } export function useSearchByImage() { - return useMutation({ + return useMutation({ mutationFn: (file: File) => postImage("/api/search/by-image", file), }); } export function useSearchByFace() { - return useMutation({ + return useMutation({ mutationFn: (file: File) => postImage("/api/search/by-face", file), }); } @@ -62,7 +95,18 @@ export function useVectorIndexStatus() { }, refetchInterval: (query) => { const status = query.state.data?.current?.status; - return status === "running" ? 2000 : false; + return status === "running" ? 2000 : 15000; + }, + }); +} + +export function useVectorStats() { + return useQuery({ + queryKey: ["vector-stats"], + queryFn: async () => { + const res = await fetch("/api/search/stats"); + if (!res.ok) throw new Error("Failed to fetch vector stats"); + return res.json(); }, }); } @@ -78,6 +122,7 @@ export function useReindexVectors() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["vector-index-status"] }); + queryClient.invalidateQueries({ queryKey: ["vector-stats"] }); }, }); } diff --git a/src/lib/vector/index-posts.ts b/src/lib/vector/index-posts.ts index 020c276..aceb4ac 100644 --- a/src/lib/vector/index-posts.ts +++ b/src/lib/vector/index-posts.ts @@ -13,8 +13,9 @@ import { type FaceVectorPayload, type PostVectorPayload, } from "./qdrant-client"; +import { saveProfileVectorStats, type VectorIndexStats } from "./stats"; -// Per-profile index state, mirrors src/lib/cloudinary-sync.ts's syncStates map. +// Per-profile index state in memory for real-time polling const indexStates = new Map(); export function getCurrentIndexState(profileId: string): VectorIndexProgress | null { @@ -87,6 +88,8 @@ async function collectTargets(profileId: string): Promise { return targets; } +const UPSERT_BATCH_SIZE = 25; + export async function runVectorIndex(profileId: string): Promise { if (indexStates.get(profileId)?.status === "running") { throw new Error("A vector index run is already in progress for this profile"); @@ -97,6 +100,24 @@ export async function runVectorIndex(profileId: string): Promise { throw new Error("Qdrant is not configured. Set QDRANT_URL/QDRANT_API_KEY env vars."); } + const startTime = Date.now(); + const startIso = new Date(startTime).toISOString(); + + // Find profile name and newest post timestamp for cutoff tracking + const [profileRecord, newestPost] = await Promise.all([ + prisma.profile.findUnique({ where: { id: profileId }, select: { name: true } }), + prisma.post.findFirst({ + where: { profileId }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }), + ]); + + const cutoffPostTakenAt = newestPost?.takenAt ?? null; + const cutoffPostDate = cutoffPostTakenAt + ? new Date(cutoffPostTakenAt * 1000).toISOString() + : null; + const targets = await collectTargets(profileId); const state: VectorIndexProgress = { @@ -108,10 +129,45 @@ export async function runVectorIndex(profileId: string): Promise { }; indexStates.set(profileId, state); + // Initial stats record + const currentStats: VectorIndexStats = { + profileId, + profileName: profileRecord?.name ?? profileId, + status: "running", + lastRunAt: startIso, + lastCompletedAt: null, + durationMs: null, + cutoffPostTakenAt, + cutoffPostDate, + totalItems: targets.length, + indexedItems: 0, + facesIndexed: 0, + failedItems: 0, + lastError: null, + updatedAt: startIso, + }; + await saveProfileVectorStats(currentStats); + try { await ensureCollections(); - for (const target of targets) { + // Batched queues to avoid RocksDB open file limits and connection saturation + const imageBatch: { id: string; vector: number[]; payload: Record }[] = []; + const faceBatch: { id: string; vector: number[]; payload: Record }[] = []; + + const flushBatches = async (force = false) => { + if (imageBatch.length >= UPSERT_BATCH_SIZE || (force && imageBatch.length > 0)) { + await upsertPoints(COLLECTIONS.POST_IMAGES, [...imageBatch]); + imageBatch.length = 0; + } + if (faceBatch.length >= UPSERT_BATCH_SIZE || (force && faceBatch.length > 0)) { + await upsertPoints(COLLECTIONS.POST_FACES, [...faceBatch]); + faceBatch.length = 0; + } + }; + + for (let i = 0; i < targets.length; i++) { + const target = targets[i]; try { const imagePayload: PostVectorPayload = { profileId, @@ -122,31 +178,39 @@ export async function runVectorIndex(profileId: string): Promise { }; const imageVector = await embedImageFromUrl(target.imageUrl); - await upsertPoints(COLLECTIONS.POST_IMAGES, [ - { - id: pointId(profileId, target.postPk, target.source, target.position), - vector: imageVector, - payload: imagePayload, - }, - ]); + imageBatch.push({ + id: pointId(profileId, target.postPk, target.source, target.position), + vector: imageVector, + payload: imagePayload, + }); const faces = await detectFacesFromUrl(target.imageUrl); if (faces.length > 0) { - const facePoints = faces.map((face, i) => { + for (let faceIdx = 0; faceIdx < faces.length; faceIdx++) { + const face = faces[faceIdx]; const payload: FaceVectorPayload = { ...imagePayload, bbox: face.bbox }; - return { - id: pointId(profileId, target.postPk, target.source, target.position, i), + faceBatch.push({ + id: pointId(profileId, target.postPk, target.source, target.position, faceIdx), vector: face.descriptor, payload, - }; - }); - await upsertPoints(COLLECTIONS.POST_FACES, facePoints); + }); + } state.facesIndexed += faces.length; } state.indexedItems += 1; + await flushBatches(false); + + // Periodically sync stats every 50 items + if (i > 0 && i % 50 === 0) { + currentStats.indexedItems = state.indexedItems; + currentStats.facesIndexed = state.facesIndexed; + currentStats.failedItems = state.failedItems; + await saveProfileVectorStats(currentStats); + } } catch (error) { state.failedItems += 1; + currentStats.lastError = error instanceof Error ? error.message : String(error); logger.error( { err: error, profileId, postPk: target.postPk, source: target.source }, "[vector-index] Failed to index item" @@ -154,19 +218,38 @@ export async function runVectorIndex(profileId: string): Promise { } } + // Flush any remaining batched points + await flushBatches(true); + + const completionIso = new Date().toISOString(); state.status = "completed"; + currentStats.status = "completed"; + currentStats.lastCompletedAt = completionIso; + currentStats.durationMs = Date.now() - startTime; + currentStats.indexedItems = state.indexedItems; + currentStats.facesIndexed = state.facesIndexed; + currentStats.failedItems = state.failedItems; + await saveProfileVectorStats(currentStats); + logger.info( { profileId, indexedItems: state.indexedItems, facesIndexed: state.facesIndexed, failedItems: state.failedItems, + durationMs: currentStats.durationMs, }, "[vector-index] Index run completed" ); } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; state.status = "failed"; - state.errorMessage = error instanceof Error ? error.message : "Unknown error"; + state.errorMessage = errorMsg; + currentStats.status = "failed"; + currentStats.lastError = errorMsg; + currentStats.durationMs = Date.now() - startTime; + await saveProfileVectorStats(currentStats); + logger.error({ err: error, profileId }, "[vector-index] Index run failed"); } } diff --git a/src/lib/vector/qdrant-client.ts b/src/lib/vector/qdrant-client.ts index f7a02f8..4834c7b 100644 --- a/src/lib/vector/qdrant-client.ts +++ b/src/lib/vector/qdrant-client.ts @@ -6,13 +6,21 @@ export interface QdrantConfig { apiKey?: string; } +export class VectorIndexNotBuiltError extends Error { + code = "INDEX_NOT_BUILT"; + constructor(message = "Vector index has not been built yet. Please index your saved posts first.") { + super(message); + this.name = "VectorIndexNotBuiltError"; + } +} + /** * Qdrant configuration from env vars (QDRANT_URL, QDRANT_API_KEY). - * Defaults to http://localhost:6333 in development if unset. + * Defaults to http://localhost:6335 in development if unset (or http://qdrant:6333 in docker). */ export function getQdrantConfig(): QdrantConfig { return { - url: process.env.QDRANT_URL || (process.env.NODE_ENV === "development" ? "http://localhost:6333" : ""), + url: process.env.QDRANT_URL || (process.env.NODE_ENV === "development" ? "http://localhost:6335" : ""), apiKey: process.env.QDRANT_API_KEY, }; } @@ -21,6 +29,29 @@ export function isQdrantConfigured(config: QdrantConfig = getQdrantConfig()): bo return !!config.url; } +/** + * Returns the public/browser-accessible Qdrant Dashboard URL for user exploration. + */ +export function getQdrantDashboardUrl(): string { + if (process.env.QDRANT_DASHBOARD_URL) { + return process.env.QDRANT_DASHBOARD_URL; + } + const config = getQdrantConfig(); + if (!config.url) return `http://localhost:${process.env.QDRANT_PORT || 6335}/dashboard`; + + try { + const parsed = new URL(config.url); + if (parsed.hostname === "qdrant") { + // In docker network, browser accesses host mapped port + const port = process.env.QDRANT_PORT || "6335"; + return `http://localhost:${port}/dashboard`; + } + return `${config.url.replace(/\/$/, "")}/dashboard`; + } catch { + return `http://localhost:${process.env.QDRANT_PORT || 6335}/dashboard`; + } +} + export const COLLECTIONS = { POST_IMAGES: "post_images", POST_FACES: "post_faces", @@ -57,6 +88,126 @@ export function getQdrantClient(): QdrantClient { return client; } +/** Checks whether a specific collection exists in Qdrant. */ +export async function checkCollectionExists(name: string): Promise { + try { + const qdrant = getQdrantClient(); + const existing = await qdrant.getCollections(); + return existing.collections.some((c) => c.name === name); + } catch { + return false; + } +} + +/** Counts total points in a collection, optionally filtered by profileId. */ +export async function countCollectionPoints(name: string, profileId?: string): Promise { + try { + const qdrant = getQdrantClient(); + const filter = profileId + ? { must: [{ key: "profileId", match: { value: profileId } }] } + : undefined; + const res = await qdrant.count(name, { filter, exact: true }); + return res.count; + } catch { + return 0; + } +} + +export interface QdrantLivenessResult { + status: "healthy" | "degraded" | "unhealthy" | "disconnected"; + latencyMs: number; + url: string; + dashboardUrl: string; + version?: string; + collections: { + post_images: { exists: boolean; pointsCount: number }; + post_faces: { exists: boolean; pointsCount: number }; + }; + error?: string; +} + +/** Deep liveness and readiness probe for the Qdrant service. */ +export async function checkQdrantLiveness(profileId?: string): Promise { + const config = getQdrantConfig(); + const dashboardUrl = getQdrantDashboardUrl(); + + if (!isQdrantConfigured(config)) { + return { + status: "disconnected", + latencyMs: 0, + url: config.url, + dashboardUrl, + collections: { + post_images: { exists: false, pointsCount: 0 }, + post_faces: { exists: false, pointsCount: 0 }, + }, + error: "QDRANT_URL environment variable is not configured", + }; + } + + const start = performance.now(); + try { + // 1. Direct HTTP probe to /livez + const livezRes = await fetch(`${config.url.replace(/\/$/, "")}/livez`, { + headers: config.apiKey ? { "api-key": config.apiKey } : undefined, + signal: AbortSignal.timeout(4000), + }); + + const latencyMs = Math.round(performance.now() - start); + + if (!livezRes.ok) { + return { + status: "unhealthy", + latencyMs, + url: config.url, + dashboardUrl, + collections: { + post_images: { exists: false, pointsCount: 0 }, + post_faces: { exists: false, pointsCount: 0 }, + }, + error: `HTTP ${livezRes.status}: ${livezRes.statusText}`, + }; + } + + // 2. Query collections status and point counts + const qdrant = getQdrantClient(); + const [existingColls, imagesCount, facesCount] = await Promise.all([ + qdrant.getCollections().catch(() => ({ collections: [] })), + countCollectionPoints(COLLECTIONS.POST_IMAGES, profileId), + countCollectionPoints(COLLECTIONS.POST_FACES, profileId), + ]); + + const collNames = new Set(existingColls.collections.map((c) => c.name)); + const imagesExist = collNames.has(COLLECTIONS.POST_IMAGES); + const facesExist = collNames.has(COLLECTIONS.POST_FACES); + + return { + status: imagesExist ? "healthy" : "degraded", + latencyMs, + url: config.url, + dashboardUrl, + collections: { + post_images: { exists: imagesExist, pointsCount: imagesCount }, + post_faces: { exists: facesExist, pointsCount: facesCount }, + }, + }; + } catch (err: unknown) { + const latencyMs = Math.round(performance.now() - start); + const errorMsg = err instanceof Error ? err.message : String(err); + return { + status: "disconnected", + latencyMs, + url: config.url, + dashboardUrl, + collections: { + post_images: { exists: false, pointsCount: 0 }, + post_faces: { exists: false, pointsCount: 0 }, + }, + error: errorMsg, + }; + } +} + /** Creates the post_images/post_faces collections (+ profileId payload index) if missing. Safe to call repeatedly. */ export async function ensureCollections(): Promise { const qdrant = getQdrantClient(); @@ -135,11 +286,26 @@ export async function searchByVector( limit: number ): Promise { const qdrant = getQdrantClient(); - const result = await qdrant.query(collection, { - query: vector, - filter: { must: [{ key: "profileId", match: { value: profileId } }] }, - limit, - with_payload: true, - }); - return result.points.map((p) => ({ score: p.score, payload: p.payload })); + + try { + const result = await qdrant.query(collection, { + query: vector, + filter: { must: [{ key: "profileId", match: { value: profileId } }] }, + limit, + with_payload: true, + }); + return result.points.map((p) => ({ score: p.score, payload: p.payload })); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if ( + message.includes("doesn't exist") || + message.includes("Not found: Collection") || + message.includes("404") + ) { + throw new VectorIndexNotBuiltError( + `Collection '${collection}' does not exist in Qdrant. Please run the vector indexer first.` + ); + } + throw err; + } } diff --git a/src/lib/vector/stats.ts b/src/lib/vector/stats.ts new file mode 100644 index 0000000..d9d432e --- /dev/null +++ b/src/lib/vector/stats.ts @@ -0,0 +1,74 @@ +import { prisma } from "@/lib/prisma"; + +export interface VectorIndexStats { + profileId: string; + profileName?: string; + status: "idle" | "running" | "completed" | "failed"; + lastRunAt: string | null; + lastCompletedAt: string | null; + durationMs: number | null; + cutoffPostTakenAt: number | null; + cutoffPostDate: string | null; + totalItems: number; + indexedItems: number; + facesIndexed: number; + failedItems: number; + lastError: string | null; + updatedAt: string; +} + +const STATS_KEY_PREFIX = "vector_index_stats_"; + +export async function getProfileVectorStats(profileId: string): Promise { + try { + const setting = await prisma.setting.findUnique({ + where: { key: `${STATS_KEY_PREFIX}${profileId}` }, + }); + if (!setting?.value) return null; + return JSON.parse(setting.value) as VectorIndexStats; + } catch { + return null; + } +} + +export async function saveProfileVectorStats(stats: VectorIndexStats): Promise { + const now = new Date().toISOString(); + stats.updatedAt = now; + + try { + await prisma.setting.upsert({ + where: { key: `${STATS_KEY_PREFIX}${stats.profileId}` }, + update: { + value: JSON.stringify(stats), + updatedAt: now, + }, + create: { + key: `${STATS_KEY_PREFIX}${stats.profileId}`, + value: JSON.stringify(stats), + updatedAt: now, + }, + }); + } catch (err) { + console.error("[vector-stats] Failed to persist vector stats:", err); + } +} + +export async function getAllProfilesVectorStats(): Promise { + try { + const settings = await prisma.setting.findMany({ + where: { key: { startsWith: STATS_KEY_PREFIX } }, + }); + + const list: VectorIndexStats[] = []; + for (const s of settings) { + try { + list.push(JSON.parse(s.value)); + } catch { + // Ignore corrupt record + } + } + return list; + } catch { + return []; + } +} diff --git a/wiki/Coolify-Self-Hosting-Guide.md b/wiki/Coolify-Self-Hosting-Guide.md index 17ef0f0..5808bb0 100644 --- a/wiki/Coolify-Self-Hosting-Guide.md +++ b/wiki/Coolify-Self-Hosting-Guide.md @@ -1,6 +1,6 @@ # 🔷 Coolify Self-Hosting Guide -[Coolify](https://coolify.io) is an all-in-one self-hostable PaaS with support for multi-server setups, push-to-deploy, and automated Let's Encrypt SSL certificates. +[Coolify](https://coolify.io) is an all-in-one self-hostable PaaS with support for multiple servers, push-to-deploy, and automatic SSL certificates. --- @@ -8,11 +8,11 @@ ### Step 1: Add New Resource 1. Open your **Coolify Dashboard**. -2. Navigate to your Project / Environment and click **+ New Resource**. +2. Navigate to your Project and click **+ New Resource**. 3. Select **Docker Compose**. ### Step 2: Paste Configuration -Paste the contents of `coolify-compose.yml`: +Paste the contents of [`coolify-compose.yml`](../coolify-compose.yml): ```yaml version: "3.8" @@ -23,15 +23,21 @@ services: pull_policy: always restart: unless-stopped expose: - - "3000" + - "5050" + 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 + - QDRANT_URL=http://qdrant:6333 + - QDRANT_PORT=${QDRANT_PORT:-6335} depends_on: mongo: condition: service_healthy + qdrant: + condition: service_healthy mongo: image: mongo:7.0 @@ -56,13 +62,33 @@ services: retries: 10 start_period: 2s + qdrant: + image: qdrant/qdrant:v1.13.4 + restart: unless-stopped + ports: + - "${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: mongo_data: + qdrant_data: ``` ### Step 3: Domain & Routing 1. In the Coolify resource view, enter your **FQDN / Domain** (e.g. `https://instagram.example.com`). -2. Set the destination port to `3000`. +2. Set the destination port to `5050`. +3. (Optional) To expose the Qdrant Dashboard UI, create a subdomain pointing to port `6335` (`https://qdrant.example.com/dashboard`). ### Step 4: Deploy 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/wiki/Docker-Compose-Deployment.md b/wiki/Docker-Compose-Deployment.md index 9b9594f..437bf0f 100644 --- a/wiki/Docker-Compose-Deployment.md +++ b/wiki/Docker-Compose-Deployment.md @@ -40,13 +40,15 @@ docker compose ps ``` You will see: -- `instagram_saved_posts_app`: Next.js web application and scraper engine (port 3000). +- `instagram_saved_posts_app`: Next.js web application and scraper engine (port 5050). +- `instagram_saved_posts_qdrant`: Qdrant vector search database & dashboard (port 6335). - `instagram_saved_posts_mongo`: MongoDB 7.0 database configured with replica set `rs0` (healthy). > [!NOTE] > The compose configuration uses `pull_policy: always` for the `app` service. This ensures `docker compose up -d` always checks and pulls the latest container image from GHCR when tracking `:latest` or `:beta`. -Access your dashboard at `http://localhost:3000` (or your server's IP address) to start the onboarding wizard! +Access your dashboard at `http://localhost:5050` (or your server's IP address) to start the onboarding wizard! +Explore vectors in the Qdrant Dashboard at `http://localhost:6335/dashboard`. --- diff --git a/wiki/Dokploy-Self-Hosting-Guide.md b/wiki/Dokploy-Self-Hosting-Guide.md index 66b3bda..854aebb 100644 --- a/wiki/Dokploy-Self-Hosting-Guide.md +++ b/wiki/Dokploy-Self-Hosting-Guide.md @@ -1,15 +1,15 @@ # 🟣 Dokploy Self-Hosting Guide -[Dokploy](https://dokploy.com) is a modern, lightweight, open-source alternative to Heroku and Portainer with built-in Traefik reverse proxy and automatic SSL certificates. +[Dokploy](https://dokploy.com) is an open-source alternative to Heroku and Coolify with automatic Traefik SSL and multi-service compose orchestration. --- ## 🚀 1-Click Compose Deployment in Dokploy -### Step 1: Create a Project & Service +### Step 1: Create a New Project & Service 1. Log in to your **Dokploy Dashboard**. 2. Click **Create Project** (e.g. `InstaSave`). -3. Click **Add Service** → Select **Compose**. +3. Click **Add Service** -> Select **Compose**. ### Step 2: Configure Compose Stack In the Compose editor, paste the contents of `dokploy-compose.yml`: @@ -23,15 +23,19 @@ 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 + - QDRANT_URL=http://qdrant:6333 + - QDRANT_PORT=6335 depends_on: mongo: condition: service_healthy + qdrant: + condition: service_healthy mongo: image: mongo:7.0 @@ -56,15 +60,35 @@ services: retries: 10 start_period: 2s + qdrant: + image: qdrant/qdrant:v1.13.4 + restart: unless-stopped + ports: + - "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: mongo_data: + qdrant_data: ``` ### Step 3: Configure Domain & SSL 1. Open the **Domains** tab for the `app` service in Dokploy. 2. Add your custom domain (e.g. `instagram.yourdomain.com`). -3. Set Port to `3000`. +3. Set Port to `5050`. 4. Enable **HTTPS (Let's Encrypt)**. +5. (Optional) Create a domain mapping for the Qdrant Dashboard UI on port `6335` (e.g. `qdrant.yourdomain.com/dashboard`). ### Step 4: Deploy -Click **Deploy**. Dokploy will pull the container images, verify MongoDB replica set health, and launch the application behind Traefik SSL! +Click **Deploy**. Dokploy will pull the container images, verify MongoDB and Qdrant health, and launch the application behind Traefik SSL! diff --git a/wiki/Reverse-Proxy-and-Authentik-SSO.md b/wiki/Reverse-Proxy-and-Authentik-SSO.md index 360dba1..07a2c34 100644 --- a/wiki/Reverse-Proxy-and-Authentik-SSO.md +++ b/wiki/Reverse-Proxy-and-Authentik-SSO.md @@ -48,7 +48,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'; @@ -67,5 +67,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:5050` if on docker network). 3. Optional: Add Cloudflare Access Applications for email OTP or OAuth authentication. From 55a7d00b5278b0bdfbc3f850dc6fac19a78e7903 Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Thu, 3 Sep 2026 16:05:40 +0300 Subject: [PATCH 03/13] Improve search ranking and match attribution Upgraded search quality across text, image, and face routes by adding score calibration, noise/drop-off filtering, and richer result metadata (raw score, match type, matched slide/image). Text search now blends vector and lexical/account matches using reciprocal rank fusion, while image/face search keep best per-post matches with better confidence scaling. Updated vector indexing/payloads and PostCard/search UI to display matched slide thumbnails and match labels, and switched CLIP vision warmup/embedding to explicit processor+projection model loading with normalized embeddings. --- scripts/warm-models.ts | 12 +- src/app/(dashboard)/search/page.tsx | 33 ++++- src/app/api/search/by-face/route.ts | 45 +++++- src/app/api/search/by-image/route.ts | 51 +++++-- src/app/api/search/by-text/route.ts | 209 +++++++++++++++++++++++++-- src/components/posts/post-card.tsx | 17 ++- src/lib/vector/image-embedding.ts | 48 ++++-- src/lib/vector/index-posts.ts | 1 + src/lib/vector/qdrant-client.ts | 1 + src/lib/vector/text-embedding.ts | 42 +++++- src/types/index.ts | 4 + 11 files changed, 409 insertions(+), 54 deletions(-) diff --git a/scripts/warm-models.ts b/scripts/warm-models.ts index f20bd29..5f30316 100644 --- a/scripts/warm-models.ts +++ b/scripts/warm-models.ts @@ -3,11 +3,17 @@ * the Transformers.js cache so the image ships with them baked in instead * of fetching from huggingface.co on first request. */ -import { pipeline, AutoTokenizer, CLIPTextModelWithProjection } from "@huggingface/transformers"; +import { + AutoProcessor, + CLIPVisionModelWithProjection, + AutoTokenizer, + CLIPTextModelWithProjection, +} from "@huggingface/transformers"; async function main() { - console.log("[warm-models] Warming CLIP vision model..."); - await pipeline("image-feature-extraction", "Xenova/clip-vit-base-patch32", { + console.log("[warm-models] Warming CLIP vision processor & projection model..."); + await AutoProcessor.from_pretrained("Xenova/clip-vit-base-patch32"); + await CLIPVisionModelWithProjection.from_pretrained("Xenova/clip-vit-base-patch32", { dtype: "q8", }); diff --git a/src/app/(dashboard)/search/page.tsx b/src/app/(dashboard)/search/page.tsx index 4c87200..d9a7514 100644 --- a/src/app/(dashboard)/search/page.tsx +++ b/src/app/(dashboard)/search/page.tsx @@ -470,12 +470,37 @@ export default function SearchPage() { const pct = Math.round(hit.score * 100); return (
- setSelectedPost(hit.post)} /> -
- = 80 ? "text-amber-400" : "text-zinc-300"}> + setSelectedPost(hit.post)} + thumbnailOverride={hit.matchedImageUrl} + matchedSlideIndex={hit.matchedSlideIndex} + /> +
+ = 85 + ? "text-amber-400" + : pct >= 70 + ? "text-emerald-400" + : "text-zinc-300" + } + > {pct}% - match + {hit.matchType && ( + + {hit.matchType === "hybrid" + ? "Hybrid" + : hit.matchType === "caption" + ? "Caption" + : hit.matchType === "account" + ? "Author" + : hit.matchType === "face" + ? "Face" + : "Visual"} + + )}
); diff --git a/src/app/api/search/by-face/route.ts b/src/app/api/search/by-face/route.ts index 2a1a935..33fdd1a 100644 --- a/src/app/api/search/by-face/route.ts +++ b/src/app/api/search/by-face/route.ts @@ -51,16 +51,47 @@ export async function POST(request: NextRequest) { ) ); - const bestByPk = new Map(); + // Helper to extract true Euclidean distance regardless of Qdrant score representation + const getEuclideanDistance = (score: number): number => { + if (score <= 0) return Math.abs(score); + if (score <= 1) return 1 / score - 1; + return score; + }; + + interface FaceHitDetails { + score: number; + calibratedScore: number; + distance: number; + bbox?: VectorSearchHit["bbox"]; + carouselPosition?: number; + imageUrl?: string; + } + + const bestByPk = new Map(); + const FACE_DISTANCE_THRESHOLD = 0.62; // Standard FaceNet same-person identity boundary + for (const hits of hitLists) { for (const hit of hits) { const pk = hit.payload?.postPk; if (typeof pk !== "string") continue; + + const distance = getEuclideanDistance(hit.score); + // Exclude faces that exceed identity threshold + if (distance > FACE_DISTANCE_THRESHOLD) continue; + + // Calibrate Euclidean distance [0.15, 0.62] -> [0.99, 0.50] + const norm = Math.max(0, Math.min(1, (distance - 0.15) / (FACE_DISTANCE_THRESHOLD - 0.15))); + const calibratedScore = Math.min(0.99, Math.max(0.50, 0.99 - norm * 0.49)); + const prev = bestByPk.get(pk); - if (prev === undefined || hit.score > prev.score) { + if (prev === undefined || calibratedScore > prev.calibratedScore) { bestByPk.set(pk, { score: hit.score, + calibratedScore, + distance, bbox: hit.payload?.bbox as VectorSearchHit["bbox"], + carouselPosition: hit.payload?.carouselPosition as number | undefined, + imageUrl: hit.payload?.imageUrl as string | undefined, }); } } @@ -73,7 +104,15 @@ export async function POST(request: NextRequest) { const results: VectorSearchHit[] = [...bestByPk.entries()] .filter(([pk]) => postByPk.has(pk)) - .map(([pk, { score, bbox }]) => ({ post: postByPk.get(pk)!, score, bbox })) + .map(([pk, details]) => ({ + post: postByPk.get(pk)!, + score: details.calibratedScore, + rawScore: details.score, + matchType: "face" as const, + matchedSlideIndex: details.carouselPosition, + matchedImageUrl: details.imageUrl, + bbox: details.bbox, + })) .sort((a, b) => b.score - a.score); return NextResponse.json({ results }); diff --git a/src/app/api/search/by-image/route.ts b/src/app/api/search/by-image/route.ts index 8efc19e..f8b77aa 100644 --- a/src/app/api/search/by-image/route.ts +++ b/src/app/api/search/by-image/route.ts @@ -38,24 +38,57 @@ export async function POST(request: NextRequest) { const vector = await embedImageFromBuffer(buffer); const hits = await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); - // Keep only the best-scoring hit per post - const bestScoreByPk = new Map(); + // Keep only the best-scoring hit per post, recording slide attribution + interface HitDetails { + score: number; + source?: "thumbnail" | "carousel"; + carouselPosition?: number; + imageUrl?: string; + } + const bestHitByPk = new Map(); for (const hit of hits) { const pk = hit.payload?.postPk; if (typeof pk !== "string") continue; - const prev = bestScoreByPk.get(pk); - if (prev === undefined || hit.score > prev) bestScoreByPk.set(pk, hit.score); + // Filter out low similarity noise + if (hit.score < 0.26) continue; + const prev = bestHitByPk.get(pk); + if (prev === undefined || hit.score > prev.score) { + bestHitByPk.set(pk, { + score: hit.score, + source: hit.payload?.source as "thumbnail" | "carousel" | undefined, + carouselPosition: hit.payload?.carouselPosition as number | undefined, + imageUrl: hit.payload?.imageUrl as string | undefined, + }); + } } + const sortedHits = [...bestHitByPk.entries()].sort((a, b) => b[1].score - a[1].score); + const topScore = sortedHits.length > 0 ? sortedHits[0][1].score : 0; + + // Filter by elbow drop-off (keep hits with score >= 65% of top score) + const filteredHits = sortedHits.filter(([, h]) => topScore > 0 && h.score >= topScore * 0.65); + const posts = await prisma.post.findMany({ - where: { profileId: profile.id, pk: { in: [...bestScoreByPk.keys()] } }, + where: { profileId: profile.id, pk: { in: filteredHits.map(([pk]) => pk) } }, }); const postByPk = new Map(posts.map((p) => [p.pk, p])); - const results: VectorSearchHit[] = [...bestScoreByPk.entries()] - .map(([pk, score]) => ({ post: postByPk.get(pk), score })) - .filter((r): r is VectorSearchHit => !!r.post) - .sort((a, b) => b.score - a.score); + const results: VectorSearchHit[] = []; + for (const [pk, hit] of filteredHits) { + const post = postByPk.get(pk); + if (!post) continue; + // Calibrate image-to-image score [0.26, 0.85] -> [0.45, 0.99] + const norm = Math.max(0, Math.min(1, (hit.score - 0.26) / 0.55)); + const calibratedScore = Math.min(0.99, Math.max(0.45, 0.45 + norm * 0.54)); + results.push({ + post, + score: calibratedScore, + rawScore: hit.score, + matchType: "visual", + matchedSlideIndex: hit.carouselPosition, + matchedImageUrl: hit.imageUrl, + }); + } return NextResponse.json({ results }); } catch (err: unknown) { diff --git a/src/app/api/search/by-text/route.ts b/src/app/api/search/by-text/route.ts index eef5c81..32a3ad1 100644 --- a/src/app/api/search/by-text/route.ts +++ b/src/app/api/search/by-text/route.ts @@ -46,27 +46,212 @@ export async function POST(request: NextRequest) { } try { - const vector = await embedText(query.trim()); - const hits = await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); + const rawQuery = query.trim(); + const vectorPromise = (async () => { + try { + const vector = await embedText(rawQuery); + return await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); + } catch (e) { + // If vector index is not built yet, we will bubble it up if text search also finds nothing + return e; + } + })(); - // Keep highest similarity score per post - const bestScoreByPk = new Map(); - for (const hit of hits) { + // Lexical / Full-Text Search across captions, hashtags, and creator accounts + const textSearchPromise = (async () => { + const cleanTerms = rawQuery + .split(/\s+/) + .map((t) => t.replace(/^[#@]/, "").trim()) + .filter((t) => t.length >= 2); + + // 1. Find creator accounts matching query + const matchingAccounts = await prisma.account.findMany({ + where: { + profileId: profile.id, + OR: [ + { username: { contains: rawQuery, mode: "insensitive" } }, + { fullName: { contains: rawQuery, mode: "insensitive" } }, + ], + }, + select: { pk: true, username: true }, + take: 30, + }); + const matchingAccountPks = matchingAccounts.map((a) => a.pk); + + // 2. Find posts matching caption or creator + const textConditions: import("@prisma/client").Prisma.PostWhereInput[] = [ + { captionText: { contains: rawQuery, mode: "insensitive" } }, + ]; + if (matchingAccountPks.length > 0) { + textConditions.push({ accountPk: { in: matchingAccountPks } }); + } + for (const term of cleanTerms) { + textConditions.push({ captionText: { contains: term, mode: "insensitive" } }); + } + + const posts = await prisma.post.findMany({ + where: { + profileId: profile.id, + OR: textConditions, + }, + select: { pk: true, captionText: true, accountPk: true }, + take: RESULT_LIMIT, + }); + + const lowerQuery = rawQuery.toLowerCase(); + return posts + .map((p) => { + const hasExact = p.captionText?.toLowerCase().includes(lowerQuery); + const isAccount = matchingAccountPks.includes(p.accountPk); + let priority = 3; + if (hasExact) priority = 1; + else if (isAccount) priority = 2; + return { pk: p.pk, priority, isAccount }; + }) + .sort((a, b) => a.priority - b.priority); + })(); + + const [vectorResult, textMatches] = await Promise.all([vectorPromise, textSearchPromise]); + + if (vectorResult instanceof Error) { + // If vector search failed because collection missing and we found no text matches either, rethrow + if ( + textMatches.length === 0 && + (vectorResult instanceof VectorIndexNotBuiltError || + vectorResult.message.includes("doesn't exist") || + vectorResult.message.includes("Not found: Collection")) + ) { + throw vectorResult; + } + } + + const vectorHits = Array.isArray(vectorResult) ? vectorResult : []; + + // Collect best visual hit per post + interface VisualMatch { + score: number; + source?: "thumbnail" | "carousel"; + carouselPosition?: number; + imageUrl?: string; + } + const bestVisualByPk = new Map(); + for (const hit of vectorHits) { const pk = hit.payload?.postPk; if (typeof pk !== "string") continue; - const prev = bestScoreByPk.get(pk); - if (prev === undefined || hit.score > prev) bestScoreByPk.set(pk, hit.score); + // Filter out pure noise (unrelated cosine similarities < 0.20) + if (hit.score < 0.20) continue; + const prev = bestVisualByPk.get(pk); + if (prev === undefined || hit.score > prev.score) { + bestVisualByPk.set(pk, { + score: hit.score, + source: hit.payload?.source as "thumbnail" | "carousel" | undefined, + carouselPosition: hit.payload?.carouselPosition as number | undefined, + imageUrl: hit.payload?.imageUrl as string | undefined, + }); + } + } + + // Rank visual results + const rankedVisual = [...bestVisualByPk.entries()].sort((a, b) => b[1].score - a[1].score); + const visualRankByPk = new Map(); + rankedVisual.forEach(([pk], i) => visualRankByPk.set(pk, i + 1)); + const topVisualScore = rankedVisual.length > 0 ? rankedVisual[0][1].score : 0; + + // Rank text results + const textRankByPk = new Map(); + textMatches.forEach((match, i) => { + textRankByPk.set(match.pk, { + rank: i + 1, + matchType: match.isAccount ? "account" : "caption", + }); + }); + + // Merge via Reciprocal Rank Fusion (RRF) + const allPks = new Set([...visualRankByPk.keys(), ...textRankByPk.keys()]); + const RRF_K = 60; + const WEIGHT_VECTOR = 1.0; + const WEIGHT_TEXT = 1.3; + + interface MergedCandidate { + pk: string; + rrfScore: number; + calibratedScore: number; + rawScore?: number; + matchType: "hybrid" | "visual" | "caption" | "account"; + matchedSlideIndex?: number; + matchedImageUrl?: string; + } + + const candidates: MergedCandidate[] = []; + + for (const pk of allPks) { + const vRank = visualRankByPk.get(pk); + const tInfo = textRankByPk.get(pk); + const vMatch = bestVisualByPk.get(pk); + + // Apply elbow filter for pure visual matches: + // If post only matched visually and its score is below 65% of the top visual score, skip noise + if (!tInfo && vMatch) { + if (vMatch.score < 0.22 || (topVisualScore > 0 && vMatch.score < topVisualScore * 0.65)) { + continue; + } + } + + const vRrf = vRank !== undefined ? WEIGHT_VECTOR / (RRF_K + vRank) : 0; + const tRrf = tInfo !== undefined ? WEIGHT_TEXT / (RRF_K + tInfo.rank) : 0; + const rrfScore = vRrf + tRrf; + + let matchType: "hybrid" | "visual" | "caption" | "account" = "visual"; + let calibratedScore = 0.5; + + if (vRank !== undefined && tInfo !== undefined) { + matchType = "hybrid"; + // Hybrid matches get highest confidence (88% - 99%) + const base = 0.88; + const boost = Math.min(0.11, ((vMatch?.score ?? 0.25) - 0.20) * 0.5); + calibratedScore = Math.min(0.99, base + boost); + } else if (tInfo !== undefined) { + matchType = tInfo.matchType; + calibratedScore = tInfo.rank <= 3 ? 0.92 : 0.85; + } else if (vMatch !== undefined) { + matchType = "visual"; + // Calibrate raw cosine score [0.20, 0.40] -> [0.45, 0.95] + const normScore = Math.max(0, Math.min(1, (vMatch.score - 0.20) / 0.20)); + calibratedScore = Math.min(0.95, Math.max(0.45, 0.45 + normScore * 0.5)); + } + + candidates.push({ + pk, + rrfScore, + calibratedScore, + rawScore: vMatch?.score, + matchType, + matchedSlideIndex: vMatch?.carouselPosition, + matchedImageUrl: vMatch?.imageUrl, + }); } + candidates.sort((a, b) => b.rrfScore - a.rrfScore); + const topCandidates = candidates.slice(0, RESULT_LIMIT); + const posts = await prisma.post.findMany({ - where: { profileId: profile.id, pk: { in: [...bestScoreByPk.keys()] } }, + where: { profileId: profile.id, pk: { in: topCandidates.map((c) => c.pk) } }, }); const postByPk = new Map(posts.map((p) => [p.pk, p])); - const results: VectorSearchHit[] = [...bestScoreByPk.entries()] - .map(([pk, score]) => ({ post: postByPk.get(pk), score })) - .filter((r): r is VectorSearchHit => !!r.post) - .sort((a, b) => b.score - a.score); + const results: VectorSearchHit[] = []; + for (const c of topCandidates) { + const post = postByPk.get(c.pk); + if (!post) continue; + results.push({ + post, + score: c.calibratedScore, + rawScore: c.rawScore, + matchType: c.matchType, + matchedSlideIndex: c.matchedSlideIndex, + matchedImageUrl: c.matchedImageUrl, + }); + } return NextResponse.json({ results }); } catch (err: unknown) { diff --git a/src/components/posts/post-card.tsx b/src/components/posts/post-card.tsx index 3ecdd76..65ecf1f 100644 --- a/src/components/posts/post-card.tsx +++ b/src/components/posts/post-card.tsx @@ -8,19 +8,24 @@ import type { Post } from "@/types"; interface PostCardProps { post: Post; onClick?: () => void; + thumbnailOverride?: string | null; + matchedSlideIndex?: number | null; } -export function PostCard({ post, onClick }: PostCardProps) { +export function PostCard({ post, onClick, thumbnailOverride, matchedSlideIndex }: PostCardProps) { + const displayUrl = + thumbnailOverride ?? post.cloudinaryThumbnailUrl ?? proxyImageUrl(post.thumbnailUrl); + return (
- {(post.cloudinaryThumbnailUrl ?? post.thumbnailUrl) ? ( + {displayUrl ? ( // eslint-disable-next-line @next/next/no-img-element {post.captionText?.slice(0, 1 && (
- 1/{post.carouselMediaCount} + + {matchedSlideIndex !== undefined && matchedSlideIndex !== null + ? `Slide ${matchedSlideIndex + 1}/${post.carouselMediaCount}` + : `1/${post.carouselMediaCount}`} +
)}
diff --git a/src/lib/vector/image-embedding.ts b/src/lib/vector/image-embedding.ts index 4f8d6d4..5d59e5c 100644 --- a/src/lib/vector/image-embedding.ts +++ b/src/lib/vector/image-embedding.ts @@ -1,23 +1,43 @@ -import { pipeline, RawImage, type ImageFeatureExtractionPipeline } from "@huggingface/transformers"; +import { + AutoProcessor, + CLIPVisionModelWithProjection, + RawImage, + type Processor, +} from "@huggingface/transformers"; -let extractorPromise: Promise | null = null; +let processorPromise: Promise | null = null; +let visionModelPromise: Promise | null = null; -function getExtractor(): Promise { - if (!extractorPromise) { - extractorPromise = pipeline("image-feature-extraction", "Xenova/clip-vit-base-patch32", { - dtype: "q8", - }); +function getProcessor(): Promise { + if (!processorPromise) { + processorPromise = AutoProcessor.from_pretrained("Xenova/clip-vit-base-patch32"); } - return extractorPromise; + return processorPromise; +} + +function getVisionModel(): Promise { + if (!visionModelPromise) { + visionModelPromise = CLIPVisionModelWithProjection.from_pretrained( + "Xenova/clip-vit-base-patch32", + { dtype: "q8" } + ); + } + return visionModelPromise; } async function embed(image: RawImage): Promise { - const extractor = await getExtractor(); - // clip-vit-base-patch32's ONNX export has no pooler layer, so `{ pool: true }` - // (the only option the .d.ts documents) throws. `pooling`/`normalize` are the - // actual runtime-supported options for this pipeline; types just lag the lib. - const output = await extractor(image, { pooling: "mean", normalize: true } as never); - return Array.from(output.data as Float32Array); + const [processor, visionModel] = await Promise.all([ + getProcessor(), + getVisionModel(), + ]); + + const imageInputs = await processor(image); + const { image_embeds } = await visionModel(imageInputs); + + const raw = Array.from(image_embeds.data as Float32Array); + // L2 normalize so cosine distance in Qdrant aligns perfectly with normalized text embeddings + const norm = Math.sqrt(raw.reduce((sum, v) => sum + v * v, 0)) || 1; + return raw.map((v) => v / norm); } /** 512-d CLIP embedding for an image at a stable HTTPS URL (Cloudinary or Instagram CDN). */ diff --git a/src/lib/vector/index-posts.ts b/src/lib/vector/index-posts.ts index aceb4ac..cdd2c55 100644 --- a/src/lib/vector/index-posts.ts +++ b/src/lib/vector/index-posts.ts @@ -175,6 +175,7 @@ export async function runVectorIndex(profileId: string): Promise { mediaType: target.mediaType, source: target.source, carouselPosition: target.position, + imageUrl: target.imageUrl, }; const imageVector = await embedImageFromUrl(target.imageUrl); diff --git a/src/lib/vector/qdrant-client.ts b/src/lib/vector/qdrant-client.ts index 4834c7b..4fda8e7 100644 --- a/src/lib/vector/qdrant-client.ts +++ b/src/lib/vector/qdrant-client.ts @@ -242,6 +242,7 @@ export interface PostVectorPayload { mediaType: number; source: VectorSource; carouselPosition?: number; + imageUrl?: string; [key: string]: unknown; } diff --git a/src/lib/vector/text-embedding.ts b/src/lib/vector/text-embedding.ts index 972dd86..af53360 100644 --- a/src/lib/vector/text-embedding.ts +++ b/src/lib/vector/text-embedding.ts @@ -27,15 +27,47 @@ function getTextModel(): Promise { /** * Generates a 512-dimensional, L2-normalized CLIP text embedding for a natural-language * search prompt. Embeds into the exact same vector space as the saved post images. + * Uses prompt ensembling (averaging variations) to significantly boost retrieval accuracy. */ export async function embedText(text: string): Promise { const [tokenizer, textModel] = await Promise.all([getTokenizer(), getTextModel()]); - const inputs = tokenizer([text], { padding: true, truncation: true }); + const trimmed = text.trim(); + // Prompt ensembling: query + descriptive templates stabilize linguistic variance + const prompts = [ + trimmed, + `a photo of ${trimmed}`, + `a picture of ${trimmed}`, + ]; + + const inputs = tokenizer(prompts, { padding: true, truncation: true }); const { text_embeds } = await textModel(inputs); - const raw = Array.from(text_embeds.data as Float32Array); - // L2 normalize so cosine distance in Qdrant aligns perfectly with normalized image embeddings - const norm = Math.sqrt(raw.reduce((sum, v) => sum + v * v, 0)) || 1; - return raw.map((v) => v / norm); + const dims = 512; + const data = text_embeds.data as Float32Array; + const avg = new Float32Array(dims); + + for (let i = 0; i < prompts.length; i++) { + const offset = i * dims; + let sumSq = 0; + for (let d = 0; d < dims; d++) { + const val = data[offset + d]; + sumSq += val * val; + } + const norm = Math.sqrt(sumSq) || 1; + for (let d = 0; d < dims; d++) { + avg[d] += data[offset + d] / norm; + } + } + + // Final L2 normalization of ensembled vector + let totalSumSq = 0; + for (let d = 0; d < dims; d++) totalSumSq += avg[d] * avg[d]; + const finalNorm = Math.sqrt(totalSumSq) || 1; + + const result: number[] = new Array(dims); + for (let d = 0; d < dims; d++) { + result[d] = avg[d] / finalNorm; + } + return result; } diff --git a/src/types/index.ts b/src/types/index.ts index 98d294d..006dbf0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -114,6 +114,10 @@ export interface CloudinarySyncProgress { export interface VectorSearchHit { post: Post; score: number; + rawScore?: number; + matchType?: "hybrid" | "visual" | "caption" | "account" | "face"; + matchedSlideIndex?: number; + matchedImageUrl?: string; /** Only present for search-by-face results — the matched face's location in the post's image. */ bbox?: { x: number; y: number; width: number; height: number }; } From dc54ee7c29e00f50102316694eee87a470c86b69 Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Thu, 3 Sep 2026 17:28:32 +0300 Subject: [PATCH 04/13] Harden vector search and deployment defaults Improves security and reliability across deployment and vector search flows. Qdrant is now bound to localhost in compose/docs, Coolify routing docs are corrected to target app port 3000, and install scripts now resolve effective mapped ports before printing access URLs. Search and indexing were made safer: added request/file size limits, capped face-query fanout, prevented stale UI search results when switching modes, fixed health/liveness status handling, blocked concurrent reindex starts, reset failed model-load caches for retry, enforced HTTPS when using Qdrant API keys on non-local hosts, and made collection initialization concurrency-safe. --- README.md | 4 +- coolify-compose.yml | 4 +- docker-compose.yml | 2 +- docs/deployment/coolify.md | 8 +- docs/deployment/dokploy.md | 4 +- docs/deployment/reverse-proxy-and-sso.md | 2 +- dokploy-compose.yml | 2 +- install.ps1 | 12 +- install.sh | 6 +- scripts/reindex-vectors.ts | 9 ++ src/app/(dashboard)/search/page.tsx | 157 +++++++++++++++-------- src/app/api/health/route.ts | 2 +- src/app/api/search/by-face/route.ts | 14 +- src/app/api/search/by-image/route.ts | 8 ++ src/app/api/search/by-text/route.ts | 24 +++- src/app/api/search/liveness/route.ts | 6 +- src/app/api/search/reindex/route.ts | 10 +- src/lib/vector/image-embedding.ts | 12 +- src/lib/vector/index-posts.ts | 62 ++++----- src/lib/vector/qdrant-client.ts | 108 +++++++++++++--- src/lib/vector/text-embedding.ts | 12 +- wiki/Coolify-Self-Hosting-Guide.md | 8 +- wiki/Docker-Compose-Deployment.md | 2 +- wiki/Dokploy-Self-Hosting-Guide.md | 4 +- wiki/Reverse-Proxy-and-Authentik-SSO.md | 2 +- 25 files changed, 347 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index 2e018fe..c84ac78 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ services: image: qdrant/qdrant:v1.13.4 restart: unless-stopped ports: - - "6335:6333" + - "127.0.0.1:6335:6333" volumes: - qdrant_data:/qdrant/storage ulimits: @@ -113,7 +113,7 @@ docker compose up -d ``` 🎉 Open **`http://localhost:5050`** in your browser and complete the 60-second onboarding wizard! -🔍 Explore your vector database & embeddings via the built-in **Qdrant Dashboard** at **`http://localhost:6335/dashboard`**. +🔍 Explore your vector database & embeddings via the built-in **Qdrant Dashboard** at **`http://localhost:6335/dashboard`** (bound to localhost for security). --- diff --git a/coolify-compose.yml b/coolify-compose.yml index b7554fb..ac6eb52 100644 --- a/coolify-compose.yml +++ b/coolify-compose.yml @@ -9,7 +9,7 @@ services: pull_policy: always restart: unless-stopped expose: - - "5050" + - "3000" ports: - "${PORT:-5050}:3000" environment: @@ -58,7 +58,7 @@ services: image: qdrant/qdrant:v1.13.4 restart: unless-stopped ports: - - "${QDRANT_PORT:-6335}:6333" + - "127.0.0.1:${QDRANT_PORT:-6335}:6333" volumes: - qdrant_data:/qdrant/storage ulimits: diff --git a/docker-compose.yml b/docker-compose.yml index 6422c29..740553c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,7 +61,7 @@ services: container_name: instagram_saved_posts_qdrant restart: unless-stopped ports: - - "${QDRANT_PORT:-6335}:6333" + - "127.0.0.1:${QDRANT_PORT:-6335}:6333" volumes: - qdrant_data:/qdrant/storage ulimits: diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md index fde1ae0..829963e 100644 --- a/docs/deployment/coolify.md +++ b/docs/deployment/coolify.md @@ -28,7 +28,7 @@ services: pull_policy: always restart: unless-stopped expose: - - "5050" + - "3000" ports: - "${PORT:-5050}:3000" environment: @@ -74,7 +74,7 @@ services: image: qdrant/qdrant:v1.13.4 restart: unless-stopped ports: - - "${QDRANT_PORT:-6335}:6333" + - "127.0.0.1:${QDRANT_PORT:-6335}:6333" volumes: - qdrant_data:/qdrant/storage ulimits: @@ -95,8 +95,8 @@ volumes: ### Step 3: Domain & Routing 1. In the Coolify resource view, configure your **FQDN / Domain** (e.g. `https://instagram.example.com`). -2. Set the destination port to `5050`. -3. (Optional) Expose the Qdrant Dashboard UI on a subdomain or port `6335` (`https://qdrant.example.com/dashboard`). +2. Set the destination port to `3000`. +3. (Optional) The Qdrant Dashboard UI is bound to `127.0.0.1:6335` for security. To access it, use an SSH tunnel (`ssh -L 6335:localhost:6335 user@server`) or route through an authenticated reverse proxy pointing to internal container network `http://qdrant:6333/dashboard`. ### Step 4: Deploy 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/dokploy.md b/docs/deployment/dokploy.md index 7f2f595..283bc22 100644 --- a/docs/deployment/dokploy.md +++ b/docs/deployment/dokploy.md @@ -73,7 +73,7 @@ services: image: qdrant/qdrant:v1.13.4 restart: unless-stopped ports: - - "6335:6333" + - "127.0.0.1:6335:6333" volumes: - qdrant_data:/qdrant/storage ulimits: @@ -97,7 +97,7 @@ volumes: 2. Add your custom domain (e.g. `instagram.yourdomain.com`). 3. Select Port `5050`. 4. Enable **HTTPS (Let's Encrypt)**. -5. (Optional) To expose the Qdrant Dashboard UI, create a domain mapping for service `qdrant` on port `6335` (e.g. `qdrant.yourdomain.com/dashboard`). +5. (Optional) The Qdrant Dashboard UI is bound to `127.0.0.1:6335` for security. To access it, use an SSH tunnel (`ssh -L 6335:localhost:6335 user@server`) or route through an authenticated reverse proxy pointing to internal container network `http://qdrant:6333/dashboard`. ### Step 4: Deploy Click **Deploy** at the top right. Dokploy will pull the container images, verify MongoDB and Qdrant health, and start the application automatically! diff --git a/docs/deployment/reverse-proxy-and-sso.md b/docs/deployment/reverse-proxy-and-sso.md index 80c963c..e83f8d1 100644 --- a/docs/deployment/reverse-proxy-and-sso.md +++ b/docs/deployment/reverse-proxy-and-sso.md @@ -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:5050` (or `http://app:5050` 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/dokploy-compose.yml b/dokploy-compose.yml index 3e0f6a1..8b94347 100644 --- a/dokploy-compose.yml +++ b/dokploy-compose.yml @@ -56,7 +56,7 @@ services: image: qdrant/qdrant:v1.13.4 restart: unless-stopped ports: - - "6335:6333" + - "127.0.0.1:6335:6333" volumes: - qdrant_data:/qdrant/storage ulimits: diff --git a/install.ps1 b/install.ps1 index 0d5d333..f0e6603 100644 --- a/install.ps1 +++ b/install.ps1 @@ -54,16 +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 " 👉 Web App: http://localhost:5050" -ForegroundColor Cyan -Write-Host " 👉 Qdrant Dashboard: http://localhost:6335/dashboard" -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:5050 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 43565f8..b8a9573 100644 --- a/install.sh +++ b/install.sh @@ -78,8 +78,10 @@ ${DOCKER_COMPOSE} up -d # Completion Banner HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost") -PORT="5050" -QDRANT_PORT="6335" +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}" diff --git a/scripts/reindex-vectors.ts b/scripts/reindex-vectors.ts index 63bff36..52703f3 100644 --- a/scripts/reindex-vectors.ts +++ b/scripts/reindex-vectors.ts @@ -49,6 +49,15 @@ async function main() { await runVectorIndex(profile.id); const state = getCurrentIndexState(profile.id); console.log(`[reindex-vectors] Done:`, state); + + if ( + state?.status === "failed" || + (typeof state?.failedItems === "number" + ? state.failedItems > 0 + : Boolean(state?.failedItems && (state.failedItems as unknown as unknown[]).length > 0)) + ) { + process.exitCode = 1; + } } await prisma.$disconnect(); diff --git a/src/app/(dashboard)/search/page.tsx b/src/app/(dashboard)/search/page.tsx index d9a7514..ca53e3e 100644 --- a/src/app/(dashboard)/search/page.tsx +++ b/src/app/(dashboard)/search/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Header } from "@/components/layout/header"; import { Card } from "@/components/ui/card"; @@ -61,8 +61,24 @@ const EXAMPLE_PROMPTS = [ 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); @@ -79,7 +95,11 @@ export default function SearchPage() { const reindexMutation = useReindexVectors(); const isPending = - searchByText.isPending || searchByImage.isPending || searchByFace.isPending; + mode === "text" + ? searchByText.isPending + : mode === "image" + ? searchByImage.isPending + : searchByFace.isPending; const handleSearchError = (err: VectorSearchError | Error) => { const isIndexNeeded = "needsIndexing" in err ? err.needsIndexing : false; @@ -92,36 +112,78 @@ export default function SearchPage() { } }; - const handleTextSubmit = (e?: React.FormEvent) => { - if (e) e.preventDefault(); - const query = textQuery.trim(); - if (!query) return; + 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(`"${query}"`); - searchByText.mutate(query, { - onSuccess: (hits) => setResults(hits), - onError: (err) => handleSearchError(err), + 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); - if (mode === "image") { + if (requestMode === "image") { searchByImage.mutate(file, { - onSuccess: (hits) => setResults(hits), - onError: (err) => handleSearchError(err), + onSuccess: (hits) => { + if (token !== searchTokenRef.current || modeRef.current !== requestMode) return; + setResults(hits); + }, + onError: (err) => { + if (token !== searchTokenRef.current || modeRef.current !== requestMode) return; + handleSearchError(err); + }, }); } else { searchByFace.mutate(file, { - onSuccess: (hits) => setResults(hits), - onError: (err) => handleSearchError(err), + onSuccess: (hits) => { + if (token !== searchTokenRef.current || modeRef.current !== requestMode) return; + setResults(hits); + }, + onError: (err) => { + if (token !== searchTokenRef.current || modeRef.current !== requestMode) return; + handleSearchError(err); + }, }); } }; @@ -143,8 +205,8 @@ export default function SearchPage() { const liveness = indexStatusData?.liveness; const stats = indexStatusData?.stats; - const dashboardUrl = liveness?.dashboardUrl ?? "http://localhost:6335/dashboard"; - const hasNeverIndexed = !isIndexRunning && (!stats || stats.indexedItems === 0); + const dashboardUrl = liveness?.dashboardUrl || null; + const hasNeverIndexed = !isLoadingStatus && !isIndexRunning && (!stats || stats.indexedItems === 0); return (
@@ -195,17 +257,19 @@ export default function SearchPage() { {/* Qdrant Dashboard Link */} - + {dashboardUrl && ( + + )} {/* Reindex Button */} )} @@ -674,12 +727,14 @@ export default function SearchPage() { )} - + {dashboardUrl ? ( + + ) :
} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 08ffd2d..428d8d6 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -32,6 +32,6 @@ export async function GET() { 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 index 33fdd1a..b916d4b 100644 --- a/src/app/api/search/by-face/route.ts +++ b/src/app/api/search/by-face/route.ts @@ -33,6 +33,14 @@ export async function POST(request: NextRequest) { ); } + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "Image file exceeds maximum allowed size of 10MB." }, + { status: 413 } + ); + } + try { const buffer = Buffer.from(await file.arrayBuffer()); const queryFaces = await detectFacesFromBuffer(buffer); @@ -43,10 +51,14 @@ export async function POST(request: NextRequest) { ); } + // Bound concurrent face search queries (e.g. max 5 faces from a group shot) + const MAX_SEARCH_FACES = 5; + const searchFaces = queryFaces.slice(0, MAX_SEARCH_FACES); + // Multiple faces in the query photo (e.g. a group shot) are all searched; // hits are merged by post below regardless of which query face matched. const hitLists = await Promise.all( - queryFaces.map((face) => + searchFaces.map((face) => searchByVector(COLLECTIONS.POST_FACES, face.descriptor, profile.id, RESULT_LIMIT) ) ); diff --git a/src/app/api/search/by-image/route.ts b/src/app/api/search/by-image/route.ts index f8b77aa..a2925a3 100644 --- a/src/app/api/search/by-image/route.ts +++ b/src/app/api/search/by-image/route.ts @@ -33,6 +33,14 @@ export async function POST(request: NextRequest) { ); } + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "Image file exceeds maximum allowed size of 10MB." }, + { status: 413 } + ); + } + try { const buffer = Buffer.from(await file.arrayBuffer()); const vector = await embedImageFromBuffer(buffer); diff --git a/src/app/api/search/by-text/route.ts b/src/app/api/search/by-text/route.ts index 32a3ad1..dba41d9 100644 --- a/src/app/api/search/by-text/route.ts +++ b/src/app/api/search/by-text/route.ts @@ -27,6 +27,14 @@ export async function POST(request: NextRequest) { ); } + const contentLength = Number(request.headers.get("content-length") || 0); + if (contentLength > 64 * 1024) { + return NextResponse.json( + { error: "Request payload exceeds size limit." }, + { status: 413 } + ); + } + let query: string | undefined; try { const body = await request.json(); @@ -45,6 +53,14 @@ export async function POST(request: NextRequest) { ); } + const MAX_QUERY_LENGTH = 1000; + if (query.length > MAX_QUERY_LENGTH) { + return NextResponse.json( + { error: `Query exceeds maximum length of ${MAX_QUERY_LENGTH} characters.` }, + { status: 413 } + ); + } + try { const rawQuery = query.trim(); const vectorPromise = (async () => { @@ -85,8 +101,12 @@ export async function POST(request: NextRequest) { if (matchingAccountPks.length > 0) { textConditions.push({ accountPk: { in: matchingAccountPks } }); } - for (const term of cleanTerms) { - textConditions.push({ captionText: { contains: term, mode: "insensitive" } }); + if (cleanTerms.length > 1) { + textConditions.push({ + AND: cleanTerms.map((term) => ({ + captionText: { contains: term, mode: "insensitive" as const }, + })), + }); } const posts = await prisma.post.findMany({ diff --git a/src/app/api/search/liveness/route.ts b/src/app/api/search/liveness/route.ts index e7f4dde..94c15ee 100644 --- a/src/app/api/search/liveness/route.ts +++ b/src/app/api/search/liveness/route.ts @@ -1,12 +1,14 @@ import { NextResponse } from "next/server"; -import { getActiveProfile } from "@/lib/active-profile"; +import { getActiveProfile, noActiveProfileResponse } from "@/lib/active-profile"; import { checkQdrantLiveness } from "@/lib/vector/qdrant-client"; export const dynamic = "force-dynamic"; export async function GET() { const profile = await getActiveProfile(); - const liveness = await checkQdrantLiveness(profile?.id); + if (!profile) return noActiveProfileResponse(); + + const liveness = await checkQdrantLiveness(profile.id); const httpStatus = liveness.status === "disconnected" ? 503 : 200; return NextResponse.json(liveness, { status: httpStatus }); diff --git a/src/app/api/search/reindex/route.ts b/src/app/api/search/reindex/route.ts index 3071bce..fe97d99 100644 --- a/src/app/api/search/reindex/route.ts +++ b/src/app/api/search/reindex/route.ts @@ -18,10 +18,18 @@ export async function POST() { { 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(() => { + 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) { diff --git a/src/lib/vector/image-embedding.ts b/src/lib/vector/image-embedding.ts index 5d59e5c..fb68e53 100644 --- a/src/lib/vector/image-embedding.ts +++ b/src/lib/vector/image-embedding.ts @@ -10,7 +10,12 @@ let visionModelPromise: Promise | null = null; function getProcessor(): Promise { if (!processorPromise) { - processorPromise = AutoProcessor.from_pretrained("Xenova/clip-vit-base-patch32"); + processorPromise = AutoProcessor.from_pretrained("Xenova/clip-vit-base-patch32").catch( + (err) => { + processorPromise = null; + throw err; + } + ); } return processorPromise; } @@ -20,7 +25,10 @@ function getVisionModel(): Promise { visionModelPromise = CLIPVisionModelWithProjection.from_pretrained( "Xenova/clip-vit-base-patch32", { dtype: "q8" } - ); + ).catch((err) => { + visionModelPromise = null; + throw err; + }); } return visionModelPromise; } diff --git a/src/lib/vector/index-posts.ts b/src/lib/vector/index-posts.ts index cdd2c55..ed10e73 100644 --- a/src/lib/vector/index-posts.ts +++ b/src/lib/vector/index-posts.ts @@ -95,60 +95,64 @@ export async function runVectorIndex(profileId: string): Promise { throw new Error("A vector index run is already in progress for this profile"); } - const qdrantConfig = getQdrantConfig(); - if (!isQdrantConfigured(qdrantConfig)) { - throw new Error("Qdrant is not configured. Set QDRANT_URL/QDRANT_API_KEY env vars."); - } - const startTime = Date.now(); const startIso = new Date(startTime).toISOString(); - // Find profile name and newest post timestamp for cutoff tracking - const [profileRecord, newestPost] = await Promise.all([ - prisma.profile.findUnique({ where: { id: profileId }, select: { name: true } }), - prisma.post.findFirst({ - where: { profileId }, - orderBy: { takenAt: "desc" }, - select: { takenAt: true }, - }), - ]); - - const cutoffPostTakenAt = newestPost?.takenAt ?? null; - const cutoffPostDate = cutoffPostTakenAt - ? new Date(cutoffPostTakenAt * 1000).toISOString() - : null; - - const targets = await collectTargets(profileId); - + // Synchronously reserve in-flight state immediately before any async awaits const state: VectorIndexProgress = { status: "running", - totalItems: targets.length, + totalItems: 0, indexedItems: 0, facesIndexed: 0, failedItems: 0, }; indexStates.set(profileId, state); - // Initial stats record const currentStats: VectorIndexStats = { profileId, - profileName: profileRecord?.name ?? profileId, + profileName: profileId, status: "running", lastRunAt: startIso, lastCompletedAt: null, durationMs: null, - cutoffPostTakenAt, - cutoffPostDate, - totalItems: targets.length, + cutoffPostTakenAt: null, + cutoffPostDate: null, + totalItems: 0, indexedItems: 0, facesIndexed: 0, failedItems: 0, lastError: null, updatedAt: startIso, }; - await saveProfileVectorStats(currentStats); try { + const qdrantConfig = getQdrantConfig(); + if (!isQdrantConfigured(qdrantConfig)) { + throw new Error("Qdrant is not configured. Set QDRANT_URL/QDRANT_API_KEY env vars."); + } + + // Find profile name and newest post timestamp for cutoff tracking + const [profileRecord, newestPost] = await Promise.all([ + prisma.profile.findUnique({ where: { id: profileId }, select: { name: true } }), + prisma.post.findFirst({ + where: { profileId }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }), + ]); + + currentStats.profileName = profileRecord?.name ?? profileId; + const cutoffPostTakenAt = newestPost?.takenAt ?? null; + currentStats.cutoffPostTakenAt = cutoffPostTakenAt; + currentStats.cutoffPostDate = cutoffPostTakenAt + ? new Date(cutoffPostTakenAt * 1000).toISOString() + : null; + + const targets = await collectTargets(profileId); + state.totalItems = targets.length; + currentStats.totalItems = targets.length; + await saveProfileVectorStats(currentStats); + await ensureCollections(); // Batched queues to avoid RocksDB open file limits and connection saturation diff --git a/src/lib/vector/qdrant-client.ts b/src/lib/vector/qdrant-client.ts index 4fda8e7..d554522 100644 --- a/src/lib/vector/qdrant-client.ts +++ b/src/lib/vector/qdrant-client.ts @@ -37,18 +37,27 @@ export function getQdrantDashboardUrl(): string { return process.env.QDRANT_DASHBOARD_URL; } const config = getQdrantConfig(); - if (!config.url) return `http://localhost:${process.env.QDRANT_PORT || 6335}/dashboard`; + if (!config.url) { + return process.env.NODE_ENV === "production" + ? "" + : `http://localhost:${process.env.QDRANT_PORT || 6335}/dashboard`; + } try { const parsed = new URL(config.url); + if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") { + const port = parsed.port || process.env.QDRANT_PORT || "6335"; + return `http://localhost:${port}/dashboard`; + } if (parsed.hostname === "qdrant") { - // In docker network, browser accesses host mapped port + // In docker network, only default to localhost when not in production + if (process.env.NODE_ENV === "production") return ""; const port = process.env.QDRANT_PORT || "6335"; return `http://localhost:${port}/dashboard`; } return `${config.url.replace(/\/$/, "")}/dashboard`; } catch { - return `http://localhost:${process.env.QDRANT_PORT || 6335}/dashboard`; + return ""; } } @@ -74,6 +83,19 @@ export function getQdrantClient(): QdrantClient { // port, so an HTTPS URL behind a reverse proxy (Dokploy domain) silently // gets the wrong port unless it's set explicitly here. const url = new URL(config.url); + if ( + config.apiKey && + url.protocol !== "https:" && + url.hostname !== "localhost" && + url.hostname !== "127.0.0.1" && + url.hostname !== "qdrant" && + !url.hostname.endsWith(".local") + ) { + throw new Error( + "Insecure Qdrant configuration: QDRANT_API_KEY must not be sent over unencrypted HTTP." + ); + } + const port = url.port ? Number(url.port) : url.protocol === "https:" @@ -182,7 +204,7 @@ export async function checkQdrantLiveness(profileId?: string): Promise { +function isAlreadyExistsError(err: unknown): boolean { + if (!err) return false; + const msg = err instanceof Error ? err.message : String(err); + if (/already exists/i.test(msg)) return true; + if (typeof err === "object" && "status" in err && (err as { status?: number }).status === 409) { + return true; + } + return false; +} + +let ensureCollectionsPromise: Promise | null = null; + +async function doEnsureCollections(): Promise { const qdrant = getQdrantClient(); - const existing = await qdrant.getCollections(); + const existing = await qdrant.getCollections().catch(() => ({ collections: [] })); const existingNames = new Set(existing.collections.map((c) => c.name)); - if (!existingNames.has(COLLECTIONS.POST_IMAGES)) { - await qdrant.createCollection(COLLECTIONS.POST_IMAGES, { - vectors: { size: IMAGE_VECTOR_SIZE, distance: "Cosine" }, - }); - } - if (!existingNames.has(COLLECTIONS.POST_FACES)) { - await qdrant.createCollection(COLLECTIONS.POST_FACES, { - vectors: { size: FACE_VECTOR_SIZE, distance: "Euclid" }, - }); + const specs = [ + { + name: COLLECTIONS.POST_IMAGES, + vectors: { size: IMAGE_VECTOR_SIZE, distance: "Cosine" as const }, + }, + { + name: COLLECTIONS.POST_FACES, + vectors: { size: FACE_VECTOR_SIZE, distance: "Euclid" as const }, + }, + ]; + + for (const spec of specs) { + if (!existingNames.has(spec.name)) { + try { + await qdrant.createCollection(spec.name, { + vectors: spec.vectors, + }); + } catch (err: unknown) { + if (isAlreadyExistsError(err)) { + // Recheck the collection exists before treating initialization as successful + const exists = await checkCollectionExists(spec.name); + if (!exists) { + throw err; + } + } else { + throw err; + } + } + } + + // Ensure its profileId payload index exists + try { + await qdrant.createPayloadIndex(spec.name, { + field_name: "profileId", + field_schema: "keyword", + }); + } catch (err: unknown) { + if (!isAlreadyExistsError(err)) { + throw err; + } + } } +} - for (const name of [COLLECTIONS.POST_IMAGES, COLLECTIONS.POST_FACES]) { - await qdrant.createPayloadIndex(name, { - field_name: "profileId", - field_schema: "keyword", +/** Creates the post_images/post_faces collections (+ profileId payload index) if missing. Safe to call repeatedly and concurrently. */ +export function ensureCollections(): Promise { + if (!ensureCollectionsPromise) { + ensureCollectionsPromise = doEnsureCollections().finally(() => { + ensureCollectionsPromise = null; }); } + return ensureCollectionsPromise; } /** Media the vector belongs to — a post's own thumbnail or one carousel slide. */ diff --git a/src/lib/vector/text-embedding.ts b/src/lib/vector/text-embedding.ts index af53360..f83e178 100644 --- a/src/lib/vector/text-embedding.ts +++ b/src/lib/vector/text-embedding.ts @@ -9,7 +9,12 @@ let textModelPromise: Promise | null = null; function getTokenizer(): Promise { if (!tokenizerPromise) { - tokenizerPromise = AutoTokenizer.from_pretrained("Xenova/clip-vit-base-patch32"); + tokenizerPromise = AutoTokenizer.from_pretrained("Xenova/clip-vit-base-patch32").catch( + (err) => { + tokenizerPromise = null; + throw err; + } + ); } return tokenizerPromise; } @@ -19,7 +24,10 @@ function getTextModel(): Promise { textModelPromise = CLIPTextModelWithProjection.from_pretrained( "Xenova/clip-vit-base-patch32", { dtype: "q8" } - ); + ).catch((err) => { + textModelPromise = null; + throw err; + }); } return textModelPromise; } diff --git a/wiki/Coolify-Self-Hosting-Guide.md b/wiki/Coolify-Self-Hosting-Guide.md index 5808bb0..9da9b8a 100644 --- a/wiki/Coolify-Self-Hosting-Guide.md +++ b/wiki/Coolify-Self-Hosting-Guide.md @@ -23,7 +23,7 @@ services: pull_policy: always restart: unless-stopped expose: - - "5050" + - "3000" ports: - "${PORT:-5050}:3000" environment: @@ -66,7 +66,7 @@ services: image: qdrant/qdrant:v1.13.4 restart: unless-stopped ports: - - "${QDRANT_PORT:-6335}:6333" + - "127.0.0.1:${QDRANT_PORT:-6335}:6333" volumes: - qdrant_data:/qdrant/storage ulimits: @@ -87,8 +87,8 @@ volumes: ### Step 3: Domain & Routing 1. In the Coolify resource view, enter your **FQDN / Domain** (e.g. `https://instagram.example.com`). -2. Set the destination port to `5050`. -3. (Optional) To expose the Qdrant Dashboard UI, create a subdomain pointing to port `6335` (`https://qdrant.example.com/dashboard`). +2. Set the destination port to `3000`. +3. (Optional) The Qdrant Dashboard UI is bound to `127.0.0.1:6335` for security. To access it, use an SSH tunnel (`ssh -L 6335:localhost:6335 user@server`) or configure an authenticated reverse proxy pointing to internal container network `http://qdrant:6333/dashboard`. ### Step 4: Deploy 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/wiki/Docker-Compose-Deployment.md b/wiki/Docker-Compose-Deployment.md index 437bf0f..b10f992 100644 --- a/wiki/Docker-Compose-Deployment.md +++ b/wiki/Docker-Compose-Deployment.md @@ -48,7 +48,7 @@ You will see: > The compose configuration uses `pull_policy: always` for the `app` service. This ensures `docker compose up -d` always checks and pulls the latest container image from GHCR when tracking `:latest` or `:beta`. Access your dashboard at `http://localhost:5050` (or your server's IP address) to start the onboarding wizard! -Explore vectors in the Qdrant Dashboard at `http://localhost:6335/dashboard`. +Explore vectors in the Qdrant Dashboard at `http://localhost:6335/dashboard` (bound to localhost `127.0.0.1` for security; use an SSH tunnel `ssh -L 6335:localhost:6335 user@server` when accessing a remote instance). --- diff --git a/wiki/Dokploy-Self-Hosting-Guide.md b/wiki/Dokploy-Self-Hosting-Guide.md index 854aebb..f151e2f 100644 --- a/wiki/Dokploy-Self-Hosting-Guide.md +++ b/wiki/Dokploy-Self-Hosting-Guide.md @@ -64,7 +64,7 @@ services: image: qdrant/qdrant:v1.13.4 restart: unless-stopped ports: - - "6335:6333" + - "127.0.0.1:6335:6333" volumes: - qdrant_data:/qdrant/storage ulimits: @@ -88,7 +88,7 @@ volumes: 2. Add your custom domain (e.g. `instagram.yourdomain.com`). 3. Set Port to `5050`. 4. Enable **HTTPS (Let's Encrypt)**. -5. (Optional) Create a domain mapping for the Qdrant Dashboard UI on port `6335` (e.g. `qdrant.yourdomain.com/dashboard`). +5. (Optional) The Qdrant Dashboard UI is bound to `127.0.0.1:6335` for security. To access it, use an SSH tunnel (`ssh -L 6335:localhost:6335 user@server`) or route through an authenticated reverse proxy pointing to internal container network `http://qdrant:6333/dashboard`. ### Step 4: Deploy Click **Deploy**. Dokploy will pull the container images, verify MongoDB and Qdrant health, and launch the application behind Traefik SSL! diff --git a/wiki/Reverse-Proxy-and-Authentik-SSO.md b/wiki/Reverse-Proxy-and-Authentik-SSO.md index 07a2c34..1711012 100644 --- a/wiki/Reverse-Proxy-and-Authentik-SSO.md +++ b/wiki/Reverse-Proxy-and-Authentik-SSO.md @@ -67,5 +67,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:5050` (or `http://app:5050` 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 OTP or OAuth authentication. From 9bcfa35e5f2b5ec63121b36b8448888d085d84cd Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Thu, 3 Sep 2026 18:02:27 +0300 Subject: [PATCH 05/13] fix(search): harden search endpoints against payload size and face DoS - Return 503 from /api/health when Qdrant is degraded or unhealthy - Guard streaming request bodies with a 64KB size limit in /api/search/by-text - Add pre-parse Content-Length guards and 10MB limits in /api/search/by-image and by-face - Cap detected query faces to 5 and limit decoded image pixels to 16MP in face detection - Apply RESULT_LIMIT to merged face hits before post retrieval --- src/app/api/health/route.ts | 2 +- src/app/api/search/by-face/route.ts | 37 +++++++++++++++++++++------- src/app/api/search/by-image/route.ts | 10 +++++++- src/app/api/search/by-text/route.ts | 26 +++++++++++++++++-- src/lib/vector/face-embedding.ts | 4 ++- 5 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 428d8d6..997676d 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -18,7 +18,7 @@ export async function GET() { latencyMs: 0, })); - const isHealthy = mongoConnected && qdrantLiveness.status !== "disconnected"; + const isHealthy = mongoConnected && qdrantLiveness.status === "healthy"; const status = isHealthy ? "healthy" : mongoConnected ? "degraded" : "unhealthy"; return NextResponse.json( diff --git a/src/app/api/search/by-face/route.ts b/src/app/api/search/by-face/route.ts index b916d4b..73ca2d6 100644 --- a/src/app/api/search/by-face/route.ts +++ b/src/app/api/search/by-face/route.ts @@ -24,6 +24,15 @@ export async function POST(request: NextRequest) { ); } + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + const contentLength = Number(request.headers.get("content-length") || 0); + if (contentLength > MAX_FILE_SIZE + 1024 * 1024) { + return NextResponse.json( + { error: "Request payload exceeds maximum allowed size of 10MB." }, + { status: 413 } + ); + } + const formData = await request.formData(); const file = formData.get("image"); if (!(file instanceof Blob)) { @@ -33,7 +42,6 @@ export async function POST(request: NextRequest) { ); } - const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB if (file.size > MAX_FILE_SIZE) { return NextResponse.json( { error: "Image file exceeds maximum allowed size of 10MB." }, @@ -51,14 +59,21 @@ export async function POST(request: NextRequest) { ); } - // Bound concurrent face search queries (e.g. max 5 faces from a group shot) const MAX_SEARCH_FACES = 5; - const searchFaces = queryFaces.slice(0, MAX_SEARCH_FACES); + 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 } + ); + } - // Multiple faces in the query photo (e.g. a group shot) are all searched; + // Multiple faces in the query photo (e.g. a small group shot) are all searched; // hits are merged by post below regardless of which query face matched. const hitLists = await Promise.all( - searchFaces.map((face) => + queryFaces.map((face) => searchByVector(COLLECTIONS.POST_FACES, face.descriptor, profile.id, RESULT_LIMIT) ) ); @@ -109,12 +124,17 @@ export async function POST(request: NextRequest) { } } + const sortedHits = [...bestByPk.entries()] + .sort((a, b) => b[1].calibratedScore - a[1].calibratedScore) + .slice(0, RESULT_LIMIT); + + const targetPks = sortedHits.map(([pk]) => pk); const posts = await prisma.post.findMany({ - where: { profileId: profile.id, pk: { in: [...bestByPk.keys()] } }, + where: { profileId: profile.id, pk: { in: targetPks } }, }); const postByPk = new Map(posts.map((p) => [p.pk, p])); - const results: VectorSearchHit[] = [...bestByPk.entries()] + const results: VectorSearchHit[] = sortedHits .filter(([pk]) => postByPk.has(pk)) .map(([pk, details]) => ({ post: postByPk.get(pk)!, @@ -124,8 +144,7 @@ export async function POST(request: NextRequest) { matchedSlideIndex: details.carouselPosition, matchedImageUrl: details.imageUrl, bbox: details.bbox, - })) - .sort((a, b) => b.score - a.score); + })); return NextResponse.json({ results }); } catch (err: unknown) { diff --git a/src/app/api/search/by-image/route.ts b/src/app/api/search/by-image/route.ts index a2925a3..c8a9efc 100644 --- a/src/app/api/search/by-image/route.ts +++ b/src/app/api/search/by-image/route.ts @@ -24,6 +24,15 @@ export async function POST(request: NextRequest) { ); } + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + const contentLength = Number(request.headers.get("content-length") || 0); + if (contentLength > MAX_FILE_SIZE + 1024 * 1024) { + return NextResponse.json( + { error: "Request payload exceeds maximum allowed size of 10MB." }, + { status: 413 } + ); + } + const formData = await request.formData(); const file = formData.get("image"); if (!(file instanceof Blob)) { @@ -33,7 +42,6 @@ export async function POST(request: NextRequest) { ); } - const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB if (file.size > MAX_FILE_SIZE) { return NextResponse.json( { error: "Image file exceeds maximum allowed size of 10MB." }, diff --git a/src/app/api/search/by-text/route.ts b/src/app/api/search/by-text/route.ts index dba41d9..0bad67d 100644 --- a/src/app/api/search/by-text/route.ts +++ b/src/app/api/search/by-text/route.ts @@ -27,17 +27,39 @@ export async function POST(request: NextRequest) { ); } + const MAX_PAYLOAD_SIZE = 64 * 1024; // 64KB const contentLength = Number(request.headers.get("content-length") || 0); - if (contentLength > 64 * 1024) { + if (contentLength > MAX_PAYLOAD_SIZE) { return NextResponse.json( { error: "Request payload exceeds size limit." }, { status: 413 } ); } + let bodyText = ""; + if (request.body) { + const reader = request.body.getReader(); + let totalBytes = 0; + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_PAYLOAD_SIZE) { + await reader.cancel(); + return NextResponse.json( + { error: "Request payload exceeds size limit." }, + { status: 413 } + ); + } + bodyText += decoder.decode(value, { stream: true }); + } + bodyText += decoder.decode(); + } + let query: string | undefined; try { - const body = await request.json(); + const body = JSON.parse(bodyText || "{}"); query = body.query; } catch { return NextResponse.json( diff --git a/src/lib/vector/face-embedding.ts b/src/lib/vector/face-embedding.ts index b7b42ae..8ee84b7 100644 --- a/src/lib/vector/face-embedding.ts +++ b/src/lib/vector/face-embedding.ts @@ -25,10 +25,12 @@ export interface DetectedFace { descriptor: number[]; } +export const MAX_FACE_IMAGE_PIXELS = 16_000_000; // 16 Megapixels + async function detectFacesInBuffer(buffer: Buffer): Promise { await loadModels(); - const { data, info } = await sharp(buffer) + const { data, info } = await sharp(buffer, { limitInputPixels: MAX_FACE_IMAGE_PIXELS }) .removeAlpha() .raw() .toBuffer({ resolveWithObject: true }); From 91f6ffd328c70154218066671b5f7bba686d4e05 Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Thu, 3 Sep 2026 18:53:17 +0300 Subject: [PATCH 06/13] fix(vector): improve attribute binding in prompt search via weighted ensembling Deconstructs prepositional clothing and scene queries (e.g. 'woman in yellow pants') into targeted attribute embeddings ('yellow pants', 'wearing yellow pants', 'yellow pants outfit', 'yellow trousers') with higher prompt weights. This counterbalances CLIP's attribute-binding bias where the subject ('woman') and upper-body torso dominate visual attention over specific lower-body garments. --- src/lib/vector/text-embedding.ts | 115 ++++++++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 9 deletions(-) diff --git a/src/lib/vector/text-embedding.ts b/src/lib/vector/text-embedding.ts index f83e178..504c968 100644 --- a/src/lib/vector/text-embedding.ts +++ b/src/lib/vector/text-embedding.ts @@ -32,6 +32,106 @@ function getTextModel(): Promise { return textModelPromise; } +interface WeightedPrompt { + prompt: string; + weight: number; +} + +/** + * Builds an intelligent, multi-aspect prompt ensemble for natural-language search queries. + * + * CLIP (Contrastive Language-Image Pretraining) suffers from the "attribute binding" + * problem: e.g. "woman in yellow pants" has high overlap with any image containing a + * woman and the color yellow (such as a yellow blouse or yellow dress), because the + * subject ("woman") and torso dominate visual attention. + * + * By deconstructing prepositional queries, isolating the core differentiating garment/object, + * boosting specific garment tokens, and adding lexical synonyms (pants -> trousers, slacks), + * we accurately steer the vector embedding toward the user's intended target. + */ +export function buildPromptEnsemble(text: string): WeightedPrompt[] { + const trimmed = text.trim(); + const prompts: WeightedPrompt[] = [ + { prompt: trimmed, weight: 1.0 }, + { prompt: `a photo of ${trimmed}`, weight: 0.8 }, + ]; + + // 1. Person/Subject with garment or attribute: + // e.g. "woman in yellow pants", "girl wearing red dress", "man in blue suit", "person with sunglasses" + const personMatch = trimmed.match( + /^(?:a\s+)?(woman|girl|lady|female|man|guy|boy|male|person|model)\s+(?:in|wearing|with)\s+(.+)$/i + ); + if (personMatch) { + const subject = personMatch[1].toLowerCase(); + const attribute = personMatch[2].trim(); + + prompts.push({ prompt: attribute, weight: 1.5 }); + prompts.push({ prompt: `wearing ${attribute}`, weight: 1.3 }); + prompts.push({ prompt: `${attribute} outfit`, weight: 1.3 }); + prompts.push({ prompt: `a photo of ${attribute}`, weight: 1.0 }); + prompts.push({ prompt: `${subject} wearing ${attribute}`, weight: 0.7 }); + + // Clothing category synonym expansion + if (/\bpants\b/i.test(attribute)) { + const trousers = attribute.replace(/\bpants\b/gi, "trousers"); + const slacks = attribute.replace(/\bpants\b/gi, "slacks"); + prompts.push({ prompt: trousers, weight: 1.1 }); + prompts.push({ prompt: slacks, weight: 0.8 }); + prompts.push({ prompt: `${trousers} outfit`, weight: 1.0 }); + } else if (/\btrousers\b/i.test(attribute)) { + const pants = attribute.replace(/\btrousers\b/gi, "pants"); + prompts.push({ prompt: pants, weight: 1.1 }); + prompts.push({ prompt: `${pants} outfit`, weight: 1.0 }); + } else if (/\bblouse\b/i.test(attribute)) { + prompts.push({ prompt: attribute.replace(/\bblouse\b/gi, "top"), weight: 0.9 }); + } else if (/\bdress\b/i.test(attribute)) { + prompts.push({ prompt: attribute.replace(/\bdress\b/gi, "gown"), weight: 0.9 }); + } else if (/\bsweater\b/i.test(attribute)) { + prompts.push({ prompt: attribute.replace(/\bsweater\b/gi, "knit"), weight: 0.9 }); + } + return prompts; + } + + // 2. Direct garment / outfit queries without subject: + // e.g. "yellow pants", "black leather jacket", "red dress" + if ( + /\b(pants|trousers|slacks|jeans|shorts|skirt|dress|blouse|shirt|sweater|jacket|coat|hoodie|boots|sneakers)\b/i.test( + trimmed + ) + ) { + prompts.push({ prompt: `${trimmed} outfit`, weight: 1.2 }); + prompts.push({ prompt: `wearing ${trimmed}`, weight: 1.1 }); + prompts.push({ prompt: `a photo of ${trimmed}`, weight: 1.0 }); + + if (/\bpants\b/i.test(trimmed)) { + const trousers = trimmed.replace(/\bpants\b/gi, "trousers"); + prompts.push({ prompt: trousers, weight: 1.0 }); + prompts.push({ prompt: `${trousers} outfit`, weight: 0.9 }); + } else if (/\btrousers\b/i.test(trimmed)) { + const pants = trimmed.replace(/\btrousers\b/gi, "pants"); + prompts.push({ prompt: pants, weight: 1.0 }); + } + return prompts; + } + + // 3. Subject + location/scene preposition: + // e.g. "cat on the couch", "car on the beach", "dog in the grass" + const sceneMatch = trimmed.match( + /^(?:a\s+)?(.+?)\s+(?:on|at|in|near|by)\s+(?:the\s+|a\s+)?(.+)$/i + ); + if (sceneMatch) { + const subject = sceneMatch[1].trim(); + const context = sceneMatch[2].trim(); + prompts.push({ prompt: `${subject} and ${context}`, weight: 1.0 }); + prompts.push({ prompt: `photo of ${subject} ${context}`, weight: 0.8 }); + return prompts; + } + + // 4. Default fallback for general concepts / scenes + prompts.push({ prompt: `a picture of ${trimmed}`, weight: 0.8 }); + return prompts; +} + /** * Generates a 512-dimensional, L2-normalized CLIP text embedding for a natural-language * search prompt. Embeds into the exact same vector space as the saved post images. @@ -41,22 +141,19 @@ export async function embedText(text: string): Promise { const [tokenizer, textModel] = await Promise.all([getTokenizer(), getTextModel()]); const trimmed = text.trim(); - // Prompt ensembling: query + descriptive templates stabilize linguistic variance - const prompts = [ - trimmed, - `a photo of ${trimmed}`, - `a picture of ${trimmed}`, - ]; + const promptList = buildPromptEnsemble(trimmed); + const promptStrings = promptList.map((p) => p.prompt); - const inputs = tokenizer(prompts, { padding: true, truncation: true }); + const inputs = tokenizer(promptStrings, { padding: true, truncation: true }); const { text_embeds } = await textModel(inputs); const dims = 512; const data = text_embeds.data as Float32Array; const avg = new Float32Array(dims); - for (let i = 0; i < prompts.length; i++) { + for (let i = 0; i < promptList.length; i++) { const offset = i * dims; + const weight = promptList[i].weight; let sumSq = 0; for (let d = 0; d < dims; d++) { const val = data[offset + d]; @@ -64,7 +161,7 @@ export async function embedText(text: string): Promise { } const norm = Math.sqrt(sumSq) || 1; for (let d = 0; d < dims; d++) { - avg[d] += data[offset + d] / norm; + avg[d] += (data[offset + d] / norm) * weight; } } From d873e64f8540b04bb6398f0aad4180b11d1f3673 Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Thu, 3 Sep 2026 22:34:41 +0300 Subject: [PATCH 07/13] fix(vector): fix text-prompt search accuracy at the root, drop prompt-ensemble hack CLIP was running as clip-vit-base-patch32 (weakest checkpoint) quantized to int8 (q8) for a browser/WebGPU deployment that never shipped -- everything runs server-side. Live testing against real data showed raw cosine scores clustered in a flat, undiscriminating 0.23-0.31 band regardless of query, with genuine matches (posts captioned "Dress: ...") scoring lower than unrelated posts on the vector leg. Switch to clip-vit-base-patch16 at fp32 (still 512-d, no Qdrant schema change, just needs a reindex). With real embeddings, the ~90-line regex prompt-ensemble hack (garment/subject/scene pattern matching + synonym tables) added to compensate for the weak model is no longer needed -- replaced with a single "a photo of {query}" template, OpenAI's own validated CLIP prompt trick. Also named the magic-number score-calibration constants in the by-text route for clarity. Co-Authored-By: Claude Sonnet 5 --- docs/features/ai-vector-search.md | 41 ++++---- scripts/warm-models.ts | 12 +-- src/app/api/search/by-text/route.ts | 62 +++++++++--- src/lib/vector/image-embedding.ts | 6 +- src/lib/vector/qdrant-client.ts | 2 +- src/lib/vector/text-embedding.ts | 145 ++-------------------------- 6 files changed, 85 insertions(+), 183 deletions(-) diff --git a/docs/features/ai-vector-search.md b/docs/features/ai-vector-search.md index 0765ec7..d319b36 100644 --- a/docs/features/ai-vector-search.md +++ b/docs/features/ai-vector-search.md @@ -1,38 +1,37 @@ --- -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" +description: "CLIP-based semantic image search, facial recognition, and Qdrant vector indexing." --- -# In-Browser & Qdrant AI Vector Search +# AI Vector Search -> **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. +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. -Saved Posts Tracker is designing privacy-first deep learning models for visual understanding and facial identification without requiring expensive third-party AI APIs. +## 1. Multimodal CLIP Embeddings -## 1. Multimodal CLIP Embeddings (In-Browser & Qdrant) +- **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. -- **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. - -### Example Natural Language Queries: +### 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/scripts/warm-models.ts b/scripts/warm-models.ts index 5f30316..249a363 100644 --- a/scripts/warm-models.ts +++ b/scripts/warm-models.ts @@ -12,15 +12,15 @@ import { async function main() { console.log("[warm-models] Warming CLIP vision processor & projection model..."); - await AutoProcessor.from_pretrained("Xenova/clip-vit-base-patch32"); - await CLIPVisionModelWithProjection.from_pretrained("Xenova/clip-vit-base-patch32", { - dtype: "q8", + await AutoProcessor.from_pretrained("Xenova/clip-vit-base-patch16"); + await CLIPVisionModelWithProjection.from_pretrained("Xenova/clip-vit-base-patch16", { + dtype: "fp32", }); console.log("[warm-models] Warming CLIP text tokenizer & projection model..."); - await AutoTokenizer.from_pretrained("Xenova/clip-vit-base-patch32"); - await CLIPTextModelWithProjection.from_pretrained("Xenova/clip-vit-base-patch32", { - dtype: "q8", + await AutoTokenizer.from_pretrained("Xenova/clip-vit-base-patch16"); + await CLIPTextModelWithProjection.from_pretrained("Xenova/clip-vit-base-patch16", { + dtype: "fp32", }); console.log("[warm-models] All CLIP weights cached successfully."); diff --git a/src/app/api/search/by-text/route.ts b/src/app/api/search/by-text/route.ts index 0bad67d..c655bb0 100644 --- a/src/app/api/search/by-text/route.ts +++ b/src/app/api/search/by-text/route.ts @@ -13,6 +13,27 @@ import type { VectorSearchHit } from "@/types"; const RESULT_LIMIT = 60; +// Reciprocal Rank Fusion tuning +const RRF_K = 60; +const WEIGHT_VECTOR = 1.0; +const WEIGHT_TEXT = 1.3; + +// Below this raw cosine score, a vector hit is treated as noise and dropped. +const VECTOR_NOISE_FLOOR = 0.2; +// A visual-only (no text match) candidate is dropped unless it clears both: +// an absolute floor, and a fraction of the top visual hit's score for this query. +const VISUAL_ELBOW_FLOOR = 0.22; +const VISUAL_ELBOW_RATIO = 0.65; +// Display-score calibration: raw cosine/rank -> a confidence percentage shown in the UI. +const HYBRID_SCORE_BASE = 0.88; +const HYBRID_SCORE_MAX = 0.99; +const HYBRID_SCORE_BOOST_SCALE = 0.5; +const TEXT_TOP_RANK_SCORE = 0.92; +const TEXT_SCORE = 0.85; +const TEXT_TOP_RANK_CUTOFF = 3; +const VISUAL_SCORE_MIN = 0.45; +const VISUAL_SCORE_MAX = 0.95; + export async function POST(request: NextRequest) { const profile = await getActiveProfile(); if (!profile) return noActiveProfileResponse(); @@ -95,7 +116,7 @@ export async function POST(request: NextRequest) { } })(); - // Lexical / Full-Text Search across captions, hashtags, and creator accounts + // Lexical / Full-Text Search across captions and creator accounts const textSearchPromise = (async () => { const cleanTerms = rawQuery .split(/\s+/) @@ -180,8 +201,8 @@ export async function POST(request: NextRequest) { for (const hit of vectorHits) { const pk = hit.payload?.postPk; if (typeof pk !== "string") continue; - // Filter out pure noise (unrelated cosine similarities < 0.20) - if (hit.score < 0.20) continue; + // Filter out pure noise (unrelated cosine similarities below the floor) + if (hit.score < VECTOR_NOISE_FLOOR) continue; const prev = bestVisualByPk.get(pk); if (prev === undefined || hit.score > prev.score) { bestVisualByPk.set(pk, { @@ -210,9 +231,6 @@ export async function POST(request: NextRequest) { // Merge via Reciprocal Rank Fusion (RRF) const allPks = new Set([...visualRankByPk.keys(), ...textRankByPk.keys()]); - const RRF_K = 60; - const WEIGHT_VECTOR = 1.0; - const WEIGHT_TEXT = 1.3; interface MergedCandidate { pk: string; @@ -232,9 +250,13 @@ export async function POST(request: NextRequest) { const vMatch = bestVisualByPk.get(pk); // Apply elbow filter for pure visual matches: - // If post only matched visually and its score is below 65% of the top visual score, skip noise + // If post only matched visually and its score is below the floor or a + // fraction of the top visual score for this query, skip it as noise. if (!tInfo && vMatch) { - if (vMatch.score < 0.22 || (topVisualScore > 0 && vMatch.score < topVisualScore * 0.65)) { + if ( + vMatch.score < VISUAL_ELBOW_FLOOR || + (topVisualScore > 0 && vMatch.score < topVisualScore * VISUAL_ELBOW_RATIO) + ) { continue; } } @@ -248,18 +270,26 @@ export async function POST(request: NextRequest) { if (vRank !== undefined && tInfo !== undefined) { matchType = "hybrid"; - // Hybrid matches get highest confidence (88% - 99%) - const base = 0.88; - const boost = Math.min(0.11, ((vMatch?.score ?? 0.25) - 0.20) * 0.5); - calibratedScore = Math.min(0.99, base + boost); + // Hybrid matches get the highest confidence band. + const boost = Math.min( + HYBRID_SCORE_MAX - HYBRID_SCORE_BASE, + ((vMatch?.score ?? 0.25) - VECTOR_NOISE_FLOOR) * HYBRID_SCORE_BOOST_SCALE + ); + calibratedScore = Math.min(HYBRID_SCORE_MAX, HYBRID_SCORE_BASE + boost); } else if (tInfo !== undefined) { matchType = tInfo.matchType; - calibratedScore = tInfo.rank <= 3 ? 0.92 : 0.85; + calibratedScore = tInfo.rank <= TEXT_TOP_RANK_CUTOFF ? TEXT_TOP_RANK_SCORE : TEXT_SCORE; } else if (vMatch !== undefined) { matchType = "visual"; - // Calibrate raw cosine score [0.20, 0.40] -> [0.45, 0.95] - const normScore = Math.max(0, Math.min(1, (vMatch.score - 0.20) / 0.20)); - calibratedScore = Math.min(0.95, Math.max(0.45, 0.45 + normScore * 0.5)); + // Calibrate raw cosine score [floor, floor*2] -> [min, max] for display. + const normScore = Math.max( + 0, + Math.min(1, (vMatch.score - VECTOR_NOISE_FLOOR) / VECTOR_NOISE_FLOOR) + ); + calibratedScore = Math.min( + VISUAL_SCORE_MAX, + Math.max(VISUAL_SCORE_MIN, VISUAL_SCORE_MIN + normScore * (VISUAL_SCORE_MAX - VISUAL_SCORE_MIN)) + ); } candidates.push({ diff --git a/src/lib/vector/image-embedding.ts b/src/lib/vector/image-embedding.ts index fb68e53..820631c 100644 --- a/src/lib/vector/image-embedding.ts +++ b/src/lib/vector/image-embedding.ts @@ -10,7 +10,7 @@ let visionModelPromise: Promise | null = null; function getProcessor(): Promise { if (!processorPromise) { - processorPromise = AutoProcessor.from_pretrained("Xenova/clip-vit-base-patch32").catch( + processorPromise = AutoProcessor.from_pretrained("Xenova/clip-vit-base-patch16").catch( (err) => { processorPromise = null; throw err; @@ -23,8 +23,8 @@ function getProcessor(): Promise { function getVisionModel(): Promise { if (!visionModelPromise) { visionModelPromise = CLIPVisionModelWithProjection.from_pretrained( - "Xenova/clip-vit-base-patch32", - { dtype: "q8" } + "Xenova/clip-vit-base-patch16", + { dtype: "fp32" } ).catch((err) => { visionModelPromise = null; throw err; diff --git a/src/lib/vector/qdrant-client.ts b/src/lib/vector/qdrant-client.ts index d554522..a64afae 100644 --- a/src/lib/vector/qdrant-client.ts +++ b/src/lib/vector/qdrant-client.ts @@ -66,7 +66,7 @@ export const COLLECTIONS = { POST_FACES: "post_faces", } as const; -export const IMAGE_VECTOR_SIZE = 512; // Xenova/clip-vit-base-patch32 +export const IMAGE_VECTOR_SIZE = 512; // Xenova/clip-vit-base-patch16 export const FACE_VECTOR_SIZE = 128; // face-api face recognition descriptor let client: QdrantClient | null = null; diff --git a/src/lib/vector/text-embedding.ts b/src/lib/vector/text-embedding.ts index 504c968..fbff465 100644 --- a/src/lib/vector/text-embedding.ts +++ b/src/lib/vector/text-embedding.ts @@ -9,7 +9,7 @@ let textModelPromise: Promise | null = null; function getTokenizer(): Promise { if (!tokenizerPromise) { - tokenizerPromise = AutoTokenizer.from_pretrained("Xenova/clip-vit-base-patch32").catch( + tokenizerPromise = AutoTokenizer.from_pretrained("Xenova/clip-vit-base-patch16").catch( (err) => { tokenizerPromise = null; throw err; @@ -22,8 +22,8 @@ function getTokenizer(): Promise { function getTextModel(): Promise { if (!textModelPromise) { textModelPromise = CLIPTextModelWithProjection.from_pretrained( - "Xenova/clip-vit-base-patch32", - { dtype: "q8" } + "Xenova/clip-vit-base-patch16", + { dtype: "fp32" } ).catch((err) => { textModelPromise = null; throw err; @@ -32,147 +32,20 @@ function getTextModel(): Promise { return textModelPromise; } -interface WeightedPrompt { - prompt: string; - weight: number; -} - -/** - * Builds an intelligent, multi-aspect prompt ensemble for natural-language search queries. - * - * CLIP (Contrastive Language-Image Pretraining) suffers from the "attribute binding" - * problem: e.g. "woman in yellow pants" has high overlap with any image containing a - * woman and the color yellow (such as a yellow blouse or yellow dress), because the - * subject ("woman") and torso dominate visual attention. - * - * By deconstructing prepositional queries, isolating the core differentiating garment/object, - * boosting specific garment tokens, and adding lexical synonyms (pants -> trousers, slacks), - * we accurately steer the vector embedding toward the user's intended target. - */ -export function buildPromptEnsemble(text: string): WeightedPrompt[] { - const trimmed = text.trim(); - const prompts: WeightedPrompt[] = [ - { prompt: trimmed, weight: 1.0 }, - { prompt: `a photo of ${trimmed}`, weight: 0.8 }, - ]; - - // 1. Person/Subject with garment or attribute: - // e.g. "woman in yellow pants", "girl wearing red dress", "man in blue suit", "person with sunglasses" - const personMatch = trimmed.match( - /^(?:a\s+)?(woman|girl|lady|female|man|guy|boy|male|person|model)\s+(?:in|wearing|with)\s+(.+)$/i - ); - if (personMatch) { - const subject = personMatch[1].toLowerCase(); - const attribute = personMatch[2].trim(); - - prompts.push({ prompt: attribute, weight: 1.5 }); - prompts.push({ prompt: `wearing ${attribute}`, weight: 1.3 }); - prompts.push({ prompt: `${attribute} outfit`, weight: 1.3 }); - prompts.push({ prompt: `a photo of ${attribute}`, weight: 1.0 }); - prompts.push({ prompt: `${subject} wearing ${attribute}`, weight: 0.7 }); - - // Clothing category synonym expansion - if (/\bpants\b/i.test(attribute)) { - const trousers = attribute.replace(/\bpants\b/gi, "trousers"); - const slacks = attribute.replace(/\bpants\b/gi, "slacks"); - prompts.push({ prompt: trousers, weight: 1.1 }); - prompts.push({ prompt: slacks, weight: 0.8 }); - prompts.push({ prompt: `${trousers} outfit`, weight: 1.0 }); - } else if (/\btrousers\b/i.test(attribute)) { - const pants = attribute.replace(/\btrousers\b/gi, "pants"); - prompts.push({ prompt: pants, weight: 1.1 }); - prompts.push({ prompt: `${pants} outfit`, weight: 1.0 }); - } else if (/\bblouse\b/i.test(attribute)) { - prompts.push({ prompt: attribute.replace(/\bblouse\b/gi, "top"), weight: 0.9 }); - } else if (/\bdress\b/i.test(attribute)) { - prompts.push({ prompt: attribute.replace(/\bdress\b/gi, "gown"), weight: 0.9 }); - } else if (/\bsweater\b/i.test(attribute)) { - prompts.push({ prompt: attribute.replace(/\bsweater\b/gi, "knit"), weight: 0.9 }); - } - return prompts; - } - - // 2. Direct garment / outfit queries without subject: - // e.g. "yellow pants", "black leather jacket", "red dress" - if ( - /\b(pants|trousers|slacks|jeans|shorts|skirt|dress|blouse|shirt|sweater|jacket|coat|hoodie|boots|sneakers)\b/i.test( - trimmed - ) - ) { - prompts.push({ prompt: `${trimmed} outfit`, weight: 1.2 }); - prompts.push({ prompt: `wearing ${trimmed}`, weight: 1.1 }); - prompts.push({ prompt: `a photo of ${trimmed}`, weight: 1.0 }); - - if (/\bpants\b/i.test(trimmed)) { - const trousers = trimmed.replace(/\bpants\b/gi, "trousers"); - prompts.push({ prompt: trousers, weight: 1.0 }); - prompts.push({ prompt: `${trousers} outfit`, weight: 0.9 }); - } else if (/\btrousers\b/i.test(trimmed)) { - const pants = trimmed.replace(/\btrousers\b/gi, "pants"); - prompts.push({ prompt: pants, weight: 1.0 }); - } - return prompts; - } - - // 3. Subject + location/scene preposition: - // e.g. "cat on the couch", "car on the beach", "dog in the grass" - const sceneMatch = trimmed.match( - /^(?:a\s+)?(.+?)\s+(?:on|at|in|near|by)\s+(?:the\s+|a\s+)?(.+)$/i - ); - if (sceneMatch) { - const subject = sceneMatch[1].trim(); - const context = sceneMatch[2].trim(); - prompts.push({ prompt: `${subject} and ${context}`, weight: 1.0 }); - prompts.push({ prompt: `photo of ${subject} ${context}`, weight: 0.8 }); - return prompts; - } - - // 4. Default fallback for general concepts / scenes - prompts.push({ prompt: `a picture of ${trimmed}`, weight: 0.8 }); - return prompts; -} - /** * Generates a 512-dimensional, L2-normalized CLIP text embedding for a natural-language * search prompt. Embeds into the exact same vector space as the saved post images. - * Uses prompt ensembling (averaging variations) to significantly boost retrieval accuracy. + * Wraps the query in OpenAI's "a photo of {label}" template — the one prompt-engineering + * trick CLIP's own paper validated across datasets, rather than query-specific heuristics. */ export async function embedText(text: string): Promise { const [tokenizer, textModel] = await Promise.all([getTokenizer(), getTextModel()]); - const trimmed = text.trim(); - const promptList = buildPromptEnsemble(trimmed); - const promptStrings = promptList.map((p) => p.prompt); - - const inputs = tokenizer(promptStrings, { padding: true, truncation: true }); + const prompt = `a photo of ${text.trim()}`; + const inputs = tokenizer([prompt], { padding: true, truncation: true }); const { text_embeds } = await textModel(inputs); - const dims = 512; const data = text_embeds.data as Float32Array; - const avg = new Float32Array(dims); - - for (let i = 0; i < promptList.length; i++) { - const offset = i * dims; - const weight = promptList[i].weight; - let sumSq = 0; - for (let d = 0; d < dims; d++) { - const val = data[offset + d]; - sumSq += val * val; - } - const norm = Math.sqrt(sumSq) || 1; - for (let d = 0; d < dims; d++) { - avg[d] += (data[offset + d] / norm) * weight; - } - } - - // Final L2 normalization of ensembled vector - let totalSumSq = 0; - for (let d = 0; d < dims; d++) totalSumSq += avg[d] * avg[d]; - const finalNorm = Math.sqrt(totalSumSq) || 1; - - const result: number[] = new Array(dims); - for (let d = 0; d < dims; d++) { - result[d] = avg[d] / finalNorm; - } - return result; + const norm = Math.sqrt(data.reduce((sum, v) => sum + v * v, 0)) || 1; + return Array.from(data, (v) => v / norm); } From 1e027efa5d68e7b7cfbf2082b25e388b8a7db4a4 Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Fri, 4 Sep 2026 12:52:08 +0300 Subject: [PATCH 08/13] Unify vector search and Qdrant indexing Refactor vector search around shared helpers for upload validation, best-hit selection, and score calibration across image, face, and text search. Replace legacy Qdrant stats/liveness plumbing with a simpler liveness/status flow, update the dashboard to consume that data, and remove unused stats hooks/routes. Also switch point IDs to a deterministic UUIDv5-compatible hash, add a vector self-check script, and tighten indexing logic so re-indexing upserts correctly instead of duplicating points. --- knip.json | 2 +- package-lock.json | 24 +- package.json | 7 +- scripts/reindex-vectors.ts | 39 +-- scripts/test-vector-search.ts | 54 ++++ src/app/(dashboard)/search/page.tsx | 48 ++-- src/app/api/search/by-face/route.ts | 171 +++--------- src/app/api/search/by-image/route.ts | 124 ++------- src/app/api/search/by-text/route.ts | 380 +++++++++------------------ src/app/api/search/liveness/route.ts | 15 -- src/app/api/search/stats/route.ts | 42 --- src/hooks/use-vector-search.ts | 70 ++--- src/lib/vector/face-embedding.ts | 45 +--- src/lib/vector/image-embedding.ts | 41 +-- src/lib/vector/index-posts.ts | 155 +++++------ src/lib/vector/qdrant-client.ts | 243 +++++++---------- src/lib/vector/search-api.ts | 100 +++++++ src/lib/vector/stats.ts | 49 +--- src/types/index.ts | 4 +- 19 files changed, 571 insertions(+), 1042 deletions(-) create mode 100644 scripts/test-vector-search.ts delete mode 100644 src/app/api/search/liveness/route.ts delete mode 100644 src/app/api/search/stats/route.ts create mode 100644 src/lib/vector/search-api.ts 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/package-lock.json b/package-lock.json index 80d7d3a..1e4e764 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,15 +33,13 @@ "recharts": "^2.15.4", "sharp": "^0.35.4", "sonner": "^2.0.8", - "tailwind-merge": "^3.6.0", - "uuid": "^14.0.2" + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^26", "@types/react": "^19", "@types/react-dom": "^19", - "@types/uuid": "^10.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", "knip": "^6.33.0", @@ -6162,13 +6160,6 @@ "integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "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", @@ -15076,19 +15067,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", - "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", diff --git a/package.json b/package.json index 0945afa..664e5e2 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "prisma:generate": "prisma generate", "postinstall": "prisma generate", "sync:docs": "node scripts/sync-docs.mjs", - "reindex:vectors": "tsx scripts/reindex-vectors.ts" + "reindex:vectors": "tsx --env-file-if-exists=.env scripts/reindex-vectors.ts", + "test:vectors": "tsx scripts/test-vector-search.ts" }, "dependencies": { "@huggingface/transformers": "^4.2.0", @@ -60,15 +61,13 @@ "recharts": "^2.15.4", "sharp": "^0.35.4", "sonner": "^2.0.8", - "tailwind-merge": "^3.6.0", - "uuid": "^14.0.2" + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^26", "@types/react": "^19", "@types/react-dom": "^19", - "@types/uuid": "^10.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", "knip": "^6.33.0", diff --git a/scripts/reindex-vectors.ts b/scripts/reindex-vectors.ts index 52703f3..699bb47 100644 --- a/scripts/reindex-vectors.ts +++ b/scripts/reindex-vectors.ts @@ -1,36 +1,13 @@ -import fs from "fs"; -import path from "path"; +// Env comes from `tsx --env-file-if-exists=.env` (see the reindex:vectors script). import { PrismaClient } from "@prisma/client"; - -// Minimal .env loader without external dependencies -function loadDotEnv() { - const envPath = path.join(process.cwd(), ".env"); - if (!fs.existsSync(envPath)) return; - for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { - const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/); - if (!m) continue; - const key = m[1]; - let val = m[2]; - if ( - (val.startsWith('"') && val.endsWith('"')) || - (val.startsWith("'") && val.endsWith("'")) - ) { - val = val.slice(1, -1); - } - if (process.env[key] === undefined) process.env[key] = val; - } -} +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; } -loadDotEnv(); - async function main() { - const { runVectorIndex, getCurrentIndexState } = await import("../src/lib/vector/index-posts"); - const prisma = new PrismaClient(); const profileArg = getArg("profile"); const all = process.argv.includes("--all"); @@ -48,16 +25,8 @@ async function main() { 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" || - (typeof state?.failedItems === "number" - ? state.failedItems > 0 - : Boolean(state?.failedItems && (state.failedItems as unknown as unknown[]).length > 0)) - ) { - process.exitCode = 1; - } + console.log("[reindex-vectors] Done:", state); + if (state?.status === "failed" || (state?.failedItems ?? 0) > 0) process.exitCode = 1; } await prisma.$disconnect(); diff --git a/scripts/test-vector-search.ts b/scripts/test-vector-search.ts new file mode 100644 index 0000000..4669b1a --- /dev/null +++ b/scripts/test-vector-search.ts @@ -0,0 +1,54 @@ +/** + * 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 { 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); + +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 index ca53e3e..4faa003 100644 --- a/src/app/(dashboard)/search/page.tsx +++ b/src/app/(dashboard)/search/page.tsx @@ -24,7 +24,6 @@ import { useSearchByImage, useSearchByFace, useVectorIndexStatus, - useVectorStats, useReindexVectors, type VectorSearchError, } from "@/hooks/use-vector-search"; @@ -91,7 +90,6 @@ export default function SearchPage() { const searchByImage = useSearchByImage(); const searchByFace = useSearchByFace(); const { data: indexStatusData, isLoading: isLoadingStatus } = useVectorIndexStatus(); - const { data: vectorStatsData, isLoading: isLoadingStats } = useVectorStats(); const reindexMutation = useReindexVectors(); const isPending = @@ -163,29 +161,17 @@ export default function SearchPage() { setSearchErrorMessage(null); setActiveQueryLabel(file.name); - if (requestMode === "image") { - searchByImage.mutate(file, { - onSuccess: (hits) => { - if (token !== searchTokenRef.current || modeRef.current !== requestMode) return; - setResults(hits); - }, - onError: (err) => { - if (token !== searchTokenRef.current || modeRef.current !== requestMode) return; - handleSearchError(err); - }, - }); - } else { - searchByFace.mutate(file, { - onSuccess: (hits) => { - if (token !== searchTokenRef.current || modeRef.current !== requestMode) return; - setResults(hits); - }, - onError: (err) => { - if (token !== searchTokenRef.current || modeRef.current !== requestMode) return; - handleSearchError(err); - }, - }); - } + // 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 = () => { @@ -613,7 +599,7 @@ export default function SearchPage() { - {isLoadingStats ? ( + {isLoadingStatus ? (
@@ -696,7 +682,7 @@ export default function SearchPage() { Qdrant Database & Cluster - {vectorStatsData?.totalProfilesIndexed ?? 0} Profiles Indexed + {liveness?.status ?? "unknown"}
@@ -704,23 +690,19 @@ export default function SearchPage() {
Image Vectors:

- {vectorStatsData?.qdrant?.profilePoints?.images?.toLocaleString() ?? 0} + {(liveness?.collections.post_images.pointsCount ?? 0).toLocaleString()}

Face Vectors:

- {vectorStatsData?.qdrant?.profilePoints?.faces?.toLocaleString() ?? 0} + {(liveness?.collections.post_faces.pointsCount ?? 0).toLocaleString()}

Cluster Latency:

{liveness?.latencyMs ?? 0} ms

-
- Storage Backend: -

Qdrant RocksDB

-
diff --git a/src/app/api/search/by-face/route.ts b/src/app/api/search/by-face/route.ts index 73ca2d6..2162a41 100644 --- a/src/app/api/search/by-face/route.ts +++ b/src/app/api/search/by-face/route.ts @@ -2,64 +2,39 @@ 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 { - COLLECTIONS, - getQdrantConfig, - isQdrantConfigured, - searchByVector, - VectorIndexNotBuiltError, -} from "@/lib/vector/qdrant-client"; + RESULT_LIMIT, + bestHitPerPost, + calibrate, + qdrantNotConfiguredResponse, + readUploadedImage, + searchErrorResponse, +} from "@/lib/vector/search-api"; import type { VectorSearchHit } from "@/types"; -const RESULT_LIMIT = 60; +// 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(); - if (!isQdrantConfigured(getQdrantConfig())) { - return NextResponse.json( - { error: "Vector search is not configured. Set QDRANT_URL environment variable.", needsIndexing: false }, - { status: 400 } - ); - } - - const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - const contentLength = Number(request.headers.get("content-length") || 0); - if (contentLength > MAX_FILE_SIZE + 1024 * 1024) { - return NextResponse.json( - { error: "Request payload exceeds maximum allowed size of 10MB." }, - { status: 413 } - ); - } + const notConfigured = qdrantNotConfiguredResponse(); + if (notConfigured) return notConfigured; - const formData = await request.formData(); - const file = formData.get("image"); - if (!(file instanceof Blob)) { - return NextResponse.json( - { error: "Missing 'image' file in form data." }, - { status: 400 } - ); - } - - if (file.size > MAX_FILE_SIZE) { - return NextResponse.json( - { error: "Image file exceeds maximum allowed size of 10MB." }, - { status: 413 } - ); - } + const upload = await readUploadedImage(request); + if ("error" in upload) return upload.error; try { - const buffer = Buffer.from(await file.arrayBuffer()); - const queryFaces = await detectFacesFromBuffer(buffer); + const queryFaces = await detectFacesFromBuffer(upload.buffer); if (queryFaces.length === 0) { return NextResponse.json( { error: "No face detected in the uploaded image.", results: [] }, - { status: 200 } + { status: 400 } ); } - - const MAX_SEARCH_FACES = 5; if (queryFaces.length > MAX_SEARCH_FACES) { return NextResponse.json( { @@ -70,106 +45,44 @@ export async function POST(request: NextRequest) { ); } - // Multiple faces in the query photo (e.g. a small group shot) are all searched; - // hits are merged by post below regardless of which query face matched. + // 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((face) => - searchByVector(COLLECTIONS.POST_FACES, face.descriptor, profile.id, RESULT_LIMIT) + queryFaces.map((descriptor) => + searchByVector(COLLECTIONS.POST_FACES, descriptor, profile.id, RESULT_LIMIT) ) ); - // Helper to extract true Euclidean distance regardless of Qdrant score representation - const getEuclideanDistance = (score: number): number => { - if (score <= 0) return Math.abs(score); - if (score <= 1) return 1 / score - 1; - return score; - }; - - interface FaceHitDetails { - score: number; - calibratedScore: number; - distance: number; - bbox?: VectorSearchHit["bbox"]; - carouselPosition?: number; - imageUrl?: string; - } - - const bestByPk = new Map(); - const FACE_DISTANCE_THRESHOLD = 0.62; // Standard FaceNet same-person identity boundary - - for (const hits of hitLists) { - for (const hit of hits) { - const pk = hit.payload?.postPk; - if (typeof pk !== "string") continue; - - const distance = getEuclideanDistance(hit.score); - // Exclude faces that exceed identity threshold - if (distance > FACE_DISTANCE_THRESHOLD) continue; - - // Calibrate Euclidean distance [0.15, 0.62] -> [0.99, 0.50] - const norm = Math.max(0, Math.min(1, (distance - 0.15) / (FACE_DISTANCE_THRESHOLD - 0.15))); - const calibratedScore = Math.min(0.99, Math.max(0.50, 0.99 - norm * 0.49)); - - const prev = bestByPk.get(pk); - if (prev === undefined || calibratedScore > prev.calibratedScore) { - bestByPk.set(pk, { - score: hit.score, - calibratedScore, - distance, - bbox: hit.payload?.bbox as VectorSearchHit["bbox"], - carouselPosition: hit.payload?.carouselPosition as number | undefined, - imageUrl: hit.payload?.imageUrl as string | undefined, - }); - } - } - } + // 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 sortedHits = [...bestByPk.entries()] - .sort((a, b) => b[1].calibratedScore - a[1].calibratedScore) + const sorted = [...best.entries()] + .sort((a, b) => b[1].quality - a[1].quality) .slice(0, RESULT_LIMIT); - const targetPks = sortedHits.map(([pk]) => pk); const posts = await prisma.post.findMany({ - where: { profileId: profile.id, pk: { in: targetPks } }, + where: { profileId: profile.id, pk: { in: sorted.map(([pk]) => pk) } }, }); const postByPk = new Map(posts.map((p) => [p.pk, p])); - const results: VectorSearchHit[] = sortedHits - .filter(([pk]) => postByPk.has(pk)) - .map(([pk, details]) => ({ - post: postByPk.get(pk)!, - score: details.calibratedScore, - rawScore: details.score, - matchType: "face" as const, - matchedSlideIndex: details.carouselPosition, - matchedImageUrl: details.imageUrl, - bbox: details.bbox, - })); + 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) { - if (err instanceof VectorIndexNotBuiltError) { - return NextResponse.json( - { - error: "Vector index has not been built yet. Please index your saved posts first.", - needsIndexing: true, - }, - { status: 400 } - ); - } - const message = err instanceof Error ? err.message : String(err); - if (message.includes("doesn't exist") || message.includes("Not found: Collection")) { - return NextResponse.json( - { - error: "Vector index collection not found. Please run the indexer first.", - needsIndexing: true, - }, - { status: 400 } - ); - } - return NextResponse.json( - { error: `Vector search error: ${message}`, needsIndexing: false }, - { status: 500 } - ); + return searchErrorResponse(err); } } diff --git a/src/app/api/search/by-image/route.ts b/src/app/api/search/by-image/route.ts index c8a9efc..af03a60 100644 --- a/src/app/api/search/by-image/route.ts +++ b/src/app/api/search/by-image/route.ts @@ -2,104 +2,54 @@ 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 { - COLLECTIONS, - getQdrantConfig, - isQdrantConfigured, - searchByVector, - VectorIndexNotBuiltError, -} from "@/lib/vector/qdrant-client"; + RESULT_LIMIT, + bestHitPerPost, + calibrate, + qdrantNotConfiguredResponse, + readUploadedImage, + searchErrorResponse, +} from "@/lib/vector/search-api"; import type { VectorSearchHit } from "@/types"; -const RESULT_LIMIT = 60; +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(); - if (!isQdrantConfigured(getQdrantConfig())) { - return NextResponse.json( - { error: "Vector search is not configured. Set QDRANT_URL environment variable.", needsIndexing: false }, - { status: 400 } - ); - } - - const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - const contentLength = Number(request.headers.get("content-length") || 0); - if (contentLength > MAX_FILE_SIZE + 1024 * 1024) { - return NextResponse.json( - { error: "Request payload exceeds maximum allowed size of 10MB." }, - { status: 413 } - ); - } + const notConfigured = qdrantNotConfiguredResponse(); + if (notConfigured) return notConfigured; - const formData = await request.formData(); - const file = formData.get("image"); - if (!(file instanceof Blob)) { - return NextResponse.json( - { error: "Missing 'image' file in form data." }, - { status: 400 } - ); - } - - if (file.size > MAX_FILE_SIZE) { - return NextResponse.json( - { error: "Image file exceeds maximum allowed size of 10MB." }, - { status: 413 } - ); - } + const upload = await readUploadedImage(request); + if ("error" in upload) return upload.error; try { - const buffer = Buffer.from(await file.arrayBuffer()); - const vector = await embedImageFromBuffer(buffer); + const vector = await embedImageFromBuffer(upload.buffer); const hits = await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); - // Keep only the best-scoring hit per post, recording slide attribution - interface HitDetails { - score: number; - source?: "thumbnail" | "carousel"; - carouselPosition?: number; - imageUrl?: string; - } - const bestHitByPk = new Map(); - for (const hit of hits) { - const pk = hit.payload?.postPk; - if (typeof pk !== "string") continue; - // Filter out low similarity noise - if (hit.score < 0.26) continue; - const prev = bestHitByPk.get(pk); - if (prev === undefined || hit.score > prev.score) { - bestHitByPk.set(pk, { - score: hit.score, - source: hit.payload?.source as "thumbnail" | "carousel" | undefined, - carouselPosition: hit.payload?.carouselPosition as number | undefined, - imageUrl: hit.payload?.imageUrl as string | undefined, - }); - } - } - - const sortedHits = [...bestHitByPk.entries()].sort((a, b) => b[1].score - a[1].score); - const topScore = sortedHits.length > 0 ? sortedHits[0][1].score : 0; - - // Filter by elbow drop-off (keep hits with score >= 65% of top score) - const filteredHits = sortedHits.filter(([, h]) => topScore > 0 && h.score >= topScore * 0.65); + 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: filteredHits.map(([pk]) => pk) } }, + 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 filteredHits) { + for (const [pk, hit] of kept) { const post = postByPk.get(pk); if (!post) continue; - // Calibrate image-to-image score [0.26, 0.85] -> [0.45, 0.99] - const norm = Math.max(0, Math.min(1, (hit.score - 0.26) / 0.55)); - const calibratedScore = Math.min(0.99, Math.max(0.45, 0.45 + norm * 0.54)); results.push({ post, - score: calibratedScore, - rawScore: hit.score, + score: calibrate(hit.quality, NOISE_FLOOR, 0.81, 0.45, 0.99), matchType: "visual", matchedSlideIndex: hit.carouselPosition, matchedImageUrl: hit.imageUrl, @@ -108,28 +58,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ results }); } catch (err: unknown) { - if (err instanceof VectorIndexNotBuiltError) { - return NextResponse.json( - { - error: "Vector index has not been built yet. Please index your saved posts first.", - needsIndexing: true, - }, - { status: 400 } - ); - } - const message = err instanceof Error ? err.message : String(err); - if (message.includes("doesn't exist") || message.includes("Not found: Collection")) { - return NextResponse.json( - { - error: "Vector index collection not found. Please run the indexer first.", - needsIndexing: true, - }, - { status: 400 } - ); - } - return NextResponse.json( - { error: `Vector search error: ${message}`, needsIndexing: false }, - { status: 500 } - ); + return searchErrorResponse(err); } } diff --git a/src/app/api/search/by-text/route.ts b/src/app/api/search/by-text/route.ts index c655bb0..a81eefa 100644 --- a/src/app/api/search/by-text/route.ts +++ b/src/app/api/search/by-text/route.ts @@ -2,86 +2,92 @@ 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 { - COLLECTIONS, - getQdrantConfig, - isQdrantConfigured, - searchByVector, - VectorIndexNotBuiltError, -} from "@/lib/vector/qdrant-client"; + RESULT_LIMIT, + bestHitPerPost, + calibrate, + qdrantNotConfiguredResponse, + searchErrorResponse, +} from "@/lib/vector/search-api"; import type { VectorSearchHit } from "@/types"; -const RESULT_LIMIT = 60; +const MAX_QUERY_LENGTH = 1000; -// Reciprocal Rank Fusion tuning +// 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 treated as noise and dropped. +// Below this raw cosine score a vector hit is noise. const VECTOR_NOISE_FLOOR = 0.2; -// A visual-only (no text match) candidate is dropped unless it clears both: -// an absolute floor, and a fraction of the top visual hit's score for this query. +// 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; -// Display-score calibration: raw cosine/rank -> a confidence percentage shown in the UI. -const HYBRID_SCORE_BASE = 0.88; -const HYBRID_SCORE_MAX = 0.99; -const HYBRID_SCORE_BOOST_SCALE = 0.5; -const TEXT_TOP_RANK_SCORE = 0.92; -const TEXT_SCORE = 0.85; -const TEXT_TOP_RANK_CUTOFF = 3; -const VISUAL_SCORE_MIN = 0.45; -const VISUAL_SCORE_MAX = 0.95; + +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(); - if (!isQdrantConfigured(getQdrantConfig())) { - return NextResponse.json( - { - error: "Vector search is not configured. Set QDRANT_URL environment variable.", - needsIndexing: false, - }, - { status: 400 } - ); - } - - const MAX_PAYLOAD_SIZE = 64 * 1024; // 64KB - const contentLength = Number(request.headers.get("content-length") || 0); - if (contentLength > MAX_PAYLOAD_SIZE) { - return NextResponse.json( - { error: "Request payload exceeds size limit." }, - { status: 413 } - ); - } + const notConfigured = qdrantNotConfiguredResponse(); + if (notConfigured) return notConfigured; - let bodyText = ""; - if (request.body) { - const reader = request.body.getReader(); - let totalBytes = 0; - const decoder = new TextDecoder(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - totalBytes += value.byteLength; - if (totalBytes > MAX_PAYLOAD_SIZE) { - await reader.cancel(); - return NextResponse.json( - { error: "Request payload exceeds size limit." }, - { status: 413 } - ); - } - bodyText += decoder.decode(value, { stream: true }); - } - bodyText += decoder.decode(); - } - - let query: string | undefined; + let query: unknown; try { - const body = JSON.parse(bodyText || "{}"); - query = body.query; + query = (await request.json())?.query; } catch { return NextResponse.json( { error: "Invalid JSON body. Expected { query: string }." }, @@ -89,14 +95,9 @@ export async function POST(request: NextRequest) { ); } - if (!query || typeof query !== "string" || !query.trim()) { - return NextResponse.json( - { error: "Query cannot be empty." }, - { status: 400 } - ); + if (typeof query !== "string" || !query.trim()) { + return NextResponse.json({ error: "Query cannot be empty." }, { status: 400 }); } - - const MAX_QUERY_LENGTH = 1000; if (query.length > MAX_QUERY_LENGTH) { return NextResponse.json( { error: `Query exceeds maximum length of ${MAX_QUERY_LENGTH} characters.` }, @@ -104,221 +105,102 @@ export async function POST(request: NextRequest) { ); } - try { - const rawQuery = query.trim(); - const vectorPromise = (async () => { - try { - const vector = await embedText(rawQuery); - return await searchByVector(COLLECTIONS.POST_IMAGES, vector, profile.id, RESULT_LIMIT); - } catch (e) { - // If vector index is not built yet, we will bubble it up if text search also finds nothing - return e; - } - })(); - - // Lexical / Full-Text Search across captions and creator accounts - const textSearchPromise = (async () => { - const cleanTerms = rawQuery - .split(/\s+/) - .map((t) => t.replace(/^[#@]/, "").trim()) - .filter((t) => t.length >= 2); - - // 1. Find creator accounts matching query - const matchingAccounts = await prisma.account.findMany({ - where: { - profileId: profile.id, - OR: [ - { username: { contains: rawQuery, mode: "insensitive" } }, - { fullName: { contains: rawQuery, mode: "insensitive" } }, - ], - }, - select: { pk: true, username: true }, - take: 30, - }); - const matchingAccountPks = matchingAccounts.map((a) => a.pk); + const rawQuery = query.trim(); - // 2. Find posts matching caption or creator - const textConditions: import("@prisma/client").Prisma.PostWhereInput[] = [ - { captionText: { contains: rawQuery, mode: "insensitive" } }, - ]; - if (matchingAccountPks.length > 0) { - textConditions.push({ accountPk: { in: matchingAccountPks } }); - } - if (cleanTerms.length > 1) { - textConditions.push({ - AND: cleanTerms.map((term) => ({ - captionText: { contains: term, mode: "insensitive" as const }, - })), - }); - } - - const posts = await prisma.post.findMany({ - where: { - profileId: profile.id, - OR: textConditions, - }, - select: { pk: true, captionText: true, accountPk: true }, - take: RESULT_LIMIT, - }); - - const lowerQuery = rawQuery.toLowerCase(); - return posts - .map((p) => { - const hasExact = p.captionText?.toLowerCase().includes(lowerQuery); - const isAccount = matchingAccountPks.includes(p.accountPk); - let priority = 3; - if (hasExact) priority = 1; - else if (isAccount) priority = 2; - return { pk: p.pk, priority, isAccount }; - }) - .sort((a, b) => a.priority - b.priority); - })(); - - const [vectorResult, textMatches] = await Promise.all([vectorPromise, textSearchPromise]); - - if (vectorResult instanceof Error) { - // If vector search failed because collection missing and we found no text matches either, rethrow - if ( - textMatches.length === 0 && - (vectorResult instanceof VectorIndexNotBuiltError || - vectorResult.message.includes("doesn't exist") || - vectorResult.message.includes("Not found: Collection")) - ) { - throw vectorResult; - } + 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 : []; - // Collect best visual hit per post - interface VisualMatch { - score: number; - source?: "thumbnail" | "carousel"; - carouselPosition?: number; - imageUrl?: string; - } - const bestVisualByPk = new Map(); - for (const hit of vectorHits) { - const pk = hit.payload?.postPk; - if (typeof pk !== "string") continue; - // Filter out pure noise (unrelated cosine similarities below the floor) - if (hit.score < VECTOR_NOISE_FLOOR) continue; - const prev = bestVisualByPk.get(pk); - if (prev === undefined || hit.score > prev.score) { - bestVisualByPk.set(pk, { - score: hit.score, - source: hit.payload?.source as "thumbnail" | "carousel" | undefined, - carouselPosition: hit.payload?.carouselPosition as number | undefined, - imageUrl: hit.payload?.imageUrl as string | undefined, - }); - } - } - - // Rank visual results - const rankedVisual = [...bestVisualByPk.entries()].sort((a, b) => b[1].score - a[1].score); - const visualRankByPk = new Map(); - rankedVisual.forEach(([pk], i) => visualRankByPk.set(pk, i + 1)); - const topVisualScore = rankedVisual.length > 0 ? rankedVisual[0][1].score : 0; + 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; - // Rank text results - const textRankByPk = new Map(); - textMatches.forEach((match, i) => { - textRankByPk.set(match.pk, { - rank: i + 1, - matchType: match.isAccount ? "account" : "caption", - }); - }); - - // Merge via Reciprocal Rank Fusion (RRF) - const allPks = new Set([...visualRankByPk.keys(), ...textRankByPk.keys()]); + const textRank = new Map( + textMatches.map((m, i) => [ + m.pk, + { rank: i + 1, matchType: (m.isAccount ? "account" : "caption") as MatchType }, + ]) + ); - interface MergedCandidate { + interface Candidate { pk: string; rrfScore: number; - calibratedScore: number; - rawScore?: number; - matchType: "hybrid" | "visual" | "caption" | "account"; + score: number; + matchType: MatchType; matchedSlideIndex?: number; matchedImageUrl?: string; } + const candidates: Candidate[] = []; - const candidates: MergedCandidate[] = []; + 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); - for (const pk of allPks) { - const vRank = visualRankByPk.get(pk); - const tInfo = textRankByPk.get(pk); - const vMatch = bestVisualByPk.get(pk); - - // Apply elbow filter for pure visual matches: - // If post only matched visually and its score is below the floor or a - // fraction of the top visual score for this query, skip it as noise. + // Visual-only candidates below the elbow are noise, not results. if (!tInfo && vMatch) { if ( - vMatch.score < VISUAL_ELBOW_FLOOR || - (topVisualScore > 0 && vMatch.score < topVisualScore * VISUAL_ELBOW_RATIO) + vMatch.quality < VISUAL_ELBOW_FLOOR || + vMatch.quality < topVisualScore * VISUAL_ELBOW_RATIO ) { continue; } } - const vRrf = vRank !== undefined ? WEIGHT_VECTOR / (RRF_K + vRank) : 0; - const tRrf = tInfo !== undefined ? WEIGHT_TEXT / (RRF_K + tInfo.rank) : 0; - const rrfScore = vRrf + tRrf; - - let matchType: "hybrid" | "visual" | "caption" | "account" = "visual"; - let calibratedScore = 0.5; + 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"; - // Hybrid matches get the highest confidence band. - const boost = Math.min( - HYBRID_SCORE_MAX - HYBRID_SCORE_BASE, - ((vMatch?.score ?? 0.25) - VECTOR_NOISE_FLOOR) * HYBRID_SCORE_BOOST_SCALE - ); - calibratedScore = Math.min(HYBRID_SCORE_MAX, HYBRID_SCORE_BASE + boost); + score = calibrate(vMatch?.quality ?? 0.25, VECTOR_NOISE_FLOOR, 0.42, 0.88, 0.99); } else if (tInfo !== undefined) { matchType = tInfo.matchType; - calibratedScore = tInfo.rank <= TEXT_TOP_RANK_CUTOFF ? TEXT_TOP_RANK_SCORE : TEXT_SCORE; + score = tInfo.rank <= 3 ? 0.92 : 0.85; } else if (vMatch !== undefined) { - matchType = "visual"; - // Calibrate raw cosine score [floor, floor*2] -> [min, max] for display. - const normScore = Math.max( - 0, - Math.min(1, (vMatch.score - VECTOR_NOISE_FLOOR) / VECTOR_NOISE_FLOOR) - ); - calibratedScore = Math.min( - VISUAL_SCORE_MAX, - Math.max(VISUAL_SCORE_MIN, VISUAL_SCORE_MIN + normScore * (VISUAL_SCORE_MAX - VISUAL_SCORE_MIN)) - ); + score = calibrate(vMatch.quality, VECTOR_NOISE_FLOOR, 0.4, 0.45, 0.95); } candidates.push({ pk, rrfScore, - calibratedScore, - rawScore: vMatch?.score, + score, matchType, matchedSlideIndex: vMatch?.carouselPosition, matchedImageUrl: vMatch?.imageUrl, }); } - candidates.sort((a, b) => b.rrfScore - a.rrfScore); - const topCandidates = candidates.slice(0, RESULT_LIMIT); + 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: topCandidates.map((c) => c.pk) } }, + 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 topCandidates) { + for (const c of top) { const post = postByPk.get(c.pk); if (!post) continue; results.push({ post, - score: c.calibratedScore, - rawScore: c.rawScore, + score: c.score, matchType: c.matchType, matchedSlideIndex: c.matchedSlideIndex, matchedImageUrl: c.matchedImageUrl, @@ -327,28 +209,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ results }); } catch (err: unknown) { - if (err instanceof VectorIndexNotBuiltError) { - return NextResponse.json( - { - error: "Vector index has not been built yet. Please index your saved posts first.", - needsIndexing: true, - }, - { status: 400 } - ); - } - const message = err instanceof Error ? err.message : String(err); - if (message.includes("doesn't exist") || message.includes("Not found: Collection")) { - return NextResponse.json( - { - error: "Vector index collection not found. Please run the indexer first.", - needsIndexing: true, - }, - { status: 400 } - ); - } - return NextResponse.json( - { error: `Vector search error: ${message}`, needsIndexing: false }, - { status: 500 } - ); + return searchErrorResponse(err); } } diff --git a/src/app/api/search/liveness/route.ts b/src/app/api/search/liveness/route.ts deleted file mode 100644 index 94c15ee..0000000 --- a/src/app/api/search/liveness/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextResponse } from "next/server"; -import { getActiveProfile, noActiveProfileResponse } from "@/lib/active-profile"; -import { checkQdrantLiveness } from "@/lib/vector/qdrant-client"; - -export const dynamic = "force-dynamic"; - -export async function GET() { - const profile = await getActiveProfile(); - if (!profile) return noActiveProfileResponse(); - - const liveness = await checkQdrantLiveness(profile.id); - - const httpStatus = liveness.status === "disconnected" ? 503 : 200; - return NextResponse.json(liveness, { status: httpStatus }); -} diff --git a/src/app/api/search/stats/route.ts b/src/app/api/search/stats/route.ts deleted file mode 100644 index fbdb956..0000000 --- a/src/app/api/search/stats/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextResponse } from "next/server"; -import { getActiveProfile, noActiveProfileResponse } from "@/lib/active-profile"; -import { - getProfileVectorStats, - getAllProfilesVectorStats, -} from "@/lib/vector/stats"; -import { - COLLECTIONS, - countCollectionPoints, - getQdrantDashboardUrl, - isQdrantConfigured, - getQdrantConfig, -} from "@/lib/vector/qdrant-client"; - -export async function GET() { - const profile = await getActiveProfile(); - if (!profile) return noActiveProfileResponse(); - - const isConfigured = isQdrantConfigured(getQdrantConfig()); - - const [activeProfileStats, allProfilesStats, imagesPoints, facesPoints] = await Promise.all([ - getProfileVectorStats(profile.id), - getAllProfilesVectorStats(), - isConfigured ? countCollectionPoints(COLLECTIONS.POST_IMAGES, profile.id) : 0, - isConfigured ? countCollectionPoints(COLLECTIONS.POST_FACES, profile.id) : 0, - ]); - - return NextResponse.json({ - activeProfile: activeProfileStats, - allProfiles: allProfilesStats, - totalProfilesIndexed: allProfilesStats.filter((p) => p.indexedItems > 0).length, - qdrant: { - configured: isConfigured, - dashboardUrl: getQdrantDashboardUrl(), - profilePoints: { - images: imagesPoints, - faces: facesPoints, - total: imagesPoints + facesPoints, - }, - }, - }); -} diff --git a/src/hooks/use-vector-search.ts b/src/hooks/use-vector-search.ts index 76e6498..a11ed08 100644 --- a/src/hooks/use-vector-search.ts +++ b/src/hooks/use-vector-search.ts @@ -28,25 +28,8 @@ export interface ReindexStatusResponse { error?: string; } -export interface VectorStatsResponse { - activeProfile: VectorIndexStats | null; - allProfiles: VectorIndexStats[]; - totalProfilesIndexed: number; - qdrant: { - configured: boolean; - dashboardUrl: string; - profilePoints: { - images: number; - faces: number; - total: number; - }; - }; -} - -async function postImage(url: string, file: File): Promise { - const formData = new FormData(); - formData.append("image", file); - const res = await fetch(url, { method: "POST", body: formData }); +async function search(url: string, init: RequestInit): Promise { + const res = await fetch(url, { method: "POST", ...init }); const body: SearchResponse = await res.json().catch(() => ({})); if (!res.ok) { throw new VectorSearchError(body.error ?? `Search failed (${res.status})`, !!body.needsIndexing); @@ -54,34 +37,33 @@ async function postImage(url: string, file: File): Promise { return body.results ?? []; } -async function postText(query: string): Promise { - const res = await fetch("/api/search/by-text", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query }), - }); - const body: SearchResponse = await res.json().catch(() => ({})); - if (!res.ok) { - throw new VectorSearchError(body.error ?? `Search failed (${res.status})`, !!body.needsIndexing); - } - return body.results ?? []; +function searchByUpload(url: string) { + return (file: File) => { + const formData = new FormData(); + formData.append("image", file); + return search(url, { body: formData }); + }; } export function useSearchByText() { return useMutation({ - mutationFn: (query: string) => postText(query), + mutationFn: (query) => + search("/api/search/by-text", { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }), }); } export function useSearchByImage() { return useMutation({ - mutationFn: (file: File) => postImage("/api/search/by-image", file), + mutationFn: searchByUpload("/api/search/by-image"), }); } export function useSearchByFace() { return useMutation({ - mutationFn: (file: File) => postImage("/api/search/by-face", file), + mutationFn: searchByUpload("/api/search/by-face"), }); } @@ -93,21 +75,8 @@ export function useVectorIndexStatus() { if (!res.ok) throw new Error("Failed to fetch vector index status"); return res.json(); }, - refetchInterval: (query) => { - const status = query.state.data?.current?.status; - return status === "running" ? 2000 : 15000; - }, - }); -} - -export function useVectorStats() { - return useQuery({ - queryKey: ["vector-stats"], - queryFn: async () => { - const res = await fetch("/api/search/stats"); - if (!res.ok) throw new Error("Failed to fetch vector stats"); - return res.json(); - }, + refetchInterval: (query) => + query.state.data?.current?.status === "running" ? 2000 : 15000, }); } @@ -120,9 +89,6 @@ export function useReindexVectors() { if (!res.ok) throw new Error(body.error ?? "Failed to start indexing"); return body; }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["vector-index-status"] }); - queryClient.invalidateQueries({ queryKey: ["vector-stats"] }); - }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["vector-index-status"] }), }); } diff --git a/src/lib/vector/face-embedding.ts b/src/lib/vector/face-embedding.ts index 8ee84b7..068c62e 100644 --- a/src/lib/vector/face-embedding.ts +++ b/src/lib/vector/face-embedding.ts @@ -4,6 +4,7 @@ import sharp from "sharp"; import path from "path"; const MODEL_PATH = path.join(process.cwd(), "node_modules/@vladmandic/face-api/model"); +const MAX_FACE_IMAGE_PIXELS = 16_000_000; // 16 MP decode bomb guard let modelsReady: Promise | null = null; @@ -20,14 +21,8 @@ function loadModels(): Promise { return modelsReady; } -export interface DetectedFace { - bbox: { x: number; y: number; width: number; height: number }; - descriptor: number[]; -} - -export const MAX_FACE_IMAGE_PIXELS = 16_000_000; // 16 Megapixels - -async function detectFacesInBuffer(buffer: Buffer): Promise { +/** 128-d face-recognition descriptors for every face found in an in-memory image buffer. */ +export async function detectFacesFromBuffer(buffer: Buffer): Promise { await loadModels(); const { data, info } = await sharp(buffer, { limitInputPixels: MAX_FACE_IMAGE_PIXELS }) @@ -47,40 +42,8 @@ async function detectFacesInBuffer(buffer: Buffer): Promise { .withFaceLandmarks() .withFaceDescriptors(); - return results.map((r) => ({ - bbox: { - x: r.detection.box.x, - y: r.detection.box.y, - width: r.detection.box.width, - height: r.detection.box.height, - }, - descriptor: Array.from(r.descriptor as Float32Array), - })); + return results.map((r) => Array.from(r.descriptor as Float32Array)); } finally { tensor.dispose(); } } - -/** All faces (128-d descriptor + bbox each) found in an image at a stable HTTPS URL. */ -export async function detectFacesFromUrl(url: string): Promise { - const resp = await fetch(url, { - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - Referer: "https://www.instagram.com/", - }, - signal: AbortSignal.timeout(15_000), - }); - - if (!resp.ok) { - throw new Error(`Failed to fetch image for face detection from URL: ${url} (${resp.status})`); - } - - const buffer = Buffer.from(await resp.arrayBuffer()); - return detectFacesInBuffer(buffer); -} - -/** All faces (128-d descriptor + bbox each) found in an in-memory image buffer (search-query uploads). */ -export async function detectFacesFromBuffer(buffer: Buffer): Promise { - return detectFacesInBuffer(buffer); -} diff --git a/src/lib/vector/image-embedding.ts b/src/lib/vector/image-embedding.ts index 820631c..bc28725 100644 --- a/src/lib/vector/image-embedding.ts +++ b/src/lib/vector/image-embedding.ts @@ -33,44 +33,15 @@ function getVisionModel(): Promise { return visionModelPromise; } -async function embed(image: RawImage): Promise { - const [processor, visionModel] = await Promise.all([ - getProcessor(), - getVisionModel(), - ]); +/** 512-d L2-normalized CLIP embedding for an in-memory image buffer. */ +export async function embedImageFromBuffer(buffer: Buffer): Promise { + const [processor, visionModel] = await Promise.all([getProcessor(), getVisionModel()]); - const imageInputs = await processor(image); - const { image_embeds } = await visionModel(imageInputs); + const image = await RawImage.fromBlob(new Blob([new Uint8Array(buffer)])); + const { image_embeds } = await visionModel(await processor(image)); + // L2 normalize so cosine distance in Qdrant aligns with the normalized text embeddings const raw = Array.from(image_embeds.data as Float32Array); - // L2 normalize so cosine distance in Qdrant aligns perfectly with normalized text embeddings const norm = Math.sqrt(raw.reduce((sum, v) => sum + v * v, 0)) || 1; return raw.map((v) => v / norm); } - -/** 512-d CLIP embedding for an image at a stable HTTPS URL (Cloudinary or Instagram CDN). */ -export async function embedImageFromUrl(url: string): Promise { - const response = await fetch(url, { - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - Referer: "https://www.instagram.com/", - }, - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - throw new Error(`Failed to fetch image from URL: ${url} (${response.status})`); - } - - const blob = await response.blob(); - const image = await RawImage.fromBlob(blob); - return embed(image); -} - -/** 512-d CLIP embedding for an in-memory image buffer (search-query uploads). */ -export async function embedImageFromBuffer(buffer: Buffer): Promise { - const blob = new Blob([new Uint8Array(buffer)]); - const image = await RawImage.fromBlob(blob); - return embed(image); -} diff --git a/src/lib/vector/index-posts.ts b/src/lib/vector/index-posts.ts index ed10e73..d163787 100644 --- a/src/lib/vector/index-posts.ts +++ b/src/lib/vector/index-posts.ts @@ -1,8 +1,8 @@ import { prisma } from "@/lib/prisma"; import { logger } from "@/lib/logger"; import type { VectorIndexProgress } from "@/types"; -import { embedImageFromUrl } from "./image-embedding"; -import { detectFacesFromUrl } from "./face-embedding"; +import { embedImageFromBuffer } from "./image-embedding"; +import { detectFacesFromBuffer } from "./face-embedding"; import { COLLECTIONS, ensureCollections, @@ -10,7 +10,6 @@ import { isQdrantConfigured, pointId, upsertPoints, - type FaceVectorPayload, type PostVectorPayload, } from "./qdrant-client"; import { saveProfileVectorStats, type VectorIndexStats } from "./stats"; @@ -31,9 +30,8 @@ interface IndexTarget { } /** - * Collects target preview images to embed. Supports Cloudinary CDN URLs as - * first preference, with automatic fallback to direct/proxied thumbnail URLs - * so indexing functions properly even without Cloudinary configured. + * Collects target preview images to embed. Prefers Cloudinary CDN URLs, falling + * back to the direct thumbnail URL so indexing works without Cloudinary configured. */ async function collectTargets(profileId: string): Promise { const targets: IndexTarget[] = []; @@ -41,10 +39,7 @@ async function collectTargets(profileId: string): Promise { const posts = await prisma.post.findMany({ where: { profileId, - OR: [ - { cloudinaryThumbnailUrl: { not: null } }, - { thumbnailUrl: { not: null } }, - ], + OR: [{ cloudinaryThumbnailUrl: { not: null } }, { thumbnailUrl: { not: null } }], }, select: { pk: true, mediaType: true, cloudinaryThumbnailUrl: true, thumbnailUrl: true }, }); @@ -65,10 +60,7 @@ async function collectTargets(profileId: string): Promise { where: { profileId, mediaType: { not: 2 }, // Exclude standalone video slides - OR: [ - { cloudinaryUrl: { not: null } }, - { mediaUrl: { not: "" } }, - ], + OR: [{ cloudinaryUrl: { not: null } }, { mediaUrl: { not: "" } }], }, select: { postPk: true, mediaType: true, position: true, cloudinaryUrl: true, mediaUrl: true }, }); @@ -88,6 +80,20 @@ async function collectTargets(profileId: string): Promise { return targets; } +/** Instagram's CDN 403s requests without a browser UA + referer. */ +async function fetchImageBuffer(url: string): Promise { + const resp = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Referer: "https://www.instagram.com/", + }, + signal: AbortSignal.timeout(15_000), + }); + if (!resp.ok) throw new Error(`Failed to fetch image: ${url} (${resp.status})`); + return Buffer.from(await resp.arrayBuffer()); +} + const UPSERT_BATCH_SIZE = 25; export async function runVectorIndex(profileId: string): Promise { @@ -98,7 +104,7 @@ export async function runVectorIndex(profileId: string): Promise { const startTime = Date.now(); const startIso = new Date(startTime).toISOString(); - // Synchronously reserve in-flight state immediately before any async awaits + // Reserve in-flight state synchronously, before any await const state: VectorIndexProgress = { status: "running", totalItems: 0, @@ -108,14 +114,12 @@ export async function runVectorIndex(profileId: string): Promise { }; indexStates.set(profileId, state); - const currentStats: VectorIndexStats = { + const stats: VectorIndexStats = { profileId, - profileName: profileId, status: "running", lastRunAt: startIso, lastCompletedAt: null, durationMs: null, - cutoffPostTakenAt: null, cutoffPostDate: null, totalItems: 0, indexedItems: 0, @@ -125,55 +129,51 @@ export async function runVectorIndex(profileId: string): Promise { updatedAt: startIso, }; + const syncStats = () => { + stats.indexedItems = state.indexedItems; + stats.facesIndexed = state.facesIndexed; + stats.failedItems = state.failedItems; + return saveProfileVectorStats(stats); + }; + try { - const qdrantConfig = getQdrantConfig(); - if (!isQdrantConfigured(qdrantConfig)) { + if (!isQdrantConfigured(getQdrantConfig())) { throw new Error("Qdrant is not configured. Set QDRANT_URL/QDRANT_API_KEY env vars."); } - // Find profile name and newest post timestamp for cutoff tracking - const [profileRecord, newestPost] = await Promise.all([ - prisma.profile.findUnique({ where: { id: profileId }, select: { name: true } }), - prisma.post.findFirst({ - where: { profileId }, - orderBy: { takenAt: "desc" }, - select: { takenAt: true }, - }), - ]); - - currentStats.profileName = profileRecord?.name ?? profileId; - const cutoffPostTakenAt = newestPost?.takenAt ?? null; - currentStats.cutoffPostTakenAt = cutoffPostTakenAt; - currentStats.cutoffPostDate = cutoffPostTakenAt - ? new Date(cutoffPostTakenAt * 1000).toISOString() + const newestPost = await prisma.post.findFirst({ + where: { profileId }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }); + stats.cutoffPostDate = newestPost?.takenAt + ? new Date(newestPost.takenAt * 1000).toISOString() : null; const targets = await collectTargets(profileId); state.totalItems = targets.length; - currentStats.totalItems = targets.length; - await saveProfileVectorStats(currentStats); + stats.totalItems = targets.length; + await syncStats(); await ensureCollections(); - // Batched queues to avoid RocksDB open file limits and connection saturation + // Batched upserts avoid RocksDB open-file limits and connection saturation const imageBatch: { id: string; vector: number[]; payload: Record }[] = []; - const faceBatch: { id: string; vector: number[]; payload: Record }[] = []; + const faceBatch: typeof imageBatch = []; const flushBatches = async (force = false) => { if (imageBatch.length >= UPSERT_BATCH_SIZE || (force && imageBatch.length > 0)) { - await upsertPoints(COLLECTIONS.POST_IMAGES, [...imageBatch]); - imageBatch.length = 0; + await upsertPoints(COLLECTIONS.POST_IMAGES, imageBatch.splice(0)); } if (faceBatch.length >= UPSERT_BATCH_SIZE || (force && faceBatch.length > 0)) { - await upsertPoints(COLLECTIONS.POST_FACES, [...faceBatch]); - faceBatch.length = 0; + await upsertPoints(COLLECTIONS.POST_FACES, faceBatch.splice(0)); } }; for (let i = 0; i < targets.length; i++) { const target = targets[i]; try { - const imagePayload: PostVectorPayload = { + const payload: PostVectorPayload = { profileId, postPk: target.postPk, mediaType: target.mediaType, @@ -182,40 +182,32 @@ export async function runVectorIndex(profileId: string): Promise { imageUrl: target.imageUrl, }; - const imageVector = await embedImageFromUrl(target.imageUrl); + // One download feeds both the CLIP embedding and face detection + const buffer = await fetchImageBuffer(target.imageUrl); + imageBatch.push({ id: pointId(profileId, target.postPk, target.source, target.position), - vector: imageVector, - payload: imagePayload, + vector: await embedImageFromBuffer(buffer), + payload, }); - const faces = await detectFacesFromUrl(target.imageUrl); - if (faces.length > 0) { - for (let faceIdx = 0; faceIdx < faces.length; faceIdx++) { - const face = faces[faceIdx]; - const payload: FaceVectorPayload = { ...imagePayload, bbox: face.bbox }; - faceBatch.push({ - id: pointId(profileId, target.postPk, target.source, target.position, faceIdx), - vector: face.descriptor, - payload, - }); - } - state.facesIndexed += faces.length; - } + const faces = await detectFacesFromBuffer(buffer); + faces.forEach((descriptor, faceIdx) => { + faceBatch.push({ + id: pointId(profileId, target.postPk, target.source, target.position, faceIdx), + vector: descriptor, + payload, + }); + }); + state.facesIndexed += faces.length; state.indexedItems += 1; - await flushBatches(false); - - // Periodically sync stats every 50 items - if (i > 0 && i % 50 === 0) { - currentStats.indexedItems = state.indexedItems; - currentStats.facesIndexed = state.facesIndexed; - currentStats.failedItems = state.failedItems; - await saveProfileVectorStats(currentStats); - } + await flushBatches(); + + if (i > 0 && i % 50 === 0) await syncStats(); } catch (error) { state.failedItems += 1; - currentStats.lastError = error instanceof Error ? error.message : String(error); + stats.lastError = error instanceof Error ? error.message : String(error); logger.error( { err: error, profileId, postPk: target.postPk, source: target.source }, "[vector-index] Failed to index item" @@ -223,18 +215,13 @@ export async function runVectorIndex(profileId: string): Promise { } } - // Flush any remaining batched points await flushBatches(true); - const completionIso = new Date().toISOString(); state.status = "completed"; - currentStats.status = "completed"; - currentStats.lastCompletedAt = completionIso; - currentStats.durationMs = Date.now() - startTime; - currentStats.indexedItems = state.indexedItems; - currentStats.facesIndexed = state.facesIndexed; - currentStats.failedItems = state.failedItems; - await saveProfileVectorStats(currentStats); + stats.status = "completed"; + stats.lastCompletedAt = new Date().toISOString(); + stats.durationMs = Date.now() - startTime; + await syncStats(); logger.info( { @@ -242,7 +229,7 @@ export async function runVectorIndex(profileId: string): Promise { indexedItems: state.indexedItems, facesIndexed: state.facesIndexed, failedItems: state.failedItems, - durationMs: currentStats.durationMs, + durationMs: stats.durationMs, }, "[vector-index] Index run completed" ); @@ -250,10 +237,10 @@ export async function runVectorIndex(profileId: string): Promise { const errorMsg = error instanceof Error ? error.message : "Unknown error"; state.status = "failed"; state.errorMessage = errorMsg; - currentStats.status = "failed"; - currentStats.lastError = errorMsg; - currentStats.durationMs = Date.now() - startTime; - await saveProfileVectorStats(currentStats); + stats.status = "failed"; + stats.lastError = errorMsg; + stats.durationMs = Date.now() - startTime; + await syncStats(); logger.error({ err: error, profileId }, "[vector-index] Index run failed"); } diff --git a/src/lib/vector/qdrant-client.ts b/src/lib/vector/qdrant-client.ts index a64afae..b0e4f73 100644 --- a/src/lib/vector/qdrant-client.ts +++ b/src/lib/vector/qdrant-client.ts @@ -1,5 +1,5 @@ import { QdrantClient } from "@qdrant/js-client-rest"; -import { v5 as uuidv5 } from "uuid"; +import { createHash } from "crypto"; export interface QdrantConfig { url: string; @@ -30,32 +30,20 @@ export function isQdrantConfigured(config: QdrantConfig = getQdrantConfig()): bo } /** - * Returns the public/browser-accessible Qdrant Dashboard URL for user exploration. + * Browser-reachable Qdrant Dashboard URL. Container-internal hostnames are not + * reachable from the user's browser, so those need QDRANT_DASHBOARD_URL set + * explicitly; otherwise this returns "" and the UI hides the link. */ -export function getQdrantDashboardUrl(): string { - if (process.env.QDRANT_DASHBOARD_URL) { - return process.env.QDRANT_DASHBOARD_URL; - } - const config = getQdrantConfig(); - if (!config.url) { - return process.env.NODE_ENV === "production" - ? "" - : `http://localhost:${process.env.QDRANT_PORT || 6335}/dashboard`; - } - +function getQdrantDashboardUrl(): string { + if (process.env.QDRANT_DASHBOARD_URL) return process.env.QDRANT_DASHBOARD_URL; + const { url } = getQdrantConfig(); try { - const parsed = new URL(config.url); + const parsed = new URL(url); if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") { - const port = parsed.port || process.env.QDRANT_PORT || "6335"; - return `http://localhost:${port}/dashboard`; + return `http://localhost:${parsed.port || process.env.QDRANT_PORT || 6335}/dashboard`; } - if (parsed.hostname === "qdrant") { - // In docker network, only default to localhost when not in production - if (process.env.NODE_ENV === "production") return ""; - const port = process.env.QDRANT_PORT || "6335"; - return `http://localhost:${port}/dashboard`; - } - return `${config.url.replace(/\/$/, "")}/dashboard`; + if (parsed.hostname === "qdrant") return ""; + return `${url.replace(/\/$/, "")}/dashboard`; } catch { return ""; } @@ -66,12 +54,12 @@ export const COLLECTIONS = { POST_FACES: "post_faces", } as const; -export const IMAGE_VECTOR_SIZE = 512; // Xenova/clip-vit-base-patch16 -export const FACE_VECTOR_SIZE = 128; // face-api face recognition descriptor +const IMAGE_VECTOR_SIZE = 512; // Xenova/clip-vit-base-patch16 +const FACE_VECTOR_SIZE = 128; // face-api face recognition descriptor let client: QdrantClient | null = null; -export function getQdrantClient(): QdrantClient { +function getQdrantClient(): QdrantClient { if (client) return client; const config = getQdrantConfig(); if (!isQdrantConfigured(config)) { @@ -79,9 +67,6 @@ export function getQdrantClient(): QdrantClient { "Qdrant is not configured. Set QDRANT_URL (and QDRANT_API_KEY) env vars." ); } - // js-client-rest defaults to port 6333 even when the URL has no explicit - // port, so an HTTPS URL behind a reverse proxy (Dokploy domain) silently - // gets the wrong port unless it's set explicitly here. const url = new URL(config.url); if ( config.apiKey && @@ -96,11 +81,10 @@ export function getQdrantClient(): QdrantClient { ); } - const port = url.port - ? Number(url.port) - : url.protocol === "https:" - ? 443 - : 80; + // js-client-rest defaults to port 6333 even when the URL has no explicit + // port, so an HTTPS URL behind a reverse proxy (Dokploy domain) silently + // gets the wrong port unless it's set explicitly here. + const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80; client = new QdrantClient({ url: config.url, apiKey: config.apiKey, @@ -110,25 +94,13 @@ export function getQdrantClient(): QdrantClient { return client; } -/** Checks whether a specific collection exists in Qdrant. */ -export async function checkCollectionExists(name: string): Promise { - try { - const qdrant = getQdrantClient(); - const existing = await qdrant.getCollections(); - return existing.collections.some((c) => c.name === name); - } catch { - return false; - } -} - -/** Counts total points in a collection, optionally filtered by profileId. */ -export async function countCollectionPoints(name: string, profileId?: string): Promise { +/** Counts points in a collection, optionally filtered by profileId. Returns 0 if the collection is missing. */ +async function countCollectionPoints(name: string, profileId?: string): Promise { try { - const qdrant = getQdrantClient(); const filter = profileId ? { must: [{ key: "profileId", match: { value: profileId } }] } : undefined; - const res = await qdrant.count(name, { filter, exact: true }); + const res = await getQdrantClient().count(name, { filter, exact: true }); return res.count; } catch { return 0; @@ -140,7 +112,6 @@ export interface QdrantLivenessResult { latencyMs: number; url: string; dashboardUrl: string; - version?: string; collections: { post_images: { exists: boolean; pointsCount: number }; post_faces: { exists: boolean; pointsCount: number }; @@ -148,96 +119,92 @@ export interface QdrantLivenessResult { error?: string; } -/** Deep liveness and readiness probe for the Qdrant service. */ +function livenessFailure( + status: QdrantLivenessResult["status"], + latencyMs: number, + url: string, + error: string +): QdrantLivenessResult { + return { + status, + latencyMs, + url, + dashboardUrl: getQdrantDashboardUrl(), + collections: { + post_images: { exists: false, pointsCount: 0 }, + post_faces: { exists: false, pointsCount: 0 }, + }, + error, + }; +} + +/** Liveness + readiness probe for the Qdrant service. */ export async function checkQdrantLiveness(profileId?: string): Promise { const config = getQdrantConfig(); - const dashboardUrl = getQdrantDashboardUrl(); - if (!isQdrantConfigured(config)) { - return { - status: "disconnected", - latencyMs: 0, - url: config.url, - dashboardUrl, - collections: { - post_images: { exists: false, pointsCount: 0 }, - post_faces: { exists: false, pointsCount: 0 }, - }, - error: "QDRANT_URL environment variable is not configured", - }; + return livenessFailure( + "disconnected", + 0, + config.url, + "QDRANT_URL environment variable is not configured" + ); } const start = performance.now(); try { - // 1. Direct HTTP probe to /livez - const livezRes = await fetch(`${config.url.replace(/\/$/, "")}/livez`, { + const livez = await fetch(`${config.url.replace(/\/$/, "")}/livez`, { headers: config.apiKey ? { "api-key": config.apiKey } : undefined, signal: AbortSignal.timeout(4000), }); - const latencyMs = Math.round(performance.now() - start); - - if (!livezRes.ok) { - return { - status: "unhealthy", + if (!livez.ok) { + return livenessFailure( + "unhealthy", latencyMs, - url: config.url, - dashboardUrl, - collections: { - post_images: { exists: false, pointsCount: 0 }, - post_faces: { exists: false, pointsCount: 0 }, - }, - error: `HTTP ${livezRes.status}: ${livezRes.statusText}`, - }; + config.url, + `HTTP ${livez.status}: ${livez.statusText}` + ); } - // 2. Query collections status and point counts const qdrant = getQdrantClient(); - const [existingColls, imagesCount, facesCount] = await Promise.all([ + const [colls, images, faces] = await Promise.all([ qdrant.getCollections().catch(() => ({ collections: [] })), countCollectionPoints(COLLECTIONS.POST_IMAGES, profileId), countCollectionPoints(COLLECTIONS.POST_FACES, profileId), ]); - - const collNames = new Set(existingColls.collections.map((c) => c.name)); - const imagesExist = collNames.has(COLLECTIONS.POST_IMAGES); - const facesExist = collNames.has(COLLECTIONS.POST_FACES); + const names = new Set(colls.collections.map((c) => c.name)); + const imagesExist = names.has(COLLECTIONS.POST_IMAGES); + const facesExist = names.has(COLLECTIONS.POST_FACES); return { status: imagesExist && facesExist ? "healthy" : "degraded", latencyMs, url: config.url, - dashboardUrl, + dashboardUrl: getQdrantDashboardUrl(), collections: { - post_images: { exists: imagesExist, pointsCount: imagesCount }, - post_faces: { exists: facesExist, pointsCount: facesCount }, + post_images: { exists: imagesExist, pointsCount: images }, + post_faces: { exists: facesExist, pointsCount: faces }, }, }; } catch (err: unknown) { - const latencyMs = Math.round(performance.now() - start); - const errorMsg = err instanceof Error ? err.message : String(err); - return { - status: "disconnected", - latencyMs, - url: config.url, - dashboardUrl, - collections: { - post_images: { exists: false, pointsCount: 0 }, - post_faces: { exists: false, pointsCount: 0 }, - }, - error: errorMsg, - }; + return livenessFailure( + "disconnected", + Math.round(performance.now() - start), + config.url, + err instanceof Error ? err.message : String(err) + ); } } function isAlreadyExistsError(err: unknown): boolean { - if (!err) return false; const msg = err instanceof Error ? err.message : String(err); if (/already exists/i.test(msg)) return true; - if (typeof err === "object" && "status" in err && (err as { status?: number }).status === 409) { - return true; - } - return false; + return ( + typeof err === "object" && + err !== null && + "status" in err && + (err as { status?: number }).status === 409 + ); } let ensureCollectionsPromise: Promise | null = null; @@ -248,45 +215,28 @@ async function doEnsureCollections(): Promise { const existingNames = new Set(existing.collections.map((c) => c.name)); const specs = [ - { - name: COLLECTIONS.POST_IMAGES, - vectors: { size: IMAGE_VECTOR_SIZE, distance: "Cosine" as const }, - }, - { - name: COLLECTIONS.POST_FACES, - vectors: { size: FACE_VECTOR_SIZE, distance: "Euclid" as const }, - }, + { name: COLLECTIONS.POST_IMAGES, size: IMAGE_VECTOR_SIZE, distance: "Cosine" as const }, + { name: COLLECTIONS.POST_FACES, size: FACE_VECTOR_SIZE, distance: "Euclid" as const }, ]; for (const spec of specs) { if (!existingNames.has(spec.name)) { try { await qdrant.createCollection(spec.name, { - vectors: spec.vectors, + vectors: { size: spec.size, distance: spec.distance }, }); - } catch (err: unknown) { - if (isAlreadyExistsError(err)) { - // Recheck the collection exists before treating initialization as successful - const exists = await checkCollectionExists(spec.name); - if (!exists) { - throw err; - } - } else { - throw err; - } + } catch (err) { + // A concurrent caller won the race — that's the outcome we wanted anyway. + if (!isAlreadyExistsError(err)) throw err; } } - - // Ensure its profileId payload index exists try { await qdrant.createPayloadIndex(spec.name, { field_name: "profileId", field_schema: "keyword", }); - } catch (err: unknown) { - if (!isAlreadyExistsError(err)) { - throw err; - } + } catch (err) { + if (!isAlreadyExistsError(err)) throw err; } } } @@ -314,13 +264,9 @@ export interface PostVectorPayload { [key: string]: unknown; } -export interface FaceVectorPayload extends PostVectorPayload { - bbox: { x: number; y: number; width: number; height: number }; -} - const POINT_ID_NAMESPACE = "e3f1a6f4-9b0e-4c2a-8f1a-7c6f2b5a9d3e"; -/** Deterministic Qdrant point id so re-indexing the same media slot upserts instead of duplicating. */ +/** Deterministic Qdrant point id (UUIDv5) so re-indexing the same media slot upserts instead of duplicating. */ export function pointId( profileId: string, postPk: string, @@ -328,10 +274,18 @@ export function pointId( position: number, faceIndex?: number ): string { - const key = `${profileId}:${postPk}:${source}:${position}${ + const name = `${profileId}:${postPk}:${source}:${position}${ faceIndex !== undefined ? `:face${faceIndex}` : "" }`; - return uuidv5(key, POINT_ID_NAMESPACE); + const ns = Buffer.from(POINT_ID_NAMESPACE.replace(/-/g, ""), "hex"); + const b = createHash("sha1") + .update(Buffer.concat([ns, Buffer.from(name)])) + .digest() + .subarray(0, 16); + b[6] = (b[6] & 0x0f) | 0x50; // version 5 + b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant + const h = b.toString("hex"); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; } export async function upsertPoints( @@ -339,8 +293,7 @@ export async function upsertPoints( points: { id: string; vector: number[]; payload: Record }[] ): Promise { if (points.length === 0) return; - const qdrant = getQdrantClient(); - await qdrant.upsert(collection, { wait: true, points }); + await getQdrantClient().upsert(collection, { wait: true, points }); } export interface SearchHit { @@ -354,10 +307,8 @@ export async function searchByVector( profileId: string, limit: number ): Promise { - const qdrant = getQdrantClient(); - try { - const result = await qdrant.query(collection, { + const result = await getQdrantClient().query(collection, { query: vector, filter: { must: [{ key: "profileId", match: { value: profileId } }] }, limit, @@ -366,11 +317,7 @@ export async function searchByVector( return result.points.map((p) => ({ score: p.score, payload: p.payload })); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - if ( - message.includes("doesn't exist") || - message.includes("Not found: Collection") || - message.includes("404") - ) { + if (/doesn't exist|Not found: Collection|404/.test(message)) { throw new VectorIndexNotBuiltError( `Collection '${collection}' does not exist in Qdrant. Please run the vector indexer first.` ); diff --git a/src/lib/vector/search-api.ts b/src/lib/vector/search-api.ts new file mode 100644 index 0000000..a75a6b6 --- /dev/null +++ b/src/lib/vector/search-api.ts @@ -0,0 +1,100 @@ +import { NextResponse } from "next/server"; +import { getQdrantConfig, isQdrantConfigured, VectorIndexNotBuiltError, type SearchHit } from "./qdrant-client"; + +export const RESULT_LIMIT = 60; +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10MB + +/** Non-null when Qdrant isn't configured — return it straight from the route. */ +export function qdrantNotConfiguredResponse(): NextResponse | null { + if (isQdrantConfigured(getQdrantConfig())) return null; + return NextResponse.json( + { + error: "Vector search is not configured. Set QDRANT_URL environment variable.", + needsIndexing: false, + }, + { status: 400 } + ); +} + +/** Reads the `image` file from a multipart body, enforcing the upload size limit. */ +export async function readUploadedImage( + request: Request +): Promise<{ buffer: Buffer } | { error: NextResponse }> { + const fail = (message: string, status: number) => ({ + error: NextResponse.json({ error: message }, { status }), + }); + + // Reject oversized uploads before buffering the whole body + if (Number(request.headers.get("content-length") || 0) > MAX_UPLOAD_BYTES + 1024 * 1024) { + return fail("Request payload exceeds maximum allowed size of 10MB.", 413); + } + + const file = (await request.formData()).get("image"); + if (!(file instanceof Blob)) return fail("Missing 'image' file in form data.", 400); + if (file.size > MAX_UPLOAD_BYTES) { + return fail("Image file exceeds maximum allowed size of 10MB.", 413); + } + return { buffer: Buffer.from(await file.arrayBuffer()) }; +} + +/** Shared error envelope for every vector search route. */ +export function searchErrorResponse(err: unknown): NextResponse { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof VectorIndexNotBuiltError || /doesn't exist|Not found: Collection/.test(message)) { + return NextResponse.json( + { + error: "Vector index has not been built yet. Please index your saved posts first.", + needsIndexing: true, + }, + { status: 400 } + ); + } + return NextResponse.json( + { error: `Vector search error: ${message}`, needsIndexing: false }, + { status: 500 } + ); +} + +export interface BestHit { + /** Higher is better, regardless of the collection's distance metric. */ + quality: number; + carouselPosition?: number; + imageUrl?: string; +} + +/** + * A post can have many indexed images (thumbnail + carousel slides); keep only its + * best one. `quality` converts a raw Qdrant score to higher-is-better, or returns + * null to drop the hit as noise. + */ +export function bestHitPerPost( + hits: SearchHit[], + quality: (score: number) => number | null +): Map { + const best = new Map(); + for (const hit of hits) { + const pk = hit.payload?.postPk; + if (typeof pk !== "string") continue; + const q = quality(hit.score); + if (q === null) continue; + const prev = best.get(pk); + if (prev === undefined || q > prev.quality) { + best.set(pk, { + quality: q, + carouselPosition: hit.payload?.carouselPosition as number | undefined, + imageUrl: hit.payload?.imageUrl as string | undefined, + }); + } + } + return best; +} + +/** + * Maps a raw similarity in [lo, hi] onto the confidence percentage shown in the UI. + * CLIP cosine scores live in a narrow band (~0.2–0.4), so the raw number would read + * as "24% match" for a perfect hit; this stretches that band to a legible range. + */ +export function calibrate(v: number, lo: number, hi: number, min: number, max: number): number { + const t = Math.min(1, Math.max(0, (v - lo) / (hi - lo))); + return min + t * (max - min); +} diff --git a/src/lib/vector/stats.ts b/src/lib/vector/stats.ts index d9d432e..e1e05d8 100644 --- a/src/lib/vector/stats.ts +++ b/src/lib/vector/stats.ts @@ -2,12 +2,11 @@ import { prisma } from "@/lib/prisma"; export interface VectorIndexStats { profileId: string; - profileName?: string; - status: "idle" | "running" | "completed" | "failed"; + status: "running" | "completed" | "failed"; lastRunAt: string | null; lastCompletedAt: string | null; durationMs: number | null; - cutoffPostTakenAt: number | null; + /** Newest post included in this run, for "index is current up to…" display. */ cutoffPostDate: string | null; totalItems: number; indexedItems: number; @@ -17,15 +16,12 @@ export interface VectorIndexStats { updatedAt: string; } -const STATS_KEY_PREFIX = "vector_index_stats_"; +const statsKey = (profileId: string) => `vector_index_stats_${profileId}`; export async function getProfileVectorStats(profileId: string): Promise { try { - const setting = await prisma.setting.findUnique({ - where: { key: `${STATS_KEY_PREFIX}${profileId}` }, - }); - if (!setting?.value) return null; - return JSON.parse(setting.value) as VectorIndexStats; + const setting = await prisma.setting.findUnique({ where: { key: statsKey(profileId) } }); + return setting?.value ? (JSON.parse(setting.value) as VectorIndexStats) : null; } catch { return null; } @@ -34,41 +30,16 @@ export async function getProfileVectorStats(profileId: string): Promise { const now = new Date().toISOString(); stats.updatedAt = now; + const key = statsKey(stats.profileId); + const value = JSON.stringify(stats); try { await prisma.setting.upsert({ - where: { key: `${STATS_KEY_PREFIX}${stats.profileId}` }, - update: { - value: JSON.stringify(stats), - updatedAt: now, - }, - create: { - key: `${STATS_KEY_PREFIX}${stats.profileId}`, - value: JSON.stringify(stats), - updatedAt: now, - }, + where: { key }, + update: { value, updatedAt: now }, + create: { key, value, updatedAt: now }, }); } catch (err) { console.error("[vector-stats] Failed to persist vector stats:", err); } } - -export async function getAllProfilesVectorStats(): Promise { - try { - const settings = await prisma.setting.findMany({ - where: { key: { startsWith: STATS_KEY_PREFIX } }, - }); - - const list: VectorIndexStats[] = []; - for (const s of settings) { - try { - list.push(JSON.parse(s.value)); - } catch { - // Ignore corrupt record - } - } - return list; - } catch { - return []; - } -} diff --git a/src/types/index.ts b/src/types/index.ts index 006dbf0..02c0469 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -113,13 +113,11 @@ export interface CloudinarySyncProgress { export interface VectorSearchHit { post: Post; + /** Display confidence 0-1, calibrated per search mode. */ score: number; - rawScore?: number; matchType?: "hybrid" | "visual" | "caption" | "account" | "face"; matchedSlideIndex?: number; matchedImageUrl?: string; - /** Only present for search-by-face results — the matched face's location in the post's image. */ - bbox?: { x: number; y: number; width: number; height: number }; } export interface VectorIndexProgress { From c168bb8e6c01d0526615d2cffbc5381c1245121c Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Fri, 4 Sep 2026 13:03:32 +0300 Subject: [PATCH 09/13] Trim redundant CI installs and prune Docker deps Simplifies `pr-beta.yml` and `release.yml` by removing npm cache/install and duplicate lint/typecheck steps that were already covered by other workflows, avoiding an extra full `npm ci` run per PR/release. Also updates the Dockerfile to delete unused `onnxruntime-node` win32/darwin binaries after install so only Linux artifacts remain in image layers. --- .github/workflows/pr-beta.yml | 16 +++++----------- .github/workflows/release.yml | 19 +++++-------------- Dockerfile | 5 +++++ 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/.github/workflows/pr-beta.yml b/.github/workflows/pr-beta.yml index 598c5d0..bac47db 100644 --- a/.github/workflows/pr-beta.yml +++ b/.github/workflows/pr-beta.yml @@ -27,7 +27,8 @@ jobs: uses: actions/setup-node@v7 with: node-version: 20 - cache: npm + # 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a515cc0..ebddd7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,8 @@ jobs: uses: actions/setup-node@v7 with: node-version: 20 - cache: npm + # 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' diff --git a/Dockerfile b/Dockerfile index 6db7298..e789fc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,11 @@ 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 From f88080291cf4ad0155a8f6b44b44e15de4bdedd3 Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Fri, 4 Sep 2026 13:12:01 +0300 Subject: [PATCH 10/13] Upgrade Node to 24; cache node_modules; set engines Switch CI and release workflows to Node.js 24 and update Docker base images to node:24-bookworm-slim. Add an actions/cache step in ci.yml to restore node_modules and skip npm ci when the cache hits to reduce CI time. Declare "engines.node": ">=22" in package.json to document the minimum Node requirement. --- .github/workflows/ci.yml | 17 ++++++++++++++--- .github/workflows/pr-beta.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- Dockerfile | 4 ++-- package.json | 3 +++ 5 files changed, 23 insertions(+), 9 deletions(-) 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 bac47db..f1471e4 100644 --- a/.github/workflows/pr-beta.yml +++ b/.github/workflows/pr-beta.yml @@ -23,10 +23,10 @@ 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 + node-version: 24 # No npm install in this workflow — only the version scripts run here, # and they use node builtins. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebddd7e..4cd2fbc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,10 +49,10 @@ 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 + node-version: 24 # No npm install in this workflow — only the version scripts run here, # and they use node builtins. diff --git a/Dockerfile b/Dockerfile index e789fc2..23cac7a 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 @@ -34,7 +34,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 \ diff --git a/package.json b/package.json index 664e5e2..66bb367 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "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", From 4a5a587ce10b3eb27aef0501ebf579f87983227d Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Fri, 4 Sep 2026 14:14:46 +0300 Subject: [PATCH 11/13] fix(vector): fail fast on stale collection dimensions; scope buildx caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureCollections() only checked that a collection existed by name, never that its vector params matched what this build writes. A post_images collection left at 768 dims by the abandoned SigLIP2 experiment therefore passed the check, and every upsert of a 512-dim CLIP vector was rejected by Qdrant with "Vector dimension error" — once per item, each swallowed into failedItems. describeVectorParamsMismatch() now compares both size and distance against the live collection and ensureCollections() throws before the indexing loop starts. It deliberately does not auto-recreate: a mismatch can also mean the app is pointed at a Qdrant holding another application's collection, and dropping that would be unrecoverable. The failure was also invisible. A run that failed before indexing anything left failedItems at 0, and the UI only rendered lastError when failedItems was above 0, so the status chip read "Not Indexed" and the callout just re-offered a Reindex button that would fail identically. Failed runs now get their own callout with the actual message, a Failed state on the modal chip, and lastError shown whenever it is present. Also give each workflow its own buildx gha cache scope. Both used the default scope, so the beta and release image exports kept overwriting each other's cache manifest; beta additionally reads the release scope so a pull request starts warm from the last master build. Co-Authored-By: Claude Opus 5 --- .github/workflows/pr-beta.yml | 9 +++++-- .github/workflows/release.yml | 4 +-- scripts/test-vector-search.ts | 19 ++++++++++++- src/app/(dashboard)/search/page.tsx | 40 +++++++++++++++++++++++---- src/lib/vector/qdrant-client.ts | 42 ++++++++++++++++++++++++++--- 5 files changed, 101 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr-beta.yml b/.github/workflows/pr-beta.yml index f1471e4..a6da63f 100644 --- a/.github/workflows/pr-beta.yml +++ b/.github/workflows/pr-beta.yml @@ -65,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: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cd2fbc..846c536 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,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/scripts/test-vector-search.ts b/scripts/test-vector-search.ts index 4669b1a..c098a9d 100644 --- a/scripts/test-vector-search.ts +++ b/scripts/test-vector-search.ts @@ -5,7 +5,7 @@ * and the higher-is-better normalisation shared by all three search modes. */ import assert from "assert"; -import { pointId } from "../src/lib/vector/qdrant-client"; +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 @@ -51,4 +51,21 @@ assert.equal(Number(faces.get("a")!.quality.toFixed(2)), 0.42); // from distance // 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 index 4faa003..69054f1 100644 --- a/src/app/(dashboard)/search/page.tsx +++ b/src/app/(dashboard)/search/page.tsx @@ -192,7 +192,9 @@ export default function SearchPage() { const liveness = indexStatusData?.liveness; const stats = indexStatusData?.stats; const dashboardUrl = liveness?.dashboardUrl || null; - const hasNeverIndexed = !isLoadingStatus && !isIndexRunning && (!stats || stats.indexedItems === 0); + const lastRunFailed = !isLoadingStatus && !isIndexRunning && stats?.status === "failed"; + const hasNeverIndexed = + !isLoadingStatus && !isIndexRunning && !lastRunFailed && (!stats || stats.indexedItems === 0); return (
@@ -284,6 +286,31 @@ export default function SearchPage() {
)} + {/* Failed Index Run Callout */} + {lastRunFailed && ( + +
+ +
+

Last index run failed

+

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

+
+
+ +
+ )} + {/* Unindexed Archive Warning Callout */} {hasNeverIndexed && ( @@ -615,7 +642,9 @@ export default function SearchPage() { Active Profile Index - {stats?.status === "completed" ? ( + {stats?.status === "failed" ? ( + Failed + ) : stats?.status === "completed" ? ( Completed @@ -667,9 +696,10 @@ export default function SearchPage() { - {stats?.failedItems && stats.failedItems > 0 ? ( -
- Failed Items: {stats.failedItems} ({stats.lastError || "Unknown error"}) + {stats?.lastError || (stats?.failedItems ?? 0) > 0 ? ( +
+ {(stats?.failedItems ?? 0) > 0 && `Failed Items: ${stats?.failedItems} — `} + {stats?.lastError || "Unknown error"}
) : null}
diff --git a/src/lib/vector/qdrant-client.ts b/src/lib/vector/qdrant-client.ts index b0e4f73..4bf69db 100644 --- a/src/lib/vector/qdrant-client.ts +++ b/src/lib/vector/qdrant-client.ts @@ -209,14 +209,41 @@ function isAlreadyExistsError(err: unknown): boolean { let ensureCollectionsPromise: Promise | null = null; +export interface VectorSpec { + name: string; + size: number; + distance: "Cosine" | "Euclid"; +} + +/** + * Describes how an existing collection's vector params differ from what this + * build writes, or null when they match. Switching embedding models changes the + * dimension (CLIP ViT-B/16 is 512, SigLIP2-base is 768), and Qdrant will not + * migrate a collection in place — every upsert just 400s with + * "Vector dimension error". Without this check that surfaces as thousands of + * silently-counted failed items instead of one actionable error. + */ +export function describeVectorParamsMismatch( + spec: VectorSpec, + actual: { size?: number; distance?: string } | undefined +): string | null { + if (!actual || typeof actual.size !== "number") { + return `Collection '${spec.name}' exists but has no single unnamed vector config; expected size ${spec.size} (${spec.distance}).`; + } + if (actual.size !== spec.size || actual.distance !== spec.distance) { + return `Collection '${spec.name}' has vectors of size ${actual.size} (${actual.distance}), but this build writes size ${spec.size} (${spec.distance}). Qdrant cannot change this in place — delete the collection and re-run the indexer.`; + } + return null; +} + async function doEnsureCollections(): Promise { const qdrant = getQdrantClient(); const existing = await qdrant.getCollections().catch(() => ({ collections: [] })); const existingNames = new Set(existing.collections.map((c) => c.name)); - const specs = [ - { name: COLLECTIONS.POST_IMAGES, size: IMAGE_VECTOR_SIZE, distance: "Cosine" as const }, - { name: COLLECTIONS.POST_FACES, size: FACE_VECTOR_SIZE, distance: "Euclid" as const }, + const specs: VectorSpec[] = [ + { name: COLLECTIONS.POST_IMAGES, size: IMAGE_VECTOR_SIZE, distance: "Cosine" }, + { name: COLLECTIONS.POST_FACES, size: FACE_VECTOR_SIZE, distance: "Euclid" }, ]; for (const spec of specs) { @@ -229,7 +256,16 @@ async function doEnsureCollections(): Promise { // A concurrent caller won the race — that's the outcome we wanted anyway. if (!isAlreadyExistsError(err)) throw err; } + } else { + // Left over from an earlier embedding model? Fail now, not once per item. + const info = await qdrant.getCollection(spec.name); + const mismatch = describeVectorParamsMismatch( + spec, + info?.config?.params?.vectors as { size?: number; distance?: string } | undefined + ); + if (mismatch) throw new Error(mismatch); } + try { await qdrant.createPayloadIndex(spec.name, { field_name: "profileId", From e14f8aafe2cba01ed79ada3320e76e6dee866af9 Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Fri, 4 Sep 2026 15:28:56 +0300 Subject: [PATCH 12/13] feat(search): make vector search an optional beta add-on, off by default Vector search pulled a Qdrant service and ~600 MB of CLIP and face-recognition weights into every deployment, which most archives never use. It is now opt-in: the presence of QDRANT_URL is the only switch, and without it the Search page shows a "not enabled" notice linking to the docs and no model is ever loaded. Compose is split rather than duplicated. Docker Compose merges override files natively, so the base stack no longer ships Qdrant and the add-on layers on: docker compose -f docker-compose.yml -f docker-compose.search.yml up -d The Dokploy and Coolify templates are pasted as a single file and cannot layer, so they drop Qdrant too and the feature doc carries the additive snippet instead of a second copy that would drift out of sync. Three things that would have broken a search-free install: - /api/health reported 503 whenever Qdrant was absent, and the container HEALTHCHECK hits that endpoint, so every install without search would have been permanently unhealthy and failed depends_on. An unconfigured vector service is now reported as disabled rather than degraded. - warm-models.ts baked the weights into the image at build time, so the cost landed on everyone regardless of whether they enabled search. The build step and the now-dead script are removed; weights download on first index into the model_cache volume the add-on file declares. - That cache volume would have come up root-owned while the app runs as nextjs, because Docker seeds a named volume from a path that did not exist in the image. The runner stage now creates and chowns it. Docs, README and .env.example are updated to present search as beta and optional, including the trade-offs and the Qdrant no-authentication warning. Co-Authored-By: Claude Opus 5 --- .env.example | 27 +++++-- Dockerfile | 14 +++- README.md | 42 ++++------ coolify-compose.yml | 27 +------ docker-compose.search.yml | 55 +++++++++++++ docker-compose.yml | 29 +------ docs/deployment/coolify.md | 30 ++----- docs/deployment/docker-compose.md | 21 ++++- docs/deployment/dokploy.md | 36 ++------- docs/features/ai-vector-search.md | 118 ++++++++++++++++++++++++++-- docs/index.md | 4 +- dokploy-compose.yml | 27 +------ scripts/warm-models.ts | 32 -------- src/app/(dashboard)/search/page.tsx | 51 +++++++++--- src/app/api/health/route.ts | 30 ++++--- src/lib/constants.ts | 4 + 16 files changed, 315 insertions(+), 232 deletions(-) create mode 100644 docker-compose.search.yml delete mode 100644 scripts/warm-models.ts diff --git a/.env.example b/.env.example index fac7d82..616e3f7 100644 --- a/.env.example +++ b/.env.example @@ -28,15 +28,26 @@ CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= # ----------------------------------------------------------------- -# 3. Vector Database - Qdrant (Pre-wired in docker-compose) -# ----------------------------------------------------------------- -# Host port exposed for Qdrant API & Web Dashboard (default: 6335) +# 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 - -# Pre-configured in docker-compose (http://qdrant:6333). -# For local dev without docker compose or custom setup: -QDRANT_URL="http://localhost:6335" +# 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= # ----------------------------------------------------------------- diff --git a/Dockerfile b/Dockerfile index 23cac7a..ab9ac31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,9 +20,10 @@ RUN rm -rf node_modules/onnxruntime-node/bin/napi-v*/win32 node_modules/onnxrunt COPY prisma ./prisma RUN npx prisma generate -# Bake CLIP weights into the image (cached layer) instead of downloading at runtime -COPY scripts/warm-models.ts ./scripts/warm-models.ts -RUN npx tsx scripts/warm-models.ts +# 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 . . @@ -60,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 cb9bb2e..1514e82 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,9 @@ services: - "5050:3000" environment: - DATABASE_URL=mongodb://mongo:27017/instagram?replicaSet=rs0&directConnection=true - - QDRANT_URL=http://qdrant:6333 depends_on: mongo: condition: service_healthy - qdrant: - condition: service_healthy mongo: image: mongo:7.0 @@ -85,35 +82,26 @@ services: retries: 10 start_period: 2s - qdrant: - image: qdrant/qdrant:v1.13.4 - restart: unless-stopped - ports: - - "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: mongo_data: - qdrant_data: ``` ```bash docker compose up -d ``` -🎉 Open **`http://localhost:5050`** in your browser and complete the 60-second onboarding wizard! -🔍 Explore your vector database & embeddings via the built-in **Qdrant Dashboard** at **`http://localhost:6335/dashboard`** (bound to localhost for security). +🎉 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). --- @@ -152,7 +140,7 @@ This project follows an automated semantic CI/CD versioning lifecycle directly c | Feature | Description | | :--- | :--- | -| 🔍 **Multimodal Vector Search** | Natural-language prompt search (CLIP), visual similarity image search, and facial recognition search with Qdrant. | +| 🔍 **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. | @@ -186,8 +174,8 @@ When running via Docker Compose, **zero environment variables are required**. Op | Variable | Default | Description | | :--- | :--- | :--- | | `PORT` | `5050` | Port exposed on host for web app | -| `QDRANT_PORT` | `6335` | Port exposed on host for Qdrant API & Dashboard UI | -| `QDRANT_URL` | `http://qdrant:6333` | Qdrant endpoint connection URL | +| `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 ac6eb52..b1487bb 100644 --- a/coolify-compose.yml +++ b/coolify-compose.yml @@ -17,10 +17,8 @@ services: - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 - # Vector search (Qdrant) - - QDRANT_URL=http://qdrant:6333 - - QDRANT_API_KEY=${QDRANT_API_KEY:-} - - QDRANT_PORT=${QDRANT_PORT:-6335} + # 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:-} @@ -28,8 +26,6 @@ services: depends_on: mongo: condition: service_healthy - qdrant: - condition: service_healthy mongo: image: mongo:7.0 @@ -54,24 +50,5 @@ services: retries: 10 start_period: 2s - qdrant: - image: qdrant/qdrant:v1.13.4 - restart: unless-stopped - ports: - - "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: mongo_data: - qdrant_data: 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 740553c..74a2562 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,10 +16,8 @@ services: - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 - # Vector search (Qdrant) - - QDRANT_URL=http://qdrant:6333 - - QDRANT_API_KEY=${QDRANT_API_KEY:-} - - QDRANT_PORT=${QDRANT_PORT:-6335} + # 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:-} @@ -29,8 +27,6 @@ services: depends_on: mongo: condition: service_healthy - qdrant: - condition: service_healthy mongo: image: mongo:7.0 @@ -56,27 +52,6 @@ services: retries: 10 start_period: 2s - qdrant: - image: qdrant/qdrant:v1.13.4 - container_name: instagram_saved_posts_qdrant - restart: unless-stopped - ports: - - "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: mongo_data: name: instagram_saved_posts_mongo_data - qdrant_data: - name: instagram_saved_posts_qdrant_data diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md index 410c691..aac1f75 100644 --- a/docs/deployment/coolify.md +++ b/docs/deployment/coolify.md @@ -36,16 +36,14 @@ services: - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 - - QDRANT_URL=http://qdrant:6333 - - QDRANT_PORT=${QDRANT_PORT:-6335} + # 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:-} depends_on: mongo: condition: service_healthy - qdrant: - condition: service_healthy mongo: image: mongo:7.0 @@ -70,33 +68,17 @@ services: retries: 10 start_period: 2s - qdrant: - image: qdrant/qdrant:v1.13.4 - restart: unless-stopped - ports: - - "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: mongo_data: - qdrant_data: ``` ### Step 3: Domain & Routing 1. In the Coolify resource view, configure your **FQDN / Domain** (e.g. `https://instagram.example.com`). 2. Set the destination port to `3000`. -3. (Optional) The Qdrant Dashboard UI is bound to `127.0.0.1:6335` for security. To access it, use an SSH tunnel (`ssh -L 6335:localhost:6335 user@server`) or route through an authenticated reverse proxy pointing to internal container network `http://qdrant:6333/dashboard`. ### 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 f306de9..d04f087 100644 --- a/docs/deployment/docker-compose.md +++ b/docs/deployment/docker-compose.md @@ -39,15 +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 5050, accessible at `http://localhost:5050`). -- `instagram_saved_posts_qdrant`: Qdrant vector database engine (port 6335, Web Dashboard at `http://localhost:6335/dashboard`). - `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`. @@ -55,14 +67,17 @@ You should see: ## ⚙️ Volume & Data Persistence -The MongoDB database stores all profiles, saved posts, carousel media, account history, and notes inside the Docker volume `instagram_saved_posts_mongo_data`. Qdrant stores multimodal vectors in `instagram_saved_posts_qdrant_data`. +The MongoDB database stores all profiles, saved posts, carousel media, account history, and notes inside the Docker volume `instagram_saved_posts_mongo_data`. To check volume details: ```bash docker volume inspect instagram_saved_posts_mongo_data -docker volume inspect instagram_saved_posts_qdrant_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 ec9afcc..d5363c4 100644 --- a/docs/deployment/dokploy.md +++ b/docs/deployment/dokploy.md @@ -34,17 +34,14 @@ services: - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 - - QDRANT_URL=http://qdrant:6333 - - QDRANT_API_KEY=${QDRANT_API_KEY} - - QDRANT_PORT=6335 + # 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} depends_on: mongo: condition: service_healthy - qdrant: - condition: service_healthy mongo: image: mongo:7.0 @@ -69,27 +66,8 @@ services: retries: 10 start_period: 2s - qdrant: - image: qdrant/qdrant:v1.13.4 - restart: unless-stopped - ports: - - "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: mongo_data: - qdrant_data: ``` ### Step 3: Configure Domain & SSL @@ -97,17 +75,19 @@ volumes: 2. Add your custom domain (e.g. `instagram.yourdomain.com`). 3. Select Port `5050`. 4. Enable **HTTPS (Let's Encrypt)**. -5. (Optional) The Qdrant Dashboard UI is bound to `127.0.0.1:6335` for security. To access it, use an SSH tunnel (`ssh -L 6335:localhost:6335 user@server`) or route through an authenticated reverse proxy pointing to internal container network `http://qdrant:6333/dashboard`. ### Step 4: Deploy -Click **Deploy** at the top right. Dokploy will pull the container images, verify MongoDB and Qdrant health, and start the application automatically! +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 -If you wish to configure permanent Cloudinary media sync or custom Qdrant keys, go to the **Environment** tab in Dokploy and add: +If you wish to configure permanent Cloudinary media sync, go to the **Environment** tab in Dokploy and add: - `CLOUDINARY_CLOUD_NAME` - `CLOUDINARY_API_KEY` - `CLOUDINARY_API_SECRET` -- `QDRANT_API_KEY` diff --git a/docs/features/ai-vector-search.md b/docs/features/ai-vector-search.md index d319b36..a635733 100644 --- a/docs/features/ai-vector-search.md +++ b/docs/features/ai-vector-search.md @@ -1,12 +1,120 @@ --- -title: "AI Vector Search" -description: "CLIP-based semantic image search, facial recognition, and Qdrant vector indexing." +title: "AI Vector Search (Beta)" +description: "Optional CLIP-based semantic image search, facial recognition, and Qdrant vector indexing. Off by default." --- -# AI Vector Search +# AI Vector Search (Beta) -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. +> **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. + +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. + +## Should you enable it? + +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: + +| | | +|---|---| +| **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 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 8b94347..917d679 100644 --- a/dokploy-compose.yml +++ b/dokploy-compose.yml @@ -15,10 +15,8 @@ services: - NODE_ENV=production - PORT=3000 - HOSTNAME=0.0.0.0 - # Vector search (Qdrant) - - QDRANT_URL=http://qdrant:6333 - - QDRANT_API_KEY=${QDRANT_API_KEY} - - QDRANT_PORT=6335 + # 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} @@ -26,8 +24,6 @@ services: depends_on: mongo: condition: service_healthy - qdrant: - condition: service_healthy mongo: image: mongo:7.0 @@ -52,24 +48,5 @@ services: retries: 10 start_period: 2s - qdrant: - image: qdrant/qdrant:v1.13.4 - restart: unless-stopped - ports: - - "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: mongo_data: - qdrant_data: diff --git a/scripts/warm-models.ts b/scripts/warm-models.ts deleted file mode 100644 index 249a363..0000000 --- a/scripts/warm-models.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Docker build-time only: downloads the CLIP model weights (vision & text) into - * the Transformers.js cache so the image ships with them baked in instead - * of fetching from huggingface.co on first request. - */ -import { - AutoProcessor, - CLIPVisionModelWithProjection, - AutoTokenizer, - CLIPTextModelWithProjection, -} from "@huggingface/transformers"; - -async function main() { - console.log("[warm-models] Warming CLIP vision processor & projection model..."); - await AutoProcessor.from_pretrained("Xenova/clip-vit-base-patch16"); - await CLIPVisionModelWithProjection.from_pretrained("Xenova/clip-vit-base-patch16", { - dtype: "fp32", - }); - - console.log("[warm-models] Warming CLIP text tokenizer & projection model..."); - await AutoTokenizer.from_pretrained("Xenova/clip-vit-base-patch16"); - await CLIPTextModelWithProjection.from_pretrained("Xenova/clip-vit-base-patch16", { - dtype: "fp32", - }); - - console.log("[warm-models] All CLIP weights cached successfully."); -} - -main().catch((err) => { - console.error("[warm-models] Failed:", err); - process.exit(1); -}); diff --git a/src/app/(dashboard)/search/page.tsx b/src/app/(dashboard)/search/page.tsx index 69054f1..586b3b4 100644 --- a/src/app/(dashboard)/search/page.tsx +++ b/src/app/(dashboard)/search/page.tsx @@ -45,6 +45,7 @@ import { Activity, CheckCircle2, } from "lucide-react"; +import { VECTOR_SEARCH_DOCS_URL } from "@/lib/constants"; import type { Post, VectorSearchHit } from "@/types"; type SearchMode = "text" | "image" | "face"; @@ -192,17 +193,25 @@ export default function SearchPage() { const liveness = indexStatusData?.liveness; const stats = indexStatusData?.stats; const dashboardUrl = liveness?.dashboardUrl || null; - const lastRunFailed = !isLoadingStatus && !isIndexRunning && stats?.status === "failed"; + // 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 = - !isLoadingStatus && !isIndexRunning && !lastRunFailed && (!stats || stats.indexedItems === 0); + searchEnabled && + !isLoadingStatus && + !isIndexRunning && + !lastRunFailed && + (!stats || stats.indexedItems === 0); return (
-
+
{/* Qdrant Liveness Badge */} {liveness && (
- {/* Database connection warning */} - {indexStatusData && !indexStatusData.configured && ( -
- -
- Qdrant is not connected. Ensure - Qdrant is running in Docker Compose and QDRANT_URL is set. + {/* 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 */} @@ -375,6 +398,7 @@ export default function SearchPage() { )} {/* Search Input Controls */} + {searchEnabled && ( + )} {/* Loading Skeleton */} {isPending && ( diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 997676d..b65e52f 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { checkQdrantLiveness } from "@/lib/vector/qdrant-client"; +import { + checkQdrantLiveness, + getQdrantConfig, + isQdrantConfigured, +} from "@/lib/vector/qdrant-client"; export const dynamic = "force-dynamic"; @@ -13,22 +17,28 @@ export async function GET() { mongoConnected = false; } - const qdrantLiveness = await checkQdrantLiveness().catch(() => ({ - status: "disconnected" as const, - latencyMs: 0, - })); + // 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 && qdrantLiveness.status === "healthy"; + const isHealthy = + mongoConnected && (!searchEnabled || qdrantLiveness?.status === "healthy"); const status = isHealthy ? "healthy" : mongoConnected ? "degraded" : "unhealthy"; return NextResponse.json( { status, mongo: mongoConnected ? "connected" : "disconnected", - vectorService: { - status: qdrantLiveness.status, - latencyMs: qdrantLiveness.latencyMs, - }, + vectorService: qdrantLiveness + ? { status: qdrantLiveness.status, latencyMs: qdrantLiveness.latencyMs } + : { status: "disabled" }, uptime: process.uptime(), timestamp: new Date().toISOString(), }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 0a504ec..e155992 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -38,3 +38,7 @@ export const GITHUB_REPO_URL = export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases`; +/** Vector search is opt-in; the search page links here when it is switched off. */ +export const VECTOR_SEARCH_DOCS_URL = + `${GITHUB_REPO_URL}/blob/master/docs/features/ai-vector-search.md`; + From 7da2e8b2ae9390947d34c17998b7e5f0e664d81b Mon Sep 17 00:00:00 2001 From: Mahmoud Nasr <239.nasr@gmail.com> Date: Fri, 4 Sep 2026 15:38:54 +0300 Subject: [PATCH 13/13] fix(ui): stop the dashboard overflowing horizontally on phones Two pages scrolled sideways at a 375px viewport, measured against a running instance: - Overview: main scrollWidth 422 vs 375. The Top Creators and Recent Sync Runs cards rendered 406px wide inside a 343px grid. As grid items they default to min-width:auto, so they refuse to shrink below their content's min-content width instead of fitting the track. - Search: main scrollWidth 544 vs 375. The tab list alone was 511px, because "Prompt Search", "Visual Similarity" and "Face Recognition" cannot fit side by side on a phone. Card gets min-w-0 once, in the component, rather than at each call site: it is the shared element every offender routed through, and it is inert for cards that are not flex or grid items. The tab labels drop their qualifier below sm ("Search", "Visual", "Face") and the triggers share the row evenly. Both pages now measure scrollWidth == clientWidth at 375px and at 320px, with no element wider than the viewport. The wide table on /scrape was checked and left alone: it already scrolls inside its own overflow-x-auto wrapper, which is the intended behaviour, and the page itself does not overflow. Co-Authored-By: Claude Opus 5 --- src/app/(dashboard)/search/page.tsx | 21 +++++++++++---------- src/components/ui/card.tsx | 6 +++++- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/app/(dashboard)/search/page.tsx b/src/app/(dashboard)/search/page.tsx index 586b3b4..0771173 100644 --- a/src/app/(dashboard)/search/page.tsx +++ b/src/app/(dashboard)/search/page.tsx @@ -404,18 +404,19 @@ export default function SearchPage() { value={mode} onValueChange={(v) => handleModeChange(v as SearchMode)} > - - - - Prompt Search + {/* Full labels overflow a phone viewport, so the qualifier drops below sm. */} + + + + Prompt Search - - - Visual Similarity + + + Visual Similarity - - - Face Recognition + + + Face Recognition diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 0267582..0c7e034 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -7,7 +7,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {