16 Commits

Author SHA1 Message Date
ff17f7a615 Añadir nixpacks.toml
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m56s
2026-01-06 15:49:44 +00:00
dab7a867eb Eliminar package-lock.json
Some checks failed
Deploy to Azure Static Web Apps / build-and-deploy (push) Has been cancelled
2026-01-06 15:48:36 +00:00
87458ccdad Merge branch 'main' of https://github.lci.ulsa.mx/AlexRG/React-Autenticado
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m49s
2025-12-05 08:43:35 -06:00
a1ea8973a7 Add Vercel configuration files and update dependencies 2025-12-05 08:43:32 -06:00
b08d918f84 Refactor deployment workflow to improve Bun setup and streamline Azure Static Web Apps deployment
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m42s
2025-11-28 10:40:34 -06:00
f1591bb9b9 Update deployment workflow to use Bun instead of Node.js
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m47s
2025-11-28 10:27:49 -06:00
965d0198a0 Merge branch 'fix/typos' 2025-11-28 10:27:27 -06:00
ba6f59c4c8 Refactor deployment workflow to use Bun instead of Node.js
Some checks failed
Deploy to Azure Static Web Apps / build-and-deploy (push) Failing after 1m18s
2025-11-28 10:22:33 -06:00
8546b99035 Add staticwebapp.config.json for navigation fallback configuration
Some checks failed
Deploy to Azure Static Web Apps / build-and-deploy (push) Failing after 2m36s
2025-11-28 10:17:15 -06:00
458c4b7973 Add deployment workflow for Azure Static Web Apps
Some checks failed
Deploy to Azure Static Web Apps / build-and-deploy (push) Has been cancelled
2025-11-28 10:16:41 -06:00
e3c1a0ce2b Add staticwebapp.config.json for navigation fallback configuration 2025-11-28 10:09:28 -06:00
76170421aa Refactor components by removing unused imports and optimizing state management; add configuration for Azure Static Web Apps 2025-11-28 09:52:53 -06:00
2db3a0570a Update API parameters and refactor file upload handling in VectorStoreDialog 2025-11-27 16:41:42 -06:00
d8ade3da75 Merge branch 'formatChatjson' 2025-11-27 16:10:41 -06:00
9d9fb3d8a8 Se corrigen errores de contexto y limpiar mensajes de ia, se actualiza la forma de mostrar archivos y vectores y se permite seleccionar varios archivos 2025-11-27 16:08:02 -06:00
a6f0010a53 Se agrga crear formatos 2025-11-26 19:44:45 -06:00
24 changed files with 579 additions and 6293 deletions

View File

@@ -0,0 +1,38 @@
name: Deploy to Azure Static Web Apps
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build
env:
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }}
VITE_BACK_ORIGIN: ${{ vars.VITE_BACK_ORIGIN }}
run: bun run build
# No hace falta instalar el CLI globalmente, usamos bunx
- name: Deploy to Azure Static Web Apps
env:
AZURE_SWA_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOYMENT_TOKEN }}
run: |
bunx @azure/static-web-apps-cli deploy ./dist \
--env production \
--deployment-token "$AZURE_SWA_DEPLOYMENT_TOKEN"

11
.vercel/README.txt Normal file
View File

@@ -0,0 +1,11 @@
> Why do I have a folder named ".vercel" in my project?
The ".vercel" folder is created when you link a directory to a Vercel project.
> What does the "project.json" file contain?
The "project.json" file contains:
- The ID of the Vercel project that you linked ("projectId")
- The ID of the user or team your Vercel project is owned by ("orgId")
> Should I commit the ".vercel" folder?
No, you should not share the ".vercel" folder with anyone.
Upon creation, it will be automatically added to your ".gitignore" file.

View File

@@ -0,0 +1,36 @@
{
"//": "This file was generated by the `vercel build` command. It is not part of the Build Output API.",
"target": "preview",
"argv": [
"C:\\Program Files\\nodejs\\node.exe",
"C:\\Users\\alex\\.bun\\install\\global\\node_modules\\vercel\\dist\\vc.js",
"build"
],
"builds": [
{
"require": "@vercel/static-build",
"requirePath": "C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\static-build\\dist\\index",
"apiVersion": 2,
"src": "package.json",
"use": "@vercel/static-build",
"config": {
"zeroConfig": true,
"framework": "vite"
},
"error": {
"name": "Error",
"stack": "Error: Command \"npm run build\" exited with 2\n at ChildProcess.<anonymous> (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:23221:9)\n at ChildProcess.emit (node:events:508:28)\n at ChildProcess.emit (node:domain:489:12)\n at cp.emit (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:14249:29)\n at maybeClose (node:internal/child_process:1101:16)\n at ChildProcess._handle.onexit (node:internal/child_process:305:5)",
"message": "Command \"npm run build\" exited with 2",
"hideStackTrace": true,
"code": "BUILD_UTILS_SPAWN_2"
}
}
],
"error": {
"name": "Error",
"stack": "Error: Command \"npm run build\" exited with 2\n at ChildProcess.<anonymous> (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:23221:9)\n at ChildProcess.emit (node:events:508:28)\n at ChildProcess.emit (node:domain:489:12)\n at cp.emit (C:\\Users\\alex\\.bun\\install\\global\\node_modules\\@vercel\\build-utils\\dist\\index.js:14249:29)\n at maybeClose (node:internal/child_process:1101:16)\n at ChildProcess._handle.onexit (node:internal/child_process:305:5)",
"message": "Command \"npm run build\" exited with 2",
"hideStackTrace": true,
"code": "BUILD_UTILS_SPAWN_2"
}
}

