Compare commits
10 Commits
eec534922a
...
699a0364b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 699a0364b6 | |||
| d1b50c8f34 | |||
| 4d2ced85a3 | |||
| 182eae1e36 | |||
| d06b961a73 | |||
| 7dcc7dc0e1 | |||
| ca6d370585 | |||
| 63f2d07bb3 | |||
| 944482b96c | |||
| d7f9ae8841 |
+9
-3
@@ -3,8 +3,8 @@ stages:
|
|||||||
|
|
||||||
variables:
|
variables:
|
||||||
APP_NAME: "generadorexamenesllms"
|
APP_NAME: "generadorexamenesllms"
|
||||||
BACKEND_PORT: "8074"
|
BACKEND_PORT: "8068"
|
||||||
FRONTEND_PORT: "8075"
|
FRONTEND_PORT: "8069"
|
||||||
|
|
||||||
deploy_to_sinbad2:
|
deploy_to_sinbad2:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
@@ -46,7 +46,12 @@ deploy_to_sinbad2:
|
|||||||
|
|
||||||
export BACKEND_PORT=$BACKEND_PORT
|
export BACKEND_PORT=$BACKEND_PORT
|
||||||
export FRONTEND_PORT=$FRONTEND_PORT
|
export FRONTEND_PORT=$FRONTEND_PORT
|
||||||
export ALLOWED_ORIGINS="http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:$FRONTEND_PORT"
|
export ENVIRONMENT=production
|
||||||
|
export PUBLIC_BASE_URL="https://sinbad2.ujaen.es/generadorexamenesllm"
|
||||||
|
export TRUSTED_HOSTS="sinbad2.ujaen.es,localhost,127.0.0.1"
|
||||||
|
export ALLOWED_ORIGINS="https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:$FRONTEND_PORT"
|
||||||
|
export VITE_APP_BASE_PATH="/generadorexamenesllm/"
|
||||||
|
export VITE_API_URL=""
|
||||||
|
|
||||||
docker compose down --remove-orphans
|
docker compose down --remove-orphans
|
||||||
docker compose build --no-cache frontend backend
|
docker compose build --no-cache frontend backend
|
||||||
@@ -56,6 +61,7 @@ deploy_to_sinbad2:
|
|||||||
- echo "Despliegue completado."
|
- echo "Despliegue completado."
|
||||||
- echo "Backend -> http://sinbad2.ujaen.es:$BACKEND_PORT"
|
- echo "Backend -> http://sinbad2.ujaen.es:$BACKEND_PORT"
|
||||||
- echo "Frontend -> http://sinbad2.ujaen.es:$FRONTEND_PORT"
|
- echo "Frontend -> http://sinbad2.ujaen.es:$FRONTEND_PORT"
|
||||||
|
- echo "Public URL -> https://sinbad2.ujaen.es/generadorexamenesllm/"
|
||||||
|
|
||||||
only:
|
only:
|
||||||
- branches
|
- branches
|
||||||
+3
-2
@@ -581,14 +581,15 @@ Todas con `Authorization: Bearer <token>`.
|
|||||||
|
|
||||||
| Código | Ejemplo |
|
| Código | Ejemplo |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| 503 | `LLM_API_KEY` no configurada (`llm_unavailable`). |
|
| 503 | LLM no configurado en el servidor (`llm_unavailable`: faltan `LLM_BASE_URL` y/o `LLM_API_KEY` en `.env`). |
|
||||||
|
| 429 | Demasiadas generaciones automáticas por usuario (`llm_rate_limited`). |
|
||||||
| 422 | JSON del modelo inválido (`parse_error`). |
|
| 422 | JSON del modelo inválido (`parse_error`). |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"code": "llm_unavailable",
|
"code": "llm_unavailable",
|
||||||
"message": "LLM_API_KEY is not configured"
|
"message": "Automatic AI generation is not available"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,185 +1,408 @@
|
|||||||
# GenExamenes IA
|
# GenExámenes IA
|
||||||
|
|
||||||
Backend para generar exámenes con IA, procesar la salida de un LLM y exportar preguntas a Moodle XML.
|
<div align="center">
|
||||||
|
|
||||||
**Guía detallada de flujo, endpoints, ejemplos y errores:** [GUIA_API_Y_FLUJO.md](GUIA_API_Y_FLUJO.md)
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
El proyecto está centrado en backend. La carpeta `frontend` se mantiene vacía a nivel de aplicación, aunque existe un servicio en Docker Compose para reservar el despliegue futuro.
|
</div>
|
||||||
|
|
||||||
## Stack
|
<div align="center">
|
||||||
|
<strong>Plataforma full-stack para diseñar exámenes con IA, gestionar materiales de contexto y exportar bancos de preguntas a Moodle XML, TXT y JSON.</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##  Overview: What is this project meant
|
||||||
|
|
||||||
|
`GenExámenes IA` está pensado para flujos docentes en los que hay que crear evaluaciones estructuradas, apoyarse en un LLM y exportar el resultado a Moodle.
|
||||||
|
|
||||||
|
Capacidades principales:
|
||||||
|
|
||||||
|
- Registro e inicio de sesión (email/contraseña y Google opcional)
|
||||||
|
- Creación de plantillas de examen (tipos de pregunta, dificultad, barajar, feedback)
|
||||||
|
- Subida de **Material IA** (PDF, DOCX, TXT, MD…) con extracción de texto para contexto
|
||||||
|
- Subida de **imágenes** para preguntas visuales (embebidas en Moodle XML)
|
||||||
|
- Generación de preguntas: **automática** (LLM en servidor), **solo prompt** o **importar JSON/TXT**
|
||||||
|
- Revisión, vinculación imagen–pregunta y exportación **Moodle XML**, TXT o JSON
|
||||||
|
- Cupo de almacenamiento por examen y controles de seguridad en producción
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> El despliegue en Sinbad2 (UJA) sigue el patrón **orcid2sword**: HTTPS en Apache, contenedores en HTTP en puertos internos (`8069` frontend, `8068` backend). Ver [deploy/DESPLIEGUE_SINBAD2.md](deploy/DESPLIEGUE_SINBAD2.md).
|
||||||
|
|
||||||
|
**URL pública (producción):** `https://sinbad2.ujaen.es/generadorexamenesllm/`
|
||||||
|
|
||||||
|
**Guía ampliada de API, flujo y errores:** [GUIA_API_Y_FLUJO.md](GUIA_API_Y_FLUJO.md)
|
||||||
|
|
||||||
|
<img width="1181" height="705" alt="image" src="https://github.com/user-attachments/assets/8176ba2d-682b-479a-abbd-14dabfa123cd" />
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##  Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
- FastAPI
|
- FastAPI
|
||||||
- PostgreSQL
|
|
||||||
- SQLAlchemy
|
- SQLAlchemy
|
||||||
- Cliente LLM para Sinbad2IA UJA (`POST /api/chat`, modelo `qwen3.5:35b`)
|
- PostgreSQL
|
||||||
- Docker Compose con servicios `backend`, `frontend` y `db`
|
- python-jose (JWT)
|
||||||
|
- bcrypt (contraseñas)
|
||||||
|
- httpx (cliente LLM Sinbad2IA)
|
||||||
|
- pypdf / python-docx / Pillow / pytesseract (extracción de materiales)
|
||||||
|
|
||||||
## Puesta en Marcha
|
### Frontend
|
||||||
|
- React 18
|
||||||
|
- Vite 5
|
||||||
|
- React Router 6
|
||||||
|
- Axios
|
||||||
|
- CSS propio (UI responsive, menú móvil)
|
||||||
|
|
||||||
Copia el ejemplo de variables dentro de la carpeta del backend:
|
### Infrastructure
|
||||||
|
- Docker / Docker Compose
|
||||||
|
- Nginx (contenedor frontend: SPA + proxy `/auth` y `/exam`)
|
||||||
|
- Apache reverse proxy (HTTPS en Sinbad2, fuera del repo)
|
||||||
|
|
||||||
|
### IA
|
||||||
|
- Sinbad2IA UJA — `POST {LLM_BASE_URL}/api/chat` (modelo por defecto `qwen3.5:35b`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##  Quick Start
|
||||||
|
|
||||||
|
Desde la raíz del proyecto:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
|
# Edita backend/.env: LLM_BASE_URL y LLM_API_KEY (solo en el servidor, no en Git)
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Después levanta los servicios:
|
URLs por defecto con Docker Compose:
|
||||||
|
|
||||||
|
| Servicio | URL local |
|
||||||
|
| :--------- | :--------------------------------- |
|
||||||
|
| Frontend | `http://localhost:8069` |
|
||||||
|
| Frontend (ruta pública) | `http://localhost:8069/generadorexamenesllm/` |
|
||||||
|
| Backend | `http://localhost:8068` |
|
||||||
|
| PostgreSQL | `localhost:5432` |
|
||||||
|
|
||||||
|
Desarrollo del frontend sin Docker (solo UI):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
La API queda disponible en:
|
Vite escucha en `http://localhost:8075` (configurable en `vite.config.js`).
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Las rutas bajo `/exam` requieren JWT de usuario (`Authorization: Bearer <token>`). El material y las imágenes son **opcionales** para generar; basta el campo «Tema / instrucciones para la IA» (mínimo 5 caracteres).
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Seguridad del LLM:** la URL y la clave del servicio de IA **no** están en el repositorio. `POST /exam/generate` solo funciona si el servidor tiene `LLM_BASE_URL` y `LLM_API_KEY` en `backend/.env`. El backend no se publica en el host en producción (solo nginx en `:8069`). Quien clone el repo **no** puede llamar al LLM sin esas variables en el despliegue.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Si la generación automática devuelve 503 (*Automatic AI generation is not available*), faltan variables LLM en el servidor o el servicio no es alcanzable desde el contenedor. Usa **Solo prompt** → LLM externo → **Pegar respuesta IA**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##  Environment Configuration
|
||||||
|
|
||||||
|
**Backend**
|
||||||
|
- Archivo principal: `backend/.env`
|
||||||
|
- Referencia: `backend/.env.example`
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
- Build Docker / ejemplo: `frontend/.env.example`
|
||||||
|
- Variables inyectadas en build: `VITE_APP_BASE_PATH`, `VITE_API_URL`, `VITE_GOOGLE_CLIENT_ID`
|
||||||
|
|
||||||
|
Variables backend importantes:
|
||||||
|
|
||||||
|
| Variable | Descripción |
|
||||||
|
| :------- | :---------- |
|
||||||
|
| `JWT_SECRET_KEY` | Secreto JWT (≥ 32 caracteres) |
|
||||||
|
| `JWT_EXPIRE_MINUTES` | Duración del token |
|
||||||
|
| `GOOGLE_CLIENT_ID` | OAuth Google (`POST /auth/google`) |
|
||||||
|
| `DATABASE_URL` | PostgreSQL |
|
||||||
|
| `ALLOWED_ORIGINS` | Orígenes CORS (HTTPS Sinbad2) |
|
||||||
|
| `TRUSTED_HOSTS` | Hosts permitidos (`TrustedHostMiddleware`) |
|
||||||
|
| `PUBLIC_BASE_URL` | URL pública con prefijo de despliegue |
|
||||||
|
| `LLM_BASE_URL` | URL interna del LLM (**solo** en `.env` del servidor, no en el repo) |
|
||||||
|
| `LLM_API_KEY` | Clave para autorizar llamadas al LLM (**obligatoria** para generación automática) |
|
||||||
|
| `LLM_MODEL` | Modelo (p. ej. `qwen3.5:35b`) |
|
||||||
|
| `LLM_TIMEOUT_SECONDS` | Timeout llamada LLM (180 s por defecto) |
|
||||||
|
| `LLM_GENERATE_RATE_LIMIT_*` | Cuota de `POST /exam/generate` por usuario |
|
||||||
|
| `MAX_STORAGE_BYTES_PER_TEMPLATE` | Cupo materiales + imágenes por examen |
|
||||||
|
|
||||||
|
Variables frontend importantes:
|
||||||
|
|
||||||
|
| Variable | Descripción |
|
||||||
|
| :------- | :---------- |
|
||||||
|
| `VITE_APP_BASE_PATH` | Prefijo SPA (`/generadorexamenesllm/` en producción) |
|
||||||
|
| `VITE_API_URL` | Vacío en producción: API en el mismo origen vía nginx |
|
||||||
|
| `VITE_GOOGLE_CLIENT_ID` | Debe coincidir con `GOOGLE_CLIENT_ID` del backend |
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> No subas secretos reales al repositorio. Rota `JWT_SECRET_KEY` y credenciales de Google antes de producción.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##  API Endpoints
|
||||||
|
|
||||||
|
Base backend (Docker local): `http://localhost:8068`
|
||||||
|
|
||||||
|
Todas las rutas `/exam/*` requieren **Bearer JWT** salvo que se indique lo contrario.
|
||||||
|
|
||||||
|
| Módulo | Método | Endpoint | Auth | Notas |
|
||||||
|
| :----- | :----: | :------- | :--- | :---- |
|
||||||
|
| Health | `GET` | `/health` | Ninguna | Liveness |
|
||||||
|
| Auth | `POST` | `/auth/register` | Ninguna | Email + contraseña |
|
||||||
|
| Auth | `POST` | `/auth/login` | Ninguna | Devuelve `access_token` |
|
||||||
|
| Auth | `POST` | `/auth/google` | Ninguna | Body: `{ "id_token": "..." }` |
|
||||||
|
| Auth | `GET` | `/auth/me` | Bearer | Usuario actual |
|
||||||
|
| Plantillas | `POST` | `/exam/templates` | Bearer | Crear examen |
|
||||||
|
| Plantillas | `GET` | `/exam/templates` | Bearer | Listar del usuario |
|
||||||
|
| Plantillas | `GET` | `/exam/templates/{id}` | Bearer | Detalle |
|
||||||
|
| Plantillas | `GET` | `/exam/templates/{id}/storage` | Bearer | Uso de almacenamiento |
|
||||||
|
| Materiales | `POST` | `/exam/templates/{id}/materials` | Bearer | `multipart/form-data` |
|
||||||
|
| Materiales | `GET` | `/exam/templates/{id}/materials` | Bearer | Listado |
|
||||||
|
| Imágenes | `POST` | `/exam/templates/{id}/images` | Bearer | Imagen para preguntas |
|
||||||
|
| Imágenes | `GET` | `/exam/images/{image_id}/content` | Bearer | Contenido binario |
|
||||||
|
| Preguntas | `GET` | `/exam/templates/{id}/questions` | Bearer | Listado |
|
||||||
|
| Preguntas | `PATCH` | `/exam/questions/{id}/image` | Bearer | Vincular imagen |
|
||||||
|
| IA | `POST` | `/exam/prompts/{template_id}` | Bearer | Construir prompt |
|
||||||
|
| IA | `POST` | `/exam/generate` | Bearer | LLM + guardar preguntas |
|
||||||
|
| IA | `POST` | `/exam/parse` | Bearer | Importar JSON/TXT externo |
|
||||||
|
| Export | `GET` | `/exam/export/xml/{template_id}` | Bearer | Moodle XML |
|
||||||
|
| Export | `GET` | `/exam/export/txt/{template_id}` | Bearer | Texto plano |
|
||||||
|
| Export | `GET` | `/exam/export/json/{template_id}` | Bearer | JSON |
|
||||||
|
| Historial | `GET` | `/exam/history` | Bearer | Resumen de exámenes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##  Request Examples
|
||||||
|
|
||||||
|
### Health
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8068/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registro e inicio de sesión
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8068/auth/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"email\":\"docente@ujaen.es\",\"password\":\"MiClaveSegura1\",\"password_confirm\":\"MiClaveSegura1\"}"
|
||||||
|
|
||||||
|
curl -X POST "http://localhost:8068/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"email\":\"docente@ujaen.es\",\"password\":\"MiClaveSegura1\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crear plantilla de examen
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8068/exam/templates" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"title\": \"Examen Tema 3\",
|
||||||
|
\"subject\": \"Sistemas Operativos\",
|
||||||
|
\"educational_level\": \"Grado\",
|
||||||
|
\"language\": \"es\",
|
||||||
|
\"settings\": {
|
||||||
|
\"question_types\": [{\"type\": \"multichoice\", \"count\": 5, \"score\": 1, \"penalty\": 0}],
|
||||||
|
\"shuffle_questions\": true,
|
||||||
|
\"shuffle_answers\": true,
|
||||||
|
\"include_feedback\": true
|
||||||
|
},
|
||||||
|
\"difficulty_profile\": {\"easy\": 2, \"medium\": 3, \"hard\": 0, \"very_hard\": 0}
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generar preguntas (solo con tema, sin materiales)
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8068/exam/generate" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"template_id\": \"TEMPLATE_UUID\",
|
||||||
|
\"topic_prompt\": \"Genera preguntas sobre aritmética y trigonometría\",
|
||||||
|
\"material_ids\": null
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exportar Moodle XML
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8068/exam/export/xml/TEMPLATE_UUID" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT" \
|
||||||
|
-o examen_moodle.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##  Security Controls
|
||||||
|
|
||||||
|
Controles implementados en backend:
|
||||||
|
|
||||||
|
- CORS con lista de orígenes permitidos
|
||||||
|
- filtrado de hosts de confianza (`TrustedHostMiddleware`)
|
||||||
|
- cabeceras de seguridad (HSTS en producción)
|
||||||
|
- límite de tamaño de petición
|
||||||
|
- rate limiting por cliente
|
||||||
|
- **LLM solo desde el servidor:** credenciales en `.env`, sin URL en el código público
|
||||||
|
- **`POST /exam/generate`:** exige JWT + `LLM_API_KEY` configurada + límite por usuario
|
||||||
|
- backend **no expuesto** en `docker-compose.yml` (solo red interna Docker; Apache → frontend)
|
||||||
|
- contraseñas con bcrypt
|
||||||
|
- JWT por usuario; cada plantilla pertenece a un único usuario
|
||||||
|
- sanitización de prompts y texto persistido
|
||||||
|
- contenedores sin privilegios innecesarios
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> En producción, fuerza HTTPS detrás de Apache y mantén `ALLOWED_ORIGINS` y `TRUSTED_HOSTS` alineados con `https://sinbad2.ujaen.es`. El servicio LLM de la UJA debería aceptar peticiones **solo** desde la red del backend y exigir `LLM_API_KEY` (firewall + clave en el propio Sinbad2IA).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##  Frontend Details
|
||||||
|
|
||||||
|
SPA React con rutas protegidas y cliente API centralizado (`frontend/src/api/client.js`).
|
||||||
|
|
||||||
|
### Rutas principales
|
||||||
|
|
||||||
|
| Ruta | Página |
|
||||||
|
| :--- | :----- |
|
||||||
|
| `/login` | Inicio de sesión |
|
||||||
|
| `/registro` | Alta de usuario |
|
||||||
|
| `/` | Panel «Mis exámenes» |
|
||||||
|
| `/plantillas/nueva` | Asistente «Nuevo examen» |
|
||||||
|
| `/plantillas/:templateId` | Detalle con pestañas: Resumen, Material IA, Imágenes, Generar, Preguntas, Exportar |
|
||||||
|
|
||||||
|
### Pestaña Generar (tres modos)
|
||||||
|
|
||||||
|
- **Generación automática:** `POST /exam/generate` — llama al LLM del servidor.
|
||||||
|
- **Solo prompt:** `POST /exam/prompts/{id}` — copiar prompt a un LLM externo.
|
||||||
|
- **Pegar respuesta IA:** `POST /exam/parse` — importar JSON o TXT.
|
||||||
|
|
||||||
|
### Cliente API (`frontend/src/api/`)
|
||||||
|
|
||||||
|
- `client.js` — Axios, JWT en `localStorage`, errores normalizados (`ApiError`)
|
||||||
|
- `auth.js`, `templates.js`, `materials.js`, `images.js`, `generation.js`, `exports.js`, `questions.js`
|
||||||
|
|
||||||
|
### Nginx en Docker (`frontend/nginx.conf`)
|
||||||
|
|
||||||
|
- Sirve la SPA bajo `/` y `/generadorexamenesllm/`
|
||||||
|
- Proxy de `/auth/` y `/exam/` al backend (`backend:8074` en red interna)
|
||||||
|
- Redirección de raíz sin barra final al login
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##  Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://sinbad2.ujaen.es:8074
|
03_GeneradorExamenes/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ ├── routes/
|
||||||
|
│ │ │ │ ├── auth.py
|
||||||
|
│ │ │ │ ├── templates.py
|
||||||
|
│ │ │ │ ├── generation.py
|
||||||
|
│ │ │ │ ├── materials.py
|
||||||
|
│ │ │ │ ├── images.py
|
||||||
|
│ │ │ │ ├── questions.py
|
||||||
|
│ │ │ │ ├── exports.py
|
||||||
|
│ │ │ │ └── history.py
|
||||||
|
│ │ │ └── dependencies.py
|
||||||
|
│ │ ├── core/
|
||||||
|
│ │ │ ├── config.py
|
||||||
|
│ │ │ ├── auth.py
|
||||||
|
│ │ │ ├── errors.py
|
||||||
|
│ │ │ ├── middleware.py
|
||||||
|
│ │ │ └── security_headers.py
|
||||||
|
│ │ ├── db/
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ ├── schemas/
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── exam_service.py
|
||||||
|
│ │ │ ├── llm.py
|
||||||
|
│ │ │ ├── prompt_builder.py
|
||||||
|
│ │ │ ├── parser.py
|
||||||
|
│ │ │ ├── material_service.py
|
||||||
|
│ │ │ ├── image_service.py
|
||||||
|
│ │ │ └── moodle_exporter.py
|
||||||
|
│ │ └── main.py
|
||||||
|
│ ├── .env.example
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── context/
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── DashboardPage.jsx
|
||||||
|
│ │ │ ├── CreateTemplatePage.jsx
|
||||||
|
│ │ │ ├── TemplateDetailPage.jsx
|
||||||
|
│ │ │ └── template/
|
||||||
|
│ │ │ ├── GenerateTab.jsx
|
||||||
|
│ │ │ ├── MaterialsTab.jsx
|
||||||
|
│ │ │ └── ExportTab.jsx
|
||||||
|
│ │ ├── App.jsx
|
||||||
|
│ │ └── main.jsx
|
||||||
|
│ ├── nginx.conf
|
||||||
|
│ ├── .env.example
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.js
|
||||||
|
├── deploy/
|
||||||
|
│ ├── DESPLIEGUE_SINBAD2.md
|
||||||
|
│ └── apache-reverse-proxy.conf
|
||||||
|
├── docs/
|
||||||
|
│ └── Manual_de_Usuario_GenExamenes_IA.docx
|
||||||
|
├── scripts/
|
||||||
|
│ └── generar_manual_usuario.py
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── GUIA_API_Y_FLUJO.md
|
||||||
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuración
|
---
|
||||||
|
|
||||||
El archivo de entorno debe estar en `backend/.env`.
|
##  Production Checklist
|
||||||
|
|
||||||
Variables principales:
|
- [ ] `ENVIRONMENT=production`
|
||||||
|
- [ ] Rotar `JWT_SECRET_KEY` y `GOOGLE_CLIENT_ID` de desarrollo
|
||||||
|
- [ ] `ALLOWED_ORIGINS` con orígenes HTTPS reales
|
||||||
|
- [ ] `TRUSTED_HOSTS` con `sinbad2.ujaen.es`
|
||||||
|
- [ ] `PUBLIC_BASE_URL=https://sinbad2.ujaen.es/generadorexamenesllm`
|
||||||
|
- [ ] Apache `ProxyPass` a puerto **8069** (frontend), no al backend directo
|
||||||
|
- [ ] Reglas Apache **antes** de WordPress en Sinbad2
|
||||||
|
- [ ] `LLM_BASE_URL` y `LLM_API_KEY` solo en `backend/.env` del servidor (no en Git)
|
||||||
|
- [ ] Comprobar conectividad backend → LLM (red interna UJA)
|
||||||
|
- [ ] Despliegue con `docker compose up` **sin** `docker-compose.dev.yml` (backend no publicado en host)
|
||||||
|
- [ ] Volumen `uploads_data` y backups de PostgreSQL
|
||||||
|
- [ ] Manual de usuario actualizado en `docs/`
|
||||||
|
|
||||||
- `JWT_SECRET_KEY`: secreto para firmar tokens JWT (mínimo 32 caracteres).
|
---
|
||||||
- `JWT_EXPIRE_MINUTES`: duración del token de acceso.
|
|
||||||
- `GOOGLE_CLIENT_ID`: Client ID de OAuth 2.0 en Google Cloud Console (para `/auth/google`).
|
|
||||||
- `DATABASE_URL`: conexión PostgreSQL usada por el backend.
|
|
||||||
- `LLM_BASE_URL`: URL base del servidor (por defecto ``; el cliente usa `/api/chat`).
|
|
||||||
- `LLM_MODEL`: modelo (por defecto `qwen3.5:35b`).
|
|
||||||
- `LLM_TIMEOUT_SECONDS`: tiempo máximo de espera (por defecto 180 s).
|
|
||||||
- `LLM_API_KEY`: opcional, solo si el servidor exige autenticación.
|
|
||||||
- `ALLOWED_ORIGINS`: orígenes permitidos por CORS.
|
|
||||||
- `MAX_STORAGE_BYTES_PER_TEMPLATE`: cupo total de almacenamiento por examen (materiales + imágenes).
|
|
||||||
|
|
||||||
Todas las rutas bajo `/exam` requieren autenticación de usuario con:
|
##  Authors and Team
|
||||||
|
|
||||||
```http
|
Este proyecto es el resultado de la colaboración con la **Universidad de Jaén** (grupo Sinbad²).
|
||||||
Authorization: Bearer <access_token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Si ya tenías una base de datos creada antes de añadir usuarios, recrea el volumen:
|
| Rol | Developer | GitHub |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Frontend** | Alexis López Moral | [@AlexisLopez-Dev](https://github.com/AlexisLopez-Dev) |
|
||||||
|
| **Backend** | Mireya Cueto Garrido | [@MireyaCueto](https://github.com/MireyaCueto) |
|
||||||
|
|
||||||
```bash
|
### Direction
|
||||||
docker compose down -v
|
* **Project Supervisor:** Luis Martínez López
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Flujo de Usuario
|
---
|
||||||
|
|
||||||
1. Registrarse o iniciar sesión.
|
<p align="center">
|
||||||
2. Crear una plantilla de examen (queda asociada al usuario).
|
Built with professional care and ❤️ for AI-assisted exam workflows at the University of Jaén.
|
||||||
3. Subir materiales de referencia (PDF, DOCX, TXT, PNG, JPG…) a la plantilla.
|
</p>
|
||||||
4. Generar un prompt guiado para el LLM (incluye el texto extraído de los ficheros).
|
|
||||||
5. Generar preguntas automáticamente con el LLM o parsear una salida externa en JSON/TXT.
|
|
||||||
6. Guardar las preguntas validadas en PostgreSQL.
|
|
||||||
7. Consultar el historial de exámenes creados.
|
|
||||||
8. Exportar el examen a Moodle XML, TXT o JSON.
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
`GET /health`
|
|
||||||
|
|
||||||
Comprueba que la API está levantada.
|
|
||||||
|
|
||||||
`POST /auth/register`
|
|
||||||
|
|
||||||
Registra un usuario con email y contraseña.
|
|
||||||
|
|
||||||
`POST /auth/login`
|
|
||||||
|
|
||||||
Devuelve un token JWT para usar en las rutas protegidas.
|
|
||||||
|
|
||||||
`POST /auth/google`
|
|
||||||
|
|
||||||
Recibe el `id_token` de Google (Sign in with Google en el frontend), verifica la cuenta y devuelve el mismo JWT de la API.
|
|
||||||
|
|
||||||
`GET /auth/me`
|
|
||||||
|
|
||||||
Devuelve los datos del usuario autenticado.
|
|
||||||
|
|
||||||
`GET /exam/history`
|
|
||||||
|
|
||||||
Lista el historial de exámenes del usuario (plantillas, preguntas y exportaciones).
|
|
||||||
|
|
||||||
`POST /exam/templates/{template_id}/materials`
|
|
||||||
|
|
||||||
Sube un fichero (`multipart/form-data`, campo `file`). Formatos: PDF, DOCX, TXT, MD, PNG, JPG, WEBP. Extrae texto y lo guarda como contexto.
|
|
||||||
|
|
||||||
`GET /exam/templates/{template_id}/materials`
|
|
||||||
|
|
||||||
Lista los materiales subidos a una plantilla.
|
|
||||||
|
|
||||||
`DELETE /exam/templates/{template_id}/materials/{material_id}`
|
|
||||||
|
|
||||||
Elimina un material.
|
|
||||||
|
|
||||||
`POST /exam/templates/{template_id}/images`
|
|
||||||
|
|
||||||
Sube una imagen para preguntas visuales (`file`, opcional `caption`). No se usa OCR: la imagen se muestra en el examen y se embebe en el XML de Moodle.
|
|
||||||
|
|
||||||
`GET /exam/templates/{template_id}/images`
|
|
||||||
|
|
||||||
Lista las imágenes de la plantilla.
|
|
||||||
|
|
||||||
`GET /exam/images/{image_id}/content`
|
|
||||||
|
|
||||||
Devuelve la imagen (requiere JWT). Para previsualizar en el frontend o en Moodle tras importar.
|
|
||||||
|
|
||||||
`DELETE /exam/templates/{template_id}/images/{image_id}`
|
|
||||||
|
|
||||||
Elimina una imagen.
|
|
||||||
|
|
||||||
`PATCH /exam/questions/{question_id}/image`
|
|
||||||
|
|
||||||
Vincula o desvincula una imagen a una pregunta existente (`{"image_id": "uuid"}` o `null`).
|
|
||||||
|
|
||||||
`POST /exam/templates`
|
|
||||||
|
|
||||||
Crea una plantilla con materia, nivel educativo, tipos de pregunta, puntuación, penalización y dificultad.
|
|
||||||
|
|
||||||
`GET /exam/templates`
|
|
||||||
|
|
||||||
Lista las plantillas del usuario autenticado.
|
|
||||||
|
|
||||||
`GET /exam/templates/{template_id}`
|
|
||||||
|
|
||||||
Obtiene una plantilla concreta.
|
|
||||||
|
|
||||||
`GET /exam/templates/{template_id}/storage`
|
|
||||||
|
|
||||||
Muestra cuánto espacio usa el examen (materiales + imágenes) y el límite configurado.
|
|
||||||
|
|
||||||
`POST /exam/prompts/{template_id}`
|
|
||||||
|
|
||||||
Genera un prompt estructurado para IA.
|
|
||||||
|
|
||||||
`POST /exam/generate`
|
|
||||||
|
|
||||||
Llama al LLM configurado, parsea la respuesta y guarda las preguntas.
|
|
||||||
|
|
||||||
`POST /exam/parse`
|
|
||||||
|
|
||||||
Procesa una salida externa de IA en formato `json` o `txt`.
|
|
||||||
|
|
||||||
`GET /exam/export/xml/{template_id}`
|
|
||||||
|
|
||||||
Exporta las preguntas en Moodle XML.
|
|
||||||
|
|
||||||
`GET /exam/export/txt/{template_id}`
|
|
||||||
|
|
||||||
Exporta las preguntas en texto plano.
|
|
||||||
|
|
||||||
`GET /exam/export/json/{template_id}`
|
|
||||||
|
|
||||||
Exporta las preguntas en JSON.
|
|
||||||
|
|
||||||
## Seguridad
|
|
||||||
|
|
||||||
- Registro e inicio de sesión con contraseña hasheada (bcrypt).
|
|
||||||
- Autenticación JWT por usuario.
|
|
||||||
- Cada examen pertenece a un único usuario; no se puede acceder al de otro.
|
|
||||||
- Rate limiting por cliente.
|
|
||||||
- Límite de tamaño de petición.
|
|
||||||
- Validación de entrada con Pydantic.
|
|
||||||
- Manejo uniforme de errores HTTP.
|
|
||||||
- Sanitización básica de prompts y respuestas antes de persistir/exportar.
|
|
||||||
|
|||||||
+24
-11
@@ -1,23 +1,36 @@
|
|||||||
# --- Aplicación ---
|
# --- Aplicación (producción Sinbad2) ---
|
||||||
APP_NAME=GenExamenes IA
|
APP_NAME=GenExamenes IA
|
||||||
ENVIRONMENT=local
|
ENVIRONMENT=production
|
||||||
# Clave legacy (reservada; las rutas /exam usan JWT de usuario).
|
|
||||||
|
PUBLIC_BASE_URL=https://sinbad2.ujaen.es/generadorexamenesllm
|
||||||
|
TRUSTED_HOSTS=sinbad2.ujaen.es,localhost,127.0.0.1
|
||||||
|
SECURITY_HSTS_SECONDS=31536000
|
||||||
|
|
||||||
API_KEY=change-me-in-production-min-16-chars
|
API_KEY=change-me-in-production-min-16-chars
|
||||||
# --- Base de datos (Docker: host "db") ---
|
|
||||||
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
|
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
|
||||||
# --- CORS (orígenes del frontend, separados por coma) ---
|
|
||||||
ALLOWED_ORIGINS=http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8075
|
ALLOWED_ORIGINS=https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8069
|
||||||
# --- Rate limiting y tamaño de petición ---
|
|
||||||
RATE_LIMIT_REQUESTS=60
|
RATE_LIMIT_REQUESTS=60
|
||||||
RATE_LIMIT_WINDOW_SECONDS=60
|
RATE_LIMIT_WINDOW_SECONDS=60
|
||||||
MAX_REQUEST_BYTES=1048576
|
MAX_REQUEST_BYTES=25165824
|
||||||
# --- JWT (login email/contraseña y sesión tras Google) ---
|
|
||||||
|
UPLOAD_DIR=/app/uploads
|
||||||
|
MAX_UPLOAD_BYTES=20971520
|
||||||
|
MAX_MATERIALS_PER_TEMPLATE=10
|
||||||
|
MAX_REFERENCE_CHARS=12000
|
||||||
|
|
||||||
|
MAX_IMAGE_BYTES=5242880
|
||||||
|
MAX_IMAGES_PER_TEMPLATE=20
|
||||||
|
MAX_STORAGE_BYTES_PER_TEMPLATE=52428800
|
||||||
|
|
||||||
JWT_SECRET_KEY=f3c9e7a1b4d8c2f6a9e1d3b7c5f2e8a4d1c7b9e3f6a2c4e8b1d7f3a9c6e2b4d8
|
JWT_SECRET_KEY=f3c9e7a1b4d8c2f6a9e1d3b7c5f2e8a4d1c7b9e3f6a2c4e8b1d7f3a9c6e2b4d8
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
JWT_EXPIRE_MINUTES=1440
|
JWT_EXPIRE_MINUTES=1440
|
||||||
# --- Google Sign-In ---
|
|
||||||
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
|
||||||
# --- LLM (Sinbad2IA UJA — sin clave) ---
|
|
||||||
LLM_BASE_URL=
|
LLM_BASE_URL=
|
||||||
LLM_MODEL=qwen3.5:35b
|
LLM_MODEL=qwen3.5:35b
|
||||||
LLM_TIMEOUT_SECONDS=180
|
LLM_TIMEOUT_SECONDS=180
|
||||||
|
|||||||
+19
-7
@@ -1,6 +1,15 @@
|
|||||||
# --- Aplicación ---
|
# --- Aplicación ---
|
||||||
APP_NAME=GenExamenes IA
|
APP_NAME=GenExamenes IA
|
||||||
ENVIRONMENT=local
|
ENVIRONMENT=production
|
||||||
|
|
||||||
|
# URL pública HTTPS (Apache termina TLS; contenedores en HTTP interno)
|
||||||
|
PUBLIC_BASE_URL=https://sinbad2.ujaen.es/generadorexamenesllm
|
||||||
|
|
||||||
|
# Hosts aceptados por TrustedHostMiddleware (sin esquema ni puerto)
|
||||||
|
TRUSTED_HOSTS=sinbad2.ujaen.es,localhost,127.0.0.1
|
||||||
|
|
||||||
|
# HSTS (segundos; 1 año por defecto)
|
||||||
|
SECURITY_HSTS_SECONDS=31536000
|
||||||
|
|
||||||
# Clave legacy (reservada; las rutas /exam usan JWT de usuario).
|
# Clave legacy (reservada; las rutas /exam usan JWT de usuario).
|
||||||
API_KEY=change-me-in-production-min-16-chars
|
API_KEY=change-me-in-production-min-16-chars
|
||||||
@@ -8,8 +17,8 @@ API_KEY=change-me-in-production-min-16-chars
|
|||||||
# --- Base de datos (Docker: host "db") ---
|
# --- Base de datos (Docker: host "db") ---
|
||||||
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
|
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
|
||||||
|
|
||||||
# --- CORS (orígenes del frontend, separados por coma) ---
|
# --- CORS (orígenes HTTPS del frontend; separados por coma) ---
|
||||||
ALLOWED_ORIGINS=http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8075
|
ALLOWED_ORIGINS=https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8069
|
||||||
|
|
||||||
# --- Rate limiting y tamaño de petición ---
|
# --- Rate limiting y tamaño de petición ---
|
||||||
RATE_LIMIT_REQUESTS=60
|
RATE_LIMIT_REQUESTS=60
|
||||||
@@ -39,10 +48,13 @@ JWT_EXPIRE_MINUTES=1440
|
|||||||
# El frontend obtiene un id_token con Google Identity Services y lo envía a POST /auth/google.
|
# El frontend obtiene un id_token con Google Identity Services y lo envía a POST /auth/google.
|
||||||
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
|
||||||
|
|
||||||
# --- LLM (Sinbad2IA UJA — sin clave) ---
|
# --- LLM (solo servidor; NO commitear valores reales) ---
|
||||||
# URL base del servidor; el cliente llama a {LLM_BASE_URL}/api/chat
|
# Obligatorias para POST /exam/generate. Sin ellas, la generación automática queda desactivada.
|
||||||
|
# La URL no debe aparecer en el repositorio: configúrala solo en el .env del servidor.
|
||||||
LLM_BASE_URL=
|
LLM_BASE_URL=
|
||||||
|
LLM_API_KEY=
|
||||||
LLM_MODEL=qwen3.5:35b
|
LLM_MODEL=qwen3.5:35b
|
||||||
LLM_TIMEOUT_SECONDS=180
|
LLM_TIMEOUT_SECONDS=180
|
||||||
# Opcional, solo si el servidor exige autenticación:
|
# Límite por usuario (generación automática)
|
||||||
# LLM_API_KEY=
|
LLM_GENERATE_RATE_LIMIT_REQUESTS=5
|
||||||
|
LLM_GENERATE_RATE_LIMIT_WINDOW_SECONDS=3600
|
||||||
|
|||||||
+6
-1
@@ -25,4 +25,9 @@ USER app
|
|||||||
|
|
||||||
EXPOSE 8074
|
EXPOSE 8074
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8074"]
|
CMD ["uvicorn", "app.main:app", \
|
||||||
|
"--host", "0.0.0.0", \
|
||||||
|
"--port", "8074", \
|
||||||
|
"--proxy-headers", \
|
||||||
|
"--forwarded-allow-ips", "*", \
|
||||||
|
"--no-server-header"]
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from app.core.auth import get_current_user
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.errors import LLMUnavailableError
|
||||||
|
from app.core.llm_rate_limit import enforce_llm_rate_limit
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def require_llm_generation(
|
||||||
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
) -> User:
|
||||||
|
"""Solo permite generación automática si el LLM está configurado por entorno (no en el repo)."""
|
||||||
|
if not settings.llm_ready:
|
||||||
|
raise LLMUnavailableError("Automatic AI generation is not available")
|
||||||
|
enforce_llm_rate_limit(current_user.id, settings)
|
||||||
|
return current_user
|
||||||
@@ -4,6 +4,7 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app.api.dependencies import get_exam_service, get_llm_client
|
from app.api.dependencies import get_exam_service, get_llm_client
|
||||||
|
from app.api.llm_guard import require_llm_generation
|
||||||
from app.core.auth import get_current_user
|
from app.core.auth import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.exam import (
|
from app.schemas.exam import (
|
||||||
@@ -37,7 +38,7 @@ def build_prompt(
|
|||||||
@router.post("/generate", response_model=ParsedQuestionsResponse)
|
@router.post("/generate", response_model=ParsedQuestionsResponse)
|
||||||
async def generate_exam(
|
async def generate_exam(
|
||||||
payload: GenerateExamRequest,
|
payload: GenerateExamRequest,
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
current_user: Annotated[User, Depends(require_llm_generation)],
|
||||||
service: Annotated[ExamService, Depends(get_exam_service)],
|
service: Annotated[ExamService, Depends(get_exam_service)],
|
||||||
llm_client: Annotated[LLMClient, Depends(get_llm_client)],
|
llm_client: Annotated[LLMClient, Depends(get_llm_client)],
|
||||||
) -> ParsedQuestionsResponse:
|
) -> ParsedQuestionsResponse:
|
||||||
|
|||||||
@@ -7,17 +7,33 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
app_name: str = "GenExamenes IA"
|
app_name: str = "GenExamenes IA"
|
||||||
environment: str = "local"
|
environment: str = "local"
|
||||||
|
public_base_url: str = "https://sinbad2.ujaen.es/generadorexamenesllm"
|
||||||
|
trusted_hosts: str = "sinbad2.ujaen.es,localhost,127.0.0.1"
|
||||||
|
security_hsts_seconds: int = Field(default=31_536_000, ge=0)
|
||||||
api_prefix: str = ""
|
api_prefix: str = ""
|
||||||
api_key: str = Field(min_length=16)
|
api_key: str = Field(min_length=16)
|
||||||
database_url: str = "postgresql+psycopg://genexamenes:genexamenes@localhost:5432/genexamenes"
|
database_url: str = "postgresql+psycopg://genexamenes:genexamenes@localhost:5432/genexamenes"
|
||||||
allowed_origins: str = "http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8075"
|
allowed_origins: str = "https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8069"
|
||||||
rate_limit_requests: int = Field(default=60, ge=1)
|
rate_limit_requests: int = Field(default=60, ge=1)
|
||||||
rate_limit_window_seconds: int = Field(default=60, ge=1)
|
rate_limit_window_seconds: int = Field(default=60, ge=1)
|
||||||
max_request_bytes: int = Field(default=1_048_576, ge=1_024)
|
max_request_bytes: int = Field(default=1_048_576, ge=1_024)
|
||||||
llm_api_key: str | None = None
|
llm_api_key: str | None = None
|
||||||
llm_base_url: str = ""
|
llm_base_url: str = Field(
|
||||||
|
default="",
|
||||||
|
description="URL base del LLM (solo servidor). No incluir en el repositorio.",
|
||||||
|
)
|
||||||
llm_model: str = "qwen3.5:35b"
|
llm_model: str = "qwen3.5:35b"
|
||||||
llm_timeout_seconds: int = Field(default=180, ge=5)
|
llm_timeout_seconds: int = Field(default=180, ge=5)
|
||||||
|
llm_generate_rate_limit_requests: int = Field(
|
||||||
|
default=5,
|
||||||
|
ge=1,
|
||||||
|
description="Máximo de POST /exam/generate por usuario y ventana.",
|
||||||
|
)
|
||||||
|
llm_generate_rate_limit_window_seconds: int = Field(
|
||||||
|
default=3600,
|
||||||
|
ge=60,
|
||||||
|
description="Ventana en segundos para el límite de generación con LLM.",
|
||||||
|
)
|
||||||
jwt_secret_key: str = Field(min_length=32)
|
jwt_secret_key: str = Field(min_length=32)
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
jwt_expire_minutes: int = Field(default=60 * 24, ge=5)
|
jwt_expire_minutes: int = Field(default=60 * 24, ge=5)
|
||||||
@@ -41,10 +57,25 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.environment.lower() in {"production", "prod"}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins(self) -> list[str]:
|
def cors_origins(self) -> list[str]:
|
||||||
return [origin.strip() for origin in self.allowed_origins.split(",") if origin.strip()]
|
return [origin.strip() for origin in self.allowed_origins.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trusted_hosts_list(self) -> list[str]:
|
||||||
|
return [host.strip() for host in self.trusted_hosts.split(",") if host.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def llm_ready(self) -> bool:
|
||||||
|
"""True solo si URL y clave del LLM están definidas en el entorno del servidor."""
|
||||||
|
return bool(self.llm_base_url.strip()) and bool(
|
||||||
|
self.llm_api_key and self.llm_api_key.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -51,9 +51,14 @@ def error_payload(code: str, message: str, details: object | None = None) -> dic
|
|||||||
def register_exception_handlers(app: FastAPI) -> None:
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
@app.exception_handler(AppError)
|
@app.exception_handler(AppError)
|
||||||
async def app_error_handler(_: Request, exc: AppError) -> ORJSONResponse:
|
async def app_error_handler(_: Request, exc: AppError) -> ORJSONResponse:
|
||||||
|
headers: dict[str, str] | None = None
|
||||||
|
retry_after = getattr(exc, "retry_after", None)
|
||||||
|
if retry_after is not None:
|
||||||
|
headers = {"Retry-After": str(retry_after)}
|
||||||
return ORJSONResponse(
|
return ORJSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
content=error_payload(exc.code, exc.message),
|
content=error_payload(exc.code, exc.message),
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(StarletteHTTPException)
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from threading import Lock
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
|
||||||
|
_lock = Lock()
|
||||||
|
_buckets: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMRateLimitError(AppError):
|
||||||
|
def __init__(self, retry_after: int) -> None:
|
||||||
|
super().__init__(
|
||||||
|
message="Too many AI generation requests. Try again later.",
|
||||||
|
status_code=429,
|
||||||
|
code="llm_rate_limited",
|
||||||
|
)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_llm_rate_limit(user_id: UUID, settings: Settings) -> None:
|
||||||
|
key = str(user_id)
|
||||||
|
now = time.monotonic()
|
||||||
|
limit = settings.llm_generate_rate_limit_requests
|
||||||
|
window = settings.llm_generate_rate_limit_window_seconds
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
bucket = _buckets[key]
|
||||||
|
while bucket and now - bucket[0] > window:
|
||||||
|
bucket.popleft()
|
||||||
|
if len(bucket) >= limit:
|
||||||
|
raise LLMRateLimitError(retry_after=window)
|
||||||
|
bucket.append(now)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from fastapi import Request, Response
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Cabeceras de seguridad; HSTS en producción o cuando el proxy indica HTTPS."""
|
||||||
|
|
||||||
|
def __init__(self, app: object, settings: Settings) -> None:
|
||||||
|
super().__init__(app)
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
|
||||||
|
forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||||
|
is_https = forwarded_proto == "https" or request.url.scheme == "https"
|
||||||
|
|
||||||
|
if is_https or self._settings.is_production:
|
||||||
|
hsts = f"max-age={self._settings.security_hsts_seconds}"
|
||||||
|
if self._settings.is_production:
|
||||||
|
hsts += "; includeSubDomains"
|
||||||
|
response.headers.setdefault("Strict-Transport-Security", hsts)
|
||||||
|
|
||||||
|
return response
|
||||||
+6
-1
@@ -1,13 +1,15 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
|
|
||||||
from app.api.routes import auth, exports, generation, health, history, images, materials, questions, templates
|
from app.api.routes import auth, exports, generation, health, history, images, materials, questions, templates
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.errors import register_exception_handlers
|
from app.core.errors import register_exception_handlers
|
||||||
from app.core.middleware import RateLimitMiddleware, RequestSizeLimitMiddleware
|
from app.core.middleware import RateLimitMiddleware, RequestSizeLimitMiddleware
|
||||||
|
from app.core.security_headers import SecurityHeadersMiddleware
|
||||||
from app.db.init_db import init_db
|
from app.db.init_db import init_db
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +27,9 @@ def create_app() -> FastAPI:
|
|||||||
# las peticiones OPTIONS (preflight) respondan antes que rate limit, etc.
|
# las peticiones OPTIONS (preflight) respondan antes que rate limit, etc.
|
||||||
app.add_middleware(RequestSizeLimitMiddleware, settings=settings)
|
app.add_middleware(RequestSizeLimitMiddleware, settings=settings)
|
||||||
app.add_middleware(RateLimitMiddleware, settings=settings)
|
app.add_middleware(RateLimitMiddleware, settings=settings)
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware, settings=settings)
|
||||||
|
if settings.trusted_hosts_list:
|
||||||
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.trusted_hosts_list)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class LLMClient:
|
|||||||
return f"{base}/api/chat"
|
return f"{base}/api/chat"
|
||||||
|
|
||||||
async def generate(self, prompt: str) -> str:
|
async def generate(self, prompt: str) -> str:
|
||||||
|
if not self.settings.llm_ready:
|
||||||
|
raise LLMUnavailableError("Automatic AI generation is not available")
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.settings.llm_model,
|
"model": self.settings.llm_model,
|
||||||
"messages": [
|
"messages": [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Desarrollo local: publica backend y PostgreSQL solo en loopback.
|
||||||
|
# Uso: docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
|
#
|
||||||
|
# Configura LLM_BASE_URL y LLM_API_KEY en backend/.env (no subir al repositorio).
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8068:8074"
|
||||||
|
|
||||||
|
db:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
+11
-10
@@ -6,13 +6,13 @@ services:
|
|||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
|
DATABASE_URL: postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
|
||||||
|
ENVIRONMENT: ${ENVIRONMENT:-production}
|
||||||
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-https://sinbad2.ujaen.es/generadorexamenesllm}
|
||||||
|
TRUSTED_HOSTS: ${TRUSTED_HOSTS:-sinbad2.ujaen.es,localhost,127.0.0.1}
|
||||||
# Sobrescribe backend/.env con el origen público del frontend en despliegue.
|
# Sobrescribe backend/.env con el origen público del frontend en despliegue.
|
||||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8075}
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8069}
|
||||||
LLM_BASE_URL:
|
expose:
|
||||||
LLM_MODEL: qwen3.5:35b
|
- "8074"
|
||||||
LLM_TIMEOUT_SECONDS: "180"
|
|
||||||
ports:
|
|
||||||
- "${BACKEND_PORT:-8074}:8074"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -24,10 +24,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ${VITE_API_URL:-http://sinbad2.ujaen.es:8074}
|
VITE_APP_BASE_PATH: ${VITE_APP_BASE_PATH:-/generadorexamenesllm/}
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
VITE_GOOGLE_CLIENT_ID: ${VITE_GOOGLE_CLIENT_ID:-}
|
VITE_GOOGLE_CLIENT_ID: ${VITE_GOOGLE_CLIENT_ID:-}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-8075}:80"
|
- "${FRONTEND_PORT:-8069}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -38,8 +39,8 @@ services:
|
|||||||
POSTGRES_DB: genexamenes
|
POSTGRES_DB: genexamenes
|
||||||
POSTGRES_USER: genexamenes
|
POSTGRES_USER: genexamenes
|
||||||
POSTGRES_PASSWORD: genexamenes
|
POSTGRES_PASSWORD: genexamenes
|
||||||
ports:
|
expose:
|
||||||
- "5432:5432"
|
- "5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_APP_BASE_PATH=/generadorexamenesllm/
|
||||||
|
VITE_API_URL=
|
||||||
|
VITE_GOOGLE_CLIENT_ID=
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# URL base del backend (accesible desde el navegador)
|
# Base pública de la app cuando va tras proxy (debe terminar en /)
|
||||||
VITE_API_URL=http://sinbad2.ujaen.es:8074
|
# Producción Sinbad2 (ProxyPass en Apache, sin /deckofcars):
|
||||||
|
VITE_APP_BASE_PATH=/generadorexamenesllm/
|
||||||
|
|
||||||
|
# URL base del backend (accesible desde el navegador).
|
||||||
|
# Si se deja vacía, usa la misma base de la app (recomendado tras proxy HTTPS).
|
||||||
|
VITE_API_URL=
|
||||||
|
|
||||||
# (Opcional) Client ID de Google para "Iniciar sesión con Google".
|
# (Opcional) Client ID de Google para "Iniciar sesión con Google".
|
||||||
# Debe coincidir con GOOGLE_CLIENT_ID del backend.
|
# Debe coincidir con GOOGLE_CLIENT_ID del backend.
|
||||||
|
|||||||
+4
-1
@@ -2,8 +2,10 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG VITE_API_URL=http://sinbad2.ujaen.es:8074
|
ARG VITE_APP_BASE_PATH=/generadorexamenesllm/
|
||||||
|
ARG VITE_API_URL=
|
||||||
ARG VITE_GOOGLE_CLIENT_ID=
|
ARG VITE_GOOGLE_CLIENT_ID=
|
||||||
|
ENV VITE_APP_BASE_PATH=$VITE_APP_BASE_PATH
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ RUN npm run build
|
|||||||
# --- Serve stage ---
|
# --- Serve stage ---
|
||||||
FROM nginx:1.27-alpine AS serve
|
FROM nginx:1.27-alpine AS serve
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY proxy_params.conf /etc/nginx/snippets/proxy_params.conf
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
+35
-3
@@ -25,7 +25,7 @@ src/
|
|||||||
|
|
||||||
## Desarrollo local
|
## Desarrollo local
|
||||||
|
|
||||||
Requisitos: Node 20+ y el backend corriendo en `http://sinbad2.ujaen.es:8074`.
|
Requisitos: Node 20+ y el backend corriendo en `http://sinbad2.ujaen.es:8068`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
@@ -38,7 +38,8 @@ npm run dev # http://sinbad2.ujaen.es:8075
|
|||||||
|
|
||||||
| Variable | Descripción |
|
| Variable | Descripción |
|
||||||
| ----------------------- | -------------------------------------------------------- |
|
| ----------------------- | -------------------------------------------------------- |
|
||||||
| `VITE_API_URL` | URL base del backend (por defecto `http://sinbad2.ujaen.es:8074`). |
|
| `VITE_APP_BASE_PATH` | Base pública de la SPA (debe terminar en `/`). En producción UJA: `/generadorexamenesllm/`. |
|
||||||
|
| `VITE_API_URL` | URL base de la API. Si se deja vacía, usa la misma base pública de la app. |
|
||||||
| `VITE_GOOGLE_CLIENT_ID` | (Opcional) Client ID de Google. Si está vacío, se oculta el botón de Google. |
|
| `VITE_GOOGLE_CLIENT_ID` | (Opcional) Client ID de Google. Si está vacío, se oculta el botón de Google. |
|
||||||
|
|
||||||
> Las variables `VITE_*` se incrustan en el build, por lo que apuntan al backend
|
> Las variables `VITE_*` se incrustan en el build, por lo que apuntan al backend
|
||||||
@@ -60,9 +61,40 @@ El `docker-compose.yml` de la raíz construye el frontend con un build multi-sta
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Las variables `VITE_API_URL` y `VITE_GOOGLE_CLIENT_ID` pueden pasarse como
|
Las variables `VITE_APP_BASE_PATH`, `VITE_API_URL` y `VITE_GOOGLE_CLIENT_ID` pueden pasarse como
|
||||||
variables de entorno al ejecutar `docker compose`.
|
variables de entorno al ejecutar `docker compose`.
|
||||||
|
|
||||||
|
## Despliegue HTTPS en Sinbad2 (patrón orcid2sword)
|
||||||
|
|
||||||
|
Documentación completa: [`deploy/DESPLIEGUE_SINBAD2.md`](../deploy/DESPLIEGUE_SINBAD2.md)
|
||||||
|
|
||||||
|
Resumen:
|
||||||
|
|
||||||
|
| Capa | Responsabilidad |
|
||||||
|
|------|-----------------|
|
||||||
|
| Apache (UJA) | Certificado SSL, `ProxyPass` a `:8069` |
|
||||||
|
| GitLab CI | `docker compose up`, build con base path |
|
||||||
|
| Nginx contenedor | SPA + proxy `/auth/` y `/exam/` al backend |
|
||||||
|
| Backend Uvicorn | `--proxy-headers`, HSTS, CORS HTTPS |
|
||||||
|
|
||||||
|
URL pública: **`https://sinbad2.ujaen.es/generadorexamenesllm/`**
|
||||||
|
|
||||||
|
Apache (fragmento):
|
||||||
|
|
||||||
|
```apache
|
||||||
|
ProxyPass /generadorexamenesllm http://host.docker.internal:8069/
|
||||||
|
ProxyPassReverse /generadorexamenesllm http://host.docker.internal:8069/
|
||||||
|
```
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_APP_BASE_PATH=/generadorexamenesllm/
|
||||||
|
VITE_API_URL=
|
||||||
|
```
|
||||||
|
|
||||||
|
El navegador habla solo con HTTPS (Apache → nginx → backend); no hay mixed content ni CORS cruzado entre puertos.
|
||||||
|
|
||||||
## Manejo de errores
|
## Manejo de errores
|
||||||
|
|
||||||
Todas las respuestas de error del backend siguen el formato
|
Todas las respuestas de error del backend siguen el formato
|
||||||
|
|||||||
+72
-6
@@ -1,21 +1,87 @@
|
|||||||
|
# Nginx del contenedor frontend (HTTP interno, puerto 80 → publicado en 8069 en Sinbad2).
|
||||||
|
#
|
||||||
|
# Flujo HTTPS (igual que orcid2sword en Sinbad2):
|
||||||
|
# 1. Usuario → https://sinbad2.ujaen.es/generadorexamenesllm/
|
||||||
|
# 2. Apache termina TLS y hace ProxyPass al puerto 8069 (HTTP).
|
||||||
|
# 3. Con ProxyPass ... http://host:8069/ Apache QUITA el prefijo /generadorexamenesllm
|
||||||
|
# y el contenedor recibe /, /assets/, /auth/, etc.
|
||||||
|
# 4. Acceso directo al puerto 8069 (sin Apache) usa el prefijo /generadorexamenesllm/
|
||||||
|
# porque el build de Vite lleva VITE_APP_BASE_PATH=/generadorexamenesllm/
|
||||||
|
|
||||||
|
map $http_x_forwarded_proto $forwarded_proto {
|
||||||
|
default $http_x_forwarded_proto;
|
||||||
|
"" $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
map $http_x_forwarded_host $forwarded_host {
|
||||||
|
default $http_x_forwarded_host;
|
||||||
|
"" $host;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# SPA: cualquier ruta desconocida sirve index.html (React Router).
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# --- API: rutas sin prefijo (Apache quita /generadorexamenesllm) ---
|
||||||
|
location /auth/ {
|
||||||
|
proxy_pass http://backend:8074/auth/;
|
||||||
|
include /etc/nginx/snippets/proxy_params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /exam/ {
|
||||||
|
proxy_pass http://backend:8074/exam/;
|
||||||
|
include /etc/nginx/snippets/proxy_params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /health {
|
||||||
|
proxy_pass http://backend:8074/health;
|
||||||
|
include /etc/nginx/snippets/proxy_params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Raíz sin barra final → login (evita pantalla en blanco por basename)
|
||||||
|
location = /generadorexamenesllm {
|
||||||
|
return 301 /generadorexamenesllm/login;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache de assets con hash.
|
# --- Mismo contenido/API bajo prefijo público (acceso directo :8069 o si Apache no quita prefijo) ---
|
||||||
location /assets/ {
|
location ^~ /generadorexamenesllm/auth/ {
|
||||||
|
proxy_pass http://backend:8074/auth/;
|
||||||
|
include /etc/nginx/snippets/proxy_params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /generadorexamenesllm/exam/ {
|
||||||
|
proxy_pass http://backend:8074/exam/;
|
||||||
|
include /etc/nginx/snippets/proxy_params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /generadorexamenesllm/health {
|
||||||
|
proxy_pass http://backend:8074/health;
|
||||||
|
include /etc/nginx/snippets/proxy_params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /generadorexamenesllm/assets/ {
|
||||||
|
alias /usr/share/nginx/html/assets/;
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
gzip on;
|
location ^~ /generadorexamenesllm/ {
|
||||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
try_files $uri $uri/ /index.html;
|
||||||
gzip_min_length 1024;
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $forwarded_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
|
proxy_set_header X-Forwarded-Host $forwarded_host;
|
||||||
|
proxy_redirect off;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import Layout from "./components/layout/Layout";
|
import Layout from "./components/layout/Layout";
|
||||||
import ProtectedRoute from "./components/layout/ProtectedRoute";
|
import ProtectedRoute from "./components/layout/ProtectedRoute";
|
||||||
|
import RootRoute from "./components/layout/RootRoute";
|
||||||
import LoginPage from "./pages/LoginPage";
|
import LoginPage from "./pages/LoginPage";
|
||||||
import RegisterPage from "./pages/RegisterPage";
|
import RegisterPage from "./pages/RegisterPage";
|
||||||
import DashboardPage from "./pages/DashboardPage";
|
|
||||||
import CreateTemplatePage from "./pages/CreateTemplatePage";
|
import CreateTemplatePage from "./pages/CreateTemplatePage";
|
||||||
import TemplateDetailPage from "./pages/TemplateDetailPage";
|
import TemplateDetailPage from "./pages/TemplateDetailPage";
|
||||||
import NotFoundPage from "./pages/NotFoundPage";
|
import NotFoundPage from "./pages/NotFoundPage";
|
||||||
@@ -21,7 +21,7 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<RootRoute />} />
|
||||||
<Route path="/plantillas/nueva" element={<CreateTemplatePage />} />
|
<Route path="/plantillas/nueva" element={<CreateTemplatePage />} />
|
||||||
<Route path="/plantillas/:templateId" element={<TemplateDetailPage />} />
|
<Route path="/plantillas/:templateId" element={<TemplateDetailPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export const API_URL =
|
export const API_URL =
|
||||||
import.meta.env.VITE_API_URL?.replace(/\/$/, "") || "http://sinbad2.ujaen.es:8074";
|
import.meta.env.VITE_API_URL?.replace(/\/$/, "") || import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||||
|
|
||||||
const TOKEN_KEY = "genex_token";
|
const TOKEN_KEY = "genex_token";
|
||||||
|
|
||||||
|
|||||||
@@ -10,37 +10,60 @@ export default function Navbar() {
|
|||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [confirmOut, setConfirmOut] = useState(false);
|
const [confirmOut, setConfirmOut] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const doLogout = () => {
|
const doLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeMobileMenu = () => setMobileOpen(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="navbar">
|
<header className="navbar">
|
||||||
<div className="navbar-inner">
|
<div className="navbar-inner">
|
||||||
<Link to="/" className="brand">
|
<Link to="/" className="brand" onClick={closeMobileMenu}>
|
||||||
<span className="brand-logo">
|
<span className="brand-logo">
|
||||||
<Icon name="document" size={18} />
|
<Icon name="document" size={18} />
|
||||||
</span>
|
</span>
|
||||||
GenExámenes IA
|
<span className="brand-text">GenExámenes IA</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="nav-links">
|
|
||||||
<NavLink to="/" end className="nav-link">
|
<Button
|
||||||
Mis exámenes
|
variant="ghost"
|
||||||
</NavLink>
|
size="sm"
|
||||||
<NavLink to="/plantillas/nueva" className="nav-link">
|
className="nav-mobile-toggle"
|
||||||
Crear examen
|
aria-label={mobileOpen ? "Cerrar menú" : "Abrir menú"}
|
||||||
</NavLink>
|
onClick={() => setMobileOpen((open) => !open)}
|
||||||
</nav>
|
>
|
||||||
<span className="nav-spacer" />
|
<Icon name={mobileOpen ? "close" : "listChecks"} size={18} />
|
||||||
<div className="nav-user">
|
</Button>
|
||||||
<div className="avatar" title={user?.email}>
|
|
||||||
{initials(user?.full_name || user?.email)}
|
<div className={`nav-collapse ${mobileOpen ? "open" : ""}`}>
|
||||||
|
<nav className="nav-links">
|
||||||
|
<NavLink to="/" end className="nav-link" onClick={closeMobileMenu}>
|
||||||
|
Mis exámenes
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/plantillas/nueva" className="nav-link" onClick={closeMobileMenu}>
|
||||||
|
Crear examen
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
<span className="nav-spacer" />
|
||||||
|
<div className="nav-user">
|
||||||
|
<div className="avatar" title={user?.email}>
|
||||||
|
{initials(user?.full_name || user?.email)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
closeMobileMenu();
|
||||||
|
setConfirmOut(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Salir
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setConfirmOut(true)}>
|
|
||||||
Salir
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import { SpinnerCenter } from "../ui/Spinner";
|
||||||
|
import DashboardPage from "../../pages/DashboardPage";
|
||||||
|
|
||||||
|
/** Raíz de la app: login si no hay sesión, dashboard si la hay. */
|
||||||
|
export default function RootRoute() {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) return <SpinnerCenter label="Cargando tu sesión…" />;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DashboardPage />;
|
||||||
|
}
|
||||||
+342
-26
@@ -31,6 +31,10 @@
|
|||||||
|
|
||||||
--font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
--font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
--maxw: 1180px;
|
--maxw: 1180px;
|
||||||
|
--h1: 30px;
|
||||||
|
--h2: 24px;
|
||||||
|
--h3: 19px;
|
||||||
|
--h4: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -48,7 +52,7 @@ body {
|
|||||||
background: var(--c-bg);
|
background: var(--c-bg);
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.55;
|
line-height: 1.6;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +68,22 @@ h1,
|
|||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
h4 {
|
h4 {
|
||||||
margin: 0 0 0.4em;
|
margin: 0 0 0.45em;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.012em;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: var(--h1);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: var(--h2);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: var(--h3);
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: var(--h4);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -82,6 +98,12 @@ button {
|
|||||||
.icon-inline {
|
.icon-inline {
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
.card-head .icon,
|
||||||
|
.tab .icon,
|
||||||
|
.btn .icon,
|
||||||
|
.badge .icon {
|
||||||
|
stroke-width: 1.9;
|
||||||
|
}
|
||||||
.icon-lg {
|
.icon-lg {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -181,7 +203,7 @@ button {
|
|||||||
.nav-link {
|
.nav-link {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--c-text-soft);
|
color: #4e566c;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@@ -228,16 +250,16 @@ button {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 26px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.page-header h1 {
|
.page-header h1 {
|
||||||
font-size: 26px;
|
font-size: clamp(24px, 3vw, 30px);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.page-header p {
|
.page-header p {
|
||||||
margin: 4px 0 0;
|
margin: 7px 0 0;
|
||||||
color: var(--c-text-soft);
|
color: #5a6277;
|
||||||
}
|
}
|
||||||
.page-header-actions {
|
.page-header-actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -245,6 +267,23 @@ button {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-lead {
|
||||||
|
max-width: 68ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--c-text);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Cards ---------- */
|
/* ---------- Cards ---------- */
|
||||||
.card {
|
.card {
|
||||||
background: var(--c-surface);
|
background: var(--c-surface);
|
||||||
@@ -253,10 +292,10 @@ button {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.card-pad {
|
.card-pad {
|
||||||
padding: 22px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
.card-head {
|
.card-head {
|
||||||
padding: 18px 22px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px solid var(--c-border);
|
border-bottom: 1px solid var(--c-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -265,9 +304,10 @@ button {
|
|||||||
.card-head h3 {
|
.card-head h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
.card-body {
|
.card-body {
|
||||||
padding: 22px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -365,12 +405,12 @@ button {
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--c-text);
|
color: #2a3143;
|
||||||
margin-bottom: 7px;
|
margin-bottom: 7px;
|
||||||
}
|
}
|
||||||
.field-hint {
|
.field-hint {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--c-text-faint);
|
color: #78809a;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.field-error {
|
.field-error {
|
||||||
@@ -440,12 +480,12 @@ button {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 3px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--c-surface-2);
|
background: var(--c-surface-2);
|
||||||
color: var(--c-text-soft);
|
color: #505a74;
|
||||||
}
|
}
|
||||||
.badge-primary {
|
.badge-primary {
|
||||||
background: var(--c-primary-soft);
|
background: var(--c-primary-soft);
|
||||||
@@ -477,10 +517,10 @@ button {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
padding: 11px 16px;
|
padding: 11px 15px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--c-text-soft);
|
color: #556079;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -492,7 +532,7 @@ button {
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
color: var(--c-text);
|
color: #252c3f;
|
||||||
}
|
}
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: var(--c-primary);
|
color: var(--c-primary);
|
||||||
@@ -627,7 +667,7 @@ button {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
color: var(--c-text-soft);
|
color: #586178;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.meta-row span {
|
.meta-row span {
|
||||||
@@ -668,13 +708,17 @@ button {
|
|||||||
}
|
}
|
||||||
.list-item-sub {
|
.list-item-sub {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--c-text-faint);
|
color: #7a839c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 56px 24px;
|
padding: 56px 24px;
|
||||||
color: var(--c-text-soft);
|
color: #5c657a;
|
||||||
|
}
|
||||||
|
.empty-state p {
|
||||||
|
margin: 6px auto 0;
|
||||||
|
max-width: 56ch;
|
||||||
}
|
}
|
||||||
.empty-state-icon {
|
.empty-state-icon {
|
||||||
margin: 0 auto 12px;
|
margin: 0 auto 12px;
|
||||||
@@ -813,15 +857,15 @@ button {
|
|||||||
}
|
}
|
||||||
.toast-content {
|
.toast-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 14px;
|
font-size: 13.5px;
|
||||||
}
|
}
|
||||||
.toast-title {
|
.toast-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
.toast-msg {
|
.toast-msg {
|
||||||
color: var(--c-text-soft);
|
color: #5b6378;
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
.toast-close {
|
.toast-close {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -863,14 +907,17 @@ button {
|
|||||||
.mt-lg {
|
.mt-lg {
|
||||||
margin-top: 26px;
|
margin-top: 26px;
|
||||||
}
|
}
|
||||||
|
.mb-sm {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
.mb {
|
.mb {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.text-soft {
|
.text-soft {
|
||||||
color: var(--c-text-soft);
|
color: #5a6379;
|
||||||
}
|
}
|
||||||
.text-faint {
|
.text-faint {
|
||||||
color: var(--c-text-faint);
|
color: #7b849c;
|
||||||
}
|
}
|
||||||
.text-sm {
|
.text-sm {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -933,3 +980,272 @@ button {
|
|||||||
border: 1px solid var(--c-border);
|
border: 1px solid var(--c-border);
|
||||||
background: var(--c-surface-2);
|
background: var(--c-surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-split {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-split-wide {
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-select-wrap {
|
||||||
|
display: none;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Responsive ---------- */
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.navbar-inner {
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 24px 16px 42px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.list-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-split,
|
||||||
|
.layout-split-wide {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
:root {
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--h1: 26px;
|
||||||
|
--h2: 22px;
|
||||||
|
--h3: 18px;
|
||||||
|
--h4: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-inner {
|
||||||
|
height: auto;
|
||||||
|
min-height: 64px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-mobile-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-collapse {
|
||||||
|
width: 100%;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0 2px;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-collapse.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p,
|
||||||
|
.page-lead {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-actions {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head,
|
||||||
|
.card-body,
|
||||||
|
.card-pad {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head > .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: -4px;
|
||||||
|
margin-right: -4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-select-wrap {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-sm {
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-lg {
|
||||||
|
min-height: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-stack {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-stack > .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
padding: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
max-height: 94vh;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head,
|
||||||
|
.modal-body,
|
||||||
|
.modal-foot {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-foot {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-foot .btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-stack {
|
||||||
|
top: auto;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 12px;
|
||||||
|
left: 12px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-center {
|
||||||
|
padding: 36px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 34px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
max-height: 320px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 761px) {
|
||||||
|
.nav-mobile-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-collapse {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
import { ToastProvider } from "./context/ToastContext";
|
import { ToastProvider } from "./context/ToastContext";
|
||||||
|
import { redirectRootToLoginIfNeeded } from "./utils/redirectRoot";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
redirectRootToLoginIfNeeded();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function CreateTemplatePage() {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Nuevo examen</h1>
|
<h1>Nuevo examen</h1>
|
||||||
<p>Define la estructura. Después podrás subir material y generar preguntas.</p>
|
<p className="page-lead">Define la estructura. Después podrás subir material y generar preguntas.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ export default function CreateTemplatePage() {
|
|||||||
className="card"
|
className="card"
|
||||||
style={{ padding: 16, marginBottom: 14, background: "var(--c-surface-2)" }}
|
style={{ padding: 16, marginBottom: 14, background: "var(--c-surface-2)" }}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb">
|
<div className="flex justify-between items-center mb wrap gap-sm">
|
||||||
<strong>Bloque {idx + 1}</strong>
|
<strong>Bloque {idx + 1}</strong>
|
||||||
{types.length > 1 && (
|
{types.length > 1 && (
|
||||||
<Button
|
<Button
|
||||||
@@ -263,7 +263,7 @@ export default function CreateTemplatePage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<div style={{ paddingTop: 26 }}>
|
<div style={{ paddingTop: 8 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Permitir varias respuestas correctas"
|
label="Permitir varias respuestas correctas"
|
||||||
checked={t.multiple_correct}
|
checked={t.multiple_correct}
|
||||||
@@ -343,7 +343,7 @@ export default function CreateTemplatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap justify-between">
|
<div className="flex gap justify-between wrap mobile-stack mt">
|
||||||
<Button type="button" variant="ghost" onClick={() => navigate("/")}>
|
<Button type="button" variant="ghost" onClick={() => navigate("/")}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ export default function DashboardPage() {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Mis exámenes</h1>
|
<h1>Mis exámenes</h1>
|
||||||
<p>Gestiona tus plantillas de examen y genera preguntas con IA.</p>
|
<p className="page-lead">Gestiona tus plantillas de examen y genera preguntas con IA.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-header-actions">
|
<div className="page-header-actions">
|
||||||
<Button onClick={() => navigate("/plantillas/nueva")}>
|
<Button onClick={() => navigate("/plantillas/nueva")} size="lg">
|
||||||
<Icon name="plus" size={16} className="icon-inline" />
|
<Icon name="plus" size={16} className="icon-inline" />
|
||||||
Nuevo examen
|
Nuevo examen
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -154,6 +154,24 @@ export default function TemplateDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs-select-wrap">
|
||||||
|
<label className="field-label" htmlFor="template-tab-select">
|
||||||
|
Sección actual
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="template-tab-select"
|
||||||
|
className="select"
|
||||||
|
value={tab}
|
||||||
|
onChange={(e) => setTab(e.target.value)}
|
||||||
|
>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function ExportTab({ templateId, template, questions }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-soft mb">
|
<p className="text-soft page-lead mb">
|
||||||
Tu examen tiene <strong>{questions.length} preguntas</strong>. Elige un
|
Tu examen tiene <strong>{questions.length} preguntas</strong>. Elige un
|
||||||
formato para descargarlo o previsualizarlo.
|
formato para descargarlo o previsualizarlo.
|
||||||
</p>
|
</p>
|
||||||
@@ -87,7 +87,7 @@ export default function ExportTab({ templateId, template, questions }) {
|
|||||||
<p className="text-sm text-soft" style={{ minHeight: 60 }}>
|
<p className="text-sm text-soft" style={{ minHeight: 60 }}>
|
||||||
{fmt.desc}
|
{fmt.desc}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-sm">
|
<div className="flex gap-sm mobile-stack">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => run(fmt, { download: true })}
|
onClick={() => run(fmt, { download: true })}
|
||||||
loading={loadingFormat === fmt.id}
|
loading={loadingFormat === fmt.id}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function GenerateTab({
|
|||||||
const topicTooShort = topic.trim().length < 5;
|
const topicTooShort = topic.trim().length < 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid" style={{ gridTemplateColumns: "1fr 340px" }}>
|
<div className="grid layout-split">
|
||||||
<div>
|
<div>
|
||||||
<div className="tabs" style={{ marginBottom: 18 }}>
|
<div className="tabs" style={{ marginBottom: 18 }}>
|
||||||
{MODES.map((m) => (
|
{MODES.map((m) => (
|
||||||
@@ -122,6 +122,9 @@ export default function GenerateTab({
|
|||||||
{mode !== "parse" && (
|
{mode !== "parse" && (
|
||||||
<div className="card mb">
|
<div className="card mb">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
<h3 className="section-title mb-sm">
|
||||||
|
{mode === "auto" ? "Generación automática" : "Construcción de prompt"}
|
||||||
|
</h3>
|
||||||
<Field
|
<Field
|
||||||
label="Tema / instrucciones para la IA"
|
label="Tema / instrucciones para la IA"
|
||||||
hint="Describe el contenido o enfoque del examen (mínimo 5 caracteres)."
|
hint="Describe el contenido o enfoque del examen (mínimo 5 caracteres)."
|
||||||
@@ -157,7 +160,7 @@ export default function GenerateTab({
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap mt">
|
<div className="flex gap mt mobile-stack">
|
||||||
{mode === "auto" ? (
|
{mode === "auto" ? (
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -244,7 +247,7 @@ export default function GenerateTab({
|
|||||||
|
|
||||||
{generated.length > 0 && (
|
{generated.length > 0 && (
|
||||||
<div className="mt-lg">
|
<div className="mt-lg">
|
||||||
<div className="flex justify-between items-center mb">
|
<div className="flex justify-between items-center mb wrap gap-sm">
|
||||||
<h3 style={{ margin: 0 }}>
|
<h3 style={{ margin: 0 }}>
|
||||||
Resultado ({generated.length} preguntas)
|
Resultado ({generated.length} preguntas)
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -65,10 +65,12 @@ export default function ImagesTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}>
|
<div className="grid layout-split">
|
||||||
<div>
|
<div>
|
||||||
<div className="card mb">
|
<div className="card mb">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
<h3 className="section-title">Subir imágenes del examen</h3>
|
||||||
|
<p className="section-subtle mb">Añade recursos visuales para preguntas con soporte gráfico.</p>
|
||||||
<FileDropzone
|
<FileDropzone
|
||||||
accept={IMAGE_ACCEPT}
|
accept={IMAGE_ACCEPT}
|
||||||
icon="image"
|
icon="image"
|
||||||
|
|||||||
@@ -67,10 +67,12 @@ export default function MaterialsTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}>
|
<div className="grid layout-split">
|
||||||
<div>
|
<div>
|
||||||
<div className="card mb">
|
<div className="card mb">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
<h3 className="section-title">Subir material para IA</h3>
|
||||||
|
<p className="section-subtle mb">Soporta PDF, DOCX, TXT, MD e imágenes con OCR.</p>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<div className="text-center" style={{ padding: 24 }}>
|
<div className="text-center" style={{ padding: 24 }}>
|
||||||
<Spinner large />
|
<Spinner large />
|
||||||
@@ -127,7 +129,7 @@ export default function MaterialsTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-sm items-center" style={{ flex: "none" }}>
|
<div className="flex gap-sm items-center mobile-stack list-item-actions" style={{ flex: "none" }}>
|
||||||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="danger-ghost"
|
variant="danger-ghost"
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ export default function OverviewTab({ template, storage, goToTab }) {
|
|||||||
const profile = template.difficulty_profile || {};
|
const profile = template.difficulty_profile || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid" style={{ gridTemplateColumns: "1.4fr 1fr" }}>
|
<div className="grid layout-split-wide">
|
||||||
<div>
|
<div>
|
||||||
<div className="card mb">
|
<div className="card mb">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h3>Estructura del examen</h3>
|
<h3>Estructura del examen</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h4 className="text-soft text-sm">Tipos de pregunta</h4>
|
<h4 className="section-title">Tipos de pregunta</h4>
|
||||||
{qTypes.map((qt, i) => (
|
{qTypes.map((qt, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex justify-between items-center"
|
className="flex justify-between items-center wrap gap-sm"
|
||||||
style={{
|
style={{
|
||||||
padding: "10px 0",
|
padding: "10px 0",
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -50,7 +50,7 @@ export default function OverviewTab({ template, storage, goToTab }) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="divider-line" />
|
<div className="divider-line" />
|
||||||
<h4 className="text-soft text-sm">Reparto por dificultad</h4>
|
<h4 className="section-title">Reparto por dificultad</h4>
|
||||||
<div className="flex gap-sm wrap">
|
<div className="flex gap-sm wrap">
|
||||||
{Object.entries(profile).map(([key, val]) =>
|
{Object.entries(profile).map(([key, val]) =>
|
||||||
val > 0 ? (
|
val > 0 ? (
|
||||||
@@ -62,6 +62,7 @@ export default function OverviewTab({ template, storage, goToTab }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divider-line" />
|
<div className="divider-line" />
|
||||||
|
<h4 className="section-title">Opciones activas</h4>
|
||||||
<div className="flex gap-sm wrap text-sm">
|
<div className="flex gap-sm wrap text-sm">
|
||||||
<Badge variant={template.settings?.shuffle_questions ? "success" : undefined}>
|
<Badge variant={template.settings?.shuffle_questions ? "success" : undefined}>
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export default function QuestionsTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb">
|
<div className="flex justify-between items-center mb wrap gap-sm">
|
||||||
<p className="text-soft" style={{ margin: 0 }}>
|
<p className="text-soft page-lead" style={{ margin: 0 }}>
|
||||||
{questions.length} preguntas guardadas. Vincula imágenes a las
|
{questions.length} preguntas guardadas. Vincula imágenes a las
|
||||||
preguntas que las necesiten.
|
preguntas que las necesiten.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const TOKEN_KEY = "genex_token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si el usuario entra en la raíz de la app sin sesión, redirige a /login
|
||||||
|
* antes de montar React (evita pantalla en blanco por basename sin barra final).
|
||||||
|
*/
|
||||||
|
export function redirectRootToLoginIfNeeded() {
|
||||||
|
const base = import.meta.env.BASE_URL || "/";
|
||||||
|
const baseNoSlash = base.replace(/\/$/, "");
|
||||||
|
const { pathname, search, hash } = window.location;
|
||||||
|
const normalizedPath = pathname.replace(/\/$/, "") || "/";
|
||||||
|
|
||||||
|
const isAppRoot =
|
||||||
|
normalizedPath === baseNoSlash ||
|
||||||
|
normalizedPath === "/" ||
|
||||||
|
pathname === base;
|
||||||
|
|
||||||
|
if (!isAppRoot) return;
|
||||||
|
if (localStorage.getItem(TOKEN_KEY)) return;
|
||||||
|
|
||||||
|
const loginUrl = `${baseNoSlash}/login${search}${hash}`;
|
||||||
|
if (pathname + search + hash !== `${baseNoSlash}/login${search}${hash}`) {
|
||||||
|
window.location.replace(loginUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-7
@@ -1,10 +1,16 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react()],
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
server: {
|
const base = env.VITE_APP_BASE_PATH || "/";
|
||||||
host: true,
|
|
||||||
port: 8075,
|
return {
|
||||||
},
|
base,
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 8075,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user