From c17c903bead5f1a50de797bbba0f11c2fc77f15f Mon Sep 17 00:00:00 2001 From: Bivekich Date: Wed, 13 Aug 2025 21:23:15 +0300 Subject: [PATCH] first commit --- .gitignore | 1 + README.md | 37 +- package-lock.json | 1222 +++++++++++++++++++++++++- package.json | 5 + prisma/schema.prisma | 17 + scripts/gateway.ts | 20 + src/app/admin/page.tsx | 6 + src/app/admin/zzap/page.tsx | 6 + src/app/api/ai/chat/route.ts | 79 ++ src/app/api/zzap/history/route.ts | 71 ++ src/app/api/zzap/screenshot/route.ts | 666 ++++++++++++++ src/app/dashboard/ai/page.tsx | 125 +++ src/app/dashboard/zzap/page.tsx | 182 ++++ src/components/ui/scroll-area.tsx | 48 + src/components/ui/sidebar.tsx | 16 +- src/lib/auth.ts | 11 +- src/lib/graphql/resolvers.ts | 50 +- src/lib/parts-db.ts | 369 +------- src/lib/partsindex-service.ts | 31 +- 19 files changed, 2578 insertions(+), 384 deletions(-) create mode 100644 scripts/gateway.ts create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/zzap/page.tsx create mode 100644 src/app/api/ai/chat/route.ts create mode 100644 src/app/api/zzap/history/route.ts create mode 100644 src/app/api/zzap/screenshot/route.ts create mode 100644 src/app/dashboard/ai/page.tsx create mode 100644 src/app/dashboard/zzap/page.tsx create mode 100644 src/components/ui/scroll-area.tsx diff --git a/.gitignore b/.gitignore index bf088c6..ef96a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ yarn-error.log* next-env.d.ts /src/generated +.zzap-session.json diff --git a/README.md b/README.md index ef05188..143537a 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,17 @@ - ✅ GraphQL API - ✅ Современный UI с Shadcn/ui - ✅ Работа с базой данных через Prisma ORM +- ✅ Скрипт «AI Gateway» (AI SDK) - 🔄 Интеграция с S3 для файлов (в разработке) ## Технологии -- **Frontend**: Next.js 15, React 19, TypeScript -- **UI**: Tailwind CSS, Shadcn/ui, Radix UI -- **Backend**: GraphQL (Apollo Server) -- **Database**: PostgreSQL + Prisma ORM -- **Forms**: React Hook Form + Zod validation -- **Storage**: AWS S3 (планируется) +- Frontend: Next.js 15, React 19, TypeScript +- UI: Tailwind CSS, Shadcn/ui, Radix UI +- Backend: GraphQL (Apollo Server) +- Database: PostgreSQL + Prisma ORM +- Forms: React Hook Form + Zod validation +- Storage: AWS S3 (планируется) ## Установка и настройка @@ -65,7 +66,23 @@ npx prisma generate npm run dev ``` -Откройте [http://localhost:3000](http://localhost:3000) в браузере. +Откройте http://localhost:3000 в браузере. + +### 5. AI Gateway (скрипт) + +Для запуска скрипта AI Gateway нужен ключ в `.env`: + +```env +AI_GATEWAY_API_KEY="your_api_key_here" +``` + +Запуск примера (TypeScript выполняется через `tsx`): + +```bash +npm run ai:gateway +``` + +Скрипт: `scripts/gateway.ts` — использует `ai` SDK и печатает стрим-ответ, usage и причину завершения. ## Первый запуск @@ -137,10 +154,10 @@ npx prisma migrate dev --name [migration-name] ## Тестирование ### Тест S3 хранилища -Откройте [http://localhost:3000/test-s3](http://localhost:3000/test-s3) для тестирования загрузки файлов в S3. +Откройте http://localhost:3000/test-s3 для тестирования загрузки файлов в S3. ### Тест GraphQL API -Откройте [http://localhost:3000/api/graphql](http://localhost:3000/api/graphql) для проверки GraphQL API. +Откройте http://localhost:3000/api/graphql для проверки GraphQL API. ## Следующие шаги @@ -149,4 +166,4 @@ npx prisma migrate dev --name [migration-name] - [ ] Добавить управление контентом - [ ] Создать систему ролей и разрешений - [ ] Добавить логирование и мониторинг -# protekauto-cms +# protek diff --git a/package-lock.json b/package-lock.json index 36ba4a4..039f825 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "protekauto-cms", "version": "0.1.0", "dependencies": { + "@ai-sdk/openai": "^1.3.24", "@apollo/client": "^3.13.8", "@apollo/experimental-nextjs-app-support": "^0.12.2", "@apollo/server": "^4.12.2", @@ -23,6 +24,7 @@ "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -37,6 +39,7 @@ "@types/pg": "^8.15.4", "@types/qrcode": "^1.5.5", "@types/uuid": "^10.0.0", + "ai": "^3.4.33", "axios": "^1.10.0", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", @@ -81,10 +84,207 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.3.3", + "tsx": "^4.19.2", "tw-animate-css": "^1.3.4", "typescript": "^5" } }, + "node_modules/@ai-sdk/openai": { + "version": "1.3.24", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.24.tgz", + "integrity": "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "0.0.26", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-0.0.26.tgz", + "integrity": "sha512-dQkfBDs2lTYpKM8389oopPdQgIU007GQyCbuPPrV+K6MtSII3HBfE0stUIMXUb44L+LK1t6GXPP7wjSzjO6uKg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-1.0.22.tgz", + "integrity": "sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "eventsource-parser": "^1.1.2", + "nanoid": "^3.3.7", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/react": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-0.0.70.tgz", + "integrity": "sha512-GnwbtjW4/4z7MleLiW+TOZC2M29eCg1tOUpuEiYFMmFNZK8mkrqM0PFZMo6UsYeUYMWqEOOcPOU9OQVJMJh7IQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "1.0.22", + "@ai-sdk/ui-utils": "0.0.50", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/solid": { + "version": "0.0.54", + "resolved": "https://registry.npmjs.org/@ai-sdk/solid/-/solid-0.0.54.tgz", + "integrity": "sha512-96KWTVK+opdFeRubqrgaJXoNiDP89gNxFRWUp0PJOotZW816AbhUf4EnDjBjXTLjXL1n0h8tGSE9sZsRkj9wQQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "1.0.22", + "@ai-sdk/ui-utils": "0.0.50" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "solid-js": "^1.7.7" + }, + "peerDependenciesMeta": { + "solid-js": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/svelte": { + "version": "0.0.57", + "resolved": "https://registry.npmjs.org/@ai-sdk/svelte/-/svelte-0.0.57.tgz", + "integrity": "sha512-SyF9ItIR9ALP9yDNAD+2/5Vl1IT6kchgyDH8xkmhysfJI6WrvJbtO1wdQ0nylvPLcsPoYu+cAlz1krU4lFHcYw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "1.0.22", + "@ai-sdk/ui-utils": "0.0.50", + "sswr": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/ui-utils": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-0.0.50.tgz", + "integrity": "sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "@ai-sdk/provider-utils": "1.0.22", + "json-schema": "^0.4.0", + "secure-json-parse": "^2.7.0", + "zod-to-json-schema": "^3.23.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/vue": { + "version": "0.0.59", + "resolved": "https://registry.npmjs.org/@ai-sdk/vue/-/vue-0.0.59.tgz", + "integrity": "sha512-+ofYlnqdc8c4F6tM0IKF0+7NagZRAiqBJpGDJ+6EYhDW8FHLUP/JFBgu32SjxSxC6IKFZxEnl68ZoP/Z38EMlw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "1.0.22", + "@ai-sdk/ui-utils": "0.0.50", + "swrv": "^1.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "vue": "^3.3.4" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -1408,6 +1608,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", @@ -1417,6 +1627,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.27.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", @@ -1426,6 +1652,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", @@ -1457,6 +1697,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -2283,6 +2965,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2546,6 +3239,15 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3322,6 +4024,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", + "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", @@ -4400,6 +5133,16 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", + "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -4717,11 +5460,16 @@ "@types/node": "*" } }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/express": { @@ -5465,6 +6213,115 @@ "win32" ] }, + "node_modules/@vue/compiler-core": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.18.tgz", + "integrity": "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.28.0", + "@vue/shared": "3.5.18", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz", + "integrity": "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-core": "3.5.18", + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.18.tgz", + "integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.28.0", + "@vue/compiler-core": "3.5.18", + "@vue/compiler-dom": "3.5.18", + "@vue/compiler-ssr": "3.5.18", + "@vue/shared": "3.5.18", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.17", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.18.tgz", + "integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.18", + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.18.tgz", + "integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.18.tgz", + "integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.18", + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.18.tgz", + "integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.18", + "@vue/runtime-core": "3.5.18", + "@vue/shared": "3.5.18", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.18.tgz", + "integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-ssr": "3.5.18", + "@vue/shared": "3.5.18" + }, + "peerDependencies": { + "vue": "3.5.18" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.18.tgz", + "integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==", + "license": "MIT", + "peer": true + }, "node_modules/@whatwg-node/promise-helpers": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", @@ -5569,7 +6426,6 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5606,6 +6462,54 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "3.4.33", + "resolved": "https://registry.npmjs.org/ai/-/ai-3.4.33.tgz", + "integrity": "sha512-plBlrVZKwPoRTmM8+D1sJac9Bq8eaa2jiZlHLZIWekKWI1yMWYZvCCEezY9ASPwRhULYDJB2VhKOBUUeg3S5JQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "@ai-sdk/provider-utils": "1.0.22", + "@ai-sdk/react": "0.0.70", + "@ai-sdk/solid": "0.0.54", + "@ai-sdk/svelte": "0.0.57", + "@ai-sdk/ui-utils": "0.0.50", + "@ai-sdk/vue": "0.0.59", + "@opentelemetry/api": "1.9.0", + "eventsource-parser": "1.1.2", + "json-schema": "^0.4.0", + "jsondiffpatch": "0.6.0", + "secure-json-parse": "^2.7.0", + "zod-to-json-schema": "^3.23.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "openai": "^4.42.0", + "react": "^18 || ^19 || ^19.0.0-rc", + "sswr": "^2.1.0", + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0", + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "openai": { + "optional": true + }, + "react": { + "optional": true + }, + "sswr": { + "optional": true + }, + "svelte": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5669,7 +6573,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -5938,7 +6841,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -6875,6 +7777,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -6912,6 +7823,12 @@ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", "license": "MIT" }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -7057,6 +7974,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -7260,6 +8190,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7671,6 +8643,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT", + "peer": true + }, "node_modules/espree": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", @@ -7715,6 +8694,16 @@ "node": ">=0.10" } }, + "node_modules/esrap": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz", + "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -7737,6 +8726,13 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT", + "peer": true + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -7763,6 +8759,15 @@ "optional": true, "peer": true }, + "node_modules/eventsource-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", + "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, "node_modules/exponential-backoff": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", @@ -8188,6 +9193,21 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "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", @@ -9034,6 +10054,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9294,6 +10324,12 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -9321,6 +10357,35 @@ "json5": "lib/cli.js" } }, + "node_modules/jsondiffpatch": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", + "integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==", + "license": "MIT", + "dependencies": { + "@types/diff-match-patch": "^1.0.36", + "chalk": "^5.3.0", + "diff-match-patch": "^1.0.5" + }, + "bin": { + "jsondiffpatch": "bin/jsondiffpatch.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/jsondiffpatch/node_modules/chalk": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", + "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -9695,6 +10760,13 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT", + "peer": true + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11682,6 +12754,12 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -12094,6 +13172,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/sswr": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sswr/-/sswr-2.2.0.tgz", + "integrity": "sha512-clTszLPZkmycALTHD1mXGU+mOtA/MIoLgS1KGTTzFNVm9rytQVykgRaP+z1zl572cz0bTqj4rFVoC2N+IGK4Sg==", + "license": "MIT", + "dependencies": { + "swrev": "^4.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -12457,6 +13547,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svelte": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.38.1.tgz", + "integrity": "sha512-fO6CLDfJYWHgfo6lQwkQU2vhCiHc2MBl6s3vEhK+sSZru17YL4R5s1v14ndRpqKAIkq8nCz6MTk1yZbESZWeyQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "esm-env": "^1.2.1", + "esrap": "^2.1.0", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/svg-pathdata": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", @@ -12467,6 +13583,34 @@ "node": ">=12.0.0" } }, + "node_modules/swr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", + "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/swrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/swrev/-/swrev-4.0.0.tgz", + "integrity": "sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==", + "license": "MIT" + }, + "node_modules/swrv": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/swrv/-/swrv-1.1.0.tgz", + "integrity": "sha512-pjllRDr2s0iTwiE5Isvip51dZGR7GjLH1gCSVyE8bQnbAx6xackXsFdojau+1O5u98yHF5V73HQGOFxKUXO9gQ==", + "license": "Apache-2.0", + "peerDependencies": { + "vue": ">=3.2.26 < 4" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -12570,6 +13714,18 @@ "utrie": "^1.0.2" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -12690,6 +13846,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.20.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.4.tgz", + "integrity": "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/ttf2woff2": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/ttf2woff2/-/ttf2woff2-8.0.0.tgz", @@ -13073,6 +14249,28 @@ "node": ">= 0.8" } }, + "node_modules/vue": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz", + "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.18", + "@vue/compiler-sfc": "3.5.18", + "@vue/runtime-dom": "3.5.18", + "@vue/server-renderer": "3.5.18", + "@vue/shared": "3.5.18" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -13476,6 +14674,13 @@ "zen-observable": "0.8.15" } }, + "node_modules/zimmerframe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", + "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", + "license": "MIT", + "peer": true + }, "node_modules/zod": { "version": "3.25.56", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.56.tgz", @@ -13484,6 +14689,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } } } } diff --git a/package.json b/package.json index f8120ed..cb9ee30 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "prisma generate && next build", "start": "next start", "lint": "next lint", + "ai:gateway": "tsx scripts/gateway.ts", "test-db": "node scripts/test-db.mjs", "db:push": "prisma db push", "db:generate": "prisma generate", @@ -18,6 +19,7 @@ "update:env": "bash scripts/update-env.sh" }, "dependencies": { + "@ai-sdk/openai": "^1.3.24", "@apollo/client": "^3.13.8", "@apollo/experimental-nextjs-app-support": "^0.12.2", "@apollo/server": "^4.12.2", @@ -33,6 +35,7 @@ "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -47,6 +50,7 @@ "@types/pg": "^8.15.4", "@types/qrcode": "^1.5.5", "@types/uuid": "^10.0.0", + "ai": "^3.4.33", "axios": "^1.10.0", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", @@ -91,6 +95,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.3.3", + "tsx": "^4.19.2", "tw-animate-css": "^1.3.4", "typescript": "^5" } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ced6a47..3b37d1b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -418,6 +418,23 @@ model PartsSearchHistory { @@map("parts_search_history") } +// История запросов ZZAP со скриншотами +model ZzapRequest { + id String @id @default(cuid()) + provider String @default("zzap") + article String + statsUrl String? + imageUrl String? + ok Boolean @default(false) + selector String? + logs Json? + requestedBy String? + createdAt DateTime @default(now()) + + @@index([article]) + @@map("zzap_requests") +} + model ClientDeliveryAddress { id String @id @default(cuid()) clientId String diff --git a/scripts/gateway.ts b/scripts/gateway.ts new file mode 100644 index 0000000..62df207 --- /dev/null +++ b/scripts/gateway.ts @@ -0,0 +1,20 @@ +import { streamText } from 'ai' +import 'dotenv/config' + +async function main() { + const result = streamText({ + model: 'openai/gpt-4.1', + prompt: 'Invent a new holiday and describe its traditions.', + }) + + for await (const textPart of result.textStream) { + process.stdout.write(textPart) + } + + console.log() + console.log('Token usage:', await result.usage) + console.log('Finish reason:', await result.finishReason) +} + +main().catch(console.error) + diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..d84a6c8 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation' + +export default function AdminRedirect() { + redirect('/dashboard') +} + diff --git a/src/app/admin/zzap/page.tsx b/src/app/admin/zzap/page.tsx new file mode 100644 index 0000000..d6a7056 --- /dev/null +++ b/src/app/admin/zzap/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation' + +export default function AdminZzapRedirect() { + redirect('/dashboard/zzap') +} + diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts new file mode 100644 index 0000000..9ff39ae --- /dev/null +++ b/src/app/api/ai/chat/route.ts @@ -0,0 +1,79 @@ +export async function POST(req: Request) { + try { + const { messages } = await req.json(); + + const response = await fetch('https://gateway.ai.cloudflare.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.AI_GATEWAY_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-3.5-turbo', + messages, + stream: true, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + const stream = new ReadableStream({ + async start(controller) { + const reader = response.body?.getReader(); + if (!reader) return; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data); + const content = parsed.choices?.[0]?.delta?.content; + if (content) { + controller.enqueue(encoder.encode(content)); + } + } catch (e) { + // Ignore parsing errors + } + } + } + } + } finally { + reader.releaseLock(); + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } catch (error) { + console.error('AI Chat Error:', error); + return new Response( + JSON.stringify({ error: 'Произошла ошибка при обработке запроса' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ); + } +} \ No newline at end of file diff --git a/src/app/api/zzap/history/route.ts b/src/app/api/zzap/history/route.ts new file mode 100644 index 0000000..8a21d37 --- /dev/null +++ b/src/app/api/zzap/history/route.ts @@ -0,0 +1,71 @@ +import type { NextRequest } from 'next/server' +import { prisma } from '@/lib/prisma' + +export const dynamic = 'force-dynamic' + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url) + const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1) + const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20', 10) || 20)) + const q = (searchParams.get('q') || '').trim() + const skip = (page - 1) * pageSize + + let items: any[] = [] + let total = 0 + if ((prisma as any).zzapRequest?.findMany) { + const where = q ? { article: { contains: q, mode: 'insensitive' as const } } : undefined + const [i, t] = await Promise.all([ + (prisma as any).zzapRequest.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + select: { + id: true, + provider: true, + article: true, + statsUrl: true, + imageUrl: true, + ok: true, + selector: true, + logs: true, + createdAt: true, + } + }), + (prisma as any).zzapRequest.count({ where }) + ]) + items = i + total = t + } else { + // Fallback via raw SQL if client not regenerated yet (Postgres quoted camelCase) + let where = 'WHERE 1=1' + if (q) { + const esc = q.replace(/['\\]/g, (m) => ({"'":"''","\\":"\\\\"}[m] as string)).replace(/[%_]/g, (m) => '\\' + m) + where += ` AND "article" ILIKE '%${esc}%' ESCAPE '\\'` + } + const rows = await prisma.$queryRawUnsafe( + `SELECT id, provider, article, "statsUrl" as "statsUrl", "imageUrl" as "imageUrl", ok, selector, logs, "createdAt" as "createdAt" + FROM "zzap_requests" + ${where} + ORDER BY "createdAt" DESC + LIMIT ${pageSize} OFFSET ${skip}` + ) + const cnt = await prisma.$queryRawUnsafe( + `SELECT COUNT(*)::int as count FROM "zzap_requests" ${where}` + ) + items = rows + total = cnt?.[0]?.count || 0 + } + + return new Response(JSON.stringify({ items, total, page, pageSize }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }) + } catch (err: any) { + return new Response(JSON.stringify({ error: String(err?.message || err || 'Unknown error') }), { + status: 500, + headers: { 'content-type': 'application/json; charset=utf-8' } + }) + } +} diff --git a/src/app/api/zzap/screenshot/route.ts b/src/app/api/zzap/screenshot/route.ts new file mode 100644 index 0000000..99456a9 --- /dev/null +++ b/src/app/api/zzap/screenshot/route.ts @@ -0,0 +1,666 @@ +import type { NextRequest } from 'next/server' +import puppeteer from 'puppeteer' +import { uploadBuffer } from '@/lib/s3' +import { prisma } from '@/lib/prisma' +import fs from 'fs' +import path from 'path' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const ZZAP_BASE = process.env.ZZAP_BASE || 'https://www.zzap.ru' +const ZZAP_TIMEOUT_MS = Number(process.env.ZZAP_TIMEOUT_MS || 30000) +const COOKIE_FILE = process.env.ZZAP_COOKIE_FILE || path.join(process.cwd(), '.zzap-session.json') +const COOKIE_TTL_MIN = Number(process.env.ZZAP_SESSION_TTL_MINUTES || 180) + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)) +} + +async function waitForAnySelector(page: any, selectors: string[], timeoutMs = 10000) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + for (const sel of selectors) { + try { if (await page.$(sel)) return sel } catch {} + } + await sleep(300) + } + return null as string | null +} + +async function setInputValue(page: any, selector: string, value: string) { + const exists = await page.$(selector) + if (!exists) return false + try { + await page.evaluate((sel, val) => { + const el = document.querySelector(sel) as HTMLInputElement | null + if (!el) return + el.focus() + el.value = val + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + el.blur() + }, selector, value) + return true + } catch { + try { + await page.click(selector, { clickCount: 3 }) + await page.type(selector, value, { delay: 20 }) + return true + } catch { + return false + } + } +} + +async function clickByText(page: any, text: string) { + const handle = await page.evaluateHandle((t: string) => { + const target = t.toLowerCase() + const candidates = Array.from(document.querySelectorAll('button, a, input[type="submit"], span, div')) as HTMLElement[] + for (const el of candidates) { + const txt = (el.innerText || el.textContent || '').trim().toLowerCase() + if (!txt) continue + if (txt.includes(target)) return el + } + return null + }, text) + try { + const el = (handle as any).asElement?.() + if (el) { + await el.click() + return true + } + } catch {} + try { await (handle as any).dispose?.() } catch {} + return false +} + +async function findLargestElementHandle(page: any, selectors: string[]) { + for (const sel of selectors) { + const handles = await page.$$(sel) + if (handles.length) { + let best = handles[0] + let bestArea = 0 + for (const h of handles) { + const box = await h.boundingBox() + const area = box ? box.width * box.height : 0 + if (area > bestArea) { + best = h + bestArea = area + } + } + return best + } + } + return null +} + +async function persistHistorySafely( + data: { article: string; statsUrl: string | null; imageUrl?: string; ok: boolean; selector?: string | null; logs?: any }, + log: (m: string) => void +) { + try { + if ((prisma as any).zzapRequest?.create) { + await (prisma as any).zzapRequest.create({ + data: { + provider: 'zzap', + article: data.article, + statsUrl: data.statsUrl || undefined, + imageUrl: data.imageUrl || undefined, + ok: data.ok, + selector: data.selector || undefined, + logs: data.logs ?? undefined + } + }) + log('DB: request persisted') + } else { + const esc = (v: any) => (v == null ? 'NULL' : `'${String(v).replace(/'/g, "''")}'`) + const logsJson = data.logs ? `'${JSON.stringify(data.logs).replace(/'/g, "''")}'::jsonb` : 'NULL' + const sql = `INSERT INTO "zzap_requests" ("provider","article","statsUrl","imageUrl","ok","selector","logs") VALUES ('zzap', ${esc(data.article)}, ${esc(data.statsUrl)}, ${esc(data.imageUrl)}, ${data.ok ? 'true' : 'false'}, ${esc(data.selector)}, ${logsJson})` + await prisma.$executeRawUnsafe(sql) + log('DB: request persisted (raw)') + } + } catch (e: any) { + log(`DB error: ${e?.message || e}`) + } +} + +async function restoreSession(page: any, log: (m: string) => void) { + try { + if (!fs.existsSync(COOKIE_FILE)) return false + const raw = fs.readFileSync(COOKIE_FILE, 'utf-8') + const data = JSON.parse(raw) as { cookies: any[]; savedAt: number } + if (!data?.cookies?.length || !data?.savedAt) return false + const ageMin = (Date.now() - data.savedAt) / 60000 + if (ageMin > COOKIE_TTL_MIN) { log(`Session expired: ${ageMin.toFixed(1)}min > ${COOKIE_TTL_MIN}min`); return false } + await page.setCookie(...data.cookies) + log('Session cookies restored') + return true + } catch (e) { + log(`Restore session error: ${String((e as any)?.message || e)}`) + return false + } +} + +async function saveSession(page: any, log: (m: string) => void) { + try { + const cookies = await page.cookies() + const payload = { cookies, savedAt: Date.now() } + fs.writeFileSync(COOKIE_FILE, JSON.stringify(payload)) + log(`Session cookies saved (${cookies.length})`) + } catch (e) { + log(`Save session error: ${String((e as any)?.message || e)}`) + } +} + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url) + const article = searchParams.get('article')?.trim() + const explicitSelector = searchParams.get('selector')?.trim() + const debug = searchParams.get('debug') === '1' + if (!article) { + return new Response(JSON.stringify({ error: 'Не передан артикул ?article=' }), { status: 400, headers: { 'content-type': 'application/json; charset=utf-8' } }) + } + + const email = process.env.ZZAP_EMAIL + const password = process.env.ZZAP_PASSWORD + if (!email || !password) { + return new Response(JSON.stringify({ error: 'Отсутствуют ZZAP_EMAIL/ZZAP_PASSWORD в .env' }), { status: 500, headers: { 'content-type': 'application/json; charset=utf-8' } }) + } + + const logs: string[] = [] + const log = (m: string) => { logs.push(m) } + + try { + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }) + const page = await browser.newPage() + let workPage: any = page + await page.setViewport({ width: 1440, height: 900 }) + await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36') + await page.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' }) + try { page.setDefaultNavigationTimeout?.(ZZAP_TIMEOUT_MS); page.setDefaultTimeout?.(ZZAP_TIMEOUT_MS) } catch {} + + // 0) Try restore session first, verify, otherwise login + let loggedInEarly = false + await restoreSession(page, log) + try { + await page.goto(ZZAP_BASE, { waitUntil: 'domcontentloaded', timeout: 60000 }) + const byDom = await page.evaluate(() => { + const byId = !!document.querySelector('#ctl00_lnkLogout') + const byText = Array.from(document.querySelectorAll('a')).some(a => /выход|выйти|logout|logoff/i.test((a.textContent||'').trim())) + return byId || byText + }).catch(() => false) + loggedInEarly = Boolean(byDom) + log(`Restored session check: loggedIn=${loggedInEarly}`) + } catch {} + + // 0b) Explicit login if not logged yet + try { + if (!loggedInEarly) { + await page.goto(`${ZZAP_BASE}/user/logon.aspx`, { waitUntil: 'domcontentloaded', timeout: Math.min(20000, ZZAP_TIMEOUT_MS) }) + log(`Open login: ${page.url()}`) + } else { + log('Skip login: already authenticated') + } + + // DevExpress (ZZap) stable selectors by id suffix / full name + const devxEmail = 'input[id$="AddrEmail1TextBox_I"]' + const devxPass = 'input[id$="PasswordTextBox_I"]' + const devxEmailName = 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$AddrEmail1TextBox"]' + const devxPassName = 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$PasswordTextBox"]' + const devxEmailFull = '#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_AddrEmail1TextBox_I' + const devxPassFull = '#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_PasswordTextBox_I' + + // Prefer known DevExpress IDs first, then legacy ASP.NET, then generic + const userSelPriority = [devxEmailFull, devxEmail, devxEmailName, '#ctl00_ContentPlaceHolder1_Login1_UserName', '#ctl00_ContentPlaceHolder1_tbLogin', 'input[name*="login" i]', 'input[type="email"]', 'input[name*="email" i]'] + const passSelPriority = [devxPassFull, devxPass, devxPassName, '#ctl00_ContentPlaceHolder1_Login1_Password', '#ctl00_ContentPlaceHolder1_tbPass', 'input[type="password"]', 'input[name*="pass" i]'] + const submitSelPriority = ['#ctl00_ContentPlaceHolder1_Login1_LoginButton', '#ctl00_ContentPlaceHolder1_btnLogin', 'button[type="submit" i]', 'input[type="submit" i]'] + + // Wait a moment for anti-bot/DevExpress to initialize + if (!loggedInEarly) await sleep(2000) + + // Wait for any input to appear explicitly + const appeared = await waitForAnySelector(page, [devxEmailFull, devxPassFull, devxEmail, devxPass, devxEmailName, devxPassName], 10000) + if (!appeared) log('Login inputs still not present after wait') + + // Try waiting explicitly for DevExpress fields + // Resolve selectors to strings (avoid handle click issues) + const emailSelectors = [devxEmailFull, devxEmail, devxEmailName, '#ctl00_ContentPlaceHolder1_Login1_UserName', '#ctl00_ContentPlaceHolder1_tbLogin', 'input[name*="login" i]', 'input[type="email"]', 'input[name*="email" i]'] + const passSelectors = [devxPassFull, devxPass, devxPassName, '#ctl00_ContentPlaceHolder1_Login1_Password', '#ctl00_ContentPlaceHolder1_tbPass', 'input[type="password"]', 'input[name*="pass" i]'] + + let emailSelUsed: string | null = null + for (const sel of emailSelectors) { if (await setInputValue(page, sel, email)) { emailSelUsed = sel; break } } + // No XPath fallback to avoid $x in older runtimes + + let passSelUsed: string | null = null + for (const sel of passSelectors) { if (await setInputValue(page, sel, password)) { passSelUsed = sel; break } } + // No XPath fallback to avoid $x in older runtimes + + if (!loggedInEarly && emailSelUsed && passSelUsed) { + log(`Login using emailSel=${emailSelUsed}, passSel=${passSelUsed}`) + + // Helper to detect login without relying only on nav + const checkLoggedIn = async () => { + const url = page.url() + if (!/logon\.aspx/i.test(url)) return true + const byDom = await page.evaluate(() => { + const byId = !!document.querySelector('#ctl00_lnkLogout') + const byText = Array.from(document.querySelectorAll('a')).some(a => /выход|выйти|logout|logoff/i.test((a.textContent||'').trim())) + return byId || byText + }).catch(() => false) + return byDom + } + + const waitStep = async (label: string) => { + await sleep(1500) + const ok = await checkLoggedIn() + log(`${label} -> loggedIn=${ok}, url=${page.url()}`) + return ok + } + + let loggedIn = false + + // 1) Press Enter on password + try { await page.focus(passSelUsed); await page.keyboard.press('Enter') ; } catch {} + loggedIn = await waitStep('After Enter') + + // 2) Click submit via known selectors + if (!loggedIn) { + for (const sel of submitSelPriority) { + try { + const el = await page.$(sel) + if (el) { + await el.click().catch(() => {}) + if (await waitStep(`After click ${sel}`)) { loggedIn = true; break } + } + } catch {} + } + } + + // 3) Click any descendant with text "Войти" inside login panel + if (!loggedIn) { + try { + const did = await page.evaluate(() => { + const root = document.querySelector('#ctl00_BodyPlace_LogonFormCallbackPanel') || document.body + if (!root) return false + const nodes = Array.from(root.querySelectorAll('button, a, span, div, input[type="submit"]')) as HTMLElement[] + const lc = 'войти' + for (const el of nodes) { + const txt = (el.innerText || el.textContent || '').trim().toLowerCase() + if (!txt) continue + if (txt.includes(lc)) { (el as HTMLElement).click(); return true } + } + return false + }) + if (did) loggedIn = await waitStep('After panel text click') + } catch {} + } + + // 4) Try submitting the form directly + if (!loggedIn) { + try { await page.evaluate(() => { (document.querySelector('form') as HTMLFormElement | null)?.submit() }) } catch {} + loggedIn = await waitStep('After form.submit()') + } + + log(`Login success=${loggedIn}`) + if (loggedIn) { await saveSession(page, log) } + } else { + if (!loggedInEarly) log('Login inputs not found; continuing') + } + + const cookies = await page.cookies() + log(`Cookies: ${cookies.map(c=>c.name).join(',')}`) + } catch (e: any) { + log(`Login step error: ${e?.message || e}`) + } + + // 1) Open homepage + await page.goto(ZZAP_BASE, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) }) + log(`Open: ${page.url()}`) + + // Cookie banners common accept + await clickByText(page, 'Соглас').catch(() => {}) + await clickByText(page, 'Принять').catch(() => {}) + await clickByText(page, 'Хорошо').catch(() => {}) + + // 2) Try to open login + const loginCandidates = ['a[href*="login" i]', 'a[href*="signin" i]', 'button[name="login" i]'] + let openedLogin = false + for (const sel of loginCandidates) { + const el = await page.$(sel) + if (el) { + await Promise.all([ + page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}), + el.click() + ]) + openedLogin = true + break + } + } + if (!openedLogin) { + await clickByText(page, 'войти').catch(() => {}) + } + log(`Login page: ${page.url()}`) + + // 3) Fill credentials + // Try DevExpress selectors first on whatever login UI is visible + const emailSel = ['#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_AddrEmail1TextBox_I', 'input[id$="AddrEmail1TextBox_I"]', 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$AddrEmail1TextBox"]', 'input[type="email"]', 'input[name="email" i]', 'input[name*="login" i]'] + const passSel = ['#ctl00_BodyPlace_LogonFormCallbackPanel_LogonFormLayout_PasswordTextBox_I', 'input[id$="PasswordTextBox_I"]', 'input[name="ctl00$BodyPlace$LogonFormCallbackPanel$LogonFormLayout$PasswordTextBox"]', 'input[type="password"]', 'input[name="password" i]'] + let emailInput = null + for (const sel of emailSel) { emailInput = await page.$(sel); if (emailInput) break } + let passInput = null + for (const sel of passSel) { passInput = await page.$(sel); if (passInput) break } + + if (emailInput && passInput) { + await emailInput.click({ clickCount: 3 }).catch(() => {}) + await emailInput.type(email, { delay: 20 }) + await passInput.type(password, { delay: 20 }) + const submitSel = ['button[type="submit" i]', 'input[type="submit" i]'] + let clicked = false + for (const sel of submitSel) { + const el = await page.$(sel) + if (el) { + await Promise.all([ + page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 60000 }).catch(() => {}), + el.click() + ]) + clicked = true + break + } + } + if (!clicked) { + await clickByText(page, 'войти').catch(() => {}) + await sleep(1500) + } + } + log(`After login: ${page.url()}`) + + // 4) Navigate to search by article (try a few patterns) + const searchUrls = [ + `${ZZAP_BASE}/public/search.aspx#rawdata=${encodeURIComponent(article)}`, + `${ZZAP_BASE}/search/?article=${encodeURIComponent(article)}`, + `${ZZAP_BASE}/search?article=${encodeURIComponent(article)}`, + `${ZZAP_BASE}/search?txt=${encodeURIComponent(article)}`, + `${ZZAP_BASE}/search?query=${encodeURIComponent(article)}`, + `${ZZAP_BASE}/catalog/?q=${encodeURIComponent(article)}` + ] + + let reached = false + for (const url of searchUrls) { + try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) }) + log(`Search try: ${url} -> ${page.url()}`) + reached = true + break + } catch {} + } + + if (!reached) { + // fallback: try search input on homepage + await page.goto(ZZAP_BASE, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) }) + const inputCandidates = ['input[type="search"]', 'input[name*="search" i]', 'input[placeholder*="артик" i]'] + let searchInput = null + for (const sel of inputCandidates) { searchInput = await page.$(sel); if (searchInput) break } + if (!searchInput) throw new Error('Не найдено поле поиска') + await searchInput.type(article, { delay: 30 }) + await Promise.all([ + page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) }).catch(() => {}), + page.keyboard.press('Enter') + ]) + } + log(`Search results: ${page.url()}`) + + // 4b) Try to open stats via explicit anchor present in the grid + try { + const statLinkSel = 'a[id$="_StatHyperlink"], a[onclick*="statpartpricehistory.aspx" i]' + const statLink = await page.waitForSelector(statLinkSel, { timeout: Math.min(8000, ZZAP_TIMEOUT_MS) }).catch(() => null) + if (statLink) { + const rel = await statLink.evaluate((el: HTMLAnchorElement) => { + const href = el.getAttribute('href') || '' + const onclick = el.getAttribute('onclick') || '' + const rx = /['"]([^'\"]*statpartpricehistory\.aspx[^'\"]*)['"]/i + const m = onclick.match(rx) + const candidate = m ? m[1] : (href && href.includes('statpartpricehistory') ? href : null) + return candidate ? candidate.replace(/&/g, '&') : null + }) + if (rel) { + const targetUrl = rel.startsWith('http') ? rel : `${ZZAP_BASE}${rel}` + const statsPage = await browser.newPage() + await statsPage.setViewport({ width: 1440, height: 900 }) + await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36') + await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' }) + const cookies = await page.cookies() + await statsPage.setCookie(...cookies) + await statsPage.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) }) + workPage = statsPage + log(`Stats page opened via anchor onclick: ${targetUrl}`) + } + } + } catch {} + + // If we already opened stats page directly, skip modal logic + const onStatsPage = () => /\/user\/statpartpricehistory\.aspx/i.test(workPage.url?.() || '') + let statsOpened = onStatsPage() + if (statsOpened) { + log(`Stats page already open: ${workPage.url?.()}`) + } + // 5) Open statistics (open modal, extract iframe src, load it as page) or new page + + // Attempt 0: scrape any statpartpricehistory URL from page HTML (onclick/inline) + try { + const rel = await page.evaluate(() => { + const html = document.documentElement?.outerHTML || '' + const m = html.match(/(https?:\/\/[^"'<> ]+)?(\/user\/statpartpricehistory\.aspx[^"'<> ]*)/i) + if (!m) return null + const full = m[1] ? `${m[1]}${m[2]}` : m[2] + return full.replace(/&/g, '&') + }) + if (rel) { + const targetUrl = rel.startsWith('http') ? rel : `${ZZAP_BASE}${rel}` + const statsPage = await browser.newPage() + await statsPage.setViewport({ width: 1440, height: 900 }) + await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36') + await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' }) + const cookies = await page.cookies() + await statsPage.setCookie(...cookies) + await statsPage.goto(targetUrl, { waitUntil: 'networkidle0', timeout: 60000 }) + workPage = statsPage + statsOpened = true + log(`Stats page opened via HTML scrape: ${targetUrl}`) + } + } catch {} + if (!statsOpened) { + await clickByText(page, 'статистика').catch(() => {}) + try { + const clickedByTitle = await page.evaluate(() => { + const cand = document.querySelector('[title*="статист" i], [alt*="статист" i]') as HTMLElement | null + if (cand) { cand.click(); return true } + return false + }) + if (clickedByTitle) { log('Clicked stats by title/alt attribute') } + } catch {} + await sleep(500) + } + // Look for iframe anywhere, not only inside DevExpress container + let modalIframeHandle = statsOpened ? null : await page.waitForSelector('iframe[src*="statpartpricehistory.aspx"]', { timeout: Math.min(4000, ZZAP_TIMEOUT_MS) }).catch(() => null) + if (!modalIframeHandle) { + // one more short wait and retry + if (!statsOpened) { + await sleep(300) + modalIframeHandle = await page.waitForSelector('iframe[src*="statpartpricehistory.aspx"]', { timeout: Math.min(2500, ZZAP_TIMEOUT_MS) }).catch(() => null) + } + } + if (modalIframeHandle) { + statsOpened = true + try { + const src: string | null = await modalIframeHandle.evaluate((el: HTMLIFrameElement) => el.getAttribute('src')) + if (src) { + const targetUrl = src.startsWith('http') ? src : `${ZZAP_BASE}${src}` + const statsPage = await browser.newPage() + await statsPage.setViewport({ width: 1440, height: 900 }) + await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36') + await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' }) + // Reuse session cookies + const cookies = await page.cookies() + await statsPage.setCookie(...cookies) + await statsPage.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) }) + workPage = statsPage + log(`Stats page opened via iframe src: ${targetUrl}`) + // We won't use the iframe anymore + modalIframeHandle = null + } + } catch {} + } + if (!statsOpened || workPage === page) { + // Fallback: direct link on page to statpartpricehistory + try { + const rel = await page.evaluate(() => { + const a = document.querySelector('a[href*="statpartpricehistory.aspx"]') as HTMLAnchorElement | null + return a?.getAttribute('href') || null + }) + if (rel) { + const targetUrl = rel.startsWith('http') ? rel : `${ZZAP_BASE}${rel}` + const statsPage = await browser.newPage() + await statsPage.setViewport({ width: 1440, height: 900 }) + await statsPage.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36') + await statsPage.setExtraHTTPHeaders({ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7' }) + const cookies = await page.cookies() + await statsPage.setCookie(...cookies) + await statsPage.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(15000, ZZAP_TIMEOUT_MS) }) + workPage = statsPage + statsOpened = true + log(`Stats page opened via anchor href: ${targetUrl}`) + } + } catch {} + } + if (!statsOpened || workPage === page) { + // Fallback: explicit selectors that might open new page + const statsSelectors = ['a[href*="statpartpricehistory" i]', 'a[href*="stat" i]', 'button[href*="stat" i]'] + for (const sel of statsSelectors) { + const el = await page.$(sel) + if (el) { + const targetCreated = new Promise((resolve) => { + const handler = async (target: any) => { + const newPage = await target.page().catch(() => null) + if (newPage) { + browser.off('targetcreated', handler) + resolve(newPage) + } + } + browser.on('targetcreated', handler) + }) + await el.click().catch(() => {}) + const maybeNewPage: any = await Promise.race([ + targetCreated, + (async () => { await page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: Math.min(8000, ZZAP_TIMEOUT_MS) }).catch(() => {}) ; return null })() + ]) + if (maybeNewPage) { + workPage = maybeNewPage + await sleep(1200) + } + statsOpened = true + break + } + } + } + log(`Stats page: ${workPage.url?.() || page.url()}`) + + // Early bailout: if we couldn't navigate away from search page quickly, return its screenshot + try { + const urlNow = workPage.url?.() || page.url() + if (/\/public\/search\.aspx/i.test(urlNow)) { + log('Bailout: still on search page, returning current page screenshot') + const buf = (await page.screenshot({ fullPage: true, type: 'png' })) as Buffer + // Save to S3 + DB history + try { + const key = `zzap/${encodeURIComponent(article)}/${Date.now()}-search.png` + const up = await uploadBuffer(buf, key, 'image/png') + logs.push(`Uploaded to S3: ${up.url}`) + await persistHistorySafely({ article, statsUrl: urlNow, imageUrl: up.url, ok: false, selector: null, logs }, log) + } catch (e: any) { + logs.push(`Persist error (bailout): ${e?.message || e}`) + } + await browser.close().catch(() => {}) + if (debug) { + return new Response(JSON.stringify({ ok: true, url: urlNow, foundSelector: null, logs }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' } + }) + } + return new Response(buf, { status: 200, headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } }) + } + } catch {} + + // 6) Wait a bit for charts to render (short) + if (onStatsPage()) { + try { await (workPage.waitForSelector?.('.highcharts-container', { timeout: Math.min(3000, ZZAP_TIMEOUT_MS) })) } catch {} + await sleep(300) + } + + // 7) Capture element screenshot + let handle = null as any + // No modal any more – we open stats full page; try in current/new page context + if (explicitSelector) { + handle = await workPage.$(explicitSelector) + } + if (!handle) { + handle = await findLargestElementHandle(workPage, [ + '.highcharts-container', + 'canvas', + 'svg', + '[id*="chart" i]', + '[class*="chart" i]' + ]) + } + + let imageBuffer: Buffer + let foundSelector: string | null = null + if (!onStatsPage() && handle) { + try { await handle.evaluate((el: any) => el.scrollIntoView({ behavior: 'instant', block: 'center' })) } catch {} + await sleep(300) + foundSelector = explicitSelector || 'auto' + imageBuffer = (await handle.screenshot({ type: 'png' })) as Buffer + } else { + // Full-page screenshot (stats page often has 3 charts; capture all) + imageBuffer = (await workPage.screenshot({ fullPage: true, type: 'png' })) as Buffer + } + + // Upload to S3 and persist history + let uploadedUrl: string | undefined + try { + const key = `zzap/${encodeURIComponent(article)}/${Date.now()}.png` + const up = await uploadBuffer(imageBuffer, key, 'image/png') + uploadedUrl = up.url + logs.push(`Uploaded to S3: ${up.url}`) + } catch (e: any) { + logs.push(`S3 upload error: ${e?.message || e}`) + } + await persistHistorySafely({ article, statsUrl: workPage.url?.() || page.url(), imageUrl: uploadedUrl, ok: true, selector: foundSelector, logs }, log) + + await browser.close().catch(() => {}) + + if (debug) { + return new Response(JSON.stringify({ ok: true, url: workPage.url?.() || page.url(), foundSelector, imageUrl: uploadedUrl, logs }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' } + }) + } + + return new Response(imageBuffer, { + status: 200, + headers: { + 'content-type': 'image/png', + 'cache-control': 'no-store' + } + }) + } catch (err: any) { + const errorBody = { error: String(err?.message || err || 'Unknown error'), logs } + return new Response(JSON.stringify(errorBody), { + status: 500, + headers: { 'content-type': 'application/json; charset=utf-8' } + }) + } +} diff --git a/src/app/dashboard/ai/page.tsx b/src/app/dashboard/ai/page.tsx new file mode 100644 index 0000000..4e75f80 --- /dev/null +++ b/src/app/dashboard/ai/page.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { useChat } from 'ai/react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Bot, Send, User } from 'lucide-react'; + +export default function AIChat() { + const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({ + api: '/api/ai/chat', + }); + + const scrollAreaRef = useRef(null); + + useEffect(() => { + if (scrollAreaRef.current) { + scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight; + } + }, [messages]); + + return ( +
+ + + + + Чат с ИИ + +

