Chatbot_highlight

This commit is contained in:
kenilkb 2026-07-22 16:59:45 +05:30
parent a127b5ffc4
commit 67acafd245
22 changed files with 3001 additions and 393 deletions

View File

@ -12,6 +12,7 @@
"@aws-sdk/s3-request-presigner": "^3.1083.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"axios": "^1.18.1",
"bcrypt": "^6.0.0",
"cheerio": "^1.2.0",
"cookie-parser": "^1.4.7",
@ -20,9 +21,12 @@
"express": "^4.19.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.3",
"mammoth": "^1.12.0",
"multer": "^2.2.0",
"nodemailer": "^9.0.3",
"pdf-parse": "^2.4.5",
"pg": "^8.22.0",
"xlsx": "^0.18.5",
"zod": "^4.4.3"
},
"devDependencies": {
@ -34,6 +38,7 @@
"@types/multer": "^2.2.0",
"@types/node": "^20.12.7",
"@types/nodemailer": "^8.0.1",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.20.0",
"nodemon": "^3.1.0",
"prisma": "^7.8.0",
@ -457,6 +462,190 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@prisma/adapter-pg": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz",
@ -1068,6 +1257,16 @@
"@types/node": "*"
}
},
"node_modules/@types/pdf-parse": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@ -1137,6 +1336,15 @@
"@types/node": "*"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -1176,6 +1384,50 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/agent-base/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/agent-base/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@ -1220,12 +1472,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@ -1236,6 +1503,18 @@
"node": ">= 6.0.0"
}
},
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -1246,6 +1525,26 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@ -1280,6 +1579,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@ -1475,6 +1780,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@ -1555,6 +1873,27 @@
"fsevents": "~2.3.2"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@ -1632,6 +1971,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@ -1649,6 +1994,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@ -1733,6 +2090,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@ -1779,6 +2145,12 @@
"node": ">=0.3.1"
}
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -1846,6 +2218,15 @@
"url": "https://dotenvx.com"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1985,6 +2366,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -2131,6 +2527,26 @@
"node": ">= 0.8"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -2148,6 +2564,22 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -2157,6 +2589,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -2322,6 +2763,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@ -2411,6 +2867,42 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -2430,6 +2922,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -2498,6 +2996,12 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -2550,6 +3054,48 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@ -2571,6 +3117,15 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -2620,6 +3175,17 @@
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
@ -2643,6 +3209,30 @@
"dev": true,
"license": "ISC"
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -2958,6 +3548,18 @@
"node": ">= 0.8"
}
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@ -3016,6 +3618,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -3039,6 +3650,38 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
@ -3256,6 +3899,12 @@
}
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
@ -3288,6 +3937,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@ -3542,6 +4200,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -3678,6 +4342,12 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@ -3688,6 +4358,18 @@
"node": ">= 0.6"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -3856,6 +4538,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
@ -3976,6 +4664,54 @@
"node": ">= 8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -13,6 +13,7 @@
"@aws-sdk/s3-request-presigner": "^3.1083.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"axios": "^1.18.1",
"bcrypt": "^6.0.0",
"cheerio": "^1.2.0",
"cookie-parser": "^1.4.7",
@ -21,9 +22,12 @@
"express": "^4.19.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.3",
"mammoth": "^1.12.0",
"multer": "^2.2.0",
"nodemailer": "^9.0.3",
"pdf-parse": "^2.4.5",
"pg": "^8.22.0",
"xlsx": "^0.18.5",
"zod": "^4.4.3"
},
"devDependencies": {
@ -35,6 +39,7 @@
"@types/multer": "^2.2.0",
"@types/node": "^20.12.7",
"@types/nodemailer": "^8.0.1",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.20.0",
"nodemon": "^3.1.0",
"prisma": "^7.8.0",

View File

@ -24,6 +24,19 @@ model Organization {
updatedAt DateTime @updatedAt
users User[]
sharedAssets SharedAsset[]
branches Branch[]
}
model Branch {
id String @id @default(uuid())
name String
code String? @unique
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
users User[]
sharedAssets SharedAsset[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
@ -36,6 +49,7 @@ model User {
inviteToken String? @unique
inviteTokenExp DateTime?
organizationId String?
branchId String?
onboardingStatus String @default("PENDING_ONBOARDING")
partnerGroup String?
assignedNdaId String?
@ -49,45 +63,50 @@ model User {
assignedNda LegalDocument? @relation("AssignedNda", fields: [assignedNdaId], references: [id], onDelete: SetNull)
assignedMsa LegalDocument? @relation("AssignedMsa", fields: [assignedMsaId], references: [id], onDelete: SetNull)
organization Organization? @relation(fields: [organizationId], references: [id])
branch Branch? @relation(fields: [branchId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
acceptances LegalAcceptance[]
auditLogs AuditLog[]
sharedAssets SharedAsset[]
downloadRequests DownloadRequest[]
chatSessions ChatSession[]
}
model Asset {
id String @id @default(uuid())
title String
type String
size Int
url String
version Int @default(1)
uploadedBy String
description String? @db.Text
categoryId String?
subcategory String?
tags String[]
downloadsCount Int @default(0)
githubUrl String?
status String @default("published")
isDownloadable Boolean @default(true)
thumbnailUrl String?
problemStatement String? @db.Text
solution String? @db.Text
contentType String?
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
verticals Vertical[] @relation("AssetVerticals")
techStacks TechStack[] @relation("AssetTechStacks")
engagementTypes EngagementType[] @relation("AssetEngagementTypes")
complianceStandards ComplianceStandard[] @relation("AssetComplianceStandards")
sharedWith SharedAsset[]
downloadRequests DownloadRequest[]
assetGroups AssetGroup[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
title String
type String
size Int
url String
version Int @default(1)
uploadedBy String
description String? @db.Text
categoryId String?
subcategory String?
tags String[]
downloadsCount Int @default(0)
githubUrl String?
status String @default("published")
isDownloadable Boolean @default(true)
thumbnailUrl String?
problemStatement String? @db.Text
solution String? @db.Text
contentType String?
includeInKnowledgeBase Boolean @default(true)
knowledgeScope String? @default("CATALOG_SHARED")
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
verticals Vertical[] @relation("AssetVerticals")
techStacks TechStack[] @relation("AssetTechStacks")
engagementTypes EngagementType[] @relation("AssetEngagementTypes")
complianceStandards ComplianceStandard[] @relation("AssetComplianceStandards")
sharedWith SharedAsset[]
downloadRequests DownloadRequest[]
assetGroups AssetGroup[]
embeddings AssetEmbedding[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Vertical {
@ -162,10 +181,12 @@ model SharedAsset {
assetId String
organizationId String
userId String?
branchId String?
createdAt DateTime @default(now())
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
branch Branch? @relation(fields: [branchId], references: [id], onDelete: Cascade)
@@unique([assetId, organizationId, userId])
}
@ -238,34 +259,69 @@ model AssetGroup {
}
model EcosystemOffering {
id String @id @default(uuid())
name String @unique
type String // "PRODUCT" | "SERVICE"
tagline String
description String @db.Text
benefits String[]
websiteUrl String
ctaText String @default("Visit Website")
logoIcon String @default("Globe")
logoUrl String?
mediaUrl String?
mediaType String? // "IMAGE" | "VIDEO" | "GIF"
orderIndex Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
name String @unique
type String // "PRODUCT" | "SERVICE"
tagline String
description String @db.Text
benefits String[]
websiteUrl String
ctaText String @default("Visit Website")
logoIcon String @default("Globe")
logoUrl String?
mediaUrl String?
mediaType String? // "IMAGE" | "VIDEO" | "GIF"
includeInKnowledgeBase Boolean @default(true)
orderIndex Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ContentShowcase {
id String @id @default(uuid())
title String
description String? @db.Text
youtubeUrl String
thumbnailUrl String?
redirectUrl String?
redirectLabel String? @default("Learn More")
orderIndex Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
title String
description String? @db.Text
youtubeUrl String
thumbnailUrl String?
redirectUrl String?
redirectLabel String? @default("Learn More")
includeInKnowledgeBase Boolean @default(true)
orderIndex Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AssetEmbedding {
id String @id @default(uuid())
assetId String
chunkIndex Int
chunkType String @default("TEXT") // TEXT, SLIDE, SHEET, PAGE, TRANSCRIPT
sourceMetadata Json? // OKF Standard JSON: { title, location: "Page 4" | "Slide 2" | "Sheet Data", okfCategory }
content String @db.Text
vector String // Vector array string representation for similarity search
createdAt DateTime @default(now())
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
@@index([assetId])
}
model ChatSession {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages ChatMessage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ChatMessage {
id String @id @default(uuid())
sessionId String
session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
sender String // "USER" | "ASSISTANT"
content String @db.Text
citations Json? // Array of OKF standardized source citations
createdAt DateTime @default(now())
}

View File

@ -18,6 +18,8 @@ import legalRoutes from './routes/legal.routes';
import ecosystemRoutes from './routes/ecosystem.routes';
import taxonomyRoutes from './routes/taxonomy.routes';
import notificationRoutes from './routes/notification.routes';
import chatRoutes from './routes/chat.routes';
import branchRoutes from './routes/branch.routes';
import { ensureBucketExists } from './utils/s3';
import { originStorage } from './utils/origin-storage';
@ -113,6 +115,8 @@ app.use('/api/v1/organizations', orgRoutes);
app.use('/api/v1/legal', legalRoutes);
app.use('/api/v1/ecosystem', ecosystemRoutes);
app.use('/api/v1/taxonomy', taxonomyRoutes);
app.use('/api/v1/chat', chatRoutes);
app.use('/api/v1', branchRoutes);
app.get('/api/v1/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });

View File

@ -0,0 +1,39 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import { BranchService } from '../services/branch.service';
const branchService = new BranchService();
export class BranchController {
public createBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { organizationId } = req.params;
const branch = await branchService.createBranch(organizationId, req.body);
res.status(201).json(branch);
} catch (err) { next(err); }
};
public getBranches = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { organizationId } = req.params;
const branches = await branchService.getOrganizationBranches(organizationId);
res.status(200).json(branches);
} catch (err) { next(err); }
};
public updateBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const branch = await branchService.updateBranch(id, req.body);
res.status(200).json(branch);
} catch (err) { next(err); }
};
public deleteBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await branchService.deleteBranch(id);
res.status(200).json({ message: 'Branch deleted successfully' });
} catch (err) { next(err); }
};
}

View File

@ -0,0 +1,107 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import { ChatService } from '../services/chat.service';
import prisma from '../utils/db';
const chatService = new ChatService();
export class ChatController {
public queryChat = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { prompt, sessionId } = req.body;
if (!prompt || !prompt.trim()) {
return res.status(400).json({ error: 'Prompt is required' });
}
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const result = await chatService.processPrompt(userId, prompt, sessionId);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
public getHistory = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const sessions = await prisma.chatSession.findMany({
where: { userId },
include: {
messages: {
take: 2,
orderBy: { createdAt: 'asc' }
}
},
orderBy: { updatedAt: 'desc' },
take: 20,
});
res.status(200).json(sessions);
} catch (err) {
next(err);
}
};
public createSession = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const session = await prisma.chatSession.create({
data: { userId }
});
res.status(201).json({ sessionId: session.id, session });
} catch (err) {
next(err);
}
};
public getSessionMessages = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
const { sessionId } = req.params;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const session = await prisma.chatSession.findFirst({
where: { id: sessionId, userId },
include: {
messages: {
orderBy: { createdAt: 'asc' }
}
}
});
if (!session) {
return res.status(404).json({ error: 'Chat session not found' });
}
res.status(200).json(session);
} catch (err) {
next(err);
}
};
public syncAssetKnowledge = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { assetId } = req.params;
await chatService.ingestAssetKnowledge(assetId);
res.status(200).json({ message: 'Asset knowledge re-indexed successfully' });
} catch (err) {
next(err);
}
};
}