View File

@@ -0,0 +1,3 @@
{
"version": 3
}

View File

@@ -0,0 +1 @@
[{"name":"vc.builder","duration":11410816,"timestamp":1109155148251,"id":"883875fc-d8b5-40c7-8fdd-f96c27a63d1b","parentId":"3583d148-7b32-444b-813e-a786bebb1401","tags":{"name":"@vercel/static-build"},"startTime":1764002346775},{"name":"vc.builder.diagnostics","duration":17,"timestamp":1109166559117,"id":"c6ad9c3e-80a6-4b2c-9d0b-9e6999814dda","parentId":"883875fc-d8b5-40c7-8fdd-f96c27a63d1b","tags":{},"startTime":1764002358186},{"name":"vc.doBuild","duration":11600581,"timestamp":1109154959803,"id":"3583d148-7b32-444b-813e-a786bebb1401","parentId":"dad52c92-34d7-49a1-a8c7-c26d68888fb8","tags":{},"startTime":1764002346587},{"name":"vc","duration":11643513,"timestamp":1109154916896,"id":"dad52c92-34d7-49a1-a8c7-c26d68888fb8","tags":{},"startTime":1764002346544}]

16
.vercel/project.json Normal file
View File

@@ -0,0 +1,16 @@
{
"projectId": "prj_TQf0vM7v0Pz1NyDhm3Ab0Jp4zB2E",
"orgId": "team_dURDB79ODIkvcyPxn5ZVT7xr",
"projectName": "acad-ia",
"settings": {
"createdAt": 1764000675314,
"framework": "vite",
"devCommand": null,
"installCommand": null,
"buildCommand": null,
"outputDirectory": null,
"rootDirectory": null,
"directoryListing": false,
"nodeVersion": "22.x"
}
}

View File

@@ -32,7 +32,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"jspdf": "^3.0.3", "jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -752,7 +752,7 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "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": ["jspdf@3.0.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "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-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ=="],
"jspdf-autotable": ["jspdf-autotable@5.0.2", "", { "peerDependencies": { "jspdf": "^2 || ^3" } }, "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ=="], "jspdf-autotable": ["jspdf-autotable@5.0.2", "", { "peerDependencies": { "jspdf": "^2 || ^3" } }, "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ=="],
@@ -1122,6 +1122,8 @@
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"jspdf/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],

5
nixpacks.toml Normal file
View File

@@ -0,0 +1,5 @@
[phases.install]
cmds = ["bun install --frozen-lockfile"]
[phases.build]
cmds = ["bun run build"]