+ Задайте любой вопрос искусственному интеллекту +

+
+ + + + {messages.length === 0 && ( +
+ +

Добро пожаловать!

+

+ Начните разговор с ИИ, задав свой первый вопрос +

+
+ )} + +
+ {messages.map((message, index) => ( +
+ {message.role === 'assistant' && ( + + + + + + )} + +
+
+ {message.content} +
+
+ + {message.role === 'user' && ( + + + + + + )} +
+ ))} + + {isLoading && ( +
+ + + + + +
+
+
+
+
+
+
+
+ )} +
+ + + + +
+ + +
+
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/zzap/page.tsx b/src/app/dashboard/zzap/page.tsx new file mode 100644 index 0000000..230d61b --- /dev/null +++ b/src/app/dashboard/zzap/page.tsx @@ -0,0 +1,182 @@ +"use client" + +import { useCallback, useEffect, useState } from 'react' +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { format } from 'date-fns' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +export default function ZzapStatsPage() { + const [article, setArticle] = useState('') + const [selector, setSelector] = useState('') + const [loading, setLoading] = useState(false) + const [imgSrc, setImgSrc] = useState(null) + const [error, setError] = useState(null) + const [debug, setDebug] = useState(false) + const [debugInfo, setDebugInfo] = useState(null) + const [history, setHistory] = useState([]) + const [historyLoading, setHistoryLoading] = useState(false) + const [historyError, setHistoryError] = useState(null) + const [historyPage, setHistoryPage] = useState(1) + const [historyPageSize] = useState(20) + const [historyTotal, setHistoryTotal] = useState(0) + const [query, setQuery] = useState('') + + const loadHistory = useCallback(async () => { + try { + setHistoryLoading(true) + setHistoryError(null) + const params = new URLSearchParams() + params.set('page', String(historyPage)) + params.set('pageSize', String(historyPageSize)) + if (query.trim()) params.set('q', query.trim()) + const res = await fetch(`/api/zzap/history?${params.toString()}`, { cache: 'no-store' }) + if (!res.ok) throw new Error(`History error ${res.status}`) + const data = await res.json() + setHistory(data.items || []) + setHistoryTotal(data.total || 0) + } catch (e: any) { + setHistoryError(e?.message || 'Не удалось получить историю') + } finally { + setHistoryLoading(false) + } + }, [historyPage, historyPageSize, query]) + + // initial load + useEffect(() => { loadHistory() }, [loadHistory]) + + const onSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + setImgSrc(null) + try { + const params = new URLSearchParams({ article }) + if (selector) params.set('selector', selector) + if (debug) params.set('debug', '1') + const res = await fetch(`/api/zzap/screenshot?${params.toString()}`) + const ct = res.headers.get('content-type') || '' + if (res.ok && ct.includes('image/png')) { + const blob = await res.blob() + const url = URL.createObjectURL(blob) + setImgSrc(url) + setDebugInfo(null) + loadHistory() + } else { + const data = await res.json().catch(() => ({})) + if (debug) { + setDebugInfo(data) + throw new Error(data?.error || `Ошибка ${res.status}`) + } + throw new Error(data?.error || `Ошибка ${res.status}`) + } + } catch (err: any) { + setError(err.message || 'Не удалось получить скриншот') + } finally { + setLoading(false) + } + }, [article, selector, debug]) + + return ( +
+ + + ZZAP: скриншот графика статистики + + Введите артикул, сервис авторизуется на zzap.ru и вернёт PNG скриншот графика. + + + +
+
+ + setArticle(e.target.value)} placeholder="например, 06A145710P" required /> +
+
+ + setSelector(e.target.value)} placeholder="например, .chart-container" /> +
+
+ setDebug(e.target.checked)} /> + +
+
+ +
+
+ + {error &&

{error}

} + {imgSrc && ( +
+

Результат

+ Скриншот графика ZZAP +
+ )} + {debugInfo && ( +
+{JSON.stringify(debugInfo, null, 2)}
+            
+ )} +
+
+ + + + История запросов + Последние 20 запросов ZZAP. Кликните по ссылке, чтобы открыть изображение. + + +
+ { setQuery(e.target.value); setHistoryPage(1); }} className="max-w-xs" /> + +
+ {historyError &&

{historyError}

} + + + + Дата + Артикул + Статус + URL статистики + Изображение + + + + {historyLoading && ( + Загрузка… + )} + {!historyLoading && history.length === 0 && ( + Пусто + )} + {history.map((item) => ( + + {item.createdAt ? format(new Date(item.createdAt), 'dd.MM.yyyy HH:mm') : '—'} + {item.article} + {item.ok ? 'OK' : 'ERR'} + + {item.statsUrl ? {item.statsUrl} : '—'} + + + {item.imageUrl ? Открыть : '—'} + + + ))} + +
+
+
Всего: {historyTotal}
+
+ +
Стр. {historyPage} / {Math.max(1, Math.ceil(historyTotal / historyPageSize))}
+ +
+
+
+
+
+ ) +} diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..63b95e3 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } \ No newline at end of file diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 2a5429e..9ef1667 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -16,7 +16,9 @@ import { Palette, Star, Image, - Shield + BarChart3, + Shield, + Bot } from 'lucide-react' import { Button } from '@/components/ui/button' import { useAuth } from '@/components/providers/AuthProvider' @@ -31,6 +33,11 @@ const navigationItems = [ href: '/dashboard', icon: Home, }, + { + title: 'ZZAP статистика', + href: '/dashboard/zzap', + icon: BarChart3, + }, { title: 'Каталог', href: '/dashboard/catalog', @@ -41,6 +48,11 @@ const navigationItems = [ href: '/dashboard/kraja', icon: Shield, }, + { + title: 'Чат с ИИ', + href: '/dashboard/ai', + icon: Bot, + }, { title: 'Навигация сайта', href: '/dashboard/navigation', @@ -153,4 +165,4 @@ export const Sidebar = ({ className }: SidebarProps) => {
) -} \ No newline at end of file +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 6b0e5d4..4d71419 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -17,11 +17,16 @@ export const createToken = (payload: JWTPayload): string => { // Верификация JWT токена export const verifyToken = (token: string): JWTPayload | null => { + // Быстрый фильтр: клиентские токены и не-JWT (без двух точек) не проверяем + if (!token || token.startsWith('client_') || token.split('.').length !== 3) { + return null + } + try { const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload return decoded - } catch (error) { - console.error('Ошибка верификации токена:', error) + } catch (_error) { + // Токен выглядел как JWT, но не прошёл проверку — тихо возвращаем null без шума в логах return null } } @@ -51,4 +56,4 @@ export const extractTokenFromHeaders = (headers: Headers): string | null => { export const getUserFromToken = (token: string | null): JWTPayload | null => { if (!token) return null return verifyToken(token) -} \ No newline at end of file +} diff --git a/src/lib/graphql/resolvers.ts b/src/lib/graphql/resolvers.ts index 930f26e..87089b5 100644 --- a/src/lib/graphql/resolvers.ts +++ b/src/lib/graphql/resolvers.ts @@ -2412,7 +2412,7 @@ export const resolvers = { console.log(`🌐 Найдено ${externalOffers.length} предложений в AutoEuro`) console.log('📦 Первые 3 внешних предложения:', externalOffers.slice(0, 3)) - // 3. Поиск в PartsIndex для получения дополнительных характеристик и изображений + // 3. Поиск в PartsIndex для получения дополнительных характеристик и изображений (может быть отключён) let partsIndexData: any = null try { @@ -2421,11 +2421,14 @@ export const resolvers = { brand: cleanBrand }) - // Используем прямой поиск по артикулу и бренду - partsIndexData = await partsIndexService.searchEntityByCode( - cleanArticleNumber, - cleanBrand - ) + // Используем прямой поиск по артикулу и бренду, только если сервис включён + const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false + if (partsIndexEnabled) { + partsIndexData = await partsIndexService.searchEntityByCode( + cleanArticleNumber, + cleanBrand + ) + } if (partsIndexData) { console.log('✅ GraphQL Resolver - найден товар в PartsIndex:', { @@ -3969,10 +3972,13 @@ export const resolvers = { // Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex if (product.article && product.brand) { try { - const partsIndexEntity = await partsIndexService.searchEntityByCode( - product.article, - product.brand - ) + const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false + const partsIndexEntity = partsIndexEnabled + ? await partsIndexService.searchEntityByCode( + product.article, + product.brand + ) + : null if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) { // Создаем временные изображения для отображения (не сохраняем в БД) @@ -4058,10 +4064,13 @@ export const resolvers = { // Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex if (product.article && product.brand) { try { - const partsIndexEntity = await partsIndexService.searchEntityByCode( - product.article, - product.brand - ) + const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false + const partsIndexEntity = partsIndexEnabled + ? await partsIndexService.searchEntityByCode( + product.article, + product.brand + ) + : null if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) { // Создаем временные изображения для отображения (не сохраняем в БД) @@ -4153,10 +4162,13 @@ export const resolvers = { // Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex if (product.article && product.brand) { try { - const partsIndexEntity = await partsIndexService.searchEntityByCode( - product.article, - product.brand - ) + const partsIndexEnabled = (process.env.PARTSINDEX_ENABLED === 'true') || false + const partsIndexEntity = partsIndexEnabled + ? await partsIndexService.searchEntityByCode( + product.article, + product.brand + ) + : null if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) { // Создаем временные изображения для отображения (не сохраняем в БД) @@ -10179,4 +10191,4 @@ export const resolvers = { } } } -} \ No newline at end of file +} diff --git a/src/lib/parts-db.ts b/src/lib/parts-db.ts index 72a00b2..a86cbba 100644 --- a/src/lib/parts-db.ts +++ b/src/lib/parts-db.ts @@ -1,363 +1,46 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck -import { Pool } from 'pg' - -class PartsDatabase { - private pool: Pool +// Temporary no-op implementation to avoid requiring 'pg' when parts index is disabled. +class NoopPartsDatabase { constructor() { - const connectionString = process.env.DATABASE_URL - if (!connectionString) { - throw new Error('DATABASE_URL environment variable is not set') - } - - this.pool = new Pool({ - connectionString, - max: 10, // Reduce concurrent connections to avoid overwhelming the DB - idleTimeoutMillis: 60000, // 1 minute - connectionTimeoutMillis: 10000, // 10 seconds - keepAlive: true, - keepAliveInitialDelayMillis: 10000, - }) - - console.log('🔌 Parts Database connection initialized (using main DATABASE_URL)') + console.warn('Parts DB disabled: using no-op implementation') } - // Create table for a specific category - async createCategoryTable(categoryId: string, categoryName: string, categoryType: 'partsindex' | 'partsapi'): Promise { - const tableName = this.getCategoryTableName(categoryId, categoryType) - - try { - const query = ` - CREATE TABLE IF NOT EXISTS "${tableName}" ( - id SERIAL PRIMARY KEY, - external_id VARCHAR(255) UNIQUE NOT NULL, - name VARCHAR(500) NOT NULL, - brand VARCHAR(255), - article VARCHAR(255), - description TEXT, - image_url VARCHAR(500), - price DECIMAL(10,2), - category_id VARCHAR(255) NOT NULL, - category_name VARCHAR(500) NOT NULL, - category_type VARCHAR(20) NOT NULL, - group_id VARCHAR(255), - group_name VARCHAR(500), - raw_data JSONB, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - - -- Create indexes for better performance - CREATE INDEX IF NOT EXISTS "idx_${tableName}_external_id" ON "${tableName}" (external_id); - CREATE INDEX IF NOT EXISTS "idx_${tableName}_category_id" ON "${tableName}" (category_id); - CREATE INDEX IF NOT EXISTS "idx_${tableName}_brand" ON "${tableName}" (brand); - CREATE INDEX IF NOT EXISTS "idx_${tableName}_article" ON "${tableName}" (article); - CREATE INDEX IF NOT EXISTS "idx_${tableName}_created_at" ON "${tableName}" (created_at); - - -- Create trigger to update updated_at timestamp - CREATE OR REPLACE FUNCTION update_${tableName}_timestamp() - RETURNS TRIGGER AS $$ - BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; - END; - $$ language 'plpgsql'; - - DROP TRIGGER IF EXISTS trigger_update_${tableName}_timestamp ON "${tableName}"; - CREATE TRIGGER trigger_update_${tableName}_timestamp - BEFORE UPDATE ON "${tableName}" - FOR EACH ROW - EXECUTE FUNCTION update_${tableName}_timestamp(); - ` - - await this.pool.query(query) - console.log(`✅ Created table ${tableName} for category: ${categoryName}`) - } catch (error) { - console.error(`❌ Error creating table ${tableName}:`, error) - throw error - } + async createCategoryTable() { + return } - // Insert or update products in category table - async insertProducts( - categoryId: string, - categoryName: string, - categoryType: 'partsindex' | 'partsapi', - products: any[], - groupId?: string, - groupName?: string - ): Promise { - const tableName = this.getCategoryTableName(categoryId, categoryType) - - console.log(`🔄 Starting to insert ${products.length} products into ${tableName}`) - - // First ensure table exists - await this.createCategoryTable(categoryId, categoryName, categoryType) - - let insertedCount = 0 - let errorCount = 0 - - // Process in smaller batches to reduce connection pressure - const batchSize = 50 - const batches: any[][] = [] - for (let i = 0; i < products.length; i += batchSize) { - batches.push(products.slice(i, i + batchSize)) - } - - console.log(`📦 Processing ${products.length} products in ${batches.length} batches of ${batchSize}`) - - for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { - const batch = batches[batchIndex] - console.log(`🔄 Processing batch ${batchIndex + 1}/${batches.length} (${batch.length} products)`) - - for (let i = 0; i < batch.length; i++) { - const globalIndex = batchIndex * batchSize + i - const product = batch[i] - - // Retry logic with exponential backoff - let retryAttempts = 0 - const maxRetries = 3 - let success = false - - while (retryAttempts < maxRetries && !success) { - try { - const values = this.prepareProductData(product, categoryId, categoryName, categoryType, groupId, groupName) - - const query = ` - INSERT INTO "${tableName}" ( - external_id, name, brand, article, description, image_url, price, - category_id, category_name, category_type, group_id, group_name, raw_data - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - ON CONFLICT (external_id) - DO UPDATE SET - name = EXCLUDED.name, - brand = EXCLUDED.brand, - article = EXCLUDED.article, - description = EXCLUDED.description, - image_url = EXCLUDED.image_url, - price = EXCLUDED.price, - group_id = EXCLUDED.group_id, - group_name = EXCLUDED.group_name, - raw_data = EXCLUDED.raw_data, - updated_at = CURRENT_TIMESTAMP - ` - - await this.pool.query(query, values) - insertedCount++ - success = true - - // Log progress every 100 insertions - if (insertedCount % 100 === 0) { - console.log(`📊 Progress: ${insertedCount}/${products.length} products inserted into ${tableName}`) - } - } catch (error: any) { - retryAttempts++ - - // Check if it's a network/connection error that might be retryable - if (error.code === 'ENOTFOUND' || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') { - if (retryAttempts < maxRetries) { - const delayMs = Math.pow(2, retryAttempts) * 1000 // Exponential backoff: 2s, 4s, 8s - console.log(`🔄 Retry ${retryAttempts}/${maxRetries} for product ${globalIndex + 1} after ${delayMs}ms delay`) - await new Promise(resolve => setTimeout(resolve, delayMs)) - continue - } - } - - // If max retries exceeded or non-retryable error - errorCount++ - console.error(`❌ Error inserting product ${globalIndex + 1}/${products.length} into ${tableName} (after ${retryAttempts} retries):`, error) - console.error(`❌ Product data:`, product) - break - } - } - } - - // Small delay between batches to allow DB to recover - if (batchIndex < batches.length - 1) { - await new Promise(resolve => setTimeout(resolve, 100)) - } - } - - console.log(`✅ Insertion complete for ${tableName}:`) - console.log(` - Successfully inserted/updated: ${insertedCount} products`) - console.log(` - Errors: ${errorCount} products`) - console.log(` - Total processed: ${products.length} products`) - - return insertedCount + async insertProducts(_categoryId, _categoryName, _categoryType, products) { + console.warn(`Parts DB noop: insertProducts called for ${products?.length || 0} items`) + return 0 } - // Get products from category table - async getProducts( - categoryId: string, - categoryType: 'partsindex' | 'partsapi', - options: { - limit?: number - offset?: number - search?: string - } = {} - ): Promise<{ products: any[], total: number }> { - const tableName = this.getCategoryTableName(categoryId, categoryType) - const { limit = 50, offset = 0, search } = options - - try { - let whereClause = '' - let searchParams: any[] = [] - - if (search) { - whereClause = 'WHERE (name ILIKE $1 OR brand ILIKE $1 OR article ILIKE $1 OR description ILIKE $1)' - searchParams = [`%${search}%`] - } - - // Count total - const countQuery = `SELECT COUNT(*) FROM "${tableName}" ${whereClause}` - const countResult = await this.pool.query(countQuery, searchParams) - const total = parseInt(countResult.rows[0].count) - - // Get products - const dataQuery = ` - SELECT * FROM "${tableName}" - ${whereClause} - ORDER BY created_at DESC - LIMIT $${searchParams.length + 1} OFFSET $${searchParams.length + 2} - ` - const dataResult = await this.pool.query(dataQuery, [...searchParams, limit, offset]) - - return { - products: dataResult.rows, - total - } - } catch (error) { - console.error(`❌ Error getting products from ${tableName}:`, error) - return { products: [], total: 0 } - } + async getProducts(_categoryId, _categoryType, _options = {}) { + return { products: [], total: 0 } } - // Get all category tables - async getCategoryTables(): Promise<{ tableName: string, categoryId: string, categoryType: string, recordCount: number }[]> { - try { - const query = ` - SELECT - tablename, - schemaname - FROM pg_tables - WHERE schemaname = 'public' - AND (tablename LIKE 'category_partsindex_%' OR tablename LIKE 'category_partsapi_%') - ORDER BY tablename - ` - - const result = await this.pool.query(query) - const tables: { tableName: string, categoryId: string, categoryType: string, recordCount: number }[] = [] - - for (const row of result.rows) { - const tableName = row.tablename - - // Parse category info from table name - const [, categoryType, categoryId] = tableName.split('_') - - // Get record count - const countQuery = `SELECT COUNT(*) FROM "${tableName}"` - const countResult = await this.pool.query(countQuery) - const recordCount = parseInt(countResult.rows[0].count) - - tables.push({ - tableName, - categoryId, - categoryType, - recordCount - }) - } - - return tables - } catch (error) { - console.error('❌ Error getting category tables:', error) - return [] - } + async getCategoryTables() { + return [] } - // Delete category table - async deleteCategoryTable(categoryId: string, categoryType: 'partsindex' | 'partsapi'): Promise { - const tableName = this.getCategoryTableName(categoryId, categoryType) - - try { - await this.pool.query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`) - console.log(`✅ Deleted table ${tableName}`) - } catch (error) { - console.error(`❌ Error deleting table ${tableName}:`, error) - throw error - } + async deleteCategoryTable() { + return } - // Helper method to generate table name - private getCategoryTableName(categoryId: string, categoryType: 'partsindex' | 'partsapi'): string { - // Sanitize category ID for use in table name - const sanitizedId = categoryId.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() + async testConnection() { + return true + } + + async close() { + return + } + + // Keep signature compatibility with previous helper + getCategoryTableName(categoryId, categoryType) { + const sanitizedId = String(categoryId || '').replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() return `category_${categoryType}_${sanitizedId}` } - - // Helper method to prepare product data - private prepareProductData( - product: any, - categoryId: string, - categoryName: string, - categoryType: 'partsindex' | 'partsapi', - groupId?: string, - groupName?: string - ): any[] { - if (categoryType === 'partsindex') { - return [ - product.id || product.external_id || `${Date.now()}_${Math.random()}`, - product.name || '', - product.brand || '', - product.article || '', - product.description || '', - product.image || '', - product.price ? parseFloat(product.price) : null, - categoryId, - categoryName, - categoryType, - groupId || null, - groupName || null, - JSON.stringify(product) - ] - } else { - // PartsAPI - return [ - product.artId || `${Date.now()}_${Math.random()}`, - product.artArticleNr || '', - product.artSupBrand || '', - product.artArticleNr || '', - product.productGroup || '', - '', // no image for PartsAPI - null, // no price for PartsAPI - categoryId, - categoryName, - categoryType, - groupId || null, - groupName || null, - JSON.stringify(product) - ] - } - } - - // Test database connection - async testConnection(): Promise { - try { - await this.pool.query('SELECT 1') - console.log('✅ Parts database connection test successful') - return true - } catch (error) { - console.error('❌ Parts database connection test failed:', error) - return false - } - } - - // Close connection pool - async close(): Promise { - await this.pool.end() - console.log('🔌 Parts database connection closed') - } } -// Export singleton instance -export const partsDb = new PartsDatabase() \ No newline at end of file +export const partsDb = new NoopPartsDatabase() diff --git a/src/lib/partsindex-service.ts b/src/lib/partsindex-service.ts index 5359c55..007d0cb 100644 --- a/src/lib/partsindex-service.ts +++ b/src/lib/partsindex-service.ts @@ -155,8 +155,10 @@ interface CacheEntry { } class PartsIndexService { - private baseURL = process.env.PARTSAPI_URL+"/v1" || 'https://api.parts-index.com/v1'; - private apiKey = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE'; + private baseHost = process.env.PARTSAPI_URL || 'https://api.parts-index.com'; + private baseURL = `${this.baseHost}/v1`; + private apiKey = process.env.PARTSAPI_KEY || 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE'; + private enabled = Boolean(process.env.PARTSAPI_URL) || process.env.PARTSINDEX_ENABLED === 'true'; // Простой in-memory кэш private cache = new Map>(); @@ -232,6 +234,10 @@ class PartsIndexService { // Получить список каталогов async getCatalogs(lang: 'ru' | 'en' = 'ru'): Promise { + if (!this.enabled) { + // Disabled: return empty to avoid external calls during local dev + return []; + } const cacheKey = `catalogs_${lang}`; // Проверяем кэш @@ -271,6 +277,9 @@ class PartsIndexService { // Получить группы каталога async getCatalogGroups(catalogId: string, lang: 'ru' | 'en' = 'ru'): Promise { + if (!this.enabled) { + return []; + } const cacheKey = `groups_${catalogId}_${lang}`; // Проверяем кэш @@ -352,6 +361,9 @@ class PartsIndexService { } = options; try { + if (!this.enabled) { + return []; + } console.log('🔍 PartsIndex запрос ВСЕХ товаров каталога:', { catalogId, groupId, @@ -434,6 +446,10 @@ class PartsIndexService { params } = options; + if (!this.enabled) { + return null; + } + // Создаем ключ кэша на основе всех параметров const cacheKey = `entities_${catalogId}_${groupId}_${lang}_${limit}_${page}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`; @@ -670,6 +686,9 @@ class PartsIndexService { lang: 'ru' | 'en' = 'ru' ): Promise { try { + if (!this.enabled) { + return null; + } console.log('🔍 PartsIndex запрос детали товара:', { catalogId, entityId, lang }); const response = await axios.get(`${this.baseURL}/catalogs/${catalogId}/entities/${entityId}`, { @@ -716,6 +735,9 @@ class PartsIndexService { lang: 'ru' | 'en' = 'ru' ): Promise { try { + if (!this.enabled) { + return null; + } console.log('🔍 PartsIndex прямой поиск по артикулу:', { code, brand, lang }); const params: any = { @@ -768,6 +790,9 @@ class PartsIndexService { lang: 'ru' | 'en' = 'ru' ): Promise { try { + if (!this.enabled) { + return null; + } console.log('🔍 PartsIndex поиск товара в конкретной категории:', { catalogId, groupId, @@ -818,4 +843,4 @@ class PartsIndexService { } } -export const partsIndexService = new PartsIndexService(); \ No newline at end of file +export const partsIndexService = new PartsIndexService();