Compare commits
19 Commits
feature/Hi
...
Repostorio
| Author | SHA1 | Date | |
|---|---|---|---|
| 31cd071175 | |||
| c44698d0c7 | |||
| 85057e0f85 | |||
| 169599874e | |||
| 9b3880a02f | |||
| 9d9fb3d8a8 | |||
| a6f0010a53 | |||
| 29231206c0 | |||
| 93c79eee77 | |||
| 6f97a83eb0 | |||
| 4ec2c2d533 | |||
| efe7faa65f | |||
| c9d66ce2e5 | |||
| f7a29ad510 | |||
| e7a47f56f8 | |||
| 214d17cf98 | |||
| 8c890d76e0 | |||
| 7105b286bf | |||
| 6d264a8214 |
4
.env.local2
Normal file
4
.env.local2
Normal file
@@ -0,0 +1,4 @@
|
||||
VITE_SUPABASE_URL=http://127.0.0.1:54321
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEzNzg2MzIsImV4cCI6MjA1Njk1NDYzMn0.g1mBmsw-i6F6e-tPv5gWkHZacyPM2Y9X0fiKVYmVYKE
|
||||
#VITE_BACK_ORIGIN=http://localhost:3001
|
||||
VITE_BACK_ORIGIN=http://localhost:3001
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,6 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
count.txt
|
||||
.env
|
||||
.env*
|
||||
.nitro
|
||||
.tanstack
|
||||
|
||||
48
bun.lock
48
bun.lock
@@ -32,6 +32,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"gsap": "^3.13.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.540.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
@@ -482,12 +484,18 @@
|
||||
|
||||
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
|
||||
|
||||
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||
|
||||
"@types/phoenix": ["@types/phoenix@1.6.6", "", {}, "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="],
|
||||
|
||||
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
@@ -536,6 +544,8 @@
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
@@ -548,6 +558,8 @@
|
||||
|
||||
"canvas-confetti": ["canvas-confetti@1.9.3", "", {}, "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g=="],
|
||||
|
||||
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@5.3.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-48af6xm9gQK8rhIcOxWwdGzIervm8BVTin+yRp9HEvU20BtVZ2lBywlIJBzwaDtvo0FvjeL7QdCADoUoqIbV3A=="],
|
||||
@@ -578,6 +590,10 @@
|
||||
|
||||
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
|
||||
|
||||
"core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="],
|
||||
|
||||
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||
|
||||
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
@@ -628,6 +644,8 @@
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.204", "", {}, "sha512-s9VbBXWxfDrl67PlO4avwh0/GU2vcwx8Fph3wlR8LJl7ySGYId59EFE17VWVcuC3sLWNPENm6Z/uGqKbkPCcXA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||
@@ -654,8 +672,12 @@
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -682,6 +704,8 @@
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
@@ -694,6 +718,8 @@
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
@@ -726,6 +752,10 @@
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jspdf": ["jspdf@3.0.3", "", { "dependencies": { "@babel/runtime": "^7.26.9", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ=="],
|
||||
|
||||
"jspdf-autotable": ["jspdf-autotable@5.0.2", "", { "peerDependencies": { "jspdf": "^2 || ^3" } }, "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
@@ -836,6 +866,8 @@
|
||||
|
||||
"nwsapi": ["nwsapi@2.2.21", "", {}, "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA=="],
|
||||
|
||||
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
@@ -844,6 +876,8 @@
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
@@ -858,6 +892,8 @@
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||
|
||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
|
||||
@@ -886,6 +922,8 @@
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
@@ -894,6 +932,8 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||
|
||||
"rollup": ["rollup@4.46.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.46.3", "@rollup/rollup-android-arm64": "4.46.3", "@rollup/rollup-darwin-arm64": "4.46.3", "@rollup/rollup-darwin-x64": "4.46.3", "@rollup/rollup-freebsd-arm64": "4.46.3", "@rollup/rollup-freebsd-x64": "4.46.3", "@rollup/rollup-linux-arm-gnueabihf": "4.46.3", "@rollup/rollup-linux-arm-musleabihf": "4.46.3", "@rollup/rollup-linux-arm64-gnu": "4.46.3", "@rollup/rollup-linux-arm64-musl": "4.46.3", "@rollup/rollup-linux-loongarch64-gnu": "4.46.3", "@rollup/rollup-linux-ppc64-gnu": "4.46.3", "@rollup/rollup-linux-riscv64-gnu": "4.46.3", "@rollup/rollup-linux-riscv64-musl": "4.46.3", "@rollup/rollup-linux-s390x-gnu": "4.46.3", "@rollup/rollup-linux-x64-gnu": "4.46.3", "@rollup/rollup-linux-x64-musl": "4.46.3", "@rollup/rollup-win32-arm64-msvc": "4.46.3", "@rollup/rollup-win32-ia32-msvc": "4.46.3", "@rollup/rollup-win32-x64-msvc": "4.46.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw=="],
|
||||
|
||||
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
|
||||
@@ -924,6 +964,8 @@
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||
|
||||
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
@@ -934,6 +976,8 @@
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.11", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow=="],
|
||||
|
||||
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||
@@ -944,6 +988,8 @@
|
||||
|
||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||
|
||||
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
|
||||
@@ -1006,6 +1052,8 @@
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||
|
||||
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||
|
||||
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
627
src/components/ai/AIChatModal.jsx
Normal file
627
src/components/ai/AIChatModal.jsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { supabase } from "@/auth/supabase";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
/* ---------- UI Mocks (sin cambios) ---------- */
|
||||
const Paperclip = (props) => (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Dialog = ({ open, onOpenChange, children }) =>
|
||||
open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
|
||||
const DialogContent = ({ className, children }) =>
|
||||
<div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}>{children}</div>;
|
||||
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
|
||||
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
|
||||
const Button = ({ onClick, disabled, className, variant, children }) => (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
${variant === "outline"
|
||||
? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
|
||||
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
|
||||
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
||||
|
||||
/* ------------- COMPONENT ------------- */
|
||||
export default function AIChatModal({ open, onClose, context, onAccept, plan_format }) {
|
||||
|
||||
const [vectorStores, setVectorStores] = useState([]);
|
||||
const [vectorFiles, setVectorFiles] = useState([]);
|
||||
const [selectedVector, setSelectedVector] = useState(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState([]);
|
||||
|
||||
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||
const [attachedPreviews, setAttachedPreviews] = useState([]);
|
||||
|
||||
// chat
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
// loading states
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const [loadingVectors, setLoadingVectors] = useState(false);
|
||||
|
||||
// conversation control
|
||||
const [conversationId, setConversationId] = useState(null);
|
||||
const [creatingConversation, setCreatingConversation] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
useEffect(scrollToBottom, [messages]);
|
||||
|
||||
const normalizeInvokeResponse = (resp) => {
|
||||
if (!resp) return null;
|
||||
const raw = resp.data;
|
||||
if (typeof raw === "string") {
|
||||
try { return JSON.parse(raw); } catch (e) { console.warn("❗ No se pudo parsear resp.data:", raw); return null; }
|
||||
}
|
||||
if (typeof raw === "object" && raw !== null) return raw;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Al abrir: reset o crear conversación
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (conversationId) {
|
||||
deleteConversation(conversationId).catch((e) => console.error(e));
|
||||
}
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setSelectedFiles([]);
|
||||
setAttachedFiles([]);
|
||||
setAttachedPreviews([]);
|
||||
setConversationId(null);
|
||||
setSelectedVector(null);
|
||||
setVectorFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context) {
|
||||
setMessages([
|
||||
{
|
||||
role: "system",
|
||||
//content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
|
||||
content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await createConversation();
|
||||
fetchVectorStores();
|
||||
})();
|
||||
|
||||
}, [open]);
|
||||
|
||||
// ---------- CREATE CONVERSATION ----------
|
||||
const createConversation = async () => {
|
||||
try {
|
||||
setCreatingConversation(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const resp = await supabase.functions.invoke("modal-conversation", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { action: "start", role: "system", content: context?.cont_conversation ?? "" }
|
||||
});
|
||||
|
||||
let parsed = null;
|
||||
if (typeof resp?.data === "string") {
|
||||
try { parsed = JSON.parse(resp.data); } catch (e) { parsed = null; }
|
||||
} else if (typeof resp?.data === "object" && resp.data !== null) parsed = resp.data;
|
||||
else parsed = resp;
|
||||
|
||||
const convId =
|
||||
parsed?.conversationId ||
|
||||
parsed?.data?.conversationId ||
|
||||
parsed?.data?.id ||
|
||||
parsed?.id ||
|
||||
parsed?.conversation_id ||
|
||||
parsed?.data?.conversation_id;
|
||||
|
||||
if (!convId) { setCreatingConversation(false); return; }
|
||||
setConversationId(convId);
|
||||
} catch (err) {
|
||||
console.error("Error creando conversación:", err);
|
||||
} finally {
|
||||
setCreatingConversation(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- DELETE CONVERSATION ----------
|
||||
const deleteConversation = async (convIdParam) => {
|
||||
try {
|
||||
const convIdToUse = convIdParam ?? conversationId;
|
||||
if (!convIdToUse) return;
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
await supabase.functions.invoke("modal-conversation", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { action: "end", conversationId: convIdToUse }
|
||||
});
|
||||
|
||||
setConversationId(null);
|
||||
} catch (err) {
|
||||
console.error("Error eliminando conversación:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- CONVERT FILE TO BASE64 ----------
|
||||
const fileToBase64 = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = (e) => reject(e);
|
||||
reader.onload = () => resolve(reader.result.split(",")[1]);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// ---------- HANDLE CONVERSATION (envío) ----------
|
||||
const handleConversation = async ({ text }) => {
|
||||
let contextText = "";
|
||||
if (context?.originalText) contextText += `CONTEXTO DEL CAMPO:\n${context.originalText}\n`;
|
||||
|
||||
if (!conversationId) {
|
||||
console.warn("No hay conversación activa todavía. conversationId:", conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
// archivos adjuntos (locales) -> base64
|
||||
let filesInput = [];
|
||||
if (attachedFiles.length > 0) {
|
||||
for (const file of attachedFiles) {
|
||||
const base64 = await fileToBase64(file);
|
||||
filesInput.push({
|
||||
type: "input_file",
|
||||
filename: file.name,
|
||||
file_data: `data:${file.type};base64,${base64}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// archivos seleccionados del vector (por id)
|
||||
if (selectedFiles.length > 0) {
|
||||
const filesFromVectors = selectedFiles.map(f => ({
|
||||
type: "input_file",
|
||||
file_id: f.id
|
||||
}));
|
||||
filesInput = [...filesInput, ...filesFromVectors];
|
||||
}
|
||||
|
||||
const promptFinal = `${contextText}\nPREGUNTA DEL USUARIO:\n${text}`;
|
||||
const payload = {
|
||||
action: "message",
|
||||
format: plan_format,
|
||||
conversationId,
|
||||
vectorStoreId: selectedVector ?? null,
|
||||
fileIds: selectedFiles.length ? selectedFiles.map(f => f.id) : [],
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: promptFinal },
|
||||
...filesInput
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const { data: invokeData, error } = await supabase.functions.invoke(
|
||||
"modal-conversation",
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: payload
|
||||
}
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
const parsed = normalizeInvokeResponse({ data: invokeData });
|
||||
|
||||
// Extraer texto del assistant (robusto)
|
||||
let assistantText = null;
|
||||
if (parsed?.data?.output_text) assistantText = parsed.data.output_text;
|
||||
if (!assistantText && Array.isArray(parsed?.data?.output)) {
|
||||
const msgBlock = parsed.data.output.find(o => o.type === "message");
|
||||
if (msgBlock?.content?.[0]?.text) assistantText = msgBlock.content[0].text;
|
||||
}
|
||||
assistantText = assistantText || "Sin respuesta del modelo.";
|
||||
|
||||
setMessages(prev => [...prev, { role: "assistant", content: cleanAssistantResponse(assistantText) }]);
|
||||
|
||||
// limpiar attachments locales (pero mantener seleccionados del vector si quieres — aquí los limpiamos)
|
||||
setAttachedFiles([]);
|
||||
setAttachedPreviews([]);
|
||||
// si quieres mantener los selectedFiles tras el envío, comenta la siguiente línea:
|
||||
setSelectedFiles([]);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error en handleConversation:", err);
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al procesar tu mensaje." }]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- VECTORES ----------
|
||||
const fetchVectorStores = async () => {
|
||||
try {
|
||||
setLoadingVectors(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { module: "vectorStores", action: "list" }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
setVectorStores(Array.isArray(data) ? data : (data?.data ?? []));
|
||||
} catch (err) {
|
||||
console.error("Error loading vector stores:", err);
|
||||
setVectorStores([]);
|
||||
} finally {
|
||||
setLoadingVectors(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchVectorStores();
|
||||
}, [open]);
|
||||
|
||||
const loadFilesForVector = async (vectorStoreId) => {
|
||||
try {
|
||||
setLoadingFiles(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { module: "vectorStoreFiles", action: "list", params: { vector_store_id: vectorStoreId } }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
setVectorFiles(Array.isArray(data) ? data : (data?.data ?? []));
|
||||
} catch (err) {
|
||||
console.error("Error loading vector files:", err);
|
||||
setVectorFiles([]);
|
||||
} finally {
|
||||
setLoadingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- UI helpers ----------
|
||||
const handleAttach = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (!files.length) return;
|
||||
setAttachedFiles(prev => [...prev, ...files]);
|
||||
setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
|
||||
};
|
||||
|
||||
// Al hacer click en un vector: expandir (solo uno a la vez) y cargar sus archivos
|
||||
const handleVectorClick = async (vector) => {
|
||||
if (selectedVector === vector.id) {
|
||||
// colapsar
|
||||
setSelectedVector(null);
|
||||
setVectorFiles([]);
|
||||
setSelectedFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedVector(vector.id);
|
||||
setSelectedFiles([]);
|
||||
await loadFilesForVector(vector.id);
|
||||
};
|
||||
|
||||
// Toggle selección de archivo (checkbox)
|
||||
const toggleFileSelection = (file) => {
|
||||
if (selectedFiles.some(f => f.id === file.id)) {
|
||||
setSelectedFiles(prev => prev.filter(f => f.id !== file.id));
|
||||
} else {
|
||||
setSelectedFiles(prev => [...prev, file]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelectedFile = (fileId) => {
|
||||
setSelectedFiles(prev => prev.filter(f => f.id !== fileId));
|
||||
};
|
||||
|
||||
// ---------- Send flow ----------
|
||||
const handleSend = async () => {
|
||||
// no permitir enviar si no hay nada
|
||||
if (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0) return;
|
||||
|
||||
if (creatingConversation) {
|
||||
// no bloqueo visible aquí por diseño; simplemente ignoramos el envío si aún creando
|
||||
return;
|
||||
}
|
||||
|
||||
if (!conversationId) {
|
||||
await createConversation();
|
||||
if (!conversationId) {
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const userText = input.trim() || (selectedFiles.length ? `Consultar ${selectedFiles.length} archivo(s) del repositorio` : "");
|
||||
setMessages(prev => [...prev, { role: "user", content: userText }]);
|
||||
setInput("");
|
||||
|
||||
await handleConversation({ text: userText });
|
||||
};
|
||||
|
||||
function cleanAIResponse(text) {
|
||||
if (!text) return text;
|
||||
|
||||
let cleaned = text;
|
||||
|
||||
// -------------------------
|
||||
// 1. Eliminar emojis
|
||||
// -------------------------
|
||||
cleaned = cleaned.replace(/[\p{Emoji}\uFE0F]/gu, "");
|
||||
|
||||
// -------------------------
|
||||
// 2. Eliminar separadores tipo ---
|
||||
// -------------------------
|
||||
cleaned = cleaned.replace(/^---+$/gm, "");
|
||||
|
||||
// -------------------------
|
||||
// 3. Eliminar saludos y frases meta
|
||||
// -------------------------
|
||||
const metaPatterns = [
|
||||
/^hola[!¡., ]*/i,
|
||||
/^buen(os|as) (días|tardes|noches)[!¡., ]*/i,
|
||||
/estoy aquí para ayudarte[.! ]*/gi,
|
||||
/aquí tienes[,:]*/gi,
|
||||
/claro[,:]*/gi,
|
||||
/como pediste[,:]*/gi,
|
||||
/como solicitaste[,:]*/gi,
|
||||
/el texto íntegro que compartiste.*$/gi,
|
||||
/te lo dejo a continuación.*$/gi,
|
||||
/¿te gustaría.*$/gi,
|
||||
/¿en qué más puedo.*$/gi,
|
||||
/si necesitas algo más.*$/gi,
|
||||
/con gusto.*$/gi,
|
||||
];
|
||||
|
||||
metaPatterns.forEach(p => {
|
||||
cleaned = cleaned.replace(p, "").trim();
|
||||
});
|
||||
|
||||
// -------------------------
|
||||
// 4. Extraer solo contenido útil
|
||||
// -------------------------
|
||||
const startMarker = "CONTEXTO DEL CAMPO";
|
||||
const startIndex = cleaned.indexOf(startMarker);
|
||||
|
||||
if (startIndex !== -1) {
|
||||
cleaned = cleaned.substring(startIndex).trim();
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// 5. Eliminar líneas vacías múltiples
|
||||
// -------------------------
|
||||
cleaned = cleaned.replace(/\n{2,}/g, "\n\n");
|
||||
|
||||
// -------------------------
|
||||
// 6. Quitar numeraciones de cortesía (opcional)
|
||||
// Ejemplo: “1. ” al inicio de líneas
|
||||
// -------------------------
|
||||
cleaned = cleaned.replace(/^\s*\d+\.\s+/gm, "");
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
|
||||
const handleApply = () => {
|
||||
const last = [...messages].reverse().find(m => m.role === "assistant");
|
||||
|
||||
if (last && onAccept) {
|
||||
const cleaned = cleanAIResponse(last.content);
|
||||
onAccept(cleaned);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const cleanAssistantResponse = (text) => {
|
||||
if (!text) return text;
|
||||
const patterns = [/^claro[, ]*/i, /^por supuesto[, ]*/i, /^aquí tienes[, ]*/i, /^con gusto[, ]*/i, /^hola[, ]*/i, /^perfecto[, ]*/i, /^entendido[, ]*/i, /^muy bien[, ]*/i, /^ok[, ]*/i];
|
||||
let cleaned = text.trim();
|
||||
for (const p of patterns) cleaned = cleaned.replace(p, "").trim();
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose} >
|
||||
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative"
|
||||
|
||||
>
|
||||
<button onClick={onClose} className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 transition z-50">✕</button>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Asistente Inteligente</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 pt-4 min-h-0">
|
||||
<div className="flex gap-6 h-full min-h-0">
|
||||
{/* Left: vectors */}
|
||||
<Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
|
||||
<CardContent className="flex flex-col flex-1 p-4">
|
||||
<h3 className="font-semibold text-sm mb-3">Repositorio de archivos</h3>
|
||||
<ScrollArea className="flex-1">
|
||||
{loadingVectors ? (
|
||||
<p className="text-gray-500 text-sm text-center mt-10">Cargando Repositorio de archivos...</p>
|
||||
) : vectorStores.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm text-center mt-10">No hay Repositorio de archivos.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{vectorStores.map((vector) => (
|
||||
<div key={vector.id}>
|
||||
{/* VECTOR */}
|
||||
<div
|
||||
onClick={() => handleVectorClick(vector)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition flex items-center justify-between
|
||||
${selectedVector === vector.id ? "bg-blue-50 border-blue-400 shadow" : "bg-white border-gray-300"}`}
|
||||
>
|
||||
<div className="truncate">
|
||||
<strong className="block truncate">{vector.name || vector.id}</strong>
|
||||
<p className="text-xs text-gray-400 truncate">{vector.description || ""}</p>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{selectedVector === vector.id ? "▼" : "▶"}</div>
|
||||
</div>
|
||||
|
||||
{/* ARCHIVOS cuando está expandido */}
|
||||
{selectedVector === vector.id && (
|
||||
<div className="ml-4 mt-2 mb-2 space-y-2">
|
||||
{loadingFiles ? (
|
||||
<p className="text-gray-400 text-sm">Cargando archivos...</p>
|
||||
) : vectorFiles.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">No hay archivos en este repositorio</p>
|
||||
) : (
|
||||
vectorFiles.map((file) => (
|
||||
<label key={file.id} className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFiles.some(f => f.id === file.id)}
|
||||
onChange={() => toggleFileSelection(file)}
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{file.filename ?? file.name ?? file.id}</div>
|
||||
<div className="text-xs text-gray-400">{file.id}</div>
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</ScrollArea>
|
||||
|
||||
{/* Resumen de archivos seleccionados (de vectores) */}
|
||||
<div className="mt-4">
|
||||
<h4 className="font-semibold text-sm mb-2">Archivos seleccionados</h4>
|
||||
{selectedFiles.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No has seleccionado archivos del repositorio</p>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-40 overflow-auto">
|
||||
{selectedFiles.map((f) => (
|
||||
<li key={f.id} className="flex items-center justify-between p-2 rounded-md border bg-white">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{f.filename ?? f.name ?? f.id}</div>
|
||||
<div className="text-xs text-gray-400 truncate">{f.id}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{/* optionally show vector id */}</span>
|
||||
<button onClick={() => removeSelectedFile(f.id)} className="text-sm text-red-500 hover:underline">Quitar</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* <div className="mt-4 flex-shrink-0">
|
||||
<Button variant="outline" className="w-full" onClick={() => alert("Funcionalidad Subir a vector store no implementada aquí")}>Subir archivo (vector)</Button>
|
||||
</div> */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right: Chat */}
|
||||
<Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
|
||||
<CardContent className="flex flex-col flex-1 p-4 min-h-0">
|
||||
<h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* CONTENEDOR SCROLL DE LOS MENSAJES */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50 break-words whitespace-pre-wrap">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm text-center mt-10">Inicia una conversación...</p>
|
||||
) : (
|
||||
messages.map((m, i) => (
|
||||
<div key={i} className={`break-words whitespace-pre-wrap p-3 rounded-xl shadow-sm max-w-[85%] ${m.role === "user" ? "bg-blue-50 text-blue-800 ml-auto" : m.role === "assistant" ? "bg-white text-gray-800 mr-auto border border-gray-200" : "bg-gray-100 text-gray-700 mr-auto"}`}>
|
||||
<strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
|
||||
<ReactMarkdown>{m.content}</ReactMarkdown>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2 p-3 bg-white border border-gray-200 rounded-xl mr-auto max-w-fit shadow-sm flex-shrink-0">
|
||||
<svg className="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">La IA está respondiendo...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attachedPreviews.length > 0 && (
|
||||
<ul className="text-xs text-gray-600 mt-2">
|
||||
{attachedPreviews.map((name, i) => (
|
||||
<li key={i}>📄 {name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4 items-end flex-shrink-0">
|
||||
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
|
||||
<Paperclip className="w-5 h-5" />
|
||||
<input type="file" accept=".pdf,.txt,.doc,.docx" multiple className="hidden" onChange={handleAttach} />
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Escribe tu pregunta..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto bg-white shadow-inner"
|
||||
style={{ minHeight: "38px" }}
|
||||
/>
|
||||
|
||||
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0)} className="shadow-md">
|
||||
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleApply} disabled={!messages.some((m) => m.role === "assistant")} className="shadow-md">
|
||||
Aplicar mejora
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
|
||||
unit: "mm",
|
||||
format: "letter",
|
||||
})
|
||||
|
||||
console.log(plan);
|
||||
|
||||
const pageWidth = doc.internal.pageSize.getWidth()
|
||||
const pageHeight = doc.internal.pageSize.getHeight()
|
||||
const margin = 20
|
||||
@@ -229,7 +230,7 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
|
||||
// LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES
|
||||
doc.setFontSize(18)
|
||||
// Manejamos la conversión a string si es necesario
|
||||
const mainTitle = (plan["titulo"] !== null && plan["titulo"] !== undefined ? String(plan["titulo"]) : "LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES").toUpperCase()
|
||||
const mainTitle = (plan["nombre"] !== null && plan["nombre"] !== undefined ? String(plan["nombre"]) : "LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES").toUpperCase()
|
||||
const mainTitleLines = doc.splitTextToSize(mainTitle, maxWidth - 20)
|
||||
doc.text(mainTitleLines, pageWidth / 2, cursorY, { align: "center" })
|
||||
cursorY += mainTitleLines.length * 8
|
||||
|
||||
@@ -8,6 +8,9 @@ import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
||||
import { toast } from "sonner"
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
|
||||
// @ts-ignore
|
||||
import AIChatModal from "../ai/AIChatModal"
|
||||
|
||||
|
||||
|
||||
/* =====================================================
|
||||
@@ -52,6 +55,8 @@ export const planTextOptions = (planId: string) =>
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
|
||||
|
||||
/* =====================================================
|
||||
Color helpers
|
||||
===================================================== */
|
||||
@@ -115,12 +120,24 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
const qc = useQueryClient()
|
||||
const auth = useSupabaseAuth()
|
||||
const [openHistorial, setOpenHistorial] = useState(false)
|
||||
const [openModalIa, setopenModalIa] = useState(false)
|
||||
if(!planId) return <div>Cargando…</div>
|
||||
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
||||
|
||||
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
||||
const [draft, setDraft] = useState("")
|
||||
|
||||
const plan_format={
|
||||
"objetivo_general": "...",
|
||||
"sistema_evaluacion": "...",
|
||||
"perfil_ingreso": "...",
|
||||
"perfil_egreso": "...",
|
||||
"competencias_genericas": "...",
|
||||
"competencias_especificas": "...",
|
||||
"indicadores_desempeno": "...",
|
||||
"pertinencia": "..."
|
||||
}
|
||||
|
||||
// --- mutation con actualización optimista ---
|
||||
const updateField = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
|
||||
@@ -162,6 +179,8 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
],
|
||||
[]
|
||||
)
|
||||
const [iaContext, setIaContext] = useState<{ key: keyof PlanTextFields; title: string; content: string } | null>(null)
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -171,9 +190,14 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
return (
|
||||
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
||||
{s.key === "historico" ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setOpenHistorial(true)}>
|
||||
Ver historial
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpenHistorial(true)}>
|
||||
Ver historial
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setopenModalIa(true)}>
|
||||
Promt
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExpandableText text={text} mono={s.mono} />
|
||||
@@ -190,7 +214,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
Copiar
|
||||
</Button>
|
||||
{s.key !== "prompt" && (
|
||||
<Button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
@@ -200,7 +224,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -268,6 +292,24 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
>
|
||||
{updateField.isPending ? "Guardando…" : "Guardar"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (!editing) return
|
||||
const current = draft
|
||||
setIaContext({
|
||||
key: editing.key,
|
||||
title: editing.title,
|
||||
content: current,
|
||||
})
|
||||
setopenModalIa(true)
|
||||
setEditing(null) // 🔹 Cierra el modal de edición
|
||||
}}
|
||||
>
|
||||
Mejorar con IA
|
||||
</Button>
|
||||
|
||||
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -279,6 +321,29 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
||||
updateField.mutate({ key, value })
|
||||
}}
|
||||
/>
|
||||
|
||||
<AIChatModal
|
||||
//plan_format={plan_format}
|
||||
open={openModalIa}
|
||||
onClose={() => setopenModalIa(false)}
|
||||
context={{
|
||||
section: null,//,iaContext?.title,
|
||||
fieldKey: null,//iaContext?.key,
|
||||
originalText: iaContext?.content,
|
||||
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId} que se encuentra en la tabla plan_estudios con el mcp para realizar los cambios que se te soliciten Responde únicamente con la información solicitada.
|
||||
No uses frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
|
||||
No uses introducciones, despedidas ni texto de relleno.
|
||||
Entrega solo el contenido útil.`,
|
||||
}}
|
||||
onAccept={(newText: string) => {
|
||||
if (iaContext) {
|
||||
updateField.mutate({ key: iaContext.key, value: newText })
|
||||
setIaContext(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
200
src/components/ui/prompt-input.tsx
Normal file
200
src/components/ui/prompt-input.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type PromptInputContextType = {
|
||||
isLoading: boolean
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
maxHeight: number | string
|
||||
onSubmit?: () => void
|
||||
disabled?: boolean
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
}
|
||||
|
||||
const PromptInputContext = createContext<PromptInputContextType>({
|
||||
isLoading: false,
|
||||
value: "",
|
||||
setValue: () => {},
|
||||
maxHeight: 240,
|
||||
onSubmit: undefined,
|
||||
disabled: false,
|
||||
textareaRef: React.createRef<HTMLTextAreaElement>(),
|
||||
})
|
||||
|
||||
function usePromptInput() {
|
||||
const context = useContext(PromptInputContext)
|
||||
if (!context) {
|
||||
throw new Error("usePromptInput must be used within a PromptInput")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
type PromptInputProps = {
|
||||
isLoading?: boolean
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
maxHeight?: number | string
|
||||
onSubmit?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function PromptInput({
|
||||
className,
|
||||
isLoading = false,
|
||||
maxHeight = 240,
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
children,
|
||||
}: PromptInputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value || "")
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInternalValue(newValue)
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<PromptInputContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
value: value ?? internalValue,
|
||||
setValue: onValueChange ?? handleChange,
|
||||
maxHeight,
|
||||
onSubmit,
|
||||
textareaRef,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input bg-background cursor-text rounded-3xl border p-2 shadow-xs",
|
||||
className
|
||||
)}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PromptInputContext.Provider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export type PromptInputTextareaProps = {
|
||||
disableAutosize?: boolean
|
||||
} & React.ComponentProps<typeof Textarea>
|
||||
|
||||
function PromptInputTextarea({
|
||||
className,
|
||||
onKeyDown,
|
||||
disableAutosize = false,
|
||||
...props
|
||||
}: PromptInputTextareaProps) {
|
||||
const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } =
|
||||
usePromptInput()
|
||||
|
||||
useEffect(() => {
|
||||
if (disableAutosize) return
|
||||
|
||||
if (!textareaRef.current) return
|
||||
|
||||
if (textareaRef.current.scrollTop === 0) {
|
||||
textareaRef.current.style.height = "auto"
|
||||
}
|
||||
|
||||
textareaRef.current.style.height =
|
||||
typeof maxHeight === "number"
|
||||
? `${Math.min(textareaRef.current.scrollHeight, maxHeight)}px`
|
||||
: `min(${textareaRef.current.scrollHeight}px, ${maxHeight})`
|
||||
}, [value, maxHeight, disableAutosize])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit?.()
|
||||
}
|
||||
onKeyDown?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"text-primary min-h-[44px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
className
|
||||
)}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
function PromptInputActions({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PromptInputActionsProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PromptInputActionProps = {
|
||||
className?: string
|
||||
tooltip: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
} & React.ComponentProps<typeof Tooltip>
|
||||
|
||||
function PromptInputAction({
|
||||
tooltip,
|
||||
children,
|
||||
className,
|
||||
side = "top",
|
||||
...props
|
||||
}: PromptInputActionProps) {
|
||||
const { disabled } = usePromptInput()
|
||||
|
||||
return (
|
||||
<Tooltip {...props}>
|
||||
<TooltipTrigger asChild disabled={disabled} onClick={event => event.stopPropagation()}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className={className}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
PromptInput,
|
||||
PromptInputTextarea,
|
||||
PromptInputActions,
|
||||
PromptInputAction,
|
||||
}
|
||||
@@ -44,13 +44,13 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
|
||||
10
src/formatos/plan.json
Normal file
10
src/formatos/plan.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"objetivo_general": "...",
|
||||
"sistema_evaluacion": "...",
|
||||
"perfil_ingreso": "...",
|
||||
"perfil_egreso": "...",
|
||||
"competencias_genericas": "...",
|
||||
"competencias_especificas": "...",
|
||||
"indicadores_desempeno": "...",
|
||||
"pertinencia": "..."
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// dummy test
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authentic
|
||||
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
||||
import { Route as AuthenticatedCarrerasRouteImport } from './routes/_authenticated/carreras'
|
||||
import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas'
|
||||
import { Route as AuthenticatedArchivos2RouteImport } from './routes/_authenticated/archivos2'
|
||||
import { Route as AuthenticatedArchivosRouteImport } from './routes/_authenticated/archivos'
|
||||
import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId'
|
||||
import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId'
|
||||
@@ -69,6 +70,11 @@ const AuthenticatedAsignaturasRoute =
|
||||
path: '/asignaturas',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedArchivos2Route = AuthenticatedArchivos2RouteImport.update({
|
||||
id: '/archivos2',
|
||||
path: '/archivos2',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedArchivosRoute = AuthenticatedArchivosRouteImport.update({
|
||||
id: '/archivos',
|
||||
path: '/archivos',
|
||||
@@ -102,6 +108,7 @@ export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/archivos': typeof AuthenticatedArchivosRoute
|
||||
'/archivos2': typeof AuthenticatedArchivos2Route
|
||||
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
||||
'/carreras': typeof AuthenticatedCarrerasRoute
|
||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||
@@ -117,6 +124,7 @@ export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/archivos': typeof AuthenticatedArchivosRoute
|
||||
'/archivos2': typeof AuthenticatedArchivos2Route
|
||||
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
||||
'/carreras': typeof AuthenticatedCarrerasRoute
|
||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||
@@ -134,6 +142,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/_authenticated/archivos': typeof AuthenticatedArchivosRoute
|
||||
'/_authenticated/archivos2': typeof AuthenticatedArchivos2Route
|
||||
'/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
||||
'/_authenticated/carreras': typeof AuthenticatedCarrerasRoute
|
||||
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
||||
@@ -151,6 +160,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/archivos'
|
||||
| '/archivos2'
|
||||
| '/asignaturas'
|
||||
| '/carreras'
|
||||
| '/dashboard'
|
||||
@@ -166,6 +176,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/archivos'
|
||||
| '/archivos2'
|
||||
| '/asignaturas'
|
||||
| '/carreras'
|
||||
| '/dashboard'
|
||||
@@ -182,6 +193,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated'
|
||||
| '/login'
|
||||
| '/_authenticated/archivos'
|
||||
| '/_authenticated/archivos2'
|
||||
| '/_authenticated/asignaturas'
|
||||
| '/_authenticated/carreras'
|
||||
| '/_authenticated/dashboard'
|
||||
@@ -265,6 +277,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedAsignaturasRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/archivos2': {
|
||||
id: '/_authenticated/archivos2'
|
||||
path: '/archivos2'
|
||||
fullPath: '/archivos2'
|
||||
preLoaderRoute: typeof AuthenticatedArchivos2RouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/archivos': {
|
||||
id: '/_authenticated/archivos'
|
||||
path: '/archivos'
|
||||
@@ -319,6 +338,7 @@ const AuthenticatedAsignaturasRouteWithChildren =
|
||||
|
||||
interface AuthenticatedRouteChildren {
|
||||
AuthenticatedArchivosRoute: typeof AuthenticatedArchivosRoute
|
||||
AuthenticatedArchivos2Route: typeof AuthenticatedArchivos2Route
|
||||
AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRouteWithChildren
|
||||
AuthenticatedCarrerasRoute: typeof AuthenticatedCarrerasRoute
|
||||
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
||||
@@ -332,6 +352,7 @@ interface AuthenticatedRouteChildren {
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedArchivosRoute: AuthenticatedArchivosRoute,
|
||||
AuthenticatedArchivos2Route: AuthenticatedArchivos2Route,
|
||||
AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRouteWithChildren,
|
||||
AuthenticatedCarrerasRoute: AuthenticatedCarrerasRoute,
|
||||
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
||||
|
||||
@@ -47,6 +47,7 @@ const nav = [
|
||||
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/usuarios", label: "Usuarios", icon: Users2Icon },
|
||||
{ to: "/archivos", label: "Archivos de referencia", icon: FileAxis3D },
|
||||
{ to: "/archivos2", label: "Repositorio de archivos", icon: FileAxis3D },
|
||||
] as const
|
||||
|
||||
function getInitials(name?: string) {
|
||||
|
||||
718
src/routes/_authenticated/archivos2.tsx
Normal file
718
src/routes/_authenticated/archivos2.tsx
Normal file
@@ -0,0 +1,718 @@
|
||||
// routes/_authenticated/archivos.tsx
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import * as Icons from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
|
||||
|
||||
interface VectorStore {
|
||||
id: string
|
||||
object: "vector_store"
|
||||
created_at: number
|
||||
name: string | null
|
||||
description?: string | null
|
||||
usage_bytes: number
|
||||
file_counts: {
|
||||
in_progress: number
|
||||
completed: number
|
||||
failed: number
|
||||
cancelled: number
|
||||
total: number
|
||||
}
|
||||
status: string
|
||||
last_active_at?: number | null
|
||||
metadata?: Record<string, any> | null
|
||||
}
|
||||
|
||||
interface VectorStoreFile {
|
||||
id: string
|
||||
object: string
|
||||
created_at: number
|
||||
vector_store_id: string
|
||||
status: string
|
||||
usage_bytes: number
|
||||
last_error?: { code: string; message: string } | null
|
||||
}
|
||||
|
||||
interface VectorStoreFileMeta {
|
||||
id: string
|
||||
user_id: string | null
|
||||
vector_store_id: string
|
||||
openai_file_id: string
|
||||
label: string | null
|
||||
tags: string[] | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type EdgeArgs = {
|
||||
module: EdgeModule
|
||||
action: string
|
||||
params?: Record<string, any>
|
||||
}
|
||||
|
||||
async function callFilesAndVectorStoresApi<T = unknown>(
|
||||
args: EdgeArgs,
|
||||
): Promise<T> {
|
||||
const { data, error } = await supabase.functions.invoke<any>(
|
||||
"files-and-vector-stores-api",
|
||||
{
|
||||
body: args,
|
||||
},
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const payload = data ?? {}
|
||||
if (payload.error) {
|
||||
const msg =
|
||||
typeof payload.error === "string"
|
||||
? payload.error
|
||||
: payload.error.message ?? "Error en la función Edge"
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
const result = payload.data !== undefined ? payload.data : payload
|
||||
return result as T
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/archivos2")({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
|
||||
module: "vectorStores",
|
||||
action: "list",
|
||||
params: {
|
||||
limit: 10,
|
||||
},
|
||||
})
|
||||
return stores ?? []
|
||||
},
|
||||
})
|
||||
|
||||
/* ====== UI helpers ====== */
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const label =
|
||||
status === "completed"
|
||||
? "Completado"
|
||||
: status === "in_progress"
|
||||
? "Procesando"
|
||||
: status
|
||||
const base = "text-[10px] px-2 py-0.5 rounded-full border"
|
||||
if (status === "completed") {
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-emerald-50 text-emerald-700 border-emerald-200`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-amber-50 text-amber-800 border-amber-200`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={`${base} bg-neutral-50 text-neutral-700 border-neutral-200`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ====== Página principal: lista repositorios (Vector Stores) ====== */
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter()
|
||||
const vectorStores = Route.useLoaderData() as VectorStore[]
|
||||
|
||||
const [q, setQ] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all")
|
||||
const [selected, setSelected] = useState<VectorStore | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = q.trim().toLowerCase()
|
||||
return vectorStores.filter((vs) => {
|
||||
if (statusFilter !== "all" && vs.status !== statusFilter) return false
|
||||
if (!term) return true
|
||||
return (
|
||||
(vs.name ?? "").toLowerCase().includes(term) ||
|
||||
(vs.description ?? "").toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
}, [vectorStores, q, statusFilter])
|
||||
|
||||
function openDetails(vs: VectorStore) {
|
||||
setSelected(vs)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
|
||||
setDeletingId(id)
|
||||
try {
|
||||
await callFilesAndVectorStoresApi({
|
||||
module: "vectorStores",
|
||||
action: "delete",
|
||||
params: { vector_store_id: id },
|
||||
})
|
||||
|
||||
await supabase
|
||||
.from("vector_store_files_meta")
|
||||
.delete()
|
||||
.eq("vector_store_id", id)
|
||||
|
||||
router.invalidate()
|
||||
} catch (err: any) {
|
||||
alert(err?.message ?? "Error al eliminar el repositorio")
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="font-mono">Repositorios de archivos</CardTitle>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Buscar por nombre o descripción…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) =>
|
||||
setStatusFilter(v as "all" | "completed" | "in_progress")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="sm:w-[160px]">
|
||||
<SelectValue placeholder="Estado" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
<SelectItem value="completed">Completados</SelectItem>
|
||||
<SelectItem value="in_progress">En proceso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Icons.FolderPlus className="w-4 h-4 mr-2" />
|
||||
Nuevo repositorio
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{filtered.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map((vs) => (
|
||||
<article
|
||||
key={vs.id}
|
||||
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
||||
>
|
||||
<header className="min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-semibold truncate">
|
||||
{vs.name || "(Sin nombre)"}
|
||||
</h3>
|
||||
<StatusBadge status={vs.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
|
||||
<Badge variant="outline">
|
||||
Archivos: {vs.file_counts?.completed ?? 0}
|
||||
</Badge>
|
||||
{typeof vs.usage_bytes === "number" && (
|
||||
<span>
|
||||
{(vs.usage_bytes / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
)}
|
||||
{vs.last_active_at && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icons.Clock3 className="w-3 h-3" />
|
||||
{new Date(vs.last_active_at * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{vs.description && (
|
||||
<p className="text-sm text-neutral-700 line-clamp-3">
|
||||
{vs.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => openDetails(vs)}>
|
||||
<Icons.Eye className="w-4 h-4 mr-1" /> Abrir
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(vs.id)}
|
||||
disabled={deletingId === vs.id}
|
||||
>
|
||||
<Icons.Trash2 className="w-4 h-4 mr-1" />
|
||||
{deletingId === vs.id ? "Eliminando…" : "Eliminar"}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-neutral-500 py-10">
|
||||
No hay repositorios todavía. Crea uno nuevo para empezar 🚀
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateVectorStoreDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={() => router.invalidate()}
|
||||
/>
|
||||
|
||||
<VectorStoreDialog
|
||||
store={selected}
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open)
|
||||
if (!open) setSelected(null)
|
||||
}}
|
||||
onUpdated={() => router.invalidate()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ====== Crear repositorio ====== */
|
||||
|
||||
function CreateVectorStoreDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) {
|
||||
alert("Escribe un nombre para el repositorio")
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
await callFilesAndVectorStoresApi<VectorStore>({
|
||||
module: "vectorStores",
|
||||
action: "create",
|
||||
params: { name: name.trim(), description: description.trim() || undefined },
|
||||
})
|
||||
onOpenChange(false)
|
||||
setName("")
|
||||
setDescription("")
|
||||
onCreated()
|
||||
} catch (err: any) {
|
||||
alert(err?.message ?? "Error al crear el repositorio")
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono">Nuevo repositorio</DialogTitle>
|
||||
<DialogDescription>
|
||||
Crea un Vector Store para agrupar archivos relacionados.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Nombre</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Planeación curricular, Entrevistas…"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Descripción (opcional)</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Breve descripción del contenido de este repositorio."
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
||||
{creating ? "Creando…" : "Crear repositorio"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/* ====== Detalle de un repositorio: archivos + subida ====== */
|
||||
|
||||
type FileRow = {
|
||||
file: VectorStoreFile
|
||||
meta: VectorStoreFileMeta | null
|
||||
}
|
||||
|
||||
function VectorStoreDialog({
|
||||
store,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdated,
|
||||
}: {
|
||||
store: VectorStore | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUpdated: () => void
|
||||
}) {
|
||||
const supabaseAuth = useSupabaseAuth()
|
||||
const [files, setFiles] = useState<FileRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [label, setLabel] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !store) return
|
||||
void refreshFiles()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, store?.id])
|
||||
|
||||
async function refreshFiles() {
|
||||
if (!store) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const vectorFiles = await callFilesAndVectorStoresApi<VectorStoreFile[]>({
|
||||
module: "vectorStoreFiles",
|
||||
action: "list",
|
||||
params: { vector_store_id: store.id },
|
||||
})
|
||||
|
||||
const { data: metaRows, error: metaError } = await supabase
|
||||
.from("vector_store_files_meta")
|
||||
.select("*")
|
||||
.eq("vector_store_id", store.id)
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
if (metaError) throw metaError
|
||||
|
||||
const meta = (metaRows ?? []) as VectorStoreFileMeta[]
|
||||
|
||||
const merged: FileRow[] = (vectorFiles ?? []).map((vf) => ({
|
||||
file: vf,
|
||||
meta: meta.find((m) => m.openai_file_id === vf.id) ?? null,
|
||||
}))
|
||||
|
||||
setFiles(merged)
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
setError(err?.message ?? "No se pudieron cargar los archivos")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleUpload() {
|
||||
if (!store || !file) {
|
||||
alert("Selecciona un archivo")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
// 1) Subir archivo a OpenAI vía Edge con FormData (igual que en tu script)
|
||||
const formData = new FormData()
|
||||
formData.append("module", "files")
|
||||
formData.append("action", "upload")
|
||||
formData.append("file", file)
|
||||
formData.append("purpose", "assistants") // o lo que uses en tu flujo
|
||||
|
||||
const { data, error } = await supabase.functions.invoke<any>(
|
||||
"files-and-vector-stores-api",
|
||||
{
|
||||
body: formData,
|
||||
},
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const uploaded = data
|
||||
// La respuesta es el objeto "file" de OpenAI:
|
||||
// { object: "file", id: "file-xxx", ... }
|
||||
const openaiFileId: string | undefined = uploaded?.id
|
||||
|
||||
if (!openaiFileId) {
|
||||
console.error("Respuesta Edge inesperada:", uploaded)
|
||||
throw new Error("La Edge Function no devolvió el id del archivo")
|
||||
}
|
||||
|
||||
// 2) Mapear archivo al Vector Store (JSON normal)
|
||||
await callFilesAndVectorStoresApi<any>({
|
||||
module: "vectorStoreFiles",
|
||||
action: "create",
|
||||
params: {
|
||||
vector_store_id: store.id,
|
||||
body: {
|
||||
file_id: openaiFileId,
|
||||
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 3) Guardar metadata en Supabase
|
||||
const { error: insertError } = await supabase
|
||||
.from("vector_store_files_meta")
|
||||
.insert({
|
||||
user_id: supabaseAuth.user?.id ?? null,
|
||||
vector_store_id: store.id,
|
||||
openai_file_id: openaiFileId,
|
||||
label: label.trim() || file.name,
|
||||
})
|
||||
|
||||
if (insertError) throw insertError
|
||||
|
||||
setFile(null)
|
||||
setLabel("")
|
||||
await refreshFiles()
|
||||
onUpdated()
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
alert(err?.message ?? "Error al subir el archivo")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
async function handleDeleteFile(fileId: string) {
|
||||
if (!store) return
|
||||
if (!confirm("¿Eliminar este archivo del repositorio y de OpenAI?")) return
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await callFilesAndVectorStoresApi<any>({
|
||||
module: "vectorStoreFiles",
|
||||
action: "delete",
|
||||
params: {
|
||||
vector_store_id: store.id,
|
||||
file_id: fileId,
|
||||
},
|
||||
})
|
||||
|
||||
// Opcional: eliminar también el archivo global de OpenAI
|
||||
await callFilesAndVectorStoresApi<any>({
|
||||
module: "files",
|
||||
action: "delete",
|
||||
params: { id: fileId },
|
||||
})
|
||||
|
||||
await supabase
|
||||
.from("vector_store_files_meta")
|
||||
.delete()
|
||||
.eq("openai_file_id", fileId)
|
||||
|
||||
await refreshFiles()
|
||||
onUpdated()
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
alert(err?.message ?? "Error al eliminar el archivo")
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!store) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icons.Folder className="h-4 w-4" />
|
||||
{store.name || "(Sin nombre)"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Gestiona los archivos asociados a este repositorio (Vector Store).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
|
||||
<StatusBadge status={store.status} />
|
||||
<Badge variant="outline">
|
||||
Archivos completados: {store.file_counts?.completed ?? 0}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
Total archivos: {store.file_counts?.total ?? 0}
|
||||
</Badge>
|
||||
{typeof store.usage_bytes === "number" && (
|
||||
<span>{(store.usage_bytes / 1024 / 1024).toFixed(2)} MB</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subida de archivo */}
|
||||
<div className="space-y-2 rounded-lg border bg-muted/50 p-4">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Agregar archivo al repositorio
|
||||
</Label>
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-end">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.txt,.md"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{file && (
|
||||
<div className="text-xs text-neutral-600">
|
||||
{file.name} · {(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Título / etiqueta</Label>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Ej.: Plan 2025, Entrevista 3…"
|
||||
/>
|
||||
<Button
|
||||
className="mt-2 w-full sm:w-auto"
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !file}
|
||||
>
|
||||
{uploading ? "Subiendo…" : "Subir al repositorio"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de archivos */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Archivos en este repositorio</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refreshFiles()}
|
||||
disabled={loading || refreshing}
|
||||
>
|
||||
<Icons.RefreshCw className="h-4 w-4 mr-1" />
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-xs text-neutral-500 py-4">
|
||||
Cargando archivos…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500 py-4">{error}</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-xs text-neutral-500 py-4">
|
||||
Todavía no hay archivos en este repositorio ⚡
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{files.map(({ file, meta }) => (
|
||||
<li
|
||||
key={file.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border bg-background px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{meta?.label || file.id}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 truncate">
|
||||
{new Date(file.created_at * 1000).toLocaleString()} ·{" "}
|
||||
{(file.usage_bytes / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={file.status} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteFile(file.id)}
|
||||
>
|
||||
<Icons.Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cerrar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -265,7 +265,7 @@ function Page() {
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="font-medium">
|
||||
{/^\s*\d+/.test(String(u.key))
|
||||
? `Unidad ${u.key && Number(u.key) ? Number(u.key) : 1}${u.__title ? `: ${u.__title}` : ""}`
|
||||
? `Unidad ${u.key && Number(u.key) ? Number(u.key) + 1 : 1}${u.__title ? `: ${u.__title}` : ""}`
|
||||
: u.__title}
|
||||
</span>
|
||||
<span className="text-[11px] text-neutral-500">{u.__temas.length} tema(s)</span>
|
||||
|
||||
@@ -138,7 +138,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.animate-aurora {
|
||||
background: radial-gradient(at 20% 30%, oklch(27.5% 0.13488 262.73), transparent 50%),
|
||||
radial-gradient(at 80% 70%, oklch(0.704 0.191 22.216), transparent 50%),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"include": ["**/*.ts", "**/*.tsx", "src/components/ai/AIChatModal.jsx", "src/components/ai/AIChatModal.js"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user