5810
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"jspdf": "^3.0.3", "jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { supabase } from "@/auth/supabase"; import { supabase } from "@/auth/supabase";
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown";
/* ---------- UI Mocks (sin cambios) ---------- */ /* ---------- UI Mocks (sin cambios) ---------- */
const Paperclip = (props) => ( const Paperclip = (props) => (
@@ -33,23 +33,28 @@ const CardContent = ({ className, children }) => <div className={`p-4 ${classNam
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>; const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
/* ------------- COMPONENT ------------- */ /* ------------- COMPONENT ------------- */
export default function AIChatModal({ open, onClose, context, onAccept }) { export default function AIChatModal({ open, onClose, context, onAccept, plan_format }) {
const [vectorStores, setVectorStores] = useState([]); const [vectorStores, setVectorStores] = useState([]);
const [vectorFiles, setVectorFiles] = useState([]); const [vectorFiles, setVectorFiles] = useState([]);
const [selectedVectorFile, setSelectedVectorFile] = useState(null); const [selectedVector, setSelectedVector] = useState(null);
const [selectedFiles, setSelectedFiles] = useState([]);
const [attachedFiles, setAttachedFiles] = useState([]); const [attachedFiles, setAttachedFiles] = useState([]);
const [attachedPreviews, setAttachedPreviews] = useState([]); const [attachedPreviews, setAttachedPreviews] = useState([]);
// chat
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
// loading states
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false); const [loadingFiles, setLoadingFiles] = useState(false);
const [loadingVectors, setLoadingVectors] = useState(false); const [loadingVectors, setLoadingVectors] = useState(false);
// conversation control
const [conversationId, setConversationId] = useState(null); const [conversationId, setConversationId] = useState(null);
const [creatingConversation, setCreatingConversation] = useState(false); // control para esperar const [creatingConversation, setCreatingConversation] = useState(false);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -57,103 +62,66 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
const normalizeInvokeResponse = (resp) => { const normalizeInvokeResponse = (resp) => {
if (!resp) return null; if (!resp) return null;
// cuando invocas funciones, Supabase siempre regresa:
// { data: "...string...", error: null, response: {} }
const raw = resp.data; const raw = resp.data;
if (typeof raw === "string") { if (typeof raw === "string") {
try { try { return JSON.parse(raw); } catch (e) { console.warn("❗ No se pudo parsear resp.data:", raw); return null; }
return JSON.parse(raw);
} catch (e) {
console.warn("❗ No se pudo parsear resp.data:", raw);
return null;
} }
}
// si ya viene como objeto
if (typeof raw === "object" && raw !== null) return raw; if (typeof raw === "object" && raw !== null) return raw;
return null; return null;
}; };
// Al abrir: reset o crear conversación // Al abrir: reset o crear conversación
useEffect(() => { useEffect(() => {
console.log(context.cont_conversation);
console.log(context);
if (!open) { if (!open) {
// si ya existe una conversación la eliminamos
if (conversationId) { if (conversationId) {
deleteConversation(conversationId).catch((e) => console.error(e)); deleteConversation(conversationId).catch((e) => console.error(e));
} }
setMessages([]); setMessages([]);
setInput(""); setInput("");
setSelectedVectorFile(null); setSelectedFiles([]);
setAttachedFiles([]); setAttachedFiles([]);
setAttachedPreviews([]); setAttachedPreviews([]);
setConversationId(null); setConversationId(null);
setSelectedVector(null);
setVectorFiles([]);
return; return;
} }
// inyectar contexto como system message
if (context) { if (context) {
setMessages([ setMessages([
{ {
role: "system", 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 || "—"}` content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
} }
]); ]);
} else {
setMessages(prev => prev); // no hacer nada si no hay contexto
} }
// crear conversación y esperar a que termine antes de permitir enviar
(async () => { (async () => {
await createConversation(); await createConversation();
// tras crear podemos también cargar vector stores
fetchVectorStores(); fetchVectorStores();
})(); })();
}, [open]); }, [open]);
// --------- CREATE CONVERSATION (robusto) ---------- // ---------- CREATE CONVERSATION ----------
const createConversation = async () => { const createConversation = async () => {
try { try {
setCreatingConversation(true); setCreatingConversation(true);
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token; const token = session?.access_token;
// llamada const resp = await supabase.functions.invoke("conversation-format", {
const resp = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: { action: "start" , role:"system", content:context.cont_conversation, } body: { action: "start", role: "system", content: context?.cont_conversation ?? "" }
}); });
console.log("createConversation -> raw resp:", resp);
// resp puede ser { data: "...json string..." } o { data: { ... } }
let parsed = null; let parsed = null;
if (typeof resp?.data === "string") { if (typeof resp?.data === "string") {
try { try { parsed = JSON.parse(resp.data); } catch (e) { parsed = null; }
parsed = JSON.parse(resp.data); } else if (typeof resp?.data === "object" && resp.data !== null) parsed = resp.data;
} catch (e) { else parsed = resp;
console.warn("No se pudo parsear resp.data como JSON:", e, resp.data);
parsed = null;
}
} else if (typeof resp?.data === "object" && resp.data !== null) {
parsed = resp.data;
} else {
// fallback: quizá la respuesta viene en resp (sin data)
parsed = resp;
}
console.log("createConversation -> parsed payload:", parsed);
// buscar el id en varios lugares (robusto)
const convId = const convId =
parsed?.conversationId || parsed?.conversationId ||
parsed?.data?.conversationId || parsed?.data?.conversationId ||
@@ -162,24 +130,16 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
parsed?.conversation_id || parsed?.conversation_id ||
parsed?.data?.conversation_id; parsed?.data?.conversation_id;
if (!convId) { if (!convId) { setCreatingConversation(false); return; }
console.warn("No se encontró conversationId en la respuesta parseada:", parsed);
setCreatingConversation(false);
return;
}
setConversationId(convId); setConversationId(convId);
console.log("🟢 Conversación creada y guardada:", convId);
} catch (err) { } catch (err) {
console.error("Error creando conversación:", err); console.error("Error creando conversación:", err);
} finally { } finally {
setCreatingConversation(false); setCreatingConversation(false);
} }
}; };
// ---------- DELETE CONVERSATION ----------
// --------- DELETE CONVERSATION (robusto) ----------
const deleteConversation = async (convIdParam) => { const deleteConversation = async (convIdParam) => {
try { try {
const convIdToUse = convIdParam ?? conversationId; const convIdToUse = convIdParam ?? conversationId;
@@ -187,13 +147,11 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token; const token = session?.access_token;
// algunas implementations esperan { action: "end", conversationId }, otras { action: "end", id } await supabase.functions.invoke("conversation-format", {
const { data, error } = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: { action: "end", conversationId: convIdToUse } body: { action: "end", conversationId: convIdToUse }
}); });
console.log("deleteConversation -> response:", data);
setConversationId(null); setConversationId(null);
} catch (err) { } catch (err) {
console.error("Error eliminando conversación:", err); console.error("Error eliminando conversación:", err);
@@ -209,11 +167,13 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
// ---------- SEND MESSAGE (usa conversationId) ---------- // ---------- HANDLE CONVERSATION (envío) ----------
const handleConversation = async ({ text }) => { const handleConversation = async ({ text }) => {
let contextText = "";
if (context?.originalText) contextText += `CONTEXTO DEL CAMPO:\n${context.originalText}\n`;
if (!conversationId) { if (!conversationId) {
console.warn("No hay conversación activa todavía. conversationId:", conversationId); console.warn("No hay conversación activa todavía. conversationId:", conversationId);
// si no hay conv, opcionalmente intentar crear una sin que el usuario note
return; return;
} }
@@ -222,8 +182,8 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token; const token = session?.access_token;
// archivos adjuntos (locales) -> base64
let filesInput = []; let filesInput = [];
if (attachedFiles.length > 0) { if (attachedFiles.length > 0) {
for (const file of attachedFiles) { for (const file of attachedFiles) {
const base64 = await fileToBase64(file); const base64 = await fileToBase64(file);
@@ -233,33 +193,37 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
file_data: `data:${file.type};base64,${base64}` file_data: `data:${file.type};base64,${base64}`
}); });
} }
}
if (selectedVectorFile) {
// si el archivo del vector viene sólo con id
filesInput.push({
type: "input_file",
file_id: selectedVectorFile.id
});
} }
// 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 = { const payload = {
action: "message", action: "message",
format: plan_format,
conversationId, conversationId,
vectorStoreId: selectedVectorFile?.vector_store_id ?? null, vectorStoreId: selectedVector ?? null,
fileIds: selectedVectorFile ? [selectedVectorFile.id] : [], fileIds: selectedFiles.length ? selectedFiles.map(f => f.id) : [],
input: [ input: [
{ {
role: "user", role: "user",
content: [ content: [
{ type: "input_text", text }, { type: "input_text", text: promptFinal },
...filesInput ...filesInput
] ]
} }
] ]
}; };
const { data: invokeData, error } = await supabase.functions.invoke( const { data: invokeData, error } = await supabase.functions.invoke(
"modal-conversation", "conversation-format",
{ {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: payload body: payload
@@ -267,40 +231,24 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
); );
if (error) throw error; if (error) throw error;
console.log("handleConversation -> RAW invokeData:", invokeData);
const parsed = normalizeInvokeResponse({ data: invokeData }); const parsed = normalizeInvokeResponse({ data: invokeData });
console.log("handleConversation -> PARSED:", parsed);
// 🔥 EXTRACTOR DEFINITIVO // Extraer texto del assistant (robusto)
let assistantText = null; let assistantText = null;
if (parsed?.data?.output_text) assistantText = parsed.data.output_text;
// 1) directo
if (parsed?.data?.output_text) {
assistantText = parsed.data.output_text;
}
// 2) buscar el message
if (!assistantText && Array.isArray(parsed?.data?.output)) { if (!assistantText && Array.isArray(parsed?.data?.output)) {
const msgBlock = parsed.data.output.find(o => o.type === "message"); const msgBlock = parsed.data.output.find(o => o.type === "message");
if (msgBlock?.content?.[0]?.text) { if (msgBlock?.content?.[0]?.text) assistantText = msgBlock.content[0].text;
assistantText = msgBlock.content[0].text;
} }
}
// 3) fallback
assistantText = assistantText || "Sin respuesta del modelo."; assistantText = assistantText || "Sin respuesta del modelo.";
setMessages(prev => [ setMessages(prev => [...prev, { role: "assistant", content: cleanAssistantResponse(assistantText) }]);
...prev,
{ role: "assistant", content: cleanAssistantResponse(assistantText) }
]);
// limpiar attachments locales (pero mantener seleccionados del vector si quieres — aquí los limpiamos)
setAttachedFiles([]); setAttachedFiles([]);
setAttachedPreviews([]); setAttachedPreviews([]);
// si quieres mantener los selectedFiles tras el envío, comenta la siguiente línea:
setSelectedFiles([]);
} catch (err) { } catch (err) {
console.error("Error en handleConversation:", err); console.error("Error en handleConversation:", err);
@@ -361,29 +309,49 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
const handleAttach = (e) => { const handleAttach = (e) => {
const files = Array.from(e.target.files); const files = Array.from(e.target.files);
if (!files.length) return; if (!files.length) return;
setAttachedFiles(prev => [...prev, ...files]); setAttachedFiles(prev => [...prev, ...files]);
setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]); 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;
}
const handleSelectVectorFile = (file) => { setSelectedVector(vector.id);
setSelectedVectorFile(file); 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 ---------- // ---------- Send flow ----------
const handleSend = async () => { const handleSend = async () => {
if (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile) return; // no permitir enviar si no hay nada
if (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0) return;
// esperar si aún se está creando la conversación
if (creatingConversation) { if (creatingConversation) {
console.log("Esperando a que se cree la conversación..."); // no bloqueo visible aquí por diseño; simplemente ignoramos el envío si aún creando
// opcional: podrías mostrar un toast; aquí simplemente retornamos
return; return;
} }
if (!conversationId) { if (!conversationId) {
console.warn("No hay conversationId — intentaremos crear una ahora.");
await createConversation(); await createConversation();
if (!conversationId) { if (!conversationId) {
setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]); setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
@@ -391,122 +359,197 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
} }
} }
const userText = input.trim() || (selectedVectorFile ? `Consultar archivo vector: ${selectedVectorFile.filename || selectedVectorFile.id}` : ""); const userText = input.trim() || (selectedFiles.length ? `Consultar ${selectedFiles.length} archivo(s) del repositorio` : "");
setMessages(prev => [...prev, { role: "user", content: userText }]); setMessages(prev => [...prev, { role: "user", content: userText }]);
setInput(""); setInput("");
await handleConversation({ text: userText }); await handleConversation({ text: userText });
}; };
const handleApply = () => { function cleanAIResponse(text) {
const last = [...messages].reverse().find(m => m.role === "assistant");
if (last && onAccept) {
onAccept(last.content);
onClose();
}
};
const cleanAssistantResponse = (text) => {
if (!text) return text; if (!text) return text;
// Frases que quieres eliminar (puedes agregar más) let cleaned = text;
const patterns = [
/^claro[, ]*/i, // -------------------------
/^por supuesto[, ]*/i, // 1. Eliminar emojis
/^aquí tienes[, ]*/i, // -------------------------
/^con gusto[, ]*/i, cleaned = cleaned.replace(/[\p{Emoji}\uFE0F]/gu, "");
/^hola[, ]*/i,
/^perfecto[, ]*/i, // -------------------------
/^entendido[, ]*/i, // 2. Eliminar separadores tipo ---
/^muy bien[, ]*/i, // -------------------------
/^ok[, ]*/i, 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,
]; ];
let cleaned = text.trim(); metaPatterns.forEach(p => {
for (const p of patterns) {
cleaned = cleaned.replace(p, "").trim(); 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 cleaned;
}; };
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose} >
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative"> <DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative"
{/* Botón siempre visible */}
<button
onClick={onClose}
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 transition z-50"
> >
<button onClick={onClose} className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 transition z-50"></button>
</button>
<DialogHeader> <DialogHeader>
<DialogTitle>Asistente Inteligente</DialogTitle> <DialogTitle>Asistente Inteligente</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex-1 pt-4 min-h-0"> <div className="flex-1 pt-4 min-h-0">
<div className="flex gap-6 h-full min-h-0"> <div className="flex gap-6 h-full min-h-0">
{/* Left: vectors */} {/* 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"> <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"> <CardContent className="flex flex-col flex-1 p-4">
<h3 className="font-semibold text-sm mb-3">Vector Stores</h3> <h3 className="font-semibold text-sm mb-3">Repositorio de archivos</h3>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
{loadingVectors ? ( {loadingVectors ? (
<p className="text-gray-500 text-sm text-center mt-10">Cargando vector stores...</p> <p className="text-gray-500 text-sm text-center mt-10">Cargando Repositorio de archivos...</p>
) : vectorStores.length === 0 ? ( ) : vectorStores.length === 0 ? (
<p className="text-gray-500 text-sm text-center mt-10">No hay vector stores.</p> <p className="text-gray-500 text-sm text-center mt-10">No hay Repositorio de archivos.</p>
) : ( ) : (
<ul className="space-y-2"> <div className="space-y-3">
{vectorStores.map((vector) => ( {vectorStores.map((vector) => (
<li key={vector.id} <div key={vector.id}>
onClick={() => loadFilesForVector(vector.id)} {/* VECTOR */}
className="border cursor-pointer hover:bg-blue-50 p-2 rounded-lg bg-white" <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"}`}
> >
<strong className="truncate">{vector.name || vector.id}</strong> <div className="truncate">
<p className="text-xs text-gray-400 truncate">{vector.description || vector.id}</p> <strong className="block truncate">{vector.name || vector.id}</strong>
</li> <p className="text-xs text-gray-400 truncate">{vector.description || ""}</p>
))} </div>
</ul> <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> </ScrollArea>
{/* Resumen de archivos seleccionados (de vectores) */}
<div className="mt-4"> <div className="mt-4">
<h4 className="font-semibold text-sm mb-2">Archivos del Vector</h4> <h4 className="font-semibold text-sm mb-2">Archivos seleccionados</h4>
{loadingFiles ? ( {selectedFiles.length === 0 ? (
<p className="text-sm text-gray-500">Cargando archivos...</p> <p className="text-sm text-gray-500">No has seleccionado archivos del repositorio</p>
) : selectedVectorFile ? (
<div className="text-sm text-gray-700 mb-2">
Seleccionado: <strong>{selectedVectorFile.filename ?? selectedVectorFile.id}</strong>
</div>
) : ( ) : (
<p className="text-sm text-gray-500">Selecciona un archivo del vector</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">
<ul className="space-y-2 max-h-40 overflow-auto mt-2"> <div className="text-sm">
{vectorFiles.map((file) => ( <div className="font-medium">{f.filename ?? f.name ?? f.id}</div>
<li key={file.id} <div className="text-xs text-gray-400 truncate">{f.id}</div>
onClick={() => handleSelectVectorFile(file)} </div>
className={`p-2 rounded-lg cursor-pointer border ${selectedVectorFile?.id === file.id ? "bg-blue-50 border-blue-300" : "bg-white"}`} <div className="flex items-center gap-2">
> <span className="text-xs text-gray-500">{/* optionally show vector id */}</span>
<div className="text-sm font-medium">{file.filename}</div> <button onClick={() => removeSelectedFile(f.id)} className="text-sm text-red-500 hover:underline">Quitar</button>
<div className="text-xs text-gray-400">{file.id}</div> </div>
</li> </li>
))} ))}
</ul> </ul>
)}
</div> </div>
<div className="mt-4 flex-shrink-0"> {/* <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> <Button variant="outline" className="w-full" onClick={() => alert("Funcionalidad Subir a vector store no implementada aquí")}>Subir archivo (vector)</Button>
</div> </div> */}
</CardContent> </CardContent>
</Card> </Card>
{/* Right: Chat */} {/* Right: Chat */}
<Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl"> <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"> <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> <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"> <div className="flex-1 flex flex-col min-h-0">
@@ -519,7 +562,6 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
<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"}`}> <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>{" "} <strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
<ReactMarkdown>{m.content}</ReactMarkdown> <ReactMarkdown>{m.content}</ReactMarkdown>
</div> </div>
)) ))
)} )}
@@ -567,7 +609,7 @@ export default function AIChatModal({ open, onClose, context, onAccept }) {
style={{ minHeight: "38px" }} style={{ minHeight: "38px" }}
/> />
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile)} className="shadow-md"> <Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0)} className="shadow-md">
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"} {creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
</Button> </Button>

