diff --git a/n8n-workflow-example.md b/n8n-workflow-example.md new file mode 100644 index 0000000..c91e864 --- /dev/null +++ b/n8n-workflow-example.md @@ -0,0 +1,112 @@ +# راهنمای تنظیم n8n Workflow برای چت + +## گزینه 1: پاسخ ساده بدون GPT + +### Node 1: Webhook +- **HTTP Method**: POST +- **Path**: `/chat` (یا هر مسیری که می‌خواهی) +- **Response Mode**: Using 'Respond to Webhook' Node + +### Node 2: Code (JavaScript) +```javascript +const inputData = $input.item.json; +const userMessage = inputData.message || ''; + +// یک پاسخ ساده برمی‌گردانیم +return { + json: { + response: `شما گفتید: "${userMessage}". این یک پاسخ تست از n8n است!` + } +}; +``` + +### Node 3: Respond to Webhook +- **Response Body**: `{{ $json.response }}` + +--- + +## گزینه 2: با استفاده از GPT (Message a model) + +### Node 1: Webhook +- **HTTP Method**: POST +- **Path**: `/chat` +- **Response Mode**: Using 'Respond to Webhook' Node + +### Node 2: Code (JavaScript) +```javascript +const inputData = $input.item.json; +const userMessage = inputData.message || ''; +const conversationHistory = inputData.conversationHistory || []; + +// ساخت messages array برای GPT +const messages = [ + { + role: 'system', + content: 'شما یک دستیار هوشمند فارسی‌زبان هستید. پاسخ‌های کوتاه و مفید بدهید.' + } +]; + +// اضافه کردن تاریخچه +conversationHistory.forEach(msg => { + messages.push({ + role: msg.role, + content: msg.content + }); +}); + +// اضافه کردن پیام فعلی +messages.push({ + role: 'user', + content: userMessage +}); + +return { + json: { + messages: messages, + model: 'gpt-4o-mini', + temperature: 0.7 + } +}; +``` + +### Node 3: Message a model +- **Model**: `gpt-4o-mini` +- **Messages**: `{{ $json.messages }}` +- **Temperature**: `{{ $json.temperature }}` +- **Credentials**: باید OpenAI API Key را تنظیم کنی + +### Node 4: Code (برای فرمت کردن پاسخ) +```javascript +const gptResponse = $input.item.json; +const responseText = gptResponse.choices?.[0]?.message?.content || + gptResponse.response || + 'پاسخی دریافت نشد'; + +return { + json: { + response: responseText + } +}; +``` + +### Node 5: Respond to Webhook +- **Response Body**: `{{ $json.response }}` + +--- + +## نکات مهم: + +1. **URL Webhook**: بعد از فعال کردن Webhook، URL را کپی کن و در `.env` بذار: + ``` + VITE_N8N_WEBHOOK_URL=https://your-n8n.com/webhook/chat + ``` + +2. **فعال کردن Workflow**: حتماً Workflow را فعال (Activate) کن + +3. **تست**: می‌توانی با Postman یا curl تست کنی: + ```bash + curl -X POST https://your-n8n.com/webhook/chat \ + -H "Content-Type: application/json" \ + -d '{"message":"سلام","userId":"test-user"}' + ``` + diff --git a/package-lock.json b/package-lock.json index be77543..c2b2d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "pwa-react-app", "version": "1.0.0", "dependencies": { + "@n8n/chat": "^1.2.1", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -390,7 +391,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -400,7 +400,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -449,7 +448,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.5" @@ -1653,7 +1651,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2342,6 +2339,27 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@n8n/chat": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@n8n/chat/-/chat-1.2.1.tgz", + "integrity": "sha512-zoYGr1T91fIJAmrf5d7YkhB9AIN5pXCFtB016s2PPRJvfA1QdYnJK3T8TktJ0KeGa9A+jeraxC9du3/YVfvXxg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@n8n/design-system": "2.2.1", + "@vueuse/core": "^10.11.0", + "highlight.js": "11.8.0", + "markdown-it-link-attributes": "^4.0.1", + "uuid": "10.0.0", + "vue": "^3.5.13", + "vue-markdown-render": "^2.2.1" + } + }, + "node_modules/@n8n/design-system": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@n8n/design-system/-/design-system-2.2.1.tgz", + "integrity": "sha512-ej8xIYC4pUGgb83Bqu/uE4WYLb0NlYIztC9SRxeHcseekIddeLaEQ70uJcGOo1qMxSm6vOJh/m4m3gDgCO557w==", + "license": "SEE LICENSE IN LICENSE.md" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3768,6 +3786,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4003,6 +4027,151 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "vue": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4096,7 +4265,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -4649,10 +4817,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/data-view-buffer": { @@ -4874,6 +5041,18 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -5264,7 +5443,6 @@ "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==", - "dev": true, "license": "MIT" }, "node_modules/esutils": { @@ -5887,6 +6065,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", + "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -6656,6 +6843,15 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6741,6 +6937,41 @@ "sourcemap-codec": "^1.4.8" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-link-attributes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/markdown-it-link-attributes/-/markdown-it-link-attributes-4.0.1.tgz", + "integrity": "sha512-pg5OK0jPLg62H4k7M9mRJLT61gUp9nvG0XveKYHMOOluASo9OEF13WlXrpAp2aj35LbedAy3QOCgQCw0tkLKAQ==", + "license": "MIT" + }, + "node_modules/markdown-it/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", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6751,6 +6982,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7344,6 +7581,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8743,7 +8989,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8753,6 +8999,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -8955,6 +9207,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -9040,6 +9305,65 @@ "workbox-window": "^7.0.0" } }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-markdown-render": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/vue-markdown-render/-/vue-markdown-render-2.3.0.tgz", + "integrity": "sha512-ZWVVKba8t0tKBlaUGaWmNynIk38gE7Bt3psC/iN2NsqpdGY15VGfBeBvF0A8cEmwHnjNVJo2IzUUqkhhfldhtg==", + "license": "MIT", + "dependencies": { + "markdown-it": "^14.1.0" + }, + "peerDependencies": { + "vue": "^3.3.4" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 5af7d25..8083b87 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"" }, "dependencies": { + "@n8n/chat": "^1.2.1", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", diff --git a/public/fonts/shabnam/Shabnam-Bold.ttf b/public/fonts/shabnam/Shabnam-Bold.ttf new file mode 100644 index 0000000..12259fc Binary files /dev/null and b/public/fonts/shabnam/Shabnam-Bold.ttf differ diff --git a/public/fonts/shabnam/Shabnam-Light.ttf b/public/fonts/shabnam/Shabnam-Light.ttf new file mode 100644 index 0000000..2b18f23 Binary files /dev/null and b/public/fonts/shabnam/Shabnam-Light.ttf differ diff --git a/public/fonts/shabnam/Shabnam.ttf b/public/fonts/shabnam/Shabnam.ttf new file mode 100644 index 0000000..a56e7d3 Binary files /dev/null and b/public/fonts/shabnam/Shabnam.ttf differ diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 8318006..6ca2e76 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -36,32 +36,51 @@ export function useAuth() { }, [setSession]); const signInWithGoogle = async () => { + if (!isSupabaseConfigured) { + throw new Error('Supabase not configured'); + } const { error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: `${window.location.origin}/account` } }); - if (error) throw error; + if (error) { + console.error('Google sign in error:', error); + throw error; + } }; const signInWithPhone = async (phone: string) => { + if (!isSupabaseConfigured) { + throw new Error('Supabase not configured'); + } const { error } = await supabase.auth.signInWithOtp({ phone, options: { channel: 'sms' } }); - if (error) throw error; + if (error) { + console.error('Phone OTP error:', error); + throw error; + } }; const verifyOTP = async (phone: string, token: string) => { - const { error } = await supabase.auth.verifyOtp({ + if (!isSupabaseConfigured) { + throw new Error('Supabase not configured'); + } + const { data, error } = await supabase.auth.verifyOtp({ phone, token, type: 'sms' }); - if (error) throw error; + if (error) { + console.error('OTP verification error:', error); + throw error; + } + return data; }; const signUp = async (email: string, password: string) => { @@ -76,11 +95,18 @@ export function useAuth() { }; const signIn = async (email: string, password: string) => { - const { error } = await supabase.auth.signInWithPassword({ + if (!isSupabaseConfigured) { + throw new Error('Supabase not configured'); + } + const { data, error } = await supabase.auth.signInWithPassword({ email, password }); - if (error) throw error; + if (error) { + console.error('Sign in error:', error); + throw error; + } + return data; }; const signOut = async () => { diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts index 92c5270..4a93e13 100644 --- a/src/hooks/useChat.ts +++ b/src/hooks/useChat.ts @@ -1,11 +1,40 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { supabase, isSupabaseConfigured, type Message } from '@/services/supabase'; +import { + generateAIResponse, + isOpenAIConfigured, + systemMessage, + type ChatCompletionMessage +} from '@/services/openai'; +import { sendMessageToN8n, isN8nConfigured, type ChatMessage as N8nChatMessage } from '@/services/n8n'; + +export type ChatMessage = Message & { + role?: 'user' | 'assistant'; + status?: 'pending' | 'sent' | 'error'; +}; + +const createLocalId = () => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; +}; export function useChat() { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); + const conversationRef = useRef([systemMessage]); + const n8nHistoryRef = useRef([]); + const isAIEnabled = isOpenAIConfigured || isN8nConfigured; useEffect(() => { + if (isAIEnabled) { + // بدون پیام خوش‌آمدگویی - کاربر باید دکمه استارت را بزند + setMessages([]); + setLoading(false); + return; + } + loadMessages(); if (!isSupabaseConfigured) { @@ -34,6 +63,10 @@ export function useChat() { }, []); const loadMessages = async () => { + if (isAIEnabled) { + return; + } + if (!isSupabaseConfigured) { setMessages([]); setLoading(false); @@ -51,7 +84,7 @@ export function useChat() { // For now, user data will be fetched separately in the component // You can create a profiles table and join it here if needed - setMessages((data || []) as Message[]); + setMessages((data || []) as ChatMessage[]); } catch (error) { console.error('Error loading messages:', error); setMessages([]); @@ -60,7 +93,77 @@ export function useChat() { } }; - const sendMessage = async (content: string, userId: string) => { + const sendMessage = async (content: string, userId: string, skipUserMessage = false) => { + if (isAIEnabled) { + const createdAt = new Date().toISOString(); + const userMessage: ChatMessage = { + id: createLocalId(), + user_id: userId, + content, + created_at: createdAt, + role: 'user', + status: 'pending' + }; + + // فقط اگر skipUserMessage false باشد، پیام کاربر را نمایش بده + if (!skipUserMessage) { + setMessages((prev) => [userMessage, ...prev]); + } + + try { + let aiContent: string; + + // اولویت با n8n است اگر تنظیم شده باشد + if (isN8nConfigured) { + // اضافه کردن پیام کاربر به تاریخچه n8n + n8nHistoryRef.current.push({ role: 'user', content, timestamp: createdAt }); + + // ارسال به n8n + aiContent = await sendMessageToN8n(content, userId, n8nHistoryRef.current); + + // اضافه کردن پاسخ به تاریخچه n8n + n8nHistoryRef.current.push({ + role: 'assistant', + content: aiContent, + timestamp: new Date().toISOString() + }); + } else if (isOpenAIConfigured) { + // استفاده از OpenAI + conversationRef.current.push({ role: 'user', content }); + aiContent = await generateAIResponse(conversationRef.current); + conversationRef.current.push({ role: 'assistant', content: aiContent }); + } else { + throw new Error('هیچ سرویس هوش مصنوعی تنظیم نشده است.'); + } + + const aiMessage: ChatMessage = { + id: createLocalId(), + user_id: 'ai-assistant', + content: aiContent, + created_at: new Date().toISOString(), + role: 'assistant', + status: 'sent' + }; + + // فقط اگر پیام کاربر نمایش داده شده، status را update کن + if (!skipUserMessage) { + setMessages((prev) => + prev.map((msg) => (msg.id === userMessage.id ? { ...msg, status: 'sent' } : msg)) + ); + } + setMessages((prev) => [aiMessage, ...prev]); + return aiMessage; + } catch (error) { + // فقط اگر پیام کاربر نمایش داده شده، status را update کن + if (!skipUserMessage) { + setMessages((prev) => + prev.map((msg) => (msg.id === userMessage.id ? { ...msg, status: 'error' } : msg)) + ); + } + throw error; + } + } + if (!isSupabaseConfigured) { throw new Error('Supabase is not configured. Cannot send messages in offline mode.'); } @@ -78,7 +181,7 @@ export function useChat() { .single(); if (error) throw error; - return data; + return data as ChatMessage; } catch (error) { console.error('Error sending message:', error); throw error; diff --git a/src/hooks/useTypewriter.ts b/src/hooks/useTypewriter.ts new file mode 100644 index 0000000..eca3375 --- /dev/null +++ b/src/hooks/useTypewriter.ts @@ -0,0 +1,77 @@ +import { useState, useEffect, useRef } from 'react'; + +interface UseTypewriterOptions { + text: string; + speed?: number; // میلی‌ثانیه بین هر کاراکتر + onComplete?: () => void; +} + +export function useTypewriter({ text, speed = 30, onComplete }: UseTypewriterOptions) { + const [displayedText, setDisplayedText] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const indexRef = useRef(0); + const timeoutRef = useRef(null); + const textRef = useRef(text); + const hasCompletedRef = useRef(false); + + useEffect(() => { + // Cleanup previous timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + // If text is empty, reset everything + if (!text || text.trim() === '') { + setDisplayedText(''); + setIsTyping(false); + indexRef.current = 0; + hasCompletedRef.current = false; + textRef.current = ''; + return; + } + + // Only restart if text actually changed + const textChanged = textRef.current !== text; + + if (textChanged) { + // Reset when text changes + setDisplayedText(''); + indexRef.current = 0; + setIsTyping(true); + hasCompletedRef.current = false; + textRef.current = text; + + const typeNextChar = () => { + if (indexRef.current < text.length) { + setDisplayedText(text.substring(0, indexRef.current + 1)); + indexRef.current += 1; + timeoutRef.current = setTimeout(typeNextChar, speed); + } else { + setIsTyping(false); + hasCompletedRef.current = true; + if (onComplete) { + onComplete(); + } + } + }; + + // Start typing immediately + timeoutRef.current = setTimeout(typeNextChar, speed); + } else if (hasCompletedRef.current) { + // Text hasn't changed and already completed, show full text + setDisplayedText(text); + setIsTyping(false); + } + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [text, speed, onComplete]); + + return { displayedText, isTyping }; +} + diff --git a/src/pages/Auth/Login/LoginPage.tsx b/src/pages/Auth/Login/LoginPage.tsx index f5c6ead..cbaa1c3 100644 --- a/src/pages/Auth/Login/LoginPage.tsx +++ b/src/pages/Auth/Login/LoginPage.tsx @@ -42,15 +42,22 @@ export default function LoginPage() { }); navigate('/account'); } catch (error: any) { + console.error('Login error:', error); let errorMessage = 'خطا در ورود'; - if (error.message) { - if (error.message.includes('Invalid login credentials')) { + if (error) { + const errorMsg = error.message || error.error?.message || String(error); + + if (errorMsg.includes('Invalid login credentials') || errorMsg.includes('invalid_credentials')) { errorMessage = 'ایمیل یا رمز عبور اشتباه است. لطفا دوباره تلاش کنید یا ثبت نام کنید.'; - } else if (error.message.includes('Email not confirmed')) { + } else if (errorMsg.includes('Email not confirmed') || errorMsg.includes('email_not_confirmed')) { errorMessage = 'لطفا ابتدا ایمیل خود را تأیید کنید.'; + } else if (errorMsg.includes('Supabase not configured')) { + errorMessage = 'Supabase تنظیم نشده است. لطفا فایل .env را بررسی کنید.'; + } else if (errorMsg.includes('Network') || errorMsg.includes('fetch')) { + errorMessage = 'خطا در اتصال به سرور. لطفا اتصال اینترنت خود را بررسی کنید.'; } else { - errorMessage = error.message; + errorMessage = errorMsg || 'خطا در ورود. لطفا دوباره تلاش کنید.'; } } @@ -70,9 +77,13 @@ export default function LoginPage() { await signInWithGoogle(); // Redirect happens automatically } catch (error: any) { + console.error('Google login error:', error); + const errorMsg = error?.message || error?.error?.message || String(error) || 'خطا در ورود با گوگل'; toast({ title: 'خطا', - description: error.message || 'خطا در ورود با گوگل', + description: errorMsg.includes('Supabase not configured') + ? 'Supabase تنظیم نشده است. لطفا فایل .env را بررسی کنید.' + : errorMsg, variant: 'destructive' }); setIsLoading(false); @@ -99,9 +110,23 @@ export default function LoginPage() { description: 'کد تأیید ارسال شد' }); } catch (error: any) { + console.error('Phone OTP error:', error); + const errorMsg = error?.message || error?.error?.message || String(error) || 'خطا در ارسال کد'; + let errorMessage = 'خطا در ارسال کد'; + + if (errorMsg.includes('Supabase not configured')) { + errorMessage = 'Supabase تنظیم نشده است. لطفا فایل .env را بررسی کنید.'; + } else if (errorMsg.includes('Invalid phone number') || errorMsg.includes('invalid_phone')) { + errorMessage = 'شماره تلفن نامعتبر است. لطفا شماره را با فرمت صحیح وارد کنید.'; + } else if (errorMsg.includes('Network') || errorMsg.includes('fetch')) { + errorMessage = 'خطا در اتصال به سرور. لطفا اتصال اینترنت خود را بررسی کنید.'; + } else { + errorMessage = errorMsg; + } + toast({ title: 'خطا', - description: error.message || 'خطا در ارسال کد', + description: errorMessage, variant: 'destructive' }); } finally { @@ -129,9 +154,25 @@ export default function LoginPage() { }); navigate('/account'); } catch (error: any) { + console.error('OTP verification error:', error); + const errorMsg = error?.message || error?.error?.message || String(error) || 'کد تأیید نامعتبر است'; + let errorMessage = 'کد تأیید نامعتبر است'; + + if (errorMsg.includes('Supabase not configured')) { + errorMessage = 'Supabase تنظیم نشده است. لطفا فایل .env را بررسی کنید.'; + } else if (errorMsg.includes('Invalid OTP') || errorMsg.includes('invalid_token')) { + errorMessage = 'کد تأیید نامعتبر است. لطفا دوباره تلاش کنید.'; + } else if (errorMsg.includes('expired')) { + errorMessage = 'کد تأیید منقضی شده است. لطفا کد جدید درخواست کنید.'; + } else if (errorMsg.includes('Network') || errorMsg.includes('fetch')) { + errorMessage = 'خطا در اتصال به سرور. لطفا اتصال اینترنت خود را بررسی کنید.'; + } else { + errorMessage = errorMsg; + } + toast({ title: 'خطا', - description: error.message || 'کد تأیید نامعتبر است', + description: errorMessage, variant: 'destructive' }); } finally { diff --git a/src/pages/Chat/ChatPage.tsx b/src/pages/Chat/ChatPage.tsx index fcae513..b8b9ed7 100644 --- a/src/pages/Chat/ChatPage.tsx +++ b/src/pages/Chat/ChatPage.tsx @@ -5,8 +5,9 @@ import { Input } from '@/components/ui/input'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useAuth } from '@/hooks/useAuth'; import { useChat } from '@/hooks/useChat'; +import { useTypewriter } from '@/hooks/useTypewriter'; import { useToast } from '@/components/ui/use-toast'; -import { Send, MessageSquare, Loader2 } from 'lucide-react'; +import { Send, MessageSquare, Loader2, Play } from 'lucide-react'; export default function ChatPage() { const { user } = useAuth(); @@ -15,13 +16,14 @@ export default function ChatPage() { const [message, setMessage] = useState(''); const [isSending, setIsSending] = useState(false); const messagesEndRef = useRef(null); + const typedMessagesRef = useRef>(new Set()); // Track which messages have been typed useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); - const handleSend = async (e: React.FormEvent) => { - e.preventDefault(); + const handleSend = async (e?: React.FormEvent, initialMessage?: string, skipUserMessage = false) => { + e?.preventDefault(); if (!user) { toast({ title: 'خطا', @@ -31,18 +33,26 @@ export default function ChatPage() { return; } - if (!message.trim()) { + const messageToSend = initialMessage || message; + if (!messageToSend.trim()) { return; } setIsSending(true); try { - await sendMessage(message, user.id); - setMessage(''); + // @ts-ignore - skipUserMessage parameter exists + await sendMessage(messageToSend, user.id, skipUserMessage); + if (!initialMessage) { + setMessage(''); + } } catch (error) { + const description = + error instanceof Error + ? error.message + : 'خطا در ارسال پیام'; toast({ title: 'خطا', - description: 'خطا در ارسال پیام', + description, variant: 'destructive' }); } finally { @@ -50,7 +60,13 @@ export default function ChatPage() { } }; + const handleStartChat = () => { + // skipUserMessage = true یعنی پیام کاربر نمایش داده نمی‌شود + handleSend(undefined, 'سلام', true); + }; + const getUserDisplayName = (msg: typeof messages[0]) => { + if (msg?.role === 'assistant') return 'دستیار هوشمند'; if (msg.user_id === user?.id) return 'شما'; // For other users, show a simplified identifier // In production, you'd fetch from a profiles table @@ -61,12 +77,113 @@ export default function ChatPage() { }; const getUserInitials = (msg: typeof messages[0]) => { + if (msg?.role === 'assistant') return 'هو'; const name = getUserDisplayName(msg); if (name === 'شما') return 'ش'; if (name.length >= 2) return name.substring(0, 2); return 'ک'; }; + // Component for message bubble with typewriter effect for AI messages + const MessageBubble = ({ + message, + isOwnMessage, + isAssistant, + getUserDisplayName, + getUserInitials + }: { + message: typeof messages[0]; + isOwnMessage: boolean; + isAssistant: boolean; + getUserDisplayName: (msg: typeof messages[0]) => string; + getUserInitials: (msg: typeof messages[0]) => string; + }) => { + // Check if this message has already been typed + const hasBeenTyped = typedMessagesRef.current.has(message.id); + + // If it's an assistant message and hasn't been typed yet, use typewriter + const shouldAnimate = isAssistant && !hasBeenTyped && !!message.content; + + // Only use typewriter for assistant messages that haven't been typed yet + const typewriterResult = useTypewriter({ + text: shouldAnimate ? message.content : '', + speed: 10, // سرعت تایپ سریع‌تر (میلی‌ثانیه) + onComplete: () => { + // Mark this message as typed when animation completes + if (isAssistant && message.id) { + typedMessagesRef.current.add(message.id); + } + } + }); + + // Determine final text to display + // Priority: animated text (if available) > message.content + let finalText: string; + if (isAssistant && shouldAnimate) { + // For assistant messages with animation, use displayedText if it exists, otherwise fallback to content + finalText = typewriterResult.displayedText.length > 0 + ? typewriterResult.displayedText + : message.content || ''; + } else { + // For user messages or already-typed assistant messages, show full content + finalText = message.content || ''; + } + + const showCursor = isAssistant && shouldAnimate && typewriterResult.isTyping; + + return ( +
+ + + + {getUserInitials(message)} + + +
+
+ + {getUserDisplayName(message)} + + + {new Date(message.created_at).toLocaleTimeString('fa-IR', { + hour: '2-digit', + minute: '2-digit' + })} + +
+
+

+ {finalText} + {showCursor && ( + | + )} +

+ {message.status === 'error' && ( + + ارسال ناموفق بود. دوباره تلاش کنید. + + )} +
+
+
+ ); + }; + return (
@@ -94,49 +211,46 @@ export default function ChatPage() { <>
{messages.length === 0 ? ( -
- هیچ پیامی وجود ندارد. اولین پیام را ارسال کنید! +
+
+ +

به چت هوشمند خوش آمدید

+

+ برای شروع گفتگو، دکمه زیر را بزنید +

+
+
) : ( messages.map((msg) => { - const isOwnMessage = msg.user_id === user?.id; + const isOwnMessage = msg.user_id === user?.id && msg.role !== 'assistant'; + const isAssistant = msg.role === 'assistant'; return ( -
- - - - {getUserInitials(msg)} - - -
-
- - {getUserDisplayName(msg)} - - - {new Date(msg.created_at).toLocaleTimeString('fa-IR', { - hour: '2-digit', - minute: '2-digit' - })} - -
-
-

{msg.content}

-
-
-
+ message={msg} + isOwnMessage={isOwnMessage} + isAssistant={isAssistant} + getUserDisplayName={getUserDisplayName} + getUserInitials={getUserInitials} + /> ); }) )} diff --git a/src/services/n8n.ts b/src/services/n8n.ts new file mode 100644 index 0000000..1a81b94 --- /dev/null +++ b/src/services/n8n.ts @@ -0,0 +1,297 @@ +// URL پیش‌فرض n8n chat flow +const DEFAULT_N8N_CHAT_URL = 'https://nn.feep.ir/webhook/ad03737e-f857-4b5c-87d0-601327b2081c/chat'; +const n8nWebhookUrl = import.meta.env.VITE_N8N_WEBHOOK_URL || DEFAULT_N8N_CHAT_URL; +const n8nApiKey = import.meta.env.VITE_N8N_API_KEY || ''; + +export type ChatMessage = { + role: 'user' | 'assistant'; + content: string; + timestamp?: string; +}; + +export const isN8nConfigured = Boolean(n8nWebhookUrl); + +// ذخیره sessionId برای هر کاربر (برای حفظ context در Simple Memory) +const sessionIdMap = new Map(); + +// تولید یا دریافت sessionId برای یک کاربر +const getOrCreateSessionId = (userId: string): string => { + if (!sessionIdMap.has(userId)) { + // تولید sessionId جدید (UUID-like) + const sessionId = `${userId}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; + sessionIdMap.set(userId, sessionId); + console.log('🔑 ایجاد sessionId جدید:', sessionId, 'برای کاربر:', userId); + } + return sessionIdMap.get(userId)!; +}; + +// در development از proxy استفاده می‌کنیم، در production از URL مستقیم +const getN8nUrl = () => { + // اگر در development هستیم و URL شامل nn.feep.ir است، از proxy استفاده کن + const useProxy = import.meta.env.DEV && n8nWebhookUrl.includes('nn.feep.ir'); + + if (useProxy) { + console.log('🔀 استفاده از proxy برای دور زدن CORS'); + // استخراج مسیر webhook از URL کامل + try { + const url = new URL(n8nWebhookUrl); + const urlPath = url.pathname; + const proxyUrl = `/api/n8n${urlPath}`; + console.log('📍 مسیر proxy:', proxyUrl, 'از URL اصلی:', n8nWebhookUrl); + return proxyUrl; + } catch (error) { + console.error('❌ خطا در parse کردن URL:', error); + // اگر URL معتبر نیست، از URL مستقیم استفاده کن + return n8nWebhookUrl; + } + } + + console.log('🌐 استفاده از URL مستقیم:', n8nWebhookUrl); + return n8nWebhookUrl; +}; + +/** + * ارسال پیام به n8n و دریافت پاسخ + * @param message - پیام کاربر + * @param userId - شناسه کاربر + * @param conversationHistory - تاریخچه مکالمه (اختیاری) + * @returns پاسخ از n8n + */ +export async function sendMessageToN8n( + message: string, + userId: string, + _conversationHistory?: ChatMessage[] // n8n Chat با Simple Memory خودش session را مدیریت می‌کند، این پارامتر برای backward compatibility است +): Promise { + if (!isN8nConfigured) { + throw new Error( + 'n8n تنظیم نشده است. مقدار VITE_N8N_WEBHOOK_URL را در فایل .env تنظیم کنید.' + ); + } + + // فرمت n8n Chat Trigger - این workflow از "When chat message received" استفاده می‌کند + // فرمت مورد نیاز: { sessionId, action: "sendMessage", chatInput } + const sessionId = getOrCreateSessionId(userId); + + const payload = { + sessionId: sessionId, + action: 'sendMessage', + chatInput: message + }; + + const headers: Record = { + 'Content-Type': 'application/json' + }; + + // اگر API Key تنظیم شده باشد، اضافه می‌کنیم + if (n8nApiKey) { + headers['Authorization'] = `Bearer ${n8nApiKey}`; + // یا می‌توانید از header دیگری استفاده کنید + // headers['X-API-Key'] = n8nApiKey; + } + + try { + const actualUrl = getN8nUrl(); + console.log('📤 ارسال به n8n:', { url: actualUrl, originalUrl: n8nWebhookUrl, payload }); + + const response = await fetch(actualUrl, { + method: 'POST', + headers, + body: JSON.stringify(payload), + // اضافه کردن mode برای جلوگیری از CORS issues + mode: 'cors', + credentials: 'omit' + }); + + console.log('📥 پاسخ از n8n:', { status: response.status, ok: response.ok }); + + if (!response.ok) { + let errorText = ''; + try { + errorText = await response.text(); + // اگر پاسخ HTML است (مثل خطای 500)، پیام بهتری نمایش بده + if (errorText.trim().startsWith(' 0) { + const content = data.choices[0]?.message?.content; + if (content && content.trim()) { + console.log('✅ پاسخ از GPT (choices):', content); + return content.trim(); + } + } + + // 2. اگر Simplify Output فعال است - پاسخ مستقیم از GPT + if (data.response && typeof data.response === 'string' && data.response.trim()) { + console.log('✅ پاسخ از n8n (response):', data.response); + return data.response.trim(); + } + + // 3. اگر response خالی است، ببین آیا در جای دیگری هست + if (data.response === '' || data.response === null) { + // شاید پاسخ در جای دیگری باشد + if (data.choices?.[0]?.message?.content) { + const content = data.choices[0].message.content; + if (content && content.trim()) { + console.log('✅ پاسخ از GPT (در choices):', content); + return content.trim(); + } + } + } + + // 4. فرمت‌های دیگر + if (typeof data === 'string' && data.trim()) { + return data.trim(); + } + + if (data.message && typeof data.message === 'string' && data.message.trim()) { + return data.message.trim(); + } + + if (data.text && typeof data.text === 'string' && data.text.trim()) { + return data.text.trim(); + } + + if (data.data?.response && typeof data.data.response === 'string' && data.data.response.trim()) { + return data.data.response.trim(); + } + + if (data.data?.message && typeof data.data.message === 'string' && data.data.message.trim()) { + return data.data.message.trim(); + } + + // 5. اگر پاسخ یک object است و هیچ فیلد متنی نداره، خطا بده + if (typeof data === 'object') { + // بررسی عمیق‌تر برای پیدا کردن متن + const findTextInObject = (obj: any): string | null => { + if (typeof obj === 'string' && obj.trim()) { + return obj.trim(); + } + if (Array.isArray(obj)) { + for (const item of obj) { + const text = findTextInObject(item); + if (text) return text; + } + } + if (obj && typeof obj === 'object') { + for (const key in obj) { + if (key === 'content' || key === 'text' || key === 'message' || key === 'response') { + const text = findTextInObject(obj[key]); + if (text) return text; + } + } + } + return null; + }; + + const foundText = findTextInObject(data); + if (foundText) { + console.log('✅ متن پیدا شد در object:', foundText); + return foundText; + } + + // اگر هیچ متنی پیدا نشد، خطا بده + console.error('❌ هیچ متن معتبری در پاسخ پیدا نشد:', JSON.stringify(data, null, 2)); + throw new Error('پاسخ از n8n خالی است یا فرمت نامعتبر دارد. لطفا Respond to Webhook را بررسی کنید.'); + } + + throw new Error('فرمت پاسخ n8n نامعتبر است. ساختار: ' + JSON.stringify(data)); + } catch (error) { + console.error('❌ خطا در sendMessageToN8n:', error); + if (error instanceof TypeError && error.message.includes('fetch')) { + throw new Error('خطا در اتصال به n8n. لطفا بررسی کنید:\n1. URL صحیح است؟\n2. n8n در دسترس است؟\n3. مشکل CORS وجود دارد؟'); + } + if (error instanceof Error) { + throw error; + } + throw new Error('خطا در ارتباط با n8n: ' + String(error)); + } +} + diff --git a/src/services/openai.ts b/src/services/openai.ts new file mode 100644 index 0000000..5f50397 --- /dev/null +++ b/src/services/openai.ts @@ -0,0 +1,70 @@ +const openAiApiKey = import.meta.env.VITE_OPENAI_API_KEY || ''; +const openAiBaseUrl = import.meta.env.VITE_OPENAI_BASE_URL || 'https://api.openai.com/v1'; +const openAiModel = import.meta.env.VITE_OPENAI_MODEL || 'gpt-4o-mini'; +const openAiOrg = import.meta.env.VITE_OPENAI_ORG_ID || ''; +const openAiProject = import.meta.env.VITE_OPENAI_PROJECT_ID || ''; + +export type ChatCompletionRole = 'system' | 'user' | 'assistant'; + +export type ChatCompletionMessage = { + role: ChatCompletionRole; + content: string; +}; + +export const isOpenAIConfigured = Boolean(openAiApiKey); + +const defaultSystemPrompt = + import.meta.env.VITE_OPENAI_SYSTEM_PROMPT || + 'You are an AI assistant that responds in Persian (Farsi) with concise, helpful answers for users of a progressive web app.'; + +export const systemMessage: ChatCompletionMessage = { + role: 'system', + content: defaultSystemPrompt, +}; + +export async function generateAIResponse(messages: ChatCompletionMessage[]): Promise { + if (!isOpenAIConfigured) { + throw new Error('کلید OpenAI تعریف نشده است. مقدار VITE_OPENAI_API_KEY را تنظیم کنید.'); + } + + const payload = { + model: openAiModel, + messages: messages.length ? messages : [systemMessage], + temperature: 0.3, + max_tokens: 800, + }; + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${openAiApiKey}`, + }; + + if (openAiOrg) { + headers['OpenAI-Organization'] = openAiOrg; + } + + if (openAiProject) { + headers['OpenAI-Project'] = openAiProject; + } + + const response = await fetch(`${openAiBaseUrl}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`خطا در دریافت پاسخ از GPT: ${errorBody}`); + } + + const data = await response.json(); + const text: string | undefined = data?.choices?.[0]?.message?.content?.trim(); + + if (!text) { + throw new Error('پاسخی از هوش مصنوعی دریافت نشد.'); + } + + return text; +} + diff --git a/src/styles/globals.css b/src/styles/globals.css index a6459b7..a6ed852 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -49,12 +49,29 @@ } } -/* Custom Font - فونت سفارشی */ +/* Custom Font - فونت سفارشی شبنم */ @font-face { - font-family: 'IRAN Rounded'; - src: url('/fonts/IRAN Rounded.ttf') format('truetype'); - font-weight: normal; + font-family: 'Shabnam'; + src: url('/fonts/shabnam/Shabnam-Light.ttf') format('truetype'); + font-weight: 300; font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Shabnam'; + src: url('/fonts/shabnam/Shabnam.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Shabnam'; + src: url('/fonts/shabnam/Shabnam-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; } @layer base { @@ -64,7 +81,7 @@ body { @apply bg-background text-foreground; - font-family: 'IRAN Rounded', 'Tahoma', 'Arial', system-ui, -apple-system, sans-serif; + font-family: 'Shabnam', 'Tahoma', 'Arial', system-ui, -apple-system, sans-serif; } } diff --git a/tailwind.config.js b/tailwind.config.js index 88e99ce..2365c48 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,3 +1,5 @@ +import defaultTheme from 'tailwindcss/defaultTheme'; + /** @type {import('tailwindcss').Config} */ export default { darkMode: ['class'], @@ -7,6 +9,9 @@ export default { ], theme: { extend: { + fontFamily: { + sans: ['Shabnam', 'Tahoma', 'Arial', ...defaultTheme.fontFamily.sans], + }, colors: { border: 'hsl(var(--border))', input: 'hsl(var(--input))', diff --git a/vite.config.ts b/vite.config.ts index 1e1cf54..eb3dbe6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -48,6 +48,29 @@ export default defineConfig({ alias: { '@': path.resolve(__dirname, './src') } + }, + server: { + proxy: { + '/api/n8n': { + target: 'https://nn.feep.ir', + changeOrigin: true, + rewrite: (path) => { + // تبدیل /api/n8n/webhook/... به /webhook/... + const rewritten = path.replace(/^\/api\/n8n/, ''); + console.log(`[Proxy] Rewriting: ${path} -> ${rewritten}`); + return rewritten; + }, + secure: true, + configure: (proxy, _options) => { + proxy.on('error', (err, _req, res) => { + console.log('[Proxy] Error:', err); + }); + proxy.on('proxyReq', (proxyReq, req, _res) => { + console.log('[Proxy] Request:', req.method, req.url, '->', proxyReq.path); + }); + } + } + } } });