View File

@ -0,0 +1,15 @@
import { Router } from 'express';
import { BranchController } from '../controllers/branch.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const branchController = new BranchController();
router.use(authenticate);
router.get('/organizations/:organizationId/branches', branchController.getBranches);
router.post('/organizations/:organizationId/branches', requireRole('ADMIN'), branchController.createBranch);
router.put('/branches/:id', requireRole('ADMIN'), branchController.updateBranch);
router.delete('/branches/:id', requireRole('ADMIN'), branchController.deleteBranch);
export default router;

View File

@ -0,0 +1,16 @@
import { Router } from 'express';
import { ChatController } from '../controllers/chat.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const chatController = new ChatController();
router.use(authenticate);
router.post('/query', chatController.queryChat);
router.get('/history', chatController.getHistory);
router.post('/sessions', chatController.createSession);
router.get('/sessions/:sessionId', chatController.getSessionMessages);
router.post('/sync/:assetId', requireRole('ADMIN'), chatController.syncAssetKnowledge);
export default router;

View File

@ -0,0 +1,38 @@
import prisma from '../utils/db';
export class BranchService {
public async createBranch(organizationId: string, data: { name: string; code?: string }) {
return prisma.branch.create({
data: {
name: data.name,
code: data.code || null,
organizationId,
}
});
}
public async getOrganizationBranches(organizationId: string) {
return prisma.branch.findMany({
where: { organizationId },
include: {
_count: {
select: { users: true, sharedAssets: true }
}
},
orderBy: { createdAt: 'desc' }
});
}
public async updateBranch(id: string, data: { name?: string; code?: string }) {
return prisma.branch.update({
where: { id },
data,
});
}
public async deleteBranch(id: string) {
return prisma.branch.delete({
where: { id }
});
}
}

View File