View File

@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { supabase } from "@/auth/supabase" import { supabase } from "@/auth/supabase"
import ReactMarkdown from "react-markdown"
import { useSupabaseAuth } from "@/auth/supabase" import { useSupabaseAuth } from "@/auth/supabase"
export function HistorialCambiosModal({ export function HistorialCambiosModal({

View File

@@ -6,15 +6,12 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { AuroraButton } from "@/components/effect/aurora-button" import { AuroraButton } from "@/components/effect/aurora-button"
import confetti from "canvas-confetti" import confetti from "canvas-confetti"
import { useQueryClient } from "@tanstack/react-query"
import { supabase, useSupabaseAuth } from "@/auth/supabase" import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Field } from "./Field" import { Field } from "./Field"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { asignaturaKeys } from "./planQueries"
import { useRouter } from "@tanstack/react-router" import { useRouter } from "@tanstack/react-router"
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) { export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient()
const router = useRouter() const router = useRouter()
const supabaseAuth = useSupabaseAuth() const supabaseAuth = useSupabaseAuth()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)

View File

@@ -33,7 +33,6 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
const sectionGap = 10 // Espacio entre recuadros de sección const sectionGap = 10 // Espacio entre recuadros de sección
const bodyFontSize = 10.5 const bodyFontSize = 10.5
const headingFontSize = 12 const headingFontSize = 12
const subHeadingFontSize = 10
const bulletGlifo = "\u21D2" // Flecha doble (⇒) para el glifo const bulletGlifo = "\u21D2" // Flecha doble (⇒) para el glifo
const bulletIndent = 6 // Sangría para el texto de la lista const bulletIndent = 6 // Sangría para el texto de la lista

View File

@@ -12,6 +12,7 @@ import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
import AIChatModal from "../ai/AIChatModal" import AIChatModal from "../ai/AIChatModal"
/* ===================================================== /* =====================================================
Query keys & fetcher Query keys & fetcher
===================================================== */ ===================================================== */
@@ -54,6 +55,8 @@ export const planTextOptions = (planId: string) =>
staleTime: 60_000, staleTime: 60_000,
}) })
/* ===================================================== /* =====================================================
Color helpers Color helpers
===================================================== */ ===================================================== */
@@ -69,7 +72,7 @@ const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb
/* ===================================================== /* =====================================================
Expandable text Expandable text
===================================================== */ ===================================================== */
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) { function ExpandableText({ text }: { text?: string | string[] | null; mono?: boolean }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
if (!text || (Array.isArray(text) && text.length === 0)) { if (!text || (Array.isArray(text) && text.length === 0)) {
return <span className="text-neutral-400"></span> return <span className="text-neutral-400"></span>
@@ -124,6 +127,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null) const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
const [draft, setDraft] = useState("") const [draft, setDraft] = useState("")
// --- mutation con actualización optimista --- // --- mutation con actualización optimista ---
const updateField = useMutation({ const updateField = useMutation({
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => { mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
@@ -304,21 +308,19 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
onClose={() => setOpenHistorial(false)} onClose={() => setOpenHistorial(false)}
planId={planId} planId={planId}
onRestore={async (key, value) => { onRestore={async (key, value) => {
updateField.mutate({ key, value }) updateField.mutate({ key: key as keyof PlanTextFields, value })
}} }}
/> />
<AIChatModal <AIChatModal
//plan_format={plan_format}
open={openModalIa} open={openModalIa}
onClose={() => setopenModalIa(false)} onClose={() => setopenModalIa(false)}
context={{ context={{
section: iaContext?.title, section: null,//,iaContext?.title,
fieldKey: iaContext?.key, fieldKey: null,//iaContext?.key,
originalText: iaContext?.content, 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. cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId}`,
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) => { onAccept={(newText: string) => {
if (iaContext) { if (iaContext) {

10
src/formatos/plan.json Normal file
View File

@@ -0,0 +1,10 @@
{
"objetivo_general": "...",
"sistema_evaluacion": "...",
"perfil_ingreso": "...",
"perfil_egreso": "...",
"competencias_genericas": "...",
"competencias_especificas": "...",
"indicadores_desempeno": "...",
"pertinencia": "..."
}

View File

@@ -1,4 +1,4 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { supabase } from "@/auth/supabase"; import { supabase } from "@/auth/supabase";
/** /**
@@ -11,8 +11,6 @@ export function useSupabaseUpdateWithHistory<T extends Record<string, any>>(
tableName: string, tableName: string,
idKey: keyof T = "id" as keyof T idKey: keyof T = "id" as keyof T
) { ) {
const qc = useQueryClient();
// Generar diferencias tipo JSON Patch // Generar diferencias tipo JSON Patch
function generateDiff(oldData: T, newData: Partial<T>) { function generateDiff(oldData: T, newData: Partial<T>) {
const changes: any[] = []; const changes: any[] = [];

View File

@@ -149,8 +149,6 @@ function Layout() {
function Sidebar({ onNavigate }: { onNavigate?: () => void }) { function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const { claims } = useSupabaseAuth() const { claims } = useSupabaseAuth()
const isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '') const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')

View File

@@ -106,7 +106,9 @@ export const Route = createFileRoute("/_authenticated/archivos")({
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({ const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
module: "vectorStores", module: "vectorStores",
action: "list", action: "list",
params: {}, params: {
limit: 10,
},
}) })
return stores ?? [] return stores ?? []
}, },
@@ -184,7 +186,7 @@ function RouteComponent() {
await callFilesAndVectorStoresApi({ await callFilesAndVectorStoresApi({
module: "vectorStores", module: "vectorStores",
action: "delete", action: "delete",
params: { id }, params: { vector_store_id: id },
}) })
await supabase await supabase
@@ -472,50 +474,54 @@ function VectorStoreDialog({
} }
} }
async function toBase64(f: File): Promise<string> {
const buf = await f.arrayBuffer()
const bytes = new Uint8Array(buf)
let binary = ""
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
async function handleUpload() { async function handleUpload() {
if (!store || !file) { if (!store || !file) {
alert("Selecciona un archivo") alert("Selecciona un archivo")
return return
} }
setUploading(true) setUploading(true)
try { try {
const fileBase64 = await toBase64(file) // 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
// 1) Subir archivo a OpenAI vía Edge (módulo files) const { data, error } = await supabase.functions.invoke<any>(
const uploaded: any = await callFilesAndVectorStoresApi<any>({ "files-and-vector-stores-api",
module: "files", {
action: "upload", body: formData,
params: {
fileBase64,
filename: file.name,
mimeType: file.type,
}, },
}) )
const openaiFileId: string = if (error) {
uploaded?.id ?? uploaded?.file?.id ?? uploaded?.data?.id 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) { if (!openaiFileId) {
console.error("Respuesta Edge inesperada:", uploaded)
throw new Error("La Edge Function no devolvió el id del archivo") throw new Error("La Edge Function no devolvió el id del archivo")
} }
// 2) Mapear archivo al Vector Store // 2) Mapear archivo al Vector Store (JSON normal)
await callFilesAndVectorStoresApi<any>({ await callFilesAndVectorStoresApi<any>({
module: "vectorStoreFiles", module: "vectorStoreFiles",
action: "create", action: "create",
params: { params: {
vector_store_id: store.id, vector_store_id: store.id,
body: {
file_id: openaiFileId, file_id: openaiFileId,
},
}, },
}) })
@@ -542,7 +548,6 @@ function VectorStoreDialog({
setUploading(false) setUploading(false)
} }
} }
async function handleDeleteFile(fileId: string) { async function handleDeleteFile(fileId: string) {
if (!store) return if (!store) return
if (!confirm("¿Eliminar este archivo del repositorio y de OpenAI?")) return if (!confirm("¿Eliminar este archivo del repositorio y de OpenAI?")) return

View File

@@ -2,7 +2,7 @@ import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query' import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/auth/supabase' import { supabase } from '@/auth/supabase'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select' import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
@@ -168,13 +168,13 @@ function RouteComponent() {
const [q, setQ] = useState(search.q ?? '') const [q, setQ] = useState(search.q ?? '')
const [sem, setSem] = useState<string>('todos') const [sem, setSem] = useState<string>('todos')
const [tipo, setTipo] = useState<string>('todos') const [tipo, setTipo] = useState<string>('todos')
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre') const [groupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '') const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
const [facultad, setFacultad] = useState("todas") const [facultad, setFacultad] = useState("todas")
const [carrera, setCarrera] = useState("todas") const [carrera, setCarrera] = useState("todas")
/* useEffect(() => { /* useEffect(() => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
router.navigate({ router.navigate({
to: '/asignaturas', to: '/asignaturas',
@@ -183,10 +183,10 @@ const [carrera, setCarrera] = useState("todas")
}) })
}, 400) }, 400)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [q]) */ }, [q]) */
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value const value = e.target.value
setQ(value) setQ(value)
router.navigate({ router.navigate({
@@ -199,18 +199,18 @@ function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
}) })
} }
// 🟣 Lista única de facultades // 🟣 Lista única de facultades
const facultadesList = useMemo(() => { const facultadesList = useMemo(() => {
const unique = new Map<string, string>() const unique = new Map<string, string>()
planes?.forEach((p) => { planes?.forEach((p) => {
const fac = p.carrera?.facultad const fac = p.carrera?.facultad
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre) if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
}) })
return Array.from(unique.entries()) return Array.from(unique.entries())
}, [planes]) }, [planes])
// 🎓 Lista de carreras según la facultad seleccionada // 🎓 Lista de carreras según la facultad seleccionada
const carrerasList = useMemo(() => { const carrerasList = useMemo(() => {
const unique = new Map<string, string>() const unique = new Map<string, string>()
planes?.forEach((p) => { planes?.forEach((p) => {
if ( if (
@@ -222,7 +222,7 @@ const carrerasList = useMemo(() => {
} }
}) })
return Array.from(unique.entries()) return Array.from(unique.entries())
}, [planes, facultad]) }, [planes, facultad])
// NEW: Clonado individual // NEW: Clonado individual
@@ -256,12 +256,6 @@ const carrerasList = useMemo(() => {
return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b)) return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b))
}, [asignaturas]) }, [asignaturas])
const tipos = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(a.tipo ?? '—'))
return Array.from(s).sort()
}, [asignaturas])
// Salud // Salud
const salud = useMemo(() => { const salud = useMemo(() => {
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0 let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
@@ -296,7 +290,7 @@ const carrerasList = useMemo(() => {
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
}) })
}, [q, sem, tipo, flag, carrera, facultad, asignaturas]) }, [q, sem, tipo, flag, carrera, facultad, asignaturas])
// Agrupación // Agrupación
@@ -316,7 +310,8 @@ const carrerasList = useMemo(() => {
}, [filtered, groupBy]) }, [filtered, groupBy])
// Helpers // Helpers
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') ; setFacultad('todas') const clearFilters = () => {
setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag(''); setFacultad('todas')
// Actualiza la URL limpiando todos los query params // Actualiza la URL limpiando todos los query params
router.navigate({ router.navigate({
to: '/asignaturas', to: '/asignaturas',
@@ -550,7 +545,12 @@ const carrerasList = useMemo(() => {
value={search.planId ?? "todos"} value={search.planId ?? "todos"}
onValueChange={(val) => { onValueChange={(val) => {
router.navigate({ router.navigate({
search: { ...search, planId: val === "todos" ? "" : val }, to: '/asignaturas',
search: {
...search,
planId: val === 'todos' ? '' : val,
},
replace: true,
}) })
}} }}
> >
@@ -828,7 +828,6 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
const horasT = a.horas_teoricas ?? 0 const horasT = a.horas_teoricas ?? 0
const horasP = a.horas_practicas ?? 0 const horasP = a.horas_practicas ?? 0
const meta = tipoMeta(a.tipo) const meta = tipoMeta(a.tipo)
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
console.log(a); console.log(a);
return ( return (

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { AcademicSections, planKeys } from "@/components/planes/academic-sections" import { AcademicSections, planKeys } from "@/components/planes/academic-sections"
import { GradientMesh } from "../../../components/planes/GradientMesh" import { GradientMesh } from "../../../components/planes/GradientMesh"
import { asignaturaExtraOptions, asignaturaKeys, asignaturasCountOptions, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries" import { asignaturaExtraOptions, asignaturaKeys, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
import { softAccentStyle } from "@/components/planes/planHelpers" import { softAccentStyle } from "@/components/planes/planHelpers"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { DialogFooter, DialogHeader } from "@/components/ui/dialog" import { DialogFooter, DialogHeader } from "@/components/ui/dialog"

View File

@@ -17,13 +17,6 @@ import { toast } from "sonner"
/* -------------------- Tipos -------------------- */
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
/* -------------------- Query Keys & Fetcher -------------------- */ /* -------------------- Query Keys & Fetcher -------------------- */
const usersKeys = { const usersKeys = {
@@ -149,35 +142,6 @@ function RouteComponent() {
carrera_id?: string | null carrera_id?: string | null
}>({ email: "", password: "" }) }>({ email: "", password: "" })
function genPassword() {
/*
Supabase requiere que las contraseñas tengan las siguientes características:
- Mínimo de 6 caracteres
- Debe contener al menos una letra minúscula
- Debe contener al menos una letra mayúscula
- Debe contener al menos un número
- Debe contener al menos un carácter especial
Para garantizar la seguridad, generaremos contraseñas de 12 caracteres en vez del mínimo de 6
*/
// 1. Generar una permutación de los números de 1 al 12 con el método Fisher-Yates
const positions = Array.from({ length: 12 }, (_, i) => i);
for (let i = positions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[positions[i], positions[j]] = [positions[j], positions[i]];
}
// 2. Las correspondencias son las siguientes:
// - El primer número indica la posición de la letra minúscula
// - El segundo número indica la posición de la letra mayúscula
// - El tercer número indica la posición del número
// - El cuarto número indica la posición del carácter especial
// - En las demás posiciones puede haber cualquier caracter alfanumérico
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
return s.slice(0, 14)
}
function RolePill({ role }: { role: Role }) { function RolePill({ role }: { role: Role }) {
const meta = ROLE_META[role] const meta = ROLE_META[role]
@@ -197,61 +161,6 @@ function RouteComponent() {
router.invalidate() router.invalidate()
} }
const upsertNombramiento = useMutation({
mutationFn: async (opts: {
user_id: string
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera"
facultad_id?: string | null
carrera_id?: string | null
}) => {
// cierra vigentes
if (opts.puesto === "jefe_carrera") {
if (!opts.carrera_id) throw new Error("Selecciona carrera")
await supabase
.from("nombramientos")
.update({ hasta: new Date().toISOString().slice(0, 10) })
.eq("puesto", "jefe_carrera")
.eq("carrera_id", opts.carrera_id)
.is("hasta", null)
} else {
if (!opts.facultad_id) throw new Error("Selecciona facultad")
await supabase
.from("nombramientos")
.update({ hasta: new Date().toISOString().slice(0, 10) })
.eq("puesto", opts.puesto)
.eq("facultad_id", opts.facultad_id)
.is("hasta", null)
}
const { error } = await supabase.from("nombramientos").insert({
user_id: opts.user_id,
puesto: opts.puesto,
facultad_id: opts.facultad_id ?? null,
carrera_id: opts.carrera_id ?? null,
desde: new Date().toISOString().slice(0, 10),
hasta: null,
})
if (error) throw error
},
onError: (e: any) => toast.error(e?.message || "Error al registrar nombramiento"),
})
const toggleBan = useMutation({
mutationFn: async (u: UserClaims) => {
throw new Error("Funcionalidad de baneo no implementada aún.")
const banned = false // cuando se tenga acceso a ese campo
// const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
// const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
// if (error) throw new Error(error.message)
return !banned
},
onSuccess: async (isBanned) => {
toast.success(isBanned ? "Usuario baneado" : "Usuario desbaneado")
await invalidateAll()
},
onError: (e: any) => toast.error(e?.message || "Error al cambiar estado de baneo"),
})
const createUser = useMutation({ const createUser = useMutation({
mutationFn: async (payload: typeof createForm) => { mutationFn: async (payload: typeof createForm) => {
// Validaciones previas // Validaciones previas
@@ -409,7 +318,7 @@ function RouteComponent() {
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex"> <Button variant="outline" size="sm" onClick={() => {}} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
<Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} <Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
</Button> </Button>
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}> <Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
@@ -425,7 +334,7 @@ function RouteComponent() {
</div> </div>
</div> </div>
<div className="sm:hidden self-start shrink-0 flex gap-1"> <div className="sm:hidden self-start shrink-0 flex gap-1">
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button> <Button variant="outline" size="icon" onClick={() => {}} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button> <Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button>
</div> </div>
</div> </div>

14
staticwebapp.config.json Normal file
View File

@@ -0,0 +1,14 @@
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": [
"/assets/*",
"/*.css",
"/*.js",
"/*.ico",
"/*.png",
"/*.jpg",
"/*.svg"
]
}
}

12
swa-cli.config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://aka.ms/azure/static-web-apps-cli/schema",
"configurations": {
"acad-ia": {
"appLocation": ".",
"outputLocation": "dist",
"appBuildCommand": "npm run build",
"run": "npm run dev",
"appDevserverUrl": "http://localhost:5173"
}
}
}