@ -0,0 +1,415 @@
import prisma from '../utils/db';
import axios from 'axios';
import { ExtractionService, OKFMetadata } from './extraction.service';
export interface CitationItem {
assetId: string;
title: string;
location: string;
type: string;
isRecommended?: boolean;
}
export class ChatService {
private extractionService = new ExtractionService();
private gatewayUrl = process.env.LLM_GATEWAY_URL || 'https://llm-devs.tech4biz.org/v1';
private apiKey = process.env.LLM_API_KEY || 'sk-TM-gxAudDVRTY-kq7_enEA';
private modelId = process.env.LLM_MODEL_ID || 'deepseek-chat';
/**
* Process a user chat prompt with RBAC-scoped, unified RAG retrieval across Assets, Showcase Reels, Ecosystem Offerings, and Legal Documents.
*/
public async processPrompt(userId: string, prompt: string, sessionId?: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
sharedAssets: true,
assignedNda: true,
assignedMsa: true,
}
});
if (!user) {
throw new Error('User session not found');
}
const partnerGroup = user.partnerGroup ? user.partnerGroup.trim().toLowerCase() : '';
const isClient = user.role === 'PARTNER_USER';
// 1. Determine accessible catalog asset IDs
let accessibleAssetIds: string[] = [];
if (user.role === 'ADMIN') {
const allAssets = await prisma.asset.findMany({ select: { id: true } });
accessibleAssetIds = allAssets.map(a => a.id);
} else {
const shared = await prisma.sharedAsset.findMany({
where: {
OR: [
{ organizationId: user.organizationId || '' },
{ userId: user.id },
user.branchId ? { branchId: user.branchId } : {},
]
},
select: { assetId: true }
});
const sharedIds = shared.map(s => s.assetId);
let groupIds: string[] = [];
if (partnerGroup) {
const groups = await prisma.assetGroup.findMany({
include: { assets: { select: { id: true } } }
});
const matchedGroup = groups.find(g => g.name.trim().toLowerCase() === partnerGroup);
if (matchedGroup) {
groupIds = matchedGroup.assets.map(a => a.id);
}
}
const publicAssets = await prisma.asset.findMany({
where: { includeInKnowledgeBase: true },
select: { id: true }
});
const publicIds = publicAssets.map(a => a.id);
accessibleAssetIds = Array.from(new Set([...sharedIds, ...groupIds, ...publicIds]));
}
// 2. Fetch Embeddings & Data Sources
let embeddingsCount = await prisma.assetEmbedding.count({
where: { assetId: { in: accessibleAssetIds } }
});
if (embeddingsCount === 0 && accessibleAssetIds.length > 0) {
await this.autoIndexCatalog(accessibleAssetIds);
}
const embeddings = await prisma.assetEmbedding.findMany({
where: {
assetId: { in: accessibleAssetIds },
asset: { includeInKnowledgeBase: true }
},
include: {
asset: {
select: {
id: true,
title: true,
type: true,
tags: true,
description: true,
problemStatement: true,
solution: true,
assetGroups: { select: { name: true } }
}
}
}
});
// 3. Fetch Featured Content (ContentShowcase) & Ecosystem Offerings
const showcaseItems = await prisma.contentShowcase.findMany({
where: { isActive: true }
});
const ecosystemOfferings = await prisma.ecosystemOffering.findMany({
where: { isActive: true }
});
// 4. Hybrid Search Engine with Normalized Fuzzy Term Matching
const normalizeStr = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, '');
const promptLower = prompt.toLowerCase().trim();
const promptNorm = normalizeStr(prompt);
const promptTerms = promptLower.split(/\s+/).filter(w => w.length > 2);
const promptVector = JSON.parse(this.extractionService.generateEmbedding(prompt));
const citationsMap = new Map<string, CitationItem>();
const contextLines: string[] = [];
// A. Match ContentShowcase items (e.g., "digitaltwin", "digital twin", "aura", "smart basket", "jonas blue")
showcaseItems.forEach((sc) => {
const fullText = (sc.title + ' ' + (sc.description || '')).toLowerCase();
const textNorm = normalizeStr(fullText);
let matchCount = 0;
promptTerms.forEach(term => {
const termNorm = normalizeStr(term);
if (termNorm && (fullText.includes(term) || textNorm.includes(termNorm))) {
matchCount++;
}
});
const isDirectMatch = (promptNorm.length >= 4 && textNorm.includes(promptNorm)) ||
promptLower.includes(sc.title.toLowerCase()) ||
sc.title.toLowerCase().includes(promptLower) ||
(promptNorm.includes('digitaltwin') && textNorm.includes('digitaltwin')) ||
(promptNorm.includes('aura') && textNorm.includes('aura')) ||
(promptNorm.includes('smartbasket') && textNorm.includes('smartbasket')) ||
(promptNorm.includes('jonasblue') && textNorm.includes('jonasblue'));
if (isDirectMatch || matchCount >= 1) {
citationsMap.set(sc.id, {
assetId: sc.id,
title: sc.title,
location: 'Featured Content Showcase',
type: 'case_study',
isRecommended: false,
});
contextLines.push(
`[Featured Content Reel] Title: "${sc.title}" | ID: "${sc.id}" | URL: "${sc.youtubeUrl}" | Description:\n${sc.description || 'Interactive product reel'}\n`
);
}
});
// B. Match EcosystemOffering items
ecosystemOfferings.forEach((eo) => {
const fullText = (eo.name + ' ' + eo.tagline + ' ' + eo.description).toLowerCase();
const textNorm = normalizeStr(fullText);
let matchCount = 0;
promptTerms.forEach(term => {
const termNorm = normalizeStr(term);
if (termNorm && (fullText.includes(term) || textNorm.includes(termNorm))) {
matchCount++;
}
});
const isDirectMatch = (promptNorm.length >= 4 && textNorm.includes(promptNorm)) ||
promptLower.includes(eo.name.toLowerCase()) ||
eo.name.toLowerCase().includes(promptLower);
if (isDirectMatch || matchCount >= 1) {
citationsMap.set(eo.id, {
assetId: eo.id,
title: eo.name,
location: 'Ecosystem Offering',
type: 'offering',
isRecommended: false,
});
contextLines.push(
`[Ecosystem Offering] Name: "${eo.name}" | Type: "${eo.type}" | Tagline: "${eo.tagline}" | Website: "${eo.websiteUrl}" | Description:\n${eo.description}\n`
);
}
});
// C. Hybrid Vector & Keyword Search on Catalog Assets
const isPureNavPrompt = promptLower.includes('theme') || promptLower.includes('dark mode') || promptLower.includes('light mode') || promptLower.includes('how to change') || promptLower.includes('appearance');
if (!isPureNavPrompt) {
const scoredChunks = embeddings.map(emb => {
let vectorScore = 0;
try {
const vec = JSON.parse(emb.vector) as number[];
vectorScore = promptVector.reduce((acc: number, val: number, i: number) => acc + val * (vec[i] || 0), 0);
} catch {
vectorScore = 0;
}
const chunkText = (emb.content + ' ' + emb.asset.title + ' ' + (emb.asset.description || '')).toLowerCase();
let keywordMatches = 0;
promptTerms.forEach(term => {
if (chunkText.includes(term)) keywordMatches += 1;
});
const hybridScore = vectorScore * 0.5 + (keywordMatches / Math.max(promptTerms.length, 1)) * 0.5;
return { chunk: emb, score: hybridScore, keywordMatches };
}).sort((a, b) => b.score - a.score);
const topAssetChunks = scoredChunks.filter(({ score, keywordMatches }) => score >= 0.15 || keywordMatches > 0).slice(0, 3);
topAssetChunks.forEach(({ chunk }, idx) => {
const meta = (chunk.sourceMetadata as unknown as OKFMetadata) || {
assetId: chunk.assetId,
assetTitle: chunk.asset.title,
assetType: chunk.asset.type,
location: `Segment ${chunk.chunkIndex + 1}`,
};
const isRecommended = partnerGroup
? chunk.asset.assetGroups.some(g => g.name.trim().toLowerCase() === partnerGroup)
: false;
citationsMap.set(chunk.assetId, {
assetId: chunk.assetId,
title: chunk.asset.title,
location: meta.location,
type: chunk.asset.type,
isRecommended,
});
contextLines.push(
`[Catalog Asset ${idx + 1}] Title: "${chunk.asset.title}" | ID: "${chunk.assetId}" | Location: "${meta.location}" | Details:\n${chunk.content}\n`
);
});
}
// D. Inject Assigned Legal Documents (NDA / MSA) if prompt asks about NDA / Legal
if (promptLower.includes('nda') || promptLower.includes('msa') || promptLower.includes('agreement') || promptLower.includes('legal') || promptLower.includes('contract')) {
const legals = await prisma.legalDocument.findMany();
legals.forEach((l: { type: string; version: string; content: string }, i: number) => {
contextLines.push(
`[Legal Document ${i + 1}] Type: "${l.type}" | Version: "${l.version}" | Content:\n${l.content.slice(0, 600)}...\n`
);
});
}
const contextBlock = contextLines.length > 0
? contextLines.join('\n---\n')
: 'No specific catalog or showcase snippets needed for this query.';
const portalGuideContext = isClient ? `
Exact Client Portal Layout & Step-by-Step Navigation Guide (${user.email}):
1. Legal Agreements & NDA/MSA Documents:
- Where to find it: Home Dashboard -> Click the "Legal Agreements" card (or navigate directly to /client/agreements).
- Note: There is NO "Legal Agreements" item in the left sidebar menu. Access it exclusively via the Home Dashboard card "Legal Agreements" or direct URL /client/agreements.
- Assigned NDA: ${user.assignedNda ? `Tech4Biz Standard NDA (v${user.assignedNda.version})` : 'Tech4Biz Standard NDA Agreement'} (Status: Active).
- Assigned MSA: ${user.assignedMsa ? `Tech4Biz Standard MSA (v${user.assignedMsa.version})` : 'Tech4Biz Standard MSA Agreement'} (Status: Active).
2. Asset Explorer (/client/assets):
- Where to find it: Left Sidebar menu -> Click "Asset Explorer" (or Home Dashboard -> "Asset Library" card).
- Features: Search shared catalog assets, filter by Taxonomy tags (Industry Verticals, Tech Stacks, Engagement Types, Compliance), view pitch decks, and request downloads.
3. Featured Content Showcase (/client/showcase):
- Where to find it: Left Sidebar menu -> Click "Featured Content".
- Features: Explore interactive video walk-throughs, YouTube/Instagram demo reels, and case studies (including Digital Twin, Diabetic Patient Time Travel, AuRa, Smart Basket, and AI reels).
4. Explore Ecosystem Offerings (/client/ecosystem):
- Where to find it: Left Sidebar menu -> Click "Explore More" (or Home Dashboard -> "Explore More" card).
- Features: Explore ecosystem products & partner integrations (CodeNuk, Cloudtopiaa).
5. Dark/Light Theme & Profile Settings:
- Theme Toggle: Click the Sun/Moon icon located in the bottom area of the left Sidebar under "Appearance".
- Profile Settings: Click "Profile Settings" in the left Sidebar menu to update password or default theme preferences.
` : `
Exact Admin Console Layout Guide for Administrator (${user.email}):
1. Partner Directory (/admin/partners): Left Sidebar -> "Partners".
2. Approvals Queue (/admin/approvals): Left Sidebar -> "Approvals Queue".
3. Legal Templates (/admin/legal): Left Sidebar -> "Legal Templates".
4. Catalog Management (/admin/assets): Left Sidebar -> "Manage Catalog".
5. Ecosystem Manager (/admin/ecosystem): Left Sidebar -> "Ecosystem Manager".
`;
const systemPrompt = `You are Tech4Biz AI Advisor, the official enterprise assistant for the Channel Partner Portal.
Your task is to provide accurate, professional, and clear answers to user questions.
Role & Context:
- User: ${user.email} (${isClient ? 'Client / Partner' : 'Portal Administrator'}).
- STIPULATION: NEVER tell a Client user to look for "Legal Agreements" in the left sidebar. State clearly: "Click the 'Legal Agreements' card on your Home Dashboard or go to /client/agreements".
- STIPULATION: NEVER mention admin console routes (/admin, Partner Directory, Legal Templates) when talking to a Client user.
Knowledge Guidelines:
- If the user asks about a specific Featured Content video or Case Study (such as "digital twin", "digitaltwin", "where is digital twin", "what is digital twin", "smart basket", "aura", "jonas blue"), identify the exact showcase item from Knowledge Context and summarize its title, description, and YouTube link.
- Answer catalog/product questions using the provided Catalog Asset Snippets.
- For navigation or portal help, use the exact Client Portal Layout & Step-by-Step Navigation Guide.
- Use bold headers and clear formatting in Markdown.`;
const messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Platform Navigation Guide:\n${portalGuideContext}\n\nKnowledge Context:\n${contextBlock}\n\nUser Question: ${prompt}` }
];
// 5. Call DeepSeek LLM Gateway API
let assistantReply = '';
try {
const response = await axios.post(`${this.gatewayUrl}/chat/completions`, {
model: this.modelId,
messages,
temperature: 0.2,
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
}
});
assistantReply = response.data.choices[0]?.message?.content || 'No response generated from AI Engine.';
} catch (err: any) {
console.error('DeepSeek Gateway Error:', err?.response?.data || err.message);
assistantReply = 'Here is the requested information from your shared catalog:\n\n' + contextBlock;
}
const citations = Array.from(citationsMap.values());
// 6. Save Chat Session & Messages
let activeSessionId = sessionId;
if (!activeSessionId) {
const session = await prisma.chatSession.create({
data: { userId }
});
activeSessionId = session.id;
}
await prisma.chatMessage.create({
data: {
sessionId: activeSessionId,
sender: 'USER',
content: prompt,
}
});
const botMessage = await prisma.chatMessage.create({
data: {
sessionId: activeSessionId,
sender: 'ASSISTANT',
content: assistantReply,
citations: citations as any,
}
});
return {
sessionId: activeSessionId,
message: botMessage,
citations,
};
}
/**
* Auto-indexes active catalog assets into AssetEmbedding for instant knowledge retrieval.
*/
public async autoIndexCatalog(assetIds: string[]) {
const assets = await prisma.asset.findMany({
where: {
id: { in: assetIds },
includeInKnowledgeBase: true,
}
});
for (const asset of assets) {
await this.ingestAssetKnowledge(asset.id);
}
}
/**
* Ingest or re-index an asset's text into vector embeddings.
*/
public async ingestAssetKnowledge(assetId: string) {
const asset = await prisma.asset.findUnique({
where: { id: assetId }
});
if (!asset) return;
await prisma.assetEmbedding.deleteMany({
where: { assetId }
});
if (!asset.includeInKnowledgeBase) return;
const okfChunks = await this.extractionService.extractOKFChunks(asset);
for (const chunk of okfChunks) {
const vector = this.extractionService.generateEmbedding(chunk.content);
await prisma.assetEmbedding.create({
data: {
assetId,
chunkIndex: chunk.chunkIndex,
chunkType: chunk.chunkType,
sourceMetadata: chunk.sourceMetadata as any,
content: chunk.content,
vector,
}
});
}
}
}

View File

@ -0,0 +1,285 @@
import fs from 'fs';
import path from 'path';
const pdfParse = require('pdf-parse');
import mammoth from 'mammoth';
import * as XLSX from 'xlsx';
import * as cheerio from 'cheerio';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { s3Client, BUCKET_NAME } from '../utils/s3';
import { ScraperService } from './scraper.service';
export interface OKFMetadata {
assetId: string;
assetTitle: string;
assetType: string;
location: string; // e.g. "Page 3", "Slide 5", "Sheet: Financials", "Transcript", "Section: Architecture"
okfCategory?: string;
isRecommended?: boolean;
}
export interface OKFChunk {
chunkIndex: number;
chunkType: 'PAGE' | 'SLIDE' | 'SHEET' | 'TRANSCRIPT' | 'TEXT';
content: string;
sourceMetadata: OKFMetadata;
}
export class ExtractionService {
private scraperService = new ScraperService();
/**
* Simple, fast deterministic embedding generator for local RAG vector search.
* Generates a 64-dimensional float vector normalized for cosine similarity.
*/
public generateEmbedding(text: string): string {
const dim = 64;
const vector = new Array(dim).fill(0);
const cleaned = text.toLowerCase().replace(/[^\w\s]/g, '');
const words = cleaned.split(/\s+/).filter(Boolean);
for (let i = 0; i < words.length; i++) {
const word = words[i];
for (let j = 0; j < word.length; j++) {
const charCode = word.charCodeAt(j);
const idx = (charCode + j * 7 + i * 3) % dim;
vector[idx] += 1;
}
}
// L2 Normalize
const magnitude = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0)) || 1;
const normalized = vector.map(v => Number((v / magnitude).toFixed(6)));
return JSON.stringify(normalized);
}
/**
* Extract text chunks from an asset binary or URL using the Open Knowledge Framework (OKF) standard format.
*/
public async extractOKFChunks(asset: {
id: string;
title: string;
type: string;
url: string;
problemStatement?: string | null;
solution?: string | null;
description?: string | null;
}): Promise<OKFChunk[]> {
const chunks: OKFChunk[] = [];
let chunkIndex = 0;
// 1. Ingest Problem Statement & Solution metadata if present
if (asset.problemStatement) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Problem Statement for ${asset.title}: ${asset.problemStatement}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Executive Overview: Problem Statement',
},
});
}
if (asset.solution) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Solution Overview for ${asset.title}: ${asset.solution}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Executive Overview: Proposed Solution',
},
});
}
// 2. URL Assets / YouTube / Web scraper handling
if (asset.type === 'url' || asset.type === 'case_study' || asset.url.startsWith('http')) {
try {
const scraped = await this.scraperService.scrapeCaseStudy(asset.url);
const fullContent = `${scraped.title}. ${scraped.problemStatement || ''} ${scraped.solution || ''}`;
// Split into 500-token chunks
const subChunks = this.splitText(fullContent, 500);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TRANSCRIPT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Web Link Content: Part ${i + 1}`,
},
});
});
} catch {
if (asset.description) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `${asset.title}: ${asset.description}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'URL Asset Metadata',
},
});
}
}
return chunks;
}
// 3. Binary S3 Assets (PDF, Word, Excel, PPTX, Text)
if (asset.url.startsWith('/uploads/')) {
const fileKey = asset.url.replace('/uploads/', '');
let buffer: Buffer;
try {
const response = await s3Client.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: fileKey,
}));
const byteArray = await response.Body?.transformToByteArray();
if (!byteArray) return chunks;
buffer = Buffer.from(byteArray);
} catch (err) {
console.error(`Failed to fetch S3 object ${fileKey} for extraction:`, err);
return chunks;
}
const ext = path.extname(fileKey).toLowerCase();
// A. PDF Files
if (ext === '.pdf' || asset.type.includes('pdf')) {
try {
const pdfData = await pdfParse(buffer);
// Split by pages if possible or chunk content
const subChunks = this.splitText(pdfData.text, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'PAGE',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `PDF Document: Page ${i + 1}`,
},
});
});
} catch (e) { console.error('PDF extraction error:', e); }
}
// B. Word Files (.docx, .doc)
else if (ext === '.docx' || ext === '.doc' || asset.type.includes('word')) {
try {
const docResult = await mammoth.extractRawText({ buffer });
const subChunks = this.splitText(docResult.value, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Word Document: Section ${i + 1}`,
},
});
});
} catch (e) { console.error('Docx extraction error:', e); }
}
// C. Excel & CSV Files (.xlsx, .xls, .csv)
else if (ext === '.xlsx' || ext === '.xls' || ext === '.csv' || asset.type.includes('spreadsheet') || asset.type.includes('csv')) {
try {
const workbook = XLSX.read(buffer, { type: 'buffer' });
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
const csvText = XLSX.utils.sheet_to_csv(sheet);
if (csvText && csvText.trim()) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'SHEET',
content: `Sheet [${sheetName}] Data:\n${csvText.slice(0, 1500)}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Spreadsheet Sheet: ${sheetName}`,
},
});
}
});
} catch (e) { console.error('Excel extraction error:', e); }
}
// D. PowerPoint Presentations (.pptx, .ppt)
else if (ext === '.pptx' || ext === '.ppt' || asset.type.includes('presentation')) {
// Simple text extraction from raw slide XML/strings
const rawString = buffer.toString('utf-8').replace(/[^\x20-\x7E]/g, ' ');
const subChunks = this.splitText(rawString, 600);
subChunks.forEach((text, i) => {
if (text.length > 50) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'SLIDE',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `PowerPoint Presentation: Slide Section ${i + 1}`,
},
});
}
});
}
// E. Plain Text / Markdown
else {
const textContent = buffer.toString('utf-8');
const subChunks = this.splitText(textContent, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Document Text: Segment ${i + 1}`,
},
});
});
}
}
return chunks;
}
private splitText(text: string, maxLen: number): string[] {
const cleaned = text.replace(/\s+/g, ' ').trim();
if (!cleaned) return [];
const words = cleaned.split(' ');
const chunks: string[] = [];
let current = '';
for (const word of words) {
if ((current + ' ' + word).length > maxLen) {
if (current) chunks.push(current.trim());
current = word;
} else {
current += (current ? ' ' : '') + word;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
}

View File

@ -17,6 +17,7 @@ import {
Globe
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { ChatDrawer } from "../../components/ui/ChatDrawer";
export const AdminLayout: React.FC = () => {
const { user, logout } = useAuthStore();
@ -283,6 +284,7 @@ export const AdminLayout: React.FC = () => {
</div>
</footer>
</main>
<ChatDrawer />
</div>
);
};

View File

@ -4,6 +4,7 @@ import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from '../../hooks/use-auth';
import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings, Globe, Video } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChatDrawer } from '../../components/ui/ChatDrawer';
export const ClientLayout: React.FC = () => {
const { user, logout } = useAuthStore();
@ -394,6 +395,8 @@ export const ClientLayout: React.FC = () => {
</div>
)}
</AnimatePresence>
{/* AI Assistant Chat Drawer */}
<ChatDrawer />
</div>
);
};

View File

@ -0,0 +1,569 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Sparkles, X, Send, Bot, User as UserIcon, FileText, Minimize2, Maximize2, RefreshCw, Compass, Eye, Plus, History, MessageSquare } from 'lucide-react';
import { axiosInstance } from '../../services/axios';
import { useAuthStore } from '../../hooks/use-auth';
import { AssetViewerModal } from '../../features/assets/components/AssetViewerModal';
import MarkdownViewer from './MarkdownViewer';
import type { Asset } from '../../types/assets';
export interface CitationItem {
assetId: string;
title: string;
location: string;
type: string;
isRecommended?: boolean;
}
export interface ChatMessage {
id?: string;
sender: 'USER' | 'ASSISTANT';
content: string;
citations?: CitationItem[];
createdAt?: string;
}
export interface ChatSessionItem {
id: string;
createdAt: string;
updatedAt: string;
messages?: ChatMessage[];
}
export const ChatDrawer: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuthStore();
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [drawerSize, setDrawerSize] = useState<'standard' | 'wide' | 'maximized'>('standard');
const [showHistory, setShowHistory] = useState(false);
const [prompt, setPrompt] = useState('');
const [loading, setLoading] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [pastSessions, setPastSessions] = useState<ChatSessionItem[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{
sender: 'ASSISTANT',
content: 'Hello! I am your **Tech4Biz AI Advisor** powered by DeepSeek. Ask me anything about our enterprise catalog assets, compliance standards, NDA agreements, or ecosystem offerings.',
}
]);
const [activePreviewAsset, setActivePreviewAsset] = useState<Asset | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isOpen]);
// Load user session history when history drawer is opened
const fetchSessionHistory = async () => {
setLoadingHistory(true);
try {
const res = await axiosInstance.get('/chat/history');
setPastSessions(res.data || []);
} catch (err) {
console.error('Failed to fetch chat history:', err);
} finally {
setLoadingHistory(false);
}
};
const handleToggleHistory = () => {
if (!showHistory) {
fetchSessionHistory();
}
setShowHistory(!showHistory);
};
const handleStartNewChat = async () => {
setLoading(true);
try {
const res = await axiosInstance.post('/chat/sessions');
setSessionId(res.data.sessionId);
setMessages([
{
sender: 'ASSISTANT',
content: 'Started a new session! I am your **Tech4Biz AI Advisor**. Ask me anything about your catalog documents, NDA agreements, or platform features.',
}
]);
setShowHistory(false);
} catch (err) {
console.error('Failed to create new session', err);
} finally {
setLoading(false);
}
};
const handleLoadSession = async (sessId: string) => {
setLoading(true);
try {
const res = await axiosInstance.get(`/chat/sessions/${sessId}`);
setSessionId(sessId);
const loadedMessages = (res.data.messages || []).map((m: any) => ({
id: m.id,
sender: m.sender,
content: m.content,
citations: m.citations || [],
createdAt: m.createdAt,
}));
setMessages(loadedMessages.length > 0 ? loadedMessages : [
{
sender: 'ASSISTANT',
content: 'Loaded past session. Ask your follow up questions below!',
}
]);
setShowHistory(false);
} catch (err) {
console.error('Failed to load session messages', err);
} finally {
setLoading(false);
}
};
const handleSend = async (customPrompt?: string) => {
const textToSend = customPrompt || prompt;
if (!textToSend.trim() || loading) return;
const userMsg: ChatMessage = {
sender: 'USER',
content: textToSend,
createdAt: new Date().toISOString(),
};
setMessages(prev => [...prev, userMsg]);
if (!customPrompt) setPrompt('');
setLoading(true);
try {
const response = await axiosInstance.post('/chat/query', {
prompt: textToSend,
sessionId,
});
const { sessionId: newSessionId, message } = response.data;
setSessionId(newSessionId);
setMessages(prev => [...prev, {
id: message.id,
sender: 'ASSISTANT',
content: message.content,
citations: message.citations || [],
createdAt: message.createdAt,
}]);
} catch (err: any) {
console.error('Chat API Error:', err);
setMessages(prev => [...prev, {
sender: 'ASSISTANT',
content: 'Sorry, I encountered an issue connecting to the AI Gateway. Please try again.',
}]);
} finally {
setLoading(false);
}
};
const openAssetPreview = async (cite: CitationItem) => {
try {
if (cite.location === 'Featured Content Showcase') {
const showcaseRes = await axiosInstance.get('/showcase');
const showcaseItem = showcaseRes.data.find((item: any) => item.id === cite.assetId || item.title === cite.title);
if (showcaseItem) {
setActivePreviewAsset({
id: showcaseItem.id,
title: showcaseItem.title,
type: 'case_study',
url: showcaseItem.youtubeUrl || showcaseItem.mediaUrl || 'https://www.youtube.com/watch?v=svJDGYlQLYw',
description: showcaseItem.description || `Verified Showcase Reel: ${showcaseItem.title}`,
isDownloadable: false,
createdAt: showcaseItem.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
categoryId: null,
organizationId: null,
} as any);
return;
}
}
const res = await axiosInstance.get(`/assets/${cite.assetId}`);
setActivePreviewAsset(res.data.asset);
} catch (err) {
console.error('Failed to fetch asset from API, constructing fallback preview:', err);
let videoUrl = '';
if (cite.title.toLowerCase().includes('digital twin') || cite.title.toLowerCase().includes('digitaltwin')) {
videoUrl = 'https://www.youtube.com/watch?v=svJDGYlQLYw';
} else if (cite.title.toLowerCase().includes('surveillance')) {
videoUrl = 'https://www.youtube.com/watch?v=svJDGYlQLYw';
}
setActivePreviewAsset({
id: cite.assetId,
title: cite.title,
type: cite.type || 'case_study',
url: videoUrl,
description: `Verified Document Source: ${cite.title} (${cite.location})`,
isDownloadable: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
categoryId: null,
organizationId: null,
} as any);
}
};
const handleGoToAsset = (cite: CitationItem) => {
setIsMinimized(true);
const typeLower = (cite.type || '').toLowerCase();
const titleLower = (cite.title || '').toLowerCase();
const isCurrentAdmin = location.pathname.startsWith('/admin') || user?.role === 'ADMIN';
if (typeLower.includes('case_study') || titleLower.includes('case study') || typeLower.includes('video')) {
navigate(isCurrentAdmin ? '/admin/showcase' : '/client/showcase', { state: { highlightAssetId: cite.assetId } });
} else if (titleLower.includes('nda') || titleLower.includes('msa') || titleLower.includes('agreement') || typeLower.includes('legal')) {
navigate(isCurrentAdmin ? '/admin/legal' : '/client/agreements', { state: { highlightAssetId: cite.assetId } });
} else if (typeLower.includes('ecosystem') || titleLower.includes('offering')) {
navigate(isCurrentAdmin ? '/admin/ecosystem' : '/client/ecosystem', { state: { highlightAssetId: cite.assetId } });
} else {
navigate(isCurrentAdmin ? '/admin/assets' : '/client/assets', { state: { highlightAssetId: cite.assetId } });
}
};
const cycleSize = () => {
if (drawerSize === 'standard') setDrawerSize('wide');
else if (drawerSize === 'wide') setDrawerSize('maximized');
else setDrawerSize('standard');
};
const getDimensions = () => {
if (isMinimized) return { width: '380px', height: '56px' };
if (drawerSize === 'maximized') return { width: '860px', height: '82vh' };
if (drawerSize === 'wide') return { width: '640px', height: '680px' };
return { width: '440px', height: '580px' };
};
const dimensions = getDimensions();
return (
<>
{/* Floating Trigger Pill */}
{!isOpen && (
<motion.button
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => { setIsOpen(true); setIsMinimized(false); }}
className="fixed bottom-6 right-6 z-50 flex items-center gap-2.5 px-4 py-3 bg-slate-900 text-white rounded-full shadow-2xl border border-slate-700/80 hover:border-emerald-500/80 transition-all cursor-pointer group"
>
<div className="p-1.5 rounded-full bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 group-hover:scale-110 transition-transform">
<Sparkles className="w-4 h-4" />
</div>
<span className="text-xs font-extrabold tracking-wide font-sans">
AI Advisor
</span>
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
</motion.button>
)}
{/* Floating Glassmorphic Drawer Window */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
height: dimensions.height,
width: dimensions.width
}}
exit={{ opacity: 0, y: 40, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
className="fixed bottom-6 right-6 z-50 bg-slate-950/95 backdrop-blur-2xl border border-slate-800 rounded-3xl shadow-2xl flex flex-col overflow-hidden text-slate-100 font-sans"
>
{/* Window Header (Clean Layout) */}
<div className="px-4 py-3 bg-slate-900/90 border-b border-slate-800 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
<Bot className="w-4 h-4" />
</div>
<div>
<h3 className="font-bold text-xs text-white">Tech4Biz AI Advisor</h3>
<p className="text-[10px] text-slate-400">Powered by DeepSeek LLM Engine</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{/* + New Chat Button */}
{!isMinimized && (
<button
type="button"
onClick={handleStartNewChat}
title="Start New Chat Session"
className="p-1.5 rounded-lg bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30 border border-emerald-500/40 text-[10px] font-bold flex items-center gap-1 transition-all cursor-pointer mr-1"
>
<Plus className="w-3.5 h-3.5" />
<span className="hidden sm:inline">New Chat</span>
</button>
)}
{/* Session History Toggle Button */}
{!isMinimized && (
<button
type="button"
onClick={handleToggleHistory}
title="Past Chat Sessions"
className={`p-1.5 rounded-lg border transition-colors cursor-pointer ${
showHistory
? 'bg-emerald-500/30 text-emerald-300 border-emerald-500/50'
: 'text-slate-400 hover:text-white hover:bg-slate-800 border-slate-800'
}`}
>
<History className="w-3.5 h-3.5" />
</button>
)}
{/* Resize Drawer Cycle Button */}
{!isMinimized && (
<button
onClick={cycleSize}
title={`Current size: ${drawerSize.toUpperCase()}. Click to toggle window width.`}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
{drawerSize === 'maximized' ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</button>
)}
{/* Minimize Button */}
<button
onClick={() => setIsMinimized(!isMinimized)}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
{isMinimized ? <Maximize2 className="w-3.5 h-3.5" /> : <Minimize2 className="w-3.5 h-3.5" />}
</button>
{/* Close Button */}
<button
onClick={() => setIsOpen(false)}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Past Session History Drawer Side Panel */}
{showHistory && !isMinimized && (
<div className="bg-slate-900 border-b border-slate-800 p-3 max-h-48 overflow-y-auto space-y-2 shrink-0">
<div className="flex items-center justify-between text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">
<span>Saved Past Sessions</span>
<span>{pastSessions.length} sessions</span>
</div>
{loadingHistory ? (
<div className="text-center p-3 text-xs text-slate-400 italic">
Loading chat history...
</div>
) : pastSessions.length === 0 ? (
<div className="text-center p-3 text-xs text-slate-500 italic">
No past sessions found. Start a conversation!
</div>
) : (
pastSessions.map((sess) => {
const firstUserMsg = sess.messages?.find(m => m.sender === 'USER')?.content || 'Chat Session';
const isActive = sess.id === sessionId;
return (
<div
key={sess.id}
onClick={() => handleLoadSession(sess.id)}
className={`p-2 rounded-xl border text-xs cursor-pointer flex items-center justify-between transition-all ${
isActive
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40'
: 'bg-slate-950/70 hover:bg-slate-800 text-slate-300 border-slate-800'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span className="truncate text-[11px] font-medium">{firstUserMsg}</span>
</div>
<span className="text-[9px] font-mono text-slate-500 shrink-0 ml-2">
{new Date(sess.updatedAt || sess.createdAt).toLocaleDateString()}
</span>
</div>
);
})
)}
</div>
)}
{/* Content Body (Visible when not minimized) */}
{!isMinimized && (
<>
{/* Messages Stream */}
<div className="flex-1 p-4 overflow-y-auto space-y-4 text-xs">
{messages.map((msg, i) => (
<div
key={i}
className={`flex gap-2.5 ${msg.sender === 'USER' ? 'flex-row-reverse' : 'flex-row'}`}
>
<div className={`p-1.5 rounded-xl shrink-0 h-fit ${
msg.sender === 'USER'
? 'bg-emerald-600 text-white'
: 'bg-slate-900 text-emerald-400 border border-slate-700'
}`}>
{msg.sender === 'USER' ? <UserIcon className="w-3.5 h-3.5" /> : <Bot className="w-3.5 h-3.5" />}
</div>
<div className={`space-y-2 max-w-[85%] ${msg.sender === 'USER' ? 'text-right' : 'text-left'}`}>
<div className={`p-3.5 rounded-2xl leading-relaxed ${
msg.sender === 'USER'
? 'bg-emerald-600 text-white font-medium rounded-tr-none whitespace-pre-wrap'
: 'bg-slate-900 text-slate-100 border border-slate-800 rounded-tl-none shadow-md'
}`}>
{msg.sender === 'USER' ? (
msg.content
) : (
<MarkdownViewer markdown={msg.content} variant="dark" />
)}
</div>
{/* Citation Cards with Direct Preview & Location Redirection */}
{msg.citations && msg.citations.length > 0 && (
<div className="space-y-1.5 pt-1">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-left">
Verified Knowledge Sources ({msg.citations.length}):
</p>
<div className="space-y-2">
{msg.citations.map((cite) => (
<div
key={cite.assetId}
className="group p-2.5 rounded-xl bg-slate-900/90 border border-slate-800 hover:border-emerald-500/50 transition-all text-left flex flex-col gap-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-emerald-400 shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-[11px] text-white group-hover:text-emerald-300 truncate">
{cite.title}
</span>
{cite.isRecommended && (
<span className="px-1.5 py-0.2 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30 text-[8px] font-extrabold uppercase shrink-0">
Recommended
</span>
)}
</div>
<span className="text-[9px] text-slate-400 block font-mono">
{cite.location}
</span>
</div>
</div>
</div>
{/* Action Buttons: Preview & Go to Location */}
<div className="flex items-center gap-2 pt-1 border-t border-slate-800">
<button
type="button"
onClick={() => openAssetPreview(cite)}
className="flex-1 py-1 px-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-[10px] font-bold flex items-center justify-center gap-1 transition-colors cursor-pointer"
>
<Eye className="w-3 h-3 text-slate-400" />
<span>Quick Preview</span>
</button>
<button
type="button"
onClick={() => handleGoToAsset(cite)}
className="flex-1 py-1 px-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 rounded-lg text-[10px] font-bold flex items-center justify-center gap-1 transition-colors cursor-pointer"
>
<Compass className="w-3 h-3 text-emerald-400" />
<span>Go to Location</span>
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
))}
{loading && (
<div className="flex items-center gap-2.5 text-slate-400 text-xs italic p-2">
<RefreshCw className="w-3.5 h-3.5 animate-spin text-emerald-400" />
Analyzing catalog knowledge with DeepSeek...
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Quick Suggestion Chips */}
<div className="px-3 py-1.5 bg-slate-900/60 border-t border-slate-800 flex gap-2 overflow-x-auto shrink-0 scrollbar-none">
<button
onClick={() => handleSend("Is there any NDA Agreement available here?")}
className="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-full text-[10px] font-semibold whitespace-nowrap transition-colors flex items-center gap-1 cursor-pointer"
>
📄 Check NDA Agreements
</button>
<button
onClick={() => handleSend("Tell me about our AI and Cybersecurity offerings")}
className="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-full text-[10px] font-semibold whitespace-nowrap transition-colors flex items-center gap-1 cursor-pointer"
>
🔍 Cybersecurity Assets
</button>
<button
onClick={() => handleSend("How to request document downloads or change themes?")}
className="px-2.5 py-1 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 border border-amber-500/30 rounded-full text-[10px] font-semibold whitespace-nowrap transition-colors flex items-center gap-1 cursor-pointer"
>
Portal Guide & Help
</button>
</div>
{/* Input Controls */}
<div className="p-3 bg-slate-900 border-t border-slate-800 flex gap-2 items-center shrink-0">
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Ask about assets, NDA agreements, or portal features..."
disabled={loading}
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500/60 transition-all font-sans"
/>
<button
onClick={() => handleSend()}
disabled={loading || !prompt.trim()}
className="p-2 bg-emerald-500 hover:bg-emerald-400 disabled:opacity-40 text-slate-950 rounded-xl transition-all font-bold cursor-pointer disabled:cursor-not-allowed"
>
<Send className="w-4 h-4" />
</button>
</div>
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* Asset Preview Modal Triggered from Citation Click */}
{activePreviewAsset && (
<AssetViewerModal
asset={activePreviewAsset}
isOpen={!!activePreviewAsset}
user={user}
onDownload={() => {}}
onClose={() => setActivePreviewAsset(null)}
/>
)}
</>
);
};

View File

@ -1,404 +1,414 @@
import React from "react";
interface MarkdownBlock {
type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "code" | "blockquote" | "ul" | "ol" | "hr" | "p";
content: string;
language?: string;
items?: string[];
type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "code" | "blockquote" | "ul" | "ol" | "hr" | "p";
content: string;
language?: string;
items?: string[];
}
interface ParseState {
blocks: MarkdownBlock[];
currentCodeBlock: { language: string; lines: string[] } | null;
currentList: { type: "ul" | "ol"; items: string[] } | null;
currentParagraphLines: string[];
blocks: MarkdownBlock[];
currentCodeBlock: { language: string; lines: string[] } | null;
currentList: { type: "ul" | "ol"; items: string[] } | null;
currentParagraphLines: string[];
}
interface InlineToken {
type: "text" | "bold" | "italic" | "code" | "link";
text: string;
url?: string;
type: "text" | "bold" | "italic" | "code" | "link";
text: string;
url?: string;
}
const flushParagraph = (state: ParseState): void => {
if (state.currentParagraphLines.length > 0) {
state.blocks.push({
type: "p",
content: state.currentParagraphLines.join(" ").trim(),
});
state.currentParagraphLines = [];
}
if (state.currentParagraphLines.length > 0) {
state.blocks.push({
type: "p",
content: state.currentParagraphLines.join(" ").trim(),
});
state.currentParagraphLines = [];
}
};
const flushList = (state: ParseState): void => {
if (state.currentList) {
state.blocks.push({
type: state.currentList.type,
content: "",
items: state.currentList.items,
});
state.currentList = null;
}
if (state.currentList) {
state.blocks.push({
type: state.currentList.type,
content: "",
items: state.currentList.items,
});
state.currentList = null;
}
};
const handleCodeBlock = (trimmed: string, state: ParseState): boolean => {
if (trimmed.startsWith("```")) {
if (state.currentCodeBlock) {
state.blocks.push({
type: "code",
content: state.currentCodeBlock.lines.join("\n"),
language: state.currentCodeBlock.language,
});
state.currentCodeBlock = null;
} else {
flushParagraph(state);
flushList(state);
const language = trimmed.slice(3).trim();
state.currentCodeBlock = { language, lines: [] };
}
return true;
if (trimmed.startsWith("```")) {
if (state.currentCodeBlock) {
state.blocks.push({
type: "code",
content: state.currentCodeBlock.lines.join("\n"),
language: state.currentCodeBlock.language,
});
state.currentCodeBlock = null;
} else {
flushParagraph(state);
flushList(state);
const language = trimmed.slice(3).trim();
state.currentCodeBlock = { language, lines: [] };
}
return false;
return true;
}
return false;
};
const handleHeading = (line: string, state: ParseState): boolean => {
const match = line.match(/^(#{1,6})\s+(.*)$/);
if (match) {
flushParagraph(state);
flushList(state);
const level = match[1].length;
state.blocks.push({
type: `h${level}` as any,
content: match[2].trim(),
});
return true;
}
return false;
const match = line.match(/^(#{1,6})\s+(.*)$/);
if (match) {
flushParagraph(state);
flushList(state);
const level = match[1].length;
state.blocks.push({
type: `h${level}` as any,
content: match[2].trim(),
});
return true;
}
return false;
};
const handleBlockquote = (trimmed: string, state: ParseState): boolean => {
if (trimmed.startsWith(">")) {
flushParagraph(state);
flushList(state);
state.blocks.push({
type: "blockquote",
content: trimmed.replace(/^>\s*/, ""),
});
return true;
}
return false;
if (trimmed.startsWith(">")) {
flushParagraph(state);
flushList(state);
state.blocks.push({
type: "blockquote",
content: trimmed.replace(/^>\s*/, ""),
});
return true;
}
return false;
};
const handleLists = (line: string, state: ParseState): boolean => {
const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)$/);
if (ulMatch) {
flushParagraph(state);
const content = ulMatch[3].trim();
if (state.currentList && state.currentList.type === "ul") {
state.currentList.items.push(content);
} else {
flushList(state);
state.currentList = { type: "ul", items: [content] };
}
return true;
const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)$/);
if (ulMatch) {
flushParagraph(state);
const content = ulMatch[3].trim();
if (state.currentList && state.currentList.type === "ul") {
state.currentList.items.push(content);
} else {
flushList(state);
state.currentList = { type: "ul", items: [content] };
}
return true;
}
const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
if (olMatch) {
flushParagraph(state);
const content = olMatch[3].trim();
if (state.currentList && state.currentList.type === "ol") {
state.currentList.items.push(content);
} else {
flushList(state);
state.currentList = { type: "ol", items: [content] };
}
return true;
const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
if (olMatch) {
flushParagraph(state);
const content = olMatch[3].trim();
if (state.currentList && state.currentList.type === "ol") {
state.currentList.items.push(content);
} else {
flushList(state);
state.currentList = { type: "ol", items: [content] };
}
return true;
}
return false;
return false;
};
const handleLine = (line: string, state: ParseState): void => {
const trimmed = line.trim();
const trimmed = line.trim();
if (handleCodeBlock(trimmed, state)) {
return;
}
if (handleCodeBlock(trimmed, state)) {
return;
}
if (state.currentCodeBlock) {
state.currentCodeBlock.lines.push(line);
return;
}
if (trimmed === "---" || trimmed === "***" || trimmed === "___") {
flushParagraph(state);
flushList(state);
state.blocks.push({ type: "hr", content: "" });
return;
}
if (handleHeading(line, state) || handleBlockquote(trimmed, state)) {
return;
}
if (handleLists(line, state)) {
return;
}
if (trimmed === "") {
flushParagraph(state);
flushList(state);
return;
}
if (state.currentCodeBlock) {
state.currentCodeBlock.lines.push(line);
return;
}
if (trimmed === "---" || trimmed === "***" || trimmed === "___") {
flushParagraph(state);
flushList(state);
state.currentParagraphLines.push(line);
state.blocks.push({ type: "hr", content: "" });
return;
}
if (handleHeading(line, state) || handleBlockquote(trimmed, state)) {
return;
}
if (handleLists(line, state)) {
return;
}
if (trimmed === "") {
flushParagraph(state);
flushList(state);
return;
}
flushList(state);
state.currentParagraphLines.push(line);
};
export const parseMarkdown = (text: string): MarkdownBlock[] => {
const lines = text.split("\n");
const state: ParseState = {
blocks: [],
currentCodeBlock: null,
currentList: null,
currentParagraphLines: [],
};
const lines = text.split("\n");
const state: ParseState = {
blocks: [],
currentCodeBlock: null,
currentList: null,
currentParagraphLines: [],
};
for (let i = 0; i < lines.length; i++) {
handleLine(lines[i], state);
}
for (let i = 0; i < lines.length; i++) {
handleLine(lines[i], state);
}
flushParagraph(state);
flushList(state);
flushParagraph(state);
flushList(state);
return state.blocks;
return state.blocks;
};
const parseInlineLinks = (tokens: InlineToken[]): InlineToken[] => {
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "link", text: match[1], url: match[2] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "link", text: match[1], url: match[2] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
return updated;
}
return updated;
};
const parseInlineUrls = (tokens: InlineToken[]): InlineToken[] => {
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /(https?:\/\/[^\s)]+)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "link", text: match[1], url: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /(https?:\/\/[^\s)]+)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "link", text: match[1], url: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
return updated;
}
return updated;
};
const parseInlineBold = (tokens: InlineToken[]): InlineToken[] => {
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /\*\*([^*]+)\*\*/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "bold", text: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /\*\*([^*]+)\*\*/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "bold", text: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
return updated;
}
return updated;
};
const parseInlineCode = (tokens: InlineToken[]): InlineToken[] => {
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /`([^`]+)`/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "code", text: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /`([^`]+)`/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "code", text: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
return updated;
}
return updated;
};
const parseInlineItalic = (tokens: InlineToken[]): InlineToken[] => {
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /\*([^*]+)\*/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "italic", text: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /\*([^*]+)\*/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "italic", text: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
return updated;
}
return updated;
};
export const renderInlineText = (text: string): React.ReactNode[] => {
if (!text) return [];
export const renderInlineText = (text: string, isDark: boolean = false): React.ReactNode[] => {
if (!text) return [];
let tokens: InlineToken[] = [{ type: "text", text }];
tokens = parseInlineLinks(tokens);
tokens = parseInlineUrls(tokens);
tokens = parseInlineBold(tokens);
tokens = parseInlineCode(tokens);
tokens = parseInlineItalic(tokens);
let tokens: InlineToken[] = [{ type: "text", text }];
tokens = parseInlineLinks(tokens);
tokens = parseInlineUrls(tokens);
tokens = parseInlineBold(tokens);
tokens = parseInlineCode(tokens);
tokens = parseInlineItalic(tokens);
return tokens.map((part, idx) => {
switch (part.type) {
case "bold":
return <strong key={idx} className="font-extrabold text-ink-900">{part.text}</strong>;
case "italic":
return <em key={idx} className="italic text-ink-800">{part.text}</em>;
case "code":
return <code key={idx} className="bg-ink-100 border border-ink-200 rounded px-1.5 py-0.5 text-xs font-mono text-emerald-700">{part.text}</code>;
case "link":
return (
<a
key={idx}
href={part.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-800 font-semibold underline break-all inline-flex items-center gap-0.5"
>
{part.text}
</a>
);
default:
return <span key={idx}>{part.text}</span>;
}
});
};
const renderListBlock = (block: MarkdownBlock, key: string): React.ReactNode => {
const Component = block.type === "ul" ? "ul" : "ol";
const listClass = block.type === "ul"
? "list-disc pl-6 space-y-1.5 my-2.5 text-sm text-ink-800"
: "list-decimal pl-6 space-y-1.5 my-2.5 text-sm text-ink-800";
return (
<Component key={key} className={listClass}>
{block.items?.map((item, idx) => (
<li key={idx}>{renderInlineText(item)}</li>
))}
</Component>
);
};
const renderHeadingBlock = (block: MarkdownBlock, key: string): React.ReactNode => {
const level = block.type.slice(1);
const classes: Record<string, string> = {
"1": "text-2xl sm:text-3xl font-extrabold text-ink-900 mt-6 mb-3 border-b border-ink-150 pb-2",
"2": "text-xl sm:text-2xl font-extrabold text-ink-900 mt-5 mb-2.5",
"3": "text-lg sm:text-xl font-bold text-ink-900 mt-4 mb-2",
"4": "text-base sm:text-lg font-bold text-ink-800 mt-3 mb-1.5",
"5": "text-sm sm:text-base font-bold text-ink-800 mt-2.5 mb-1.5",
"6": "text-xs sm:text-sm font-bold text-ink-700 mt-2 mb-1",
};
const Component = block.type as any;
return (
<Component key={key} className={classes[level] || ""}>
{renderInlineText(block.content)}
</Component>
);
};
const renderBlock = (block: MarkdownBlock, index: number): React.ReactNode => {
const key = `${block.type}-${index}`;
if (block.type.startsWith("h") && block.type.length === 2 && block.type !== "hr") {
return renderHeadingBlock(block, key);
}
switch (block.type) {
case "blockquote":
return (
<blockquote key={key} className="border-l-4 border-primary-500 pl-4 py-1 italic bg-primary-50/20 text-ink-700 my-3 rounded-r-lg">
{renderInlineText(block.content)}
</blockquote>
);
case "ul":
case "ol":
return renderListBlock(block, key);
case "code":
return (
<div key={key} className="my-4 rounded-xl border border-ink-200 bg-ink-900 text-ink-100 p-4 overflow-x-auto shadow-inner relative group select-text">
{block.language && (
<div className="absolute right-3 top-3 text-[10px] uppercase font-bold text-ink-400 select-none">
{block.language}
</div>
)}
<pre className="font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto">
{block.content}
</pre>
</div>
);
case "hr":
return <hr key={key} className="border-t border-ink-200 my-6" />;
default:
return <p key={key} className="text-sm text-ink-800 leading-relaxed my-2">{renderInlineText(block.content)}</p>;
return tokens.map((part, idx) => {
switch (part.type) {
case "bold":
return <strong key={idx} className={isDark ? "font-extrabold text-white" : "font-extrabold text-ink-900"}>{part.text}</strong>;
case "italic":
return <em key={idx} className={isDark ? "italic text-slate-200" : "italic text-ink-800"}>{part.text}</em>;
case "code":
return (
<code key={idx} className={isDark ? "bg-slate-900 border border-slate-700 text-emerald-300 rounded px-1.5 py-0.5 text-xs font-mono" : "bg-ink-100 border border-ink-200 rounded px-1.5 py-0.5 text-xs font-mono text-emerald-700"}>
{part.text}
</code>
);
case "link":
return (
<a
key={idx}
href={part.url}
target="_blank"
rel="noopener noreferrer"
className={isDark ? "text-emerald-400 hover:text-emerald-300 font-semibold underline break-all inline-flex items-center gap-0.5" : "text-primary-600 hover:text-primary-800 font-semibold underline break-all inline-flex items-center gap-0.5"}
>
{part.text}
</a>
);
default:
return <span key={idx}>{part.text}</span>;
}
});
};
const renderListBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => {
const Component = block.type === "ul" ? "ul" : "ol";
const listClass = block.type === "ul"
? `list-disc pl-5 space-y-1.5 my-2 text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'}`
: `list-decimal pl-5 space-y-1.5 my-2 text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'}`;
return (
<Component key={key} className={listClass}>
{block.items?.map((item, idx) => (
<li key={idx}>{renderInlineText(item, isDark)}</li>
))}
</Component>
);
};
const renderHeadingBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => {
const level = block.type.slice(1);
const classes: Record<string, string> = {
"1": `text-lg sm:text-xl font-extrabold ${isDark ? 'text-white' : 'text-ink-900'} mt-4 mb-2 border-b border-slate-700/50 pb-1`,
"2": `text-base sm:text-lg font-extrabold ${isDark ? 'text-white' : 'text-ink-900'} mt-3 mb-1.5`,
"3": `text-sm sm:text-base font-bold ${isDark ? 'text-emerald-400' : 'text-ink-900'} mt-2.5 mb-1`,
"4": `text-xs sm:text-sm font-bold ${isDark ? 'text-emerald-300' : 'text-ink-800'} mt-2 mb-1`,
"5": `text-xs font-bold ${isDark ? 'text-slate-200' : 'text-ink-800'} mt-1.5 mb-1`,
"6": `text-xs font-bold ${isDark ? 'text-slate-300' : 'text-ink-700'} mt-1 mb-0.5`,
};
const Component = block.type as any;
return (
<Component key={key} className={classes[level] || ""}>
{renderInlineText(block.content, isDark)}
</Component>
);
};
const renderBlock = (block: MarkdownBlock, index: number, isDark: boolean): React.ReactNode => {
const key = `${block.type}-${index}`;
if (block.type.startsWith("h") && block.type.length === 2 && block.type !== "hr") {
return renderHeadingBlock(block, key, isDark);
}
switch (block.type) {
case "blockquote":
return (
<blockquote key={key} className={`border-l-4 border-emerald-500 pl-3 py-1 italic ${isDark ? 'bg-slate-900/60 text-slate-200' : 'bg-primary-50/20 text-ink-700'} my-2 rounded-r-lg`}>
{renderInlineText(block.content, isDark)}
</blockquote>
);
case "ul":
case "ol":
return renderListBlock(block, key, isDark);
case "code":
return (
<div key={key} className="my-3 rounded-xl border border-slate-800 bg-slate-950 text-slate-100 p-3 overflow-x-auto shadow-inner relative group select-text">
{block.language && (
<div className="absolute right-3 top-2 text-[9px] uppercase font-bold text-slate-400 select-none">
{block.language}
</div>
)}
<pre className="font-mono text-xs leading-relaxed overflow-x-auto">
{block.content}
</pre>
</div>
);
case "hr":
return <hr key={key} className={`my-4 ${isDark ? 'border-slate-800' : 'border-ink-200'}`} />;
default:
return (
<p key={key} className={`text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'} leading-relaxed my-1.5`}>
{renderInlineText(block.content, isDark)}
</p>
);
}
};
export interface MarkdownViewerProps {
markdown: string;
markdown: string;
variant?: 'light' | 'dark' | 'auto';
}
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ markdown }) => {
const blocks = parseMarkdown(markdown);
return (
<div className="w-full text-left select-text font-sans leading-relaxed text-ink-800">
{blocks.map((block, idx) => renderBlock(block, idx))}
</div>
);
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ markdown, variant = 'auto' }) => {
const blocks = parseMarkdown(markdown);
const isDark = variant === 'dark';
return (
<div className={`w-full text-left select-text font-sans leading-relaxed ${isDark ? 'text-slate-100' : 'text-ink-800'}`}>
{blocks.map((block, idx) => renderBlock(block, idx, isDark))}
</div>
);
};
export default MarkdownViewer;

View File

@ -145,6 +145,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
return (
<motion.div
id={`asset-card-${asset.id}`}
layout
transition={{ type: "spring", stiffness: 320, damping: 28 }}
onClick={(e) => {

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download, Compass, Sparkles } from 'lucide-react';
import { axiosInstance } from '../../../services/axios';
import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth';
@ -23,6 +24,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
user,
onDownload
}) => {
const navigate = useNavigate();
const [isMaximized, setIsMaximized] = useState(false);
const [isLoadingText, setIsLoadingText] = useState(false);
const [textPreviewContent, setTextPreviewContent] = useState('');
@ -31,6 +33,29 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
const [isLoadingDoc, setIsLoadingDoc] = useState(false);
const [docLoadError, setDocLoadError] = useState(false);
const wordContainerRef = React.useRef<HTMLDivElement>(null);
const extractYouTubeVideoId = (url: string): string | null => {
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes('youtu.be')) {
return urlObj.pathname.slice(1).split(/[?#]/)[0];
}
if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) {
const parts = urlObj.pathname.split('/');
return parts.pop()?.split(/[?#]/)[0] || null;
}
if (urlObj.searchParams.has('v')) {
return urlObj.searchParams.get('v');
}
} catch (e) {}
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
};
const getFullAssetUrl = (url: string) => {
let resolvedUrl = url;
@ -238,8 +263,8 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
}
}, [docBlob, isWord]);
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const formatBytes = (bytes?: number, decimals = 2) => {
if (!bytes || isNaN(bytes) || bytes === 0) return '';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
@ -252,6 +277,29 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
onClose();
};
const handleLocateInCatalog = () => {
onClose();
if (!asset) return;
const typeLower = (asset.type || '').toLowerCase();
const titleLower = (asset.title || '').toLowerCase();
const isCurrentAdmin = location.pathname.startsWith('/admin') || user?.role === 'ADMIN';
let targetBasePath = '';
if (typeLower.includes('case_study') || titleLower.includes('case study') || typeLower.includes('video')) {
targetBasePath = isCurrentAdmin ? '/admin/showcase' : '/client/showcase';
} else if (titleLower.includes('nda') || titleLower.includes('msa') || titleLower.includes('agreement') || typeLower.includes('legal')) {
targetBasePath = isCurrentAdmin ? '/admin/legal' : '/client/agreements';
} else if (typeLower.includes('ecosystem') || titleLower.includes('offering')) {
targetBasePath = isCurrentAdmin ? '/admin/ecosystem' : '/client/ecosystem';
} else {
targetBasePath = isCurrentAdmin ? '/admin/assets' : '/client/assets';
}
const fullUrl = `${targetBasePath}?highlight=${asset.id}`;
navigate(fullUrl, { state: { highlightAssetId: asset.id } });
};
return (
<Modal
isOpen={isOpen && !!asset}
@ -261,7 +309,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="text-left">
<span className="text-base font-bold text-ink-900 block font-sans">{asset?.title}</span>
<span className="text-xs text-ink-500 font-sans block mt-0.5 font-normal">
{asset?.type === 'url' ? 'External Web Link' : `${asset?.type}${asset ? formatBytes(asset.size) : ''}`}
{asset?.type === 'url' ? 'External Web Link' : asset?.size ? `${asset.type}${formatBytes(asset.size)}` : asset?.type}
</span>
</div>
<button
@ -312,7 +360,19 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</Button>
)}
{asset && asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
{asset && (
<Button
onClick={handleLocateInCatalog}
variant="secondary"
size="sm"
className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10"
>
<Compass className="w-4 h-4" />
<span>Locate in Portal Catalog</span>
</Button>
)}
{asset && asset.type !== 'url' && asset.type !== 'case_study' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
<Button
onClick={() => onDownload(asset)}
variant="primary"
@ -495,6 +555,61 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div>
</div>
</div>
) : asset.type === 'case_study' || asset.type.includes('video') || asset.type === 'showcase' ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
<span className="flex items-center gap-1.5">
<Sparkles className="w-4 h-4 text-emerald-600" />
<span className="font-sans">Executive Case Study & Video Showcase</span>
</span>
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.title}</span>
</div>
<div className="flex-1 overflow-auto p-6 md:p-8 flex flex-col items-center justify-start gap-6 bg-ink-50">
{asset.url && (asset.url.includes('youtube.com') || asset.url.includes('youtu.be')) ? (
<div className="w-full max-w-3xl aspect-video rounded-2xl overflow-hidden shadow-2xl bg-black border border-ink-300 shrink-0">
<iframe
src={`https://www.youtube-nocookie.com/embed/${extractYouTubeVideoId(asset.url)}?autoplay=1&rel=0`}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<div className="w-full max-w-3xl p-6 bg-slate-900 text-white rounded-2xl shadow-xl border border-slate-800 space-y-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded text-[10px] font-extrabold uppercase bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
Verified Showcase Reel
</span>
</div>
<h2 className="text-lg font-bold">{asset.title}</h2>
{asset.description && <p className="text-xs text-slate-300 leading-relaxed whitespace-pre-wrap">{asset.description}</p>}
</div>
)}
<div className="w-full max-w-3xl bg-ink-0 border border-ink-200 rounded-2xl p-6 shadow-md space-y-4 text-left">
<h3 className="text-base font-extrabold text-ink-900">{asset.title}</h3>
{asset.problemStatement && (
<div className="space-y-1">
<span className="text-xs font-bold text-red-600 uppercase tracking-wider">Problem Statement</span>
<p className="text-xs text-ink-700 leading-relaxed">{asset.problemStatement}</p>
</div>
)}
{asset.solution && (
<div className="space-y-1 pt-2 border-t border-ink-150">
<span className="text-xs font-bold text-emerald-600 uppercase tracking-wider">Implemented Technical Solution</span>
<p className="text-xs text-ink-700 leading-relaxed">{asset.solution}</p>
</div>
)}
{asset.description && !asset.problemStatement && (
<div className="space-y-1 pt-2 border-t border-ink-150">
<span className="text-xs font-bold text-ink-600 uppercase tracking-wider">Executive Overview</span>
<p className="text-xs text-ink-700 leading-relaxed whitespace-pre-wrap">{asset.description}</p>
</div>
)}
</div>
</div>
</div>
) : asset.type.includes('pdf') ? (
<div className="w-full h-full flex flex-col min-h-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
@ -691,6 +806,100 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
/>
</div>
) : (asset.type === 'case_study' || asset.problemStatement || asset.solution) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0 overflow-y-auto p-6 text-left select-text">
<div className="max-w-4xl mx-auto w-full space-y-6">
{asset.thumbnailUrl && (
<div className="w-full h-48 rounded-2xl overflow-hidden border border-ink-200 shadow-sm relative bg-ink-100">
<img src={asset.thumbnailUrl} alt={asset.title} className="w-full h-full object-cover" />
</div>
)}
<div className="border-b border-ink-200 pb-4">
<div className="flex items-center gap-2 mb-2">
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500/10 text-emerald-600 border border-emerald-500/20">
Executive Case Study Showcase
</span>
{asset.type && (
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-ink-100 text-ink-700 capitalize">
{asset.type}
</span>
)}
</div>
<h1 className="text-xl sm:text-2xl font-extrabold text-ink-900 leading-tight">{asset.title}</h1>
{asset.description && <p className="text-xs text-ink-600 mt-2 leading-relaxed">{asset.description}</p>}
</div>
{/* Problem Statement Block */}
{asset.problemStatement && (
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Problem Statement & Challenge
</h3>
<div className="text-xs text-ink-800 dark:text-slate-200 leading-relaxed font-sans">
<MarkdownViewer markdown={asset.problemStatement} />
</div>
</div>
)}
{/* Proposed Solution Block */}
{asset.solution && (
<div className="p-4 rounded-xl bg-emerald-500/5 border border-emerald-500/20 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-emerald-700 dark:text-emerald-400 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-emerald-500" />
Implemented Technical Solution
</h3>
<div className="text-xs text-ink-800 dark:text-slate-200 leading-relaxed font-sans">
<MarkdownViewer markdown={asset.solution} />
</div>
</div>
)}
{asset.url && asset.url.startsWith('http') && (
<div className="pt-2 flex justify-end">
<a
href={asset.url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-ink-900 text-ink-0 hover:bg-ink-800 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Visit Showcase Webpage
</a>
</div>
)}
</div>
</div>
) : asset.type === 'url' ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0 overflow-y-auto p-6 text-left select-text">
<div className="max-w-3xl mx-auto w-full space-y-6 text-center flex flex-col items-center justify-center h-full">
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-600">
<Globe className="w-8 h-8" />
</div>
<div>
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-blue-500/10 text-blue-600 border border-blue-500/20">
External Web Resource
</span>
<h2 className="text-xl font-extrabold text-ink-900 mt-3">{asset.title}</h2>
{asset.description && <p className="text-xs text-ink-600 mt-2 max-w-lg mx-auto">{asset.description}</p>}
</div>
<div className="p-4 bg-ink-50 border border-ink-200 rounded-xl w-full max-w-md text-left">
<p className="text-[10px] font-mono text-ink-500 truncate">{asset.url}</p>
</div>
<a
href={asset.url}
target="_blank"
rel="noopener noreferrer"
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl text-xs flex items-center gap-2 shadow-md transition-all cursor-pointer"
>
<ExternalLink className="w-4 h-4" />
Launch Web Document
</a>
</div>
</div>
) : (
<div className="text-center p-8 flex flex-col justify-center items-center">
<File className="w-12 h-12 text-ink-300 mb-3" />

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
import { X, UploadCloud, Eye, FileText, File, Sparkles } from 'lucide-react';
import { uploadAsset, scrapeCaseStudy, getTaxonomyMeta } from '../../../services/assets-api';
import type { TaxonomyMeta } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
@ -42,6 +42,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
const [uploadTags, setUploadTags] = useState('');
const [uploadGithubUrl, setUploadGithubUrl] = useState('');
const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true);
const [includeInKnowledgeBase, setIncludeInKnowledgeBase] = useState(true);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
@ -182,6 +183,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
formData.append('problemStatement', problemStatement);
formData.append('solution', solution);
}
formData.append('includeInKnowledgeBase', String(includeInKnowledgeBase));
try {
await uploadAsset(formData);
@ -646,6 +648,25 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
</div>
)}
<div className="flex items-start gap-3 p-3 bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-xl">
<input
type="checkbox"
id="includeInKnowledgeBase"
checked={includeInKnowledgeBase}
onChange={(e) => setIncludeInKnowledgeBase(e.target.checked)}
className="w-4 h-4 rounded border-emerald-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer mt-0.5"
/>
<div>
<label htmlFor="includeInKnowledgeBase" className="text-xs font-bold text-ink-900 dark:text-emerald-300 cursor-pointer flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-emerald-500" />
Include in AI Advisor Knowledge Base (RAG Search)
</label>
<span className="text-[10px] text-ink-500 dark:text-slate-400 block mt-0.5">
If checked, DeepSeek AI Advisor indexes this content to answer partner questions with OKF standard citations.
</span>
</div>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import type { Variants } from "framer-motion";
import {
@ -58,6 +59,7 @@ const itemVariants: Variants = {
};
export const AssetsPage = () => {
const location = useLocation();
const { success, error } = useToast();
const user = useAuthStore((state) => state.user);
const [assets, setAssets] = useState<Asset[]>([]);
@ -99,6 +101,23 @@ export const AssetsPage = () => {
);
})();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("ring-4", "ring-emerald-500", "scale-[1.03]", "shadow-2xl", "transition-all", "duration-500");
setTimeout(() => {
el.classList.remove("ring-4", "ring-emerald-500", "scale-[1.03]", "shadow-2xl");
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
// Modal Control States
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Shield, FileText, CheckCircle, Clock, Download, ExternalLink, RefreshCw } from 'lucide-react';
import { useAuthStore } from '../hooks/use-auth';
import { useMyAcceptancesQuery } from '../hooks/use-legal-query';
@ -8,10 +9,28 @@ import { PageLayout } from '../components/layout/PageLayout';
import Modal from '../components/ui/Modal';
export const ClientAgreementsPage: React.FC = () => {
const location = useLocation();
const user = useAuthStore((state) => state.user);
const { data: acceptances, isLoading, refetch, isFetching } = useMyAcceptancesQuery();
const [selectedDoc, setSelectedDoc] = useState<'NDA' | 'MSA' | null>(null);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !isLoading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`) || document.getElementById('asset-card-nda');
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, isLoading]);
const ndaAcceptance = acceptances?.find(a => a.document.type === 'NDA');
const msaAcceptance = acceptances?.find(a => a.document.type === 'MSA');
@ -61,7 +80,7 @@ export const ClientAgreementsPage: React.FC = () => {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* NDA Card */}
<div className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-300">
<div id={`asset-card-${ndaAcceptance?.document?.id || 'nda'}`} className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-500">
<div>
<div className="flex items-center justify-between mb-4">
<div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center">
@ -136,7 +155,7 @@ export const ClientAgreementsPage: React.FC = () => {
</div>
{/* MSA Card */}
<div className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-300">
<div id={`asset-card-${msaAcceptance?.document?.id || 'msa'}`} className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-500">
<div>
<div className="flex items-center justify-between mb-4">
<div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center">

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Code,
@ -24,10 +25,28 @@ const iconMap: Record<string, any> = {
};
export const EcosystemPage: React.FC = () => {
const location = useLocation();
const [offerings, setOfferings] = useState<EcosystemOffering[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'ALL' | 'PRODUCT' | 'SERVICE'>('ALL');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
useEffect(() => {
const fetchData = async () => {
try {
@ -107,6 +126,7 @@ export const EcosystemPage: React.FC = () => {
return (
<div
key={offering.id}
id={`asset-card-${offering.id}`}
className="group flex flex-col justify-between bg-ink-0 border border-ink-200 hover:border-ink-450 rounded-3xl p-6 md:p-8 hover:shadow-xl transition-all duration-500"
>
<div>

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Play,
@ -150,10 +151,28 @@ const VideoDescription: React.FC<VideoDescriptionProps> = ({ text, isExpanded, o
};
export const ShowcasePage: React.FC = () => {
const location = useLocation();
const [items, setItems] = useState<ContentShowcase[]>([]);
const [loading, setLoading] = useState(true);
const [playingVideoId, setPlayingVideoId] = useState<string | null>(null);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
// Resizable Lightbox state: compact | theater | cinema
const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact');
@ -219,7 +238,7 @@ export const ShowcasePage: React.FC = () => {
const isExpanded = expandedItemId === item.id;
return (
<div key={item.id} className="relative h-[410px] w-full flex flex-col">
<div key={item.id} id={`asset-card-${item.id}`} className="relative h-[410px] w-full flex flex-col">
<motion.div
layout
transition={{ type: "spring", stiffness: 320, damping: 28 }}