31 Commits

Author SHA1 Message Date
egutierrez 8ad80defcf feat: Remove @tabler/icons-react dependency and add Login page component 2025-06-21 02:13:19 +02:00
egutierrez aef8791151 feat: Implement main application shell with navigation and color scheme toggle
- Added Appshell component with responsive navbar and main content area
- Integrated ColorSchemeToggle for light/dark mode switching
- Created Welcome component with styled title and introductory text
- Developed ChatPage for LLM interaction with WebSocket support
- Implemented Biblioteca for managing notes with rich text editor
- Added LoginPage for user authentication with error handling
- Introduced MessageList and MessageBubble components for chat messages
- Styled components with CSS modules for consistent design
2025-06-21 02:01:21 +02:00
egutierrez 3d5deef0fb feat: Implement user management functionality with login and CRUD operations 2025-06-21 01:40:19 +02:00
egutierrez 3438102dc0 feat: Add initial task list for project planning and user management 2025-06-17 00:38:00 +02:00
egutierrez 9ee8daa295 feat: Implement WebSocket support for chat functionality and refactor chat service
- Added WebSocket endpoint for real-time chat interactions.
- Refactored ChatPage component to utilize WebSocket for sending and receiving messages.
- Updated chat service to handle streaming responses from the LLM agent.
- Introduced error handling for WebSocket connections and message processing.
- Modified Editor_Test to include AppShellWithMenu for better layout.
- Adjusted file path in generar_tree.py for correct directory structure.
- Created llm_chat_endpoint_v1.py and llm_chat_srvc.py for handling chat requests and responses.
- Established logging for WebSocket interactions and errors.
2025-06-17 00:19:36 +02:00
egutierrez 6d6fab5634 feat: Add Chat LLM functionality with message input and display components 2025-06-16 23:18:18 +02:00
egutierrez 9c638fc3e5 Generacion de estructura ddd para backend
feat: Refactor API structure by consolidating endpoints and removing deprecated files
2025-06-16 22:22:41 +02:00
egutierrez 43f6fb03fe feat: Refactor CamaraNoir component layout and update content for live streaming 2025-06-16 22:12:28 +02:00
egutierrez ac83907e7c feat: Update routing paths and enhance navigation links for Biblioteca and CameraNoir 2025-06-11 23:19:23 +02:00
egutierrez 3cd267ee6e Gestionado todo el frontend para domain driven design
feat: Implement LlamadorAPI component for API interaction with dynamic request handling

feat: Create MetodoSelect component for selecting HTTP methods with visual feedback

feat: Add VisualizacionesRandom component to display various charts using ECharts

feat: Develop custom 404 Error page with holographic shader effect

feat: Create HoloShader component for dynamic background effects on 404 page

style: Add CSS styles for Appshell layout and navigation

feat: Build Appshell component to manage application layout and navigation

feat: Add ColorSchemeToggle component for switching between light and dark themes

feat: Create Plantilla component as a template for future pages

style: Define styles for Welcome component

feat: Implement Welcome component with introductory text and links

feat: Develop HomePage component to serve as the main entry point of the application

feat: Create Biblioteca component for managing notes and libraries with rich text editor

feat: Add Editor_Test component for testing rich text editor functionality

style: Define styles for the rich text editor in Biblioteca
2025-06-11 22:53:32 +02:00
egutierrez e1b756ac99 feat: Implement cookie extraction script for Chrome v20 and enhance browser interaction 2025-06-01 15:31:13 +02:00
egutierrez 628cddc3ae feat: Enhance logging and add chart endpoints
- Updated LoggerDB to remove all active sinks on initialization.
- Added a new PostgresCredencial setup in notas_mmr.py for database connection.
- Replaced print statements with logger calls for better logging in notas_mmr.py.
- Introduced new FastAPI endpoints for various chart types (bar, line, pie, scatter).
- Created Editor_biblioteca.css for styling the rich text editor.
- Implemented Editor_Test.tsx to test the rich text editor functionality.
2025-06-01 00:33:48 +02:00
egutierrez cf6a768f6b Refactor and enhance MCP client and server functionality
- Removed prueba_cliente_mcp.py as it was no longer needed.
- Updated prueba_loop_agente.py to integrate MCPServerRunner for managing server instances.
- Modified prueba_mcp.py to implement a new structure for starting and stopping MCP servers.
- Enhanced AgenteAI class to support multiple MCP blocks execution.
- Improved MCPClient with timeout handling for tool calls.
- Added new sandbox files for children's stories.
- Created a simple ERP system with a main entry point.
- Added unit tests for the ERP system.
- Implemented MCPServerRunner to manage server processes.
- Developed server_files.py to handle file operations securely within a sandbox environment.
- Introduced ElementoWeb and Navegador classes for web scraping functionalities.
- Enhanced Scrapper and Tab classes for better interaction with web pages.
2025-05-25 13:49:08 +02:00
egutierrez a62778a030 feat: add ECharts and related components for data visualization
- Added `echarts` and `echarts-for-react` dependencies to the project.
- Created new pages for visualizations: `VisualizacionesRandom` and `Camara_noir`.
- Implemented `CanvasDisplay`, `ControlPanel`, `CaptureGrid`, `GridConfigPanel`, and `FrameCard` components for camera functionality.
- Integrated WebSocket for real-time image capture in `useCamaraNoir` hook.
- Developed FastAPI backend with endpoints for various chart data (bar, line, pie, scatter).
- Updated routing to include new analytics paths for visualizations.
- Modified submenu links to reflect new analytics options.
2025-05-22 01:45:57 +02:00
egutierrez 6b491a9a41 Implementación del cliente Ollama y su credencial, integración de logging en base de datos, y mejoras en la gestión de herramientas MCP. 2025-05-19 22:57:01 +02:00
egutierrez 9db2f70009 Actualizacion para mcp 2025-05-16 02:12:33 +02:00
egutierrez 95c1762ca7 env-example añadido 2025-05-15 01:11:24 +02:00
egutierrez 6e716f8f98 Se guarda carpeta Config y pequeños cambios en mcp 2025-05-15 00:51:26 +02:00
egutierrez c13240b481 Notas y bibliotecas funcionando 2025-05-14 02:06:33 +02:00
egutierrez bf1814bb8e Cambios a las 3 bases Model mapper repo para que funcionen a partir de las clases heredando todos los metodos comunes 2025-05-12 01:24:44 +02:00
egutierrez 712bd877b8 Notas en frontend funcionando y pudiendo subir mas por sus endpoints 2025-05-11 02:30:55 +02:00
egutierrez b34d52036e endpoint_biblioteca_funcionando 2025-05-10 20:08:51 +02:00
egutierrez c47b9474f4 feat: Implement text manager API and database connection
- Added `text_manager.py` to handle the creation of text libraries via FastAPI.
- Introduced database connection management in `conexion.py` using PostgreSQL credentials from environment variables.
- Created abstract base class `EmbedderABC` in `Base_Embedder.py` for embedding models.
- Developed `OpenAIEmbedder` class to generate embeddings using OpenAI's API.
- Implemented `OpenAIEmbedderModel` and repository pattern for managing OpenAI embedders in `Openai_embedder_mmr.py`.
- Established `Biblioteca` class for managing text libraries and their associated notes in `biblioteca.py`.
- Created SQLAlchemy models and mappers for `Biblioteca` and `Nota` in `biblioteca_mmr.py` and `notas_biblioteca_mmr.py`.
- Added functionality for dynamic table generation for notes associated with libraries.
- Included comprehensive methods for adding, retrieving, and managing notes and libraries in their respective repositories.
2025-05-10 17:52:43 +02:00
egutierrez c646bc1fef feat: Implement GeneradorIDUnico class for unique ID generation and verification 2025-05-09 23:49:13 +02:00
egutierrez f7879e9557 chore: Allow tracking of config/.env file by updating .gitignore 2025-05-09 19:48:03 +02:00
egutierrez 41b307f4bb chore: Remove specific .env file entry from .gitignore to allow tracking of local configuration 2025-05-09 19:47:48 +02:00
egutierrez 1022e23a0d chore: Remove .env file containing sensitive database and project configuration 2025-05-09 19:47:41 +02:00
egutierrez 0d1ffcd1ff feat: Add ColorSchemeToggle component to HomePage for theme switching functionality 2025-05-09 01:15:19 +02:00
egutierrez b087271255 feat: Update Appshell styling for improved layout; adjust padding and height in title class; modify GridDashboard component for margin in Group 2025-05-09 00:45:48 +02:00
egutierrez 2becc8bc7c feat: Update routing and submenu links for Grid_Dashboard; modify Welcome component text and Appshell styles 2025-05-09 00:22:47 +02:00
egutierrez 8d35da1972 feat: Add GridDashboard component and update routing; replace Prueba_appshell with Grid_Dashboard 2025-05-08 23:31:05 +02:00
141 changed files with 11545 additions and 1919 deletions
+6
View File
@@ -0,0 +1,6 @@
# Inicia el frontend en una nueva ventana de PowerShell
Start-Process powershell -ArgumentList "-NoExit", "-Command", 'cd ./frontend; npm run dev; pause'
# Inicia el backend en una nueva ventana de PowerShell
Start-Process powershell -ArgumentList "-NoExit", "-Command", 'cd ./backend; uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000; pause'
-7
View File
@@ -1,7 +0,0 @@
# backend/api/router.py
from fastapi import APIRouter
from backend.api.v1.endpoints import ping
router = APIRouter()
router.include_router(ping.router, prefix="/api/v1")
@@ -0,0 +1,57 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/bar")
def get_bar_chart():
return {
"xAxis": {"type": "category", "data": ["Ene", "Feb", "Mar", "Abr"]},
"yAxis": {"type": "value"},
"series": [{"data": [5, 20, 36, 10], "type": "bar"}]
}
@router.get("/line")
def get_line_chart():
return {
"xAxis": {"type": "category", "data": ["Semana 1", "Semana 2", "Semana 3", "Semana 4"]},
"yAxis": {"type": "value"},
"series": [{"data": [15, 25, 18, 30], "type": "line"}]
}
@router.get("/pie")
def get_pie_chart():
return {
"tooltip": {"trigger": "item"},
"legend": {"top": "5%", "left": "center"},
"series": [{
"name": "Accesos",
"type": "pie",
"radius": ["40%", "70%"],
"avoidLabelOverlap": False,
"itemStyle": {"borderRadius": 10, "borderColor": "#fff", "borderWidth": 2},
"label": {"show": False, "position": "center"},
"emphasis": {
"label": {"show": True, "fontSize": 16, "fontWeight": "bold"}
},
"labelLine": {"show": False},
"data": [
{"value": 1048, "name": "Search"},
{"value": 735, "name": "Direct"},
{"value": 580, "name": "Email"},
{"value": 484, "name": "Union Ads"},
{"value": 300, "name": "Video Ads"}
]
}]
}
@router.get("/scatter")
def get_scatter_chart():
return {
"xAxis": {},
"yAxis": {},
"series": [{
"symbolSize": 20,
"data": [[10, 8], [20, 20], [30, 10], [40, 30], [50, 15]],
"type": "scatter"
}]
}
@@ -4,6 +4,6 @@ from fastapi import APIRouter
router = APIRouter() router = APIRouter()
@router.get("/ping") @router.get("/")
async def ping(): async def ping():
return {"message": "pong"} return {"message": "pong"}
@@ -0,0 +1,44 @@
# backend/domains/llm/agent_endpoints.py
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from fastapi.concurrency import run_in_threadpool
from backend.backend_domains.llms.llm_chat_srvc import construir_agente_llm, responder, responder_stream
from domains.Logger.logger_db import LoggerDB, logger
from entrypoint.init_db import db_credencial
LoggerDB(db_credencial, "logger_llm", created_by="sistema")
router = APIRouter()
agente = construir_agente_llm() # inicializa el agente una vez
# 📥 Schema para entrada de prompt
class ChatInput(BaseModel):
prompt: str
# ✅ Endpoint de respuesta simple
@router.post("/chat", summary="Enviar prompt y obtener respuesta completa del agente")
async def chat_endpoint(data: ChatInput):
try:
return await responder(data.prompt, agente)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception("[ERROR] Fallo durante respuesta del agente:")
raise HTTPException(status_code=500, detail="Error interno al procesar la solicitud.")
# 🔁 Endpoint de streaming
@router.post("/chat-stream", summary="Enviar prompt y recibir respuesta del agente en streaming")
async def chat_stream_endpoint(data: ChatInput):
try:
return StreamingResponse(
responder_stream(data.prompt, agente),
media_type="text/plain"
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception("[ERROR] Fallo durante respuesta en streaming:")
raise HTTPException(status_code=500, detail="Error interno en el agente.")
@@ -0,0 +1,84 @@
# src/services/agent_service.py
from domains.ApiKeys.openai_apikey_mmr import OpenAICredencialRepo
from domains.ConexionSql.Postgres_conexion import PostgresConexion
from domains.ConexionApis.OpenAi_conexion import OpenAICliente
from domains.Llms.Modelos.Openai_model import ModeloOpenAI
from domains.Llms.Agente import AgenteAI
from domains.Llms.Memory.postgres_MemoryConv import MemoryConvPostgres
from domains.Llms.MCPs.McpClient import MCPClient
from domains.Llms.MCPs.McpClient_Registry import ClientRegistry
from entrypoint.init_db import db_credencial
from domains.Logger.logger_db import LoggerDB, logger
LoggerDB(db_credencial, "logger_llm", created_by="sistema")
from typing import AsyncGenerator
# 🔧 Inicialización única del agente
def construir_agente_llm() -> AgenteAI:
logger.info("[INICIO] Inicializando agente LLM...")
conexion = PostgresConexion(db_credencial)
# Paso 1: Obtener credencial
repo = OpenAICredencialRepo(conexion)
credencial = repo.get_by_id("OPAK20250513-61b29978b7604031014")
if not credencial:
raise ValueError("No se encontró la credencial OpenAI")
logger.debug(f"[OK] Credencial OpenAI cargada: {credencial.titulo}")
# Paso 2: Crear cliente
cliente = OpenAICliente(credencial)
# Paso 3: Instanciar modelo
modelo = ModeloOpenAI(
cliente=cliente,
model="gpt-4o",
temperature=1
)
# Paso 4: Memoria en PostgreSQL
memoria = MemoryConvPostgres(
credencial=db_credencial,
nombre_tabla="memoria_conversacion_pruebas",
k=10
)
# Paso 5: Herramientas MCP (ej. archivos)
archivos = MCPClient.from_http(
name="files",
url="http://127.0.0.1:4201/fs"
)
registry = ClientRegistry()
registry.add("files", archivos)
# Paso 6: Agente
agente = AgenteAI(
modelo=modelo,
nombre="Asistente Inteligente",
descripcion="",
system_prompt="",
rol="asistente",
objetivos=[],
max_iterations=0,
memoria=memoria,
mcp=registry
)
logger.success("[OK] Agente LLM listo.")
return agente
# ⚡ Función simple
async def responder(prompt: str, agente: AgenteAI) -> str:
logger.info(f"[Petición] Prompt recibido: {prompt[:50]}...")
respuesta = await agente.interactuar_en_bucle(prompt=prompt, stream=False)
logger.debug(f"[Respuesta] {respuesta[:100]}...")
return respuesta
# 🔁 Función en streaming
async def responder_stream(prompt: str, agente: AgenteAI) -> AsyncGenerator[str, None]:
logger.info(f"[Streaming] Prompt recibido: {prompt[:50]}...")
async for token in agente.interactuar_en_bucle(prompt=prompt, stream=True):
yield token
@@ -0,0 +1,35 @@
from fastapi import WebSocket, APIRouter, WebSocketDisconnect
from backend.backend_domains.llms.llm_chat_srvc import construir_agente_llm
from domains.Logger.logger_db import LoggerDB, logger
from entrypoint.init_db import db_credencial
import json
LoggerDB(db_credencial, "logger_llm_ws", created_by="sistema")
router = APIRouter()
agente = construir_agente_llm()
@router.websocket("/ws/chat")
async def chat_ws(websocket: WebSocket):
await websocket.accept()
try:
data = await websocket.receive_text()
parsed = json.loads(data)
prompt = parsed.get("prompt")
if not prompt:
await websocket.send_text("⚠️ Prompt vacío.")
await websocket.close()
return
# ✅ Solución: hacer await antes de iterar
respuesta_gen = await agente.interactuar_en_bucle(prompt=prompt, stream=True)
async for token in respuesta_gen:
await websocket.send_text(token)
await websocket.close()
except WebSocketDisconnect:
logger.info("🔌 WebSocket desconectado por el cliente.")
except Exception as e:
logger.exception("❌ Error en WebSocket:")
await websocket.close()
@@ -0,0 +1,113 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import Path
from backend.backend_domains.text_manager.text_manager_schema import BibliotecaInput, NotaInput
from fastapi.concurrency import run_in_threadpool
from backend.db.conexion import get_conexion
from backend.backend_domains.text_manager.text_manager_srvc import *
from domains.ConexionSql.Postgres_conexion import PostgresConexion
from entrypoint.init_db import db_credencial
from domains.Logger.logger_db import LoggerDB, logger
LoggerDB(db_credencial, "logger_textos", created_by="sistema")
router = APIRouter()
@router.post("/biblioteca", summary="Crear una nueva biblioteca")
async def crear_biblioteca_endpoint(
data: BibliotecaInput,
conexion: PostgresConexion = Depends(get_conexion)
):
try:
return await run_in_threadpool(
crear_biblioteca,
data.nombre_biblioteca,
conexion,
data.descripcion,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error interno al crear la biblioteca: {str(e)}")
@router.get("/list", summary="Listar todas las bibliotecas")
def listar_todas_bibliotecas(
conexion: PostgresConexion = Depends(get_conexion)
):
try:
return listar_bibliotecas(conexion=conexion)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail="Error interno al listar las bibliotecas")
@router.post("/nota/{biblioteca_id}", summary="Agregar una nota a una biblioteca")
def agregar_nota(
biblioteca_id: str = Path(..., description="ID de la biblioteca a la que se agregará la nota"),
nota: NotaInput = ..., # viene del body
conexion: PostgresConexion = Depends(get_conexion)
):
try:
return agregar_nota_a_biblioteca(
conexion=conexion,
biblioteca_id=biblioteca_id,
titulo=nota.titulo,
texto=nota.texto,
tags=nota.tags,
conexiones=nota.conexiones,
resumen=nota.resumen
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail="Error interno al agregar la nota")
@router.get("/nota/list/{biblioteca_id}", summary="Listar todas las notas de una biblioteca")
def listar_notas(
biblioteca_id: str,
conexion: PostgresConexion = Depends(get_conexion)
):
try:
return listar_notas_de_biblioteca(conexion, biblioteca_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail="Error interno al listar las notas")
@router.delete("/nota/{biblioteca_id}/{nota_id}", summary="Eliminar una nota por ID")
def eliminar_nota_endpoint(
biblioteca_id: str = Path(..., description="ID de la biblioteca"),
nota_id: str = Path(..., description="ID de la nota a eliminar"),
conexion: PostgresConexion = Depends(get_conexion)
):
try:
return eliminar_nota(conexion=conexion, biblioteca_id=biblioteca_id, nota_id=nota_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception:
raise HTTPException(status_code=500, detail="Error interno al eliminar la nota")
@router.put("/nota/{biblioteca_id}/{nota_id}", summary="Actualizar una nota por ID")
def actualizar_nota_endpoint(
biblioteca_id: str = Path(..., description="ID de la biblioteca"),
nota_id: str = Path(..., description="ID de la nota a actualizar"),
nota: NotaInput = ..., # body
conexion: PostgresConexion = Depends(get_conexion)
):
try:
return actualizar_nota(conexion=conexion, biblioteca_id=biblioteca_id, nota_id=nota_id, nota_input=nota)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception:
raise HTTPException(status_code=500, detail="Error interno al actualizar la nota")
@@ -0,0 +1,15 @@
from pydantic import BaseModel
from typing import List, Optional
class NotaInput(BaseModel):
titulo: str
texto: str = ""
tags: Optional[List[str]] = []
conexiones: Optional[List[str]] = []
resumen: Optional[str] = ""
class BibliotecaInput(BaseModel):
nombre_biblioteca: str
descripcion: str
@@ -0,0 +1,188 @@
from domains.TextManager.biblioteca import Biblioteca
from domains.TextManager.biblioteca_mmr import BibliotecaRepo
from domains.Llms.Embedders.Openai_embedder import OpenAIEmbedder
from domains.ApiKeys.openai_apikey_mmr import OpenAICredencialRepo
from domains.ConexionSql.Postgres_conexion import PostgresConexion
from domains.TextManager.nota import Nota
from domains.TextManager.notas_mmr import generar_tabla_nota_para_biblioteca, NotaRepo
from sqlalchemy import MetaData
from backend.backend_domains.text_manager.text_manager_schema import NotaInput
from entrypoint.init_db import db_credencial
from domains.Logger.logger_db import LoggerDB, logger
LoggerDB(db_credencial, "logger_textos", created_by="sistema")
def crear_biblioteca(nombre_biblioteca: str, conexion: PostgresConexion, descripcion: str = None):
logger.info("[INICIO] Creando biblioteca...")
try:
logger.info("[Paso 1] Obteniendo credencial...")
cred_repo = OpenAICredencialRepo(conexion)
credencial = cred_repo.get_by_id("OPAK20250513-61b29978b7604031014")
logger.debug(f"[OK] Credencial obtenida: {credencial.titulo if credencial else '❌ None'}")
logger.info("[Paso 2] Instanciando embedder...")
embedder = OpenAIEmbedder(credencial, model="text-embedding-3-large")
logger.debug("[OK] Embedder instanciado")
logger.info("[Paso 3] Instanciando biblioteca...")
biblioteca = Biblioteca(
nombre=nombre_biblioteca,
embedder=embedder,
descripcion=descripcion
)
logger.debug(f"[OK] Biblioteca instanciada con ID: {biblioteca.id}")
logger.info("[Paso 4] Guardando en base de datos...")
repo = BibliotecaRepo(conexion)
repo.add(biblioteca=biblioteca)
logger.success("[OK] Biblioteca guardada")
logger.info("[Paso 5] Generando modelo de notas...")
biblioteca.generar_modelo_notas(conexion)
logger.success("[OK] Modelo de notas generado")
logger.success("[FIN] Biblioteca creada correctamente")
return {
"mensaje": f"Biblioteca '{nombre_biblioteca}' creada con éxito.",
"id": biblioteca.id
}
except Exception as e:
logger.exception("[ERROR] Ocurrió una excepción:")
raise
def listar_bibliotecas(conexion: PostgresConexion) -> list[dict]:
repo = BibliotecaRepo(conexion)
bibliotecas: list[Biblioteca] = repo.get_all()
return [
{
"id": b.id,
"nombre": b.nombre,
"descripcion": b.descripcion,
"vector_dim": b.vector_dim
}
for b in bibliotecas
]
def agregar_nota_a_biblioteca(
conexion: PostgresConexion,
biblioteca_id: str,
titulo: str,
texto: str = "",
tags: list[str] = None,
conexiones: list[str] = None,
resumen: str = ""
):
repo_biblioteca = BibliotecaRepo(conexion)
biblioteca = repo_biblioteca.get_by_id(biblioteca_id)
if biblioteca is None:
raise ValueError(f"No se encontró la biblioteca con ID {biblioteca_id}")
nota = Nota(
titulo=titulo,
texto=texto,
tags=tags or [],
conexiones=conexiones or [],
resumen=resumen or "",
)
logger.debug(
f"[DEBUG] Nota creada: titulo='{nota.titulo}', "
f"texto_len={len(nota.texto)}, "
f"tags={len(nota.tags)}, "
f"conexiones={len(nota.conexiones)}, "
f"resumen_len={len(nota.resumen)}"
)
metadata = MetaData()
tabla, ModeloNota = generar_tabla_nota_para_biblioteca(
biblioteca.nombre,
biblioteca.vector_dim,
metadata
)
metadata.create_all(conexion.get_engine())
repo_nota = NotaRepo(conexion.get_session(), ModeloNota)
nota_id = repo_nota.add(nota)
resultado = {
"mensaje": f"Nota '{titulo}' agregada con éxito a la biblioteca '{biblioteca.nombre}'.",
"nota_id": nota_id
}
logger.success(f"[SUCCESS] {resultado['mensaje']}")
return resultado
def listar_notas_de_biblioteca(conexion: PostgresConexion, biblioteca_id: str) -> list[dict]:
repo_biblioteca = BibliotecaRepo(conexion)
biblioteca = repo_biblioteca.get_by_id(biblioteca_id)
if not biblioteca:
raise ValueError(f"No se encontró la biblioteca con ID: {biblioteca_id}")
metadata = MetaData()
tabla, ModeloNota = generar_tabla_nota_para_biblioteca(biblioteca.nombre, biblioteca.vector_dim, metadata)
metadata.create_all(conexion.get_engine())
repo_nota = NotaRepo(conexion.get_session(), ModeloNota)
notas = repo_nota.get_all()
return [
{
"id": n.id,
"titulo": n.titulo,
"tags": n.tags,
"texto": n.texto,
"resumen": n.resumen,
"conexiones": n.conexiones
}
for n in notas
]
def eliminar_nota(conexion: PostgresConexion, biblioteca_id: str, nota_id: str) -> dict:
repo_biblioteca = BibliotecaRepo(conexion)
biblioteca = repo_biblioteca.get_by_id(biblioteca_id)
if not biblioteca:
raise ValueError(f"No se encontró la biblioteca con ID: {biblioteca_id}")
metadata = MetaData()
_, ModeloNota = generar_tabla_nota_para_biblioteca(biblioteca.nombre, biblioteca.vector_dim, metadata)
metadata.create_all(conexion.get_engine())
repo_nota = NotaRepo(conexion.get_session(), ModeloNota)
fue_eliminada = repo_nota.delete_by_id(nota_id)
if fue_eliminada:
logger.success(f"Nota '{nota_id}' eliminada correctamente.")
return {"mensaje": f"Nota '{nota_id}' eliminada correctamente."}
else:
raise ValueError(f"No se encontró la nota con ID: {nota_id}")
def actualizar_nota(conexion: PostgresConexion, biblioteca_id: str, nota_id: str, nota_input: NotaInput) -> dict:
repo_biblioteca = BibliotecaRepo(conexion)
biblioteca = repo_biblioteca.get_by_id(biblioteca_id)
if not biblioteca:
raise ValueError(f"No se encontró la biblioteca con ID: {biblioteca_id}")
metadata = MetaData()
_, ModeloNota = generar_tabla_nota_para_biblioteca(biblioteca.nombre, biblioteca.vector_dim, metadata)
metadata.create_all(conexion.get_engine())
repo_nota = NotaRepo(conexion.get_session(), ModeloNota)
nota_actualizada = Nota(
titulo=nota_input.titulo,
texto=nota_input.texto,
tags=nota_input.tags or [],
conexiones=nota_input.conexiones or [],
resumen=nota_input.resumen or ""
)
fue_actualizada = repo_nota.update(nota_id, nota_actualizada)
if fue_actualizada:
logger.success(f"Nota '{nota_id}' actualizada correctamente.")
return {"mensaje": f"Nota '{nota_id}' actualizada correctamente."}
else:
raise ValueError(f"No se encontró la nota con ID: {nota_id}")
@@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from domains.Usuario.usuario_mmr import UsuarioRepo, Usuario, UsuarioModel
from backend.db.conexion import get_conexion
router = APIRouter()
@router.post("/usuarios/", response_model=dict)
def crear_usuario(nombre: str, email: str, db: Session = Depends(get_conexion)):
repo = UsuarioRepo(db)
usuario = Usuario(id=None, nombre=nombre, email=email)
usuario_id = repo.add(usuario)
return {"id": usuario_id}
@router.get("/usuarios/{usuario_id}", response_model=dict)
def obtener_usuario(usuario_id: int, db: Session = Depends(get_conexion)):
repo = UsuarioRepo(db)
usuario = repo.get_by_id(usuario_id)
if not usuario:
raise HTTPException(status_code=404, detail="Usuario no encontrado")
return {"id": usuario.id, "nombre": usuario.nombre, "email": usuario.email, "activo": usuario.activo}
@router.get("/usuarios/", response_model=list)
def listar_usuarios(db: Session = Depends(get_conexion)):
repo = UsuarioRepo(db)
usuarios = repo.get_all()
return [{"id": u.id, "nombre": u.nombre, "email": u.email, "activo": u.activo} for u in usuarios]
@router.delete("/usuarios/{usuario_id}", response_model=dict)
def eliminar_usuario(usuario_id: int, db: Session = Depends(get_conexion)):
repo = UsuarioRepo(db)
exito = repo.delete_by_id(usuario_id)
if not exito:
raise HTTPException(status_code=404, detail="Usuario no encontrado")
return {"ok": True}
+10
View File
@@ -0,0 +1,10 @@
# backend/db/conexion.py
from entrypoint.init_db import db_credencial
from domains.ConexionSql.Postgres_conexion import PostgresConexion
def get_conexion():
conexion = PostgresConexion(db_credencial)
try:
yield conexion
finally:
conexion.close()
+6 -3
View File
@@ -2,7 +2,9 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from backend.api.v1.router import router from backend.router_v1 import router
from backend.backend_domains.llms import llm_chat_ws_endpoint_v1
app = FastAPI( app = FastAPI(
title="Fitz Backend", title="Fitz Backend",
@@ -13,7 +15,7 @@ app = FastAPI(
# Configuración de CORS # Configuración de CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173"], # Solo permite tu frontend local allow_origins=["http://localhost:5173", "http://0.0.0.0:5173"], # Solo permite tu frontend local
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -21,4 +23,5 @@ app.add_middleware(
# Incluye las rutas de tu API # Incluye las rutas de tu API
app.include_router(router) app.include_router(router, prefix="/api/v1", tags=["v1"])
app.include_router(llm_chat_ws_endpoint_v1.router)
+15
View File
@@ -0,0 +1,15 @@
# backend/api/router_v1.py
from fastapi import APIRouter
from backend.backend_domains.experiments import charts_examples_endpoint_v1 as charts
from backend.backend_domains.experiments import ping_endpoint_v1
from backend.backend_domains.text_manager import text_manager_endpoint_v1
from backend.backend_domains.llms import llm_chat_endpoint_v1
from backend.backend_domains.usuarios_endpoint_v1 import router as usuarios_router
router = APIRouter()
router.include_router(ping_endpoint_v1.router, prefix="/ping")
router.include_router(text_manager_endpoint_v1.router, prefix="/text_manager")
router.include_router(charts.router, prefix="/charts")
router.include_router(llm_chat_endpoint_v1.router, prefix="/llm", tags=["Agente LLM"])
router.include_router(usuarios_router, prefix="/usuarios", tags=["Usuarios"])
+17
View File
@@ -0,0 +1,17 @@
import os
import shutil
def eliminar_pycache(directorio):
eliminados = 0
for root, dirs, files in os.walk(directorio):
for d in dirs:
if d == "__pycache__":
ruta = os.path.join(root, d)
print(f"🗑️ Eliminando: {ruta}")
shutil.rmtree(ruta)
eliminados += 1
print(f"\n✅ Eliminados {eliminados} directorios '__pycache__'.")
if __name__ == "__main__":
ruta_raiz = os.path.dirname(os.path.abspath(__file__)) # Carpeta actual
eliminar_pycache(ruta_raiz)
+9
View File
@@ -0,0 +1,9 @@
import asyncio
from fastmcp.client import Client
async def main():
async with Client("http://127.0.0.1:4300") as client:
is_alive = await client.ping()
print("Ping exitoso:", is_alive)
asyncio.run(main())
-14
View File
@@ -1,14 +0,0 @@
# Tipo de base de datos
DB_TITLE=Production_Fitz_db
DB_USER=postgres
DB_PASSWORD=7Souw9SFD5P5RYpRWuTVvFkY7zlxATcN
DB_HOST=localhost
DB_PORT=5432
DB_NAME=fitz_db
# Contraseña maestra de la aplicacion
MASTER_PASSWORD=¤òAíÀÇ®8IgÄËïºÅ2a3duÎ4Ô¯5¦ï¤ç··sôÃmL9xWß
#Ruta del proyecto
PROJECT_PATH= E:\Fitz_Studio
+14
View File
@@ -0,0 +1,14 @@
# Tipo de base de datos
DB_TITLE=
DB_USER=
DB_PASSWORD=
DB_HOST=
DB_PORT=
DB_NAME=
# Contraseña maestra de la aplicacion
MASTER_PASSWORD=
#Ruta del proyecto
PROJECT_PATH=
+26 -975
View File
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,14 @@
from domains.Security.GenerarIDs import GeneradorIDUnico
class OpenAICredencial: class OpenAICredencial:
def __init__(self, titulo: str, api_key: str, organizacion: str = None): def __init__(self, titulo: str, api_key: str, organizacion: str = None, id: str = None):
""" """
:param titulo: Nombre descriptivo para esta credencial. :param titulo: Nombre descriptivo para esta credencial.
:param api_key: Clave secreta de la API de OpenAI. :param api_key: Clave secreta de la API de OpenAI.
:param organizacion: (Opcional) ID de la organización asociada a la cuenta de OpenAI. :param organizacion: (Opcional) ID de la organización asociada a la cuenta de OpenAI.
""" """
self.titulo = titulo self.id = id if id is not None else GeneradorIDUnico("OPAK").generar()
self.titulo = titulo
self.api_key = api_key self.api_key = api_key
self.organizacion = organizacion self.organizacion = organizacion
@@ -3,11 +3,18 @@ import base64
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import Column, Integer, String from sqlalchemy import Column, Integer, String
from src.ConexionSql.Base_conexion import ConexionBase from domains.ConexionSql.Base_conexion import ConexionBase
from src.base import Base from domains.base import Base
from src.ApiKeys.openai_apikey import OpenAICredencial from domains.ApiKeys.openai_apikey import OpenAICredencial
from src.Security.Encriptar import Encriptar_fernet from domains.Security.Encriptar import Encriptar_fernet
from entrypoint import ENV_PATH from entrypoint import ENV_PATH
from domains.ArquitectureLayer.Mapper import Mapper_base
from sqlalchemy import Column, String
from domains.ArquitectureLayer.Model import Model_base
from domains.ArquitectureLayer.Repo import Repo_base
# ---------------------- # ----------------------
# Cargar clave maestra # Cargar clave maestra
@@ -21,10 +28,10 @@ if pssword is None:
# MODELO (SQLAlchemy) # MODELO (SQLAlchemy)
# ---------------------- # ----------------------
class OpenAICredencialModel(Base): class OpenAICredencialModel(Base, Model_base):
__tablename__ = 'openai_credenciales' __tablename__ = 'openai_credenciales'
id = Column(Integer, primary_key=True) id = Column(String, primary_key=True)
titulo = Column(String, nullable=False) titulo = Column(String, nullable=False)
api_key = Column(String, nullable=False) # Encriptada como base64 string api_key = Column(String, nullable=False) # Encriptada como base64 string
organizacion = Column(String, nullable=True) organizacion = Column(String, nullable=True)
@@ -33,30 +40,23 @@ class OpenAICredencialModel(Base):
# MAPPER # MAPPER
# ---------------------- # ----------------------
class OpenAICredencialMapper: class OpenAICredencialMapper(Mapper_base[OpenAICredencial, OpenAICredencialModel]):
@staticmethod
def to_dict(obj: OpenAICredencial) -> dict:
return {
"titulo": obj.titulo,
"api_key": base64.b64encode(
Encriptar_fernet.encriptar(obj.api_key, pssword)
).decode('utf-8'),
"organizacion": obj.organizacion
}
@staticmethod @staticmethod
def from_dict(data: dict) -> OpenAICredencial: def to_model(obj: OpenAICredencial) -> OpenAICredencialModel:
return OpenAICredencial( return OpenAICredencialModel(
titulo=data["titulo"], id=obj.id,
api_key=Encriptar_fernet.desencriptar( titulo=obj.titulo,
base64.b64decode(data["api_key"]), pssword api_key=base64.b64encode(
), Encriptar_fernet.encriptar(obj.api_key, pssword)
organizacion=data.get("organizacion") ).decode("utf-8"),
organizacion=obj.organizacion
) )
@staticmethod @staticmethod
def from_model(model: OpenAICredencialModel) -> OpenAICredencial: def from_model(model: OpenAICredencialModel) -> OpenAICredencial:
return OpenAICredencial( return OpenAICredencial(
id=model.id,
titulo=model.titulo, titulo=model.titulo,
api_key=Encriptar_fernet.desencriptar( api_key=Encriptar_fernet.desencriptar(
base64.b64decode(model.api_key), pssword base64.b64decode(model.api_key), pssword
@@ -64,29 +64,44 @@ class OpenAICredencialMapper:
organizacion=model.organizacion organizacion=model.organizacion
) )
@staticmethod
def to_dict(obj: OpenAICredencial) -> dict:
return {
"id": obj.id,
"titulo": obj.titulo,
"api_key": base64.b64encode(
Encriptar_fernet.encriptar(obj.api_key, pssword)
).decode("utf-8"),
"organizacion": obj.organizacion
}
@staticmethod
def from_dict(data: dict) -> OpenAICredencial:
return OpenAICredencial(
id=data["id"],
titulo=data["titulo"],
api_key=Encriptar_fernet.desencriptar(
base64.b64decode(data["api_key"]), pssword
),
organizacion=data.get("organizacion")
)
# ---------------------- # ----------------------
# REPO # REPO
# ---------------------- # ----------------------
class OpenAICredencialRepo: class OpenAICredencialRepo(Repo_base[OpenAICredencialModel, OpenAICredencial]):
def __init__(self, conexion: ConexionBase): def __init__(self, conexion: ConexionBase):
self.session = conexion.get_session() super().__init__(
session=conexion.get_session(),
def add(self, credencial: OpenAICredencial) -> int: modelo=OpenAICredencialModel,
data = OpenAICredencialMapper.to_dict(credencial) mapper=OpenAICredencialMapper
model = OpenAICredencialModel(**data) )
self.session.add(model)
self.session.commit()
return model.id
def get_all(self) -> list[OpenAICredencial]:
models = self.session.query(OpenAICredencialModel).all()
return [OpenAICredencialMapper.from_model(m) for m in models]
def get_by_titulo(self, titulo: str) -> OpenAICredencial | None: def get_by_titulo(self, titulo: str) -> OpenAICredencial | None:
model = self.session.query(OpenAICredencialModel).filter_by(titulo=titulo).first() model = (
return OpenAICredencialMapper.from_model(model) if model else None self.session.query(self.Modelo)
.filter_by(titulo=titulo, sys_deleted_at=None)
def get_by_id(self, id_: int) -> OpenAICredencial | None: .first()
model = self.session.get(OpenAICredencialModel, id_) )
return OpenAICredencialMapper.from_model(model) if model else None return self.Mapper.from_model(model) if model else None
+75
View File
@@ -0,0 +1,75 @@
# src\ArquitectureLayer\Mapper.py
from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Type
import json
TDominio = TypeVar("TDominio")
TModelo = TypeVar("TModelo")
class Mapper_base(ABC, Generic[TDominio, TModelo]):
# ----------------------------
# Conversiones individuales
# ----------------------------
@staticmethod
@abstractmethod
def to_model(obj: TDominio) -> TModelo:
"""Convierte objeto de dominio a modelo ORM"""
pass
@staticmethod
@abstractmethod
def from_model(model: TModelo) -> TDominio:
"""Convierte modelo ORM a objeto de dominio"""
pass
@staticmethod
@abstractmethod
def to_dict(obj: TDominio) -> dict:
"""Convierte objeto de dominio a diccionario plano"""
pass
@staticmethod
@abstractmethod
def from_dict(d: dict) -> TDominio:
"""Convierte diccionario plano a objeto de dominio"""
pass
@classmethod
def to_json(cls, obj: TDominio) -> str:
"""Convierte objeto de dominio a JSON string"""
return json.dumps(cls.to_dict(obj), default=str)
@classmethod
def from_json(cls, json_str: str) -> TDominio:
"""Convierte JSON string a objeto de dominio"""
return cls.from_dict(json.loads(json_str))
# ----------------------------
# Conversiones en lote (bulk)
# ----------------------------
@classmethod
def to_model_list(cls, objs: list[TDominio]) -> list[TModelo]:
return [cls.to_model(o) for o in objs]
@classmethod
def from_model_list(cls, models: list[TModelo]) -> list[TDominio]:
return [cls.from_model(m) for m in models]
@classmethod
def to_dict_list(cls, objs: list[TDominio]) -> list[dict]:
return [cls.to_dict(o) for o in objs]
@classmethod
def from_dict_list(cls, dicts: list[dict]) -> list[TDominio]:
return [cls.from_dict(d) for d in dicts]
@classmethod
def to_json_list(cls, objs: list[TDominio]) -> str:
return json.dumps(cls.to_dict_list(objs), default=str)
@classmethod
def from_json_list(cls, json_str: str) -> list[TDominio]:
return cls.from_dict_list(json.loads(json_str))
+63
View File
@@ -0,0 +1,63 @@
# src\ArquitectureLayer\Model.py
from sqlalchemy import Column, DateTime, String, Integer, Text, func
from sqlalchemy.ext.declarative import declared_attr, as_declarative
from datetime import datetime
@as_declarative()
class Model_base:
__abstract__ = True
@declared_attr
def sys_created_at(cls):
return Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@declared_attr
def sys_created_by(cls):
return Column(String, nullable=True)
@declared_attr
def sys_updated_at(cls):
return Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
@declared_attr
def sys_updated_by(cls):
return Column(String, nullable=True)
@declared_attr
def sys_version(cls):
return Column(Integer, default=1, nullable=False)
@declared_attr
def sys_notes(cls):
return Column(Text, nullable=True)
@declared_attr
def sys_deleted_at(cls):
return Column(DateTime(timezone=True), nullable=True)
def __repr__(self):
id_val = getattr(self, "id", None)
return f"<{self.__class__.__name__} id={id_val}>"
def __str__(self):
cls = self.__class__.__name__
id_val = getattr(self, "id", None)
return f"{cls}(id={id_val})"
def __json__(self) -> dict:
"""Devuelve una representación JSON serializable (dict plano)."""
out = {}
# Prevención de error: solo ejecuta si __table__ existe
if not hasattr(self, "__table__"):
return out
for attr in self.__table__.columns:
val = getattr(self, attr.name, None)
if isinstance(val, datetime):
out[attr.name] = val.isoformat()
else:
out[attr.name] = val
return out
+148
View File
@@ -0,0 +1,148 @@
# src\ArquitectureLayer\Repo.py
from abc import ABC
from typing import Type, TypeVar, Generic, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime
from domains.ArquitectureLayer.Mapper import Mapper_base # Asegúrate de importar tu ABC base
TModelo = TypeVar("TModelo")
TDominio = TypeVar("TDominio")
class Repo_base(ABC, Generic[TModelo, TDominio]):
def __init__(self, session: Session, modelo: type[TModelo], mapper: type[Mapper_base[TDominio, TModelo]]):
self.session = session
self.Modelo = modelo
self.Mapper = mapper
# ----------------------
# ADD
# ----------------------
def add(self, dominio: TDominio, created_by: Optional[str] = None, notes: Optional[str] = None) -> str:
data = self.Mapper.to_dict(dominio)
data.update({
"sys_created_by": created_by,
"sys_notes": notes,
"sys_version": 1
})
model = self.Modelo(**data)
self.session.add(model)
self.session.commit()
return model.id
def add_many(self, dominios: list[TDominio], created_by: Optional[str] = None, notes: Optional[str] = None) -> list[str]:
ids = []
for dominio in dominios:
data = self.Mapper.to_dict(dominio)
data.update({
"sys_created_by": created_by,
"sys_notes": notes,
"sys_version": 1
})
model = self.Modelo(**data)
self.session.add(model)
ids.append(model.id)
self.session.commit()
return ids
# ----------------------
# GET
# ----------------------
def get_by_id(self, id_: str) -> Optional[TDominio]:
model = self.session.query(self.Modelo).filter_by(id=id_, sys_deleted_at=None).first()
return self.Mapper.from_model(model) if model else None
def get_all(self) -> list[TDominio]:
models = self.session.query(self.Modelo).filter_by(sys_deleted_at=None).all()
return self.Mapper.from_model_list(models)
def get_paginated(self, offset: int = 0, limit: int = 10) -> list[TDominio]:
models = self.session.query(self.Modelo).filter_by(sys_deleted_at=None).offset(offset).limit(limit).all()
return self.Mapper.from_model_list(models)
def get_deleted(self) -> list[TDominio]:
models = self.session.query(self.Modelo).filter(self.Modelo.sys_deleted_at.isnot(None)).all()
return self.Mapper.from_model_list(models)
# ----------------------
# UPDATE
# ----------------------
def update(self, id_: str, new_data: dict, updated_by: Optional[str] = None, notes: Optional[str] = None) -> bool:
model = self.session.query(self.Modelo).filter_by(id=id_, sys_deleted_at=None).first()
if not model:
return False
for key, value in new_data.items():
if hasattr(model, key):
setattr(model, key, value)
model.sys_updated_by = updated_by
model.sys_notes = notes
model.sys_version = (model.sys_version or 1) + 1
self.session.commit()
return True
def bulk_update(self, updates: list[tuple[str, dict]], updated_by: Optional[str] = None, notes: Optional[str] = None) -> int:
count = 0
for id_, data in updates:
if self.update(id_, data, updated_by=updated_by, notes=notes):
count += 1
return count
# ----------------------
# DELETE
# ----------------------
def delete_by_id(self, id_: str) -> bool:
model = self.session.query(self.Modelo).filter_by(id=id_).first()
if model:
self.session.delete(model)
self.session.commit()
return True
return False
def delete_all(self) -> int:
deleted = self.session.query(self.Modelo).delete()
self.session.commit()
return deleted
# ----------------------
# SOFT DELETE
# ----------------------
def soft_delete(self, id_: str, deleted_by: Optional[str] = None, notes: Optional[str] = None) -> bool:
model = self.session.query(self.Modelo).filter_by(id=id_, sys_deleted_at=None).first()
if model:
model.sys_deleted_at = datetime.now()
model.sys_updated_by = deleted_by
model.sys_notes = notes
model.sys_version = (model.sys_version or 1) + 1
self.session.commit()
return True
return False
def soft_restore(self, id_: str, restored_by: Optional[str] = None, notes: Optional[str] = None) -> bool:
model = self.session.query(self.Modelo).filter_by(id=id_).first()
if model and model.sys_deleted_at is not None:
model.sys_deleted_at = None
model.sys_updated_by = restored_by
model.sys_notes = notes
model.sys_version = (model.sys_version or 1) + 1
self.session.commit()
return True
return False
# ----------------------
# OTROS
# ----------------------
def exists(self, id_: str) -> bool:
return self.session.query(self.Modelo).filter_by(id=id_, sys_deleted_at=None).first() is not None
def count(self) -> int:
return self.session.query(self.Modelo).filter_by(sys_deleted_at=None).count()
+62
View File
@@ -0,0 +1,62 @@
import requests
from domains.Credenciales.ollama_credencial import OllamaCredencial
class OllamaCliente:
def __init__(self, credencial: OllamaCredencial):
"""
Inicializa el cliente de Ollama con una instancia de OllamaCredencial.
"""
self.credencial = credencial
self.base_url = self.credencial.base_url
# --- Chat Completions ---
def chat_completion(self, model: str, messages: list, stream: bool = False, **kwargs):
url = f"{self.base_url}/api/chat"
payload = {
"model": model,
"messages": messages,
"stream": stream,
**kwargs
}
response = requests.post(url, json=payload, stream=stream)
response.raise_for_status()
return self._handle_stream(response) if stream else response.json()
def _handle_stream(self, response):
for line in response.iter_lines():
if line:
try:
parsed = line.decode("utf-8")
# Extraer contenido si está en JSON como {"message":{"content":"..."},...}
if parsed.startswith("{"):
import json
data = json.loads(parsed)
if "message" in data and "content" in data["message"]:
yield data["message"]["content"]
except Exception:
continue
# --- Text Completion (legacy) ---
def completion(self, model: str, prompt: str, **kwargs):
url = f"{self.base_url}/api/generate"
payload = {
"model": model,
"prompt": prompt,
**kwargs
}
response = requests.post(url, json=payload)
response.raise_for_status()
return response.json()
# --- Embeddings ---
def embedding(self, model: str, input: str | list[str], **kwargs):
url = f"{self.base_url}/api/embeddings"
payload = {
"model": model,
"prompt": input,
**kwargs
}
response = requests.post(url, json=payload)
response.raise_for_status()
return response.json()
@@ -1,5 +1,5 @@
from openai import OpenAI from openai import OpenAI
from src.ApiKeys.openai_apikey import OpenAICredencial from domains.ApiKeys.openai_apikey import OpenAICredencial
class OpenAICliente: class OpenAICliente:
def __init__(self, credencial: OpenAICredencial): def __init__(self, credencial: OpenAICredencial):
+39
View File
@@ -0,0 +1,39 @@
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.engine import Engine
from sqlalchemy import create_engine, text
from sqlalchemy.exc import SQLAlchemyError
class ConexionBase(ABC):
def __init__(self, uri: str):
self.estado = "pendiente"
self.timestamp = datetime.now(timezone.utc)
self._engine: Engine = create_engine(uri)
self._Session = sessionmaker(bind=self._engine)
self._session_instance: Session | None = None
@abstractmethod
def get_session(self) -> Session:
if self._session_instance is None:
self._session_instance = self._Session()
return self._session_instance
@abstractmethod
def get_engine(self) -> Engine:
return self._engine
def probar_conexion(self) -> bool:
try:
with self._engine.connect() as connection:
connection.execute(text("SELECT 1"))
self.estado = "exito"
return True
except SQLAlchemyError:
self.estado = "fallo"
return False
def close(self):
if self._session_instance is not None:
self._session_instance.close()
self._session_instance = None
@@ -1,11 +1,11 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.engine import Engine
from src.ConexionSql.Base_conexion import ConexionBase
from src.Credenciales.postgres_credencial import PostgresCredencial
from domains.ConexionSql.Base_conexion import ConexionBase
from domains.Credenciales.postgres_credencial import PostgresCredencial
class PostgresConexion(ConexionBase): class PostgresConexion(ConexionBase):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -18,28 +18,41 @@ class PostgresConexion(ConexionBase):
self.port = credencial.port self.port = credencial.port
self.dbname = credencial.dbname self.dbname = credencial.dbname
self.user = credencial.user self.user = credencial.user
self.password = credencial.password # se guarda la contraseña self.password = credencial.password
uri = credencial.get_uri() uri = credencial.get_uri()
else: else:
self.user = kwargs.get("user") self.user = kwargs.get("user")
self.password = kwargs.get("password") # se guarda la contraseña self.password = kwargs.get("password")
self.host = kwargs.get("host") self.host = kwargs.get("host")
self.port = kwargs.get("port", 5432) self.port = kwargs.get("port", 5432)
self.dbname = kwargs.get("db") or kwargs.get("dbname") self.dbname = kwargs.get("db") or kwargs.get("dbname")
uri = f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.dbname}" uri = f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.dbname}"
self.engine = create_engine(uri) self._engine: Engine = create_engine(uri)
self.SessionLocal = sessionmaker(bind=self.engine) self._Session = sessionmaker(bind=self._engine)
def get_session(self): # ✅ INICIALIZAR LA SESIÓN AQUÍ
return self.SessionLocal() self._session_instance: Session | None = None
def get_session(self) -> Session:
if self._session_instance is None:
self._session_instance = self._Session()
return self._session_instance
def get_engine(self) -> Engine:
return self._engine
def probar_conexion(self) -> bool: def probar_conexion(self) -> bool:
try: try:
with self.engine.connect() as connection: with self._engine.connect() as connection:
connection.execute(text("SELECT 1")) connection.execute(text("SELECT 1"))
self.estado = "exito" self.estado = "exito"
return True return True
except SQLAlchemyError: except SQLAlchemyError:
self.estado = "fallo" self.estado = "fallo"
return False return False
def close(self):
if self._session_instance is not None:
self._session_instance.close()
self._session_instance = None
+20
View File
@@ -0,0 +1,20 @@
from domains.Security.GenerarIDs import GeneradorIDUnico
class OllamaCredencial:
def __init__(self, titulo: str, base_url: str = "http://localhost:11434", id: str = None):
"""
:param titulo: Nombre descriptivo para esta credencial de Ollama.
:param base_url: URL base donde está corriendo el servidor de Ollama (por defecto: localhost).
"""
self.id = id if id is not None else GeneradorIDUnico("OLLA").generar()
self.titulo = titulo
self.base_url = base_url.rstrip("/")
def get_headers(self) -> dict:
"""
Retorna encabezados para autenticación si se requiere en el futuro.
Por defecto, Ollama local no usa headers especiales.
"""
return {
"Content-Type": "application/json"
}
@@ -1,5 +1,8 @@
from domains.Security.GenerarIDs import GeneradorIDUnico
class PostgresCredencial: class PostgresCredencial:
def __init__(self, titulo: str, host: str, port: int, dbname: str, user: str, password: str): def __init__(self, titulo: str, host: str, port: int, dbname: str, user: str, password: str, id: str = None):
self.id = id if id is not None else GeneradorIDUnico("PGCR").generar()
self.titulo = titulo self.titulo = titulo
self.host = host self.host = host
self.port = port self.port = port
@@ -7,6 +10,7 @@ class PostgresCredencial:
self.user = user self.user = user
self.password = password self.password = password
def get_uri(self) -> str: def get_uri(self) -> str:
""" """
Retorna una URI de conexión para PostgreSQL. Retorna una URI de conexión para PostgreSQL.
@@ -2,11 +2,18 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import Column, Integer, String from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy import DateTime, Text, func
import base64
from src.ConexionSql.Base_conexion import ConexionBase
from src.base import Base from domains.ArquitectureLayer.Mapper import Mapper_base
from src.Credenciales.postgres_credencial import PostgresCredencial from domains.ArquitectureLayer.Model import Model_base
from src.Security.Encriptar import Encriptar_fernet from domains.ArquitectureLayer.Repo import Repo_base
from domains.ConexionSql.Base_conexion import ConexionBase
from domains.base import Base
from domains.Credenciales.postgres_credencial import PostgresCredencial
from domains.Security.Encriptar import Encriptar_fernet
# ---------------------- # ----------------------
# Cargar clave maestra # Cargar clave maestra
@@ -21,10 +28,10 @@ if pssword is None:
# MODELO (SQLAlchemy) # MODELO (SQLAlchemy)
# ---------------------- # ----------------------
class PostgresCredencialModel(Base): class PostgresCredencialModel(Base, Model_base):
__tablename__ = 'postgres_credenciales' __tablename__ = 'postgres_credenciales'
id = Column(Integer, primary_key=True) id = Column(String, primary_key=True)
titulo = Column(String, nullable=False) titulo = Column(String, nullable=False)
host = Column(String, nullable=False) host = Column(String, nullable=False)
port = Column(Integer, nullable=False) port = Column(Integer, nullable=False)
@@ -36,12 +43,40 @@ class PostgresCredencialModel(Base):
# MAPPER # MAPPER
# ---------------------- # ----------------------
import base64 class PostgresCredencialMapper(Mapper_base[PostgresCredencial, PostgresCredencialModel]):
@staticmethod
def to_model(obj: PostgresCredencial) -> PostgresCredencialModel:
return PostgresCredencialModel(
id=obj.id,
titulo=obj.titulo,
host=obj.host,
port=obj.port,
dbname=obj.dbname,
user=obj.user,
password=base64.b64encode(
Encriptar_fernet.encriptar(obj.password, pssword)
).decode('utf-8')
)
@staticmethod
def from_model(model: PostgresCredencialModel) -> PostgresCredencial:
return PostgresCredencial(
id=model.id,
titulo=model.titulo,
host=model.host,
port=model.port,
dbname=model.dbname,
user=model.user,
password=Encriptar_fernet.desencriptar(
base64.b64decode(model.password), pssword
)
)
class PostgresCredencialMapper:
@staticmethod @staticmethod
def to_dict(obj: PostgresCredencial) -> dict: def to_dict(obj: PostgresCredencial) -> dict:
return { return {
"id": obj.id,
"titulo": obj.titulo, "titulo": obj.titulo,
"host": obj.host, "host": obj.host,
"port": obj.port, "port": obj.port,
@@ -55,6 +90,7 @@ class PostgresCredencialMapper:
@staticmethod @staticmethod
def from_dict(data: dict) -> PostgresCredencial: def from_dict(data: dict) -> PostgresCredencial:
return PostgresCredencial( return PostgresCredencial(
id=data["id"],
titulo=data["titulo"], titulo=data["titulo"],
host=data["host"], host=data["host"],
port=data["port"], port=data["port"],
@@ -65,42 +101,22 @@ class PostgresCredencialMapper:
) )
) )
@staticmethod
def from_model(model: PostgresCredencialModel) -> PostgresCredencial:
return PostgresCredencial(
titulo=model.titulo,
host=model.host,
port=model.port,
dbname=model.dbname,
user=model.user,
password=Encriptar_fernet.desencriptar(
base64.b64decode(model.password), pssword
)
)
# ---------------------- # ----------------------
# REPO # REPO
# ---------------------- # ----------------------
class PostgresCredencialRepo: class PostgresCredencialRepo(Repo_base[PostgresCredencialModel, PostgresCredencial]):
def __init__(self, conexion: ConexionBase): def __init__(self, conexion: ConexionBase):
self.session = conexion.get_session() super().__init__(
session=conexion.get_session(),
def add(self, credencial: PostgresCredencial) -> int: modelo=PostgresCredencialModel,
data = PostgresCredencialMapper.to_dict(credencial) mapper=PostgresCredencialMapper
model = PostgresCredencialModel(**data) )
self.session.add(model)
self.session.commit()
return model.id
def get_all(self) -> list[PostgresCredencial]:
models = self.session.query(PostgresCredencialModel).all()
return [PostgresCredencialMapper.from_model(m) for m in models]
def get_by_titulo(self, titulo: str) -> PostgresCredencial | None: def get_by_titulo(self, titulo: str) -> PostgresCredencial | None:
model = self.session.query(PostgresCredencialModel).filter_by(titulo=titulo).first() model = (
return PostgresCredencialMapper.from_model(model) if model else None self.session.query(self.Modelo)
.filter_by(titulo=titulo, sys_deleted_at=None)
def get_by_id(self, id_: int) -> PostgresCredencial | None: .first()
model = self.session.get(PostgresCredencialModel, id_) )
return PostgresCredencialMapper.from_model(model) if model else None return self.Mapper.from_model(model) if model else None
+443
View File
@@ -0,0 +1,443 @@
from domains.Llms.Modelos.Base_model import ModeloABC
from domains.Llms.Memory.Base_MemoryConv import MemoryConvABC
from domains.Llms.MCPs.McpClient_Registry import ClientRegistry
from datetime import datetime
from typing import Optional, List, Union, AsyncGenerator
import re
import json
from entrypoint.init_db import db_credencial
from domains.Logger.logger_db import LoggerDB, logger
LoggerDB(db_credencial, "logger_agentes", created_by="sistema")
class AgenteAI:
def __init__(
self,
modelo: ModeloABC,
nombre: str,
descripcion: str,
system_prompt: str,
rol: str,
objetivos: List[str],
max_iterations: int = 1,
memoria: Optional[MemoryConvABC] = None,
version: str = "1.0.0",
mcp: Optional[ClientRegistry] = None,
output_schema: Optional[dict] = None,
):
self.modelo = modelo
self.memoria = memoria
self.output_schema = output_schema
self.nombre = nombre
self.descripcion = descripcion
self.system_prompt = system_prompt
self.max_iterations = max_iterations
self.rol = rol
self.objetivos = objetivos
self.version = version
self.created_at = datetime.now()
self.updated_at = self.created_at
self.numero_interacciones = 0
self.mcp = mcp # <-- Aquí guardamos el registry
def actualizar_configuracion(self, **kwargs):
for clave, valor in kwargs.items():
if hasattr(self, clave):
setattr(self, clave, valor)
self.updated_at = datetime.now()
async def generar_system_prompt(self) -> str:
info = f"""Eres un agente de texto y te llamas {self.nombre}
### Descripción:
{self.descripcion}
### Rol:
{self.rol}
### Objetivos:
{chr(10).join(f"- {o}" for o in self.objetivos)}
### System Prompt:
{self.system_prompt}
Siempre estructura tus respuestas con claridad, y termina con <END> cuando hayas completado la tarea principal del usuario.
""".strip()
return info
async def construir_prompt_usuario(self, prompt_usuario: str) -> str:
bloques = []
if self.mcp:
tools_str = await self._obtener_herramientas_disponibles_str()
bloques.append(f"### Herramientas disponibles (MCP):\n{tools_str}")
bloques.append("""### Instrucciones para actuar con herramientas MCP:
Eres un agente conversacional con acceso a herramientas MCP. Cuando el usuario te haga una solicitud, sigue este proceso paso a paso:
---
🧠 **Piensa**:
Reflexiona en voz alta. Explica claramente qué crees que se necesita hacer y por qué.
🎯 **Decide**:
Elige si puedes resolverlo tú solo, si necesitas más información del usuario, o si una herramienta MCP sería útil.
⚙️ **Actúa**:
Si decides usar una herramienta, **escribe el bloque MCP justo después**, sin ningún texto extra después del bloque.
---
### Formato MCP:
```mcp
{
"server": "tools",
"tool": "get_current_user",
"input": {}
}
```
---
### ❗ REGLAS IMPORTANTES:
- **Puedes pensar y decidir con texto normal**, pero:
- El **bloque MCP debe ser lo último** que aparece en tu mensaje.
- **NO escribas nada después del bloque MCP.**
- Solo usa `<END>` cuando:
- hayas terminado completamente la tarea del usuario,
- e interpretado la salida de las herramientas que usaste.
- Puedes hacer múltiples pasos si es necesario: usar una herramienta, esperar su salida, analizarla, usar otra, etc.
- Si decides no usar herramientas, simplemente responde como lo harías normalmente.
- Si no estás seguro de algo, **pide aclaraciones al usuario** antes de actuar.
---
✅ Correcto:
```mcp
{
"server": "tools",
"tool": "generate_uuid",
"input": {}
}
````
🔵 Siempre usa ` ```mcp ` (con triple backtick y la palabra `mcp`) antes del JSON. No escribas nada después del bloque.
````
---
### ✅ Ejemplo correcto:
Necesito generar un identificador único para el usuario.
Para eso usaré la herramienta `generate_uuid` disponible.
```mcp
{
"server": "tools",
"tool": "generate_uuid",
"input": {}
}
""")
if self.memoria:
historial = self.memoria.cargar_historial_chat()
if historial:
memoria_str = "\n".join(
[f"{msg['role']}: {msg['content']}" for msg in historial]
)
bloques.append(f"### Memoria del chat:\n{memoria_str}")
if self.output_schema:
schema_str = str(self.output_schema)
bloques.append(f"### Salida esperada:\n{schema_str}")
bloques.append(f"### Prompt del usuario:\n{prompt_usuario}")
return "\n\n".join(bloques)
### Conseguir las herramientas disponibles
async def _obtener_herramientas_disponibles_str(self) -> str:
logger.info("Inicio de obtención de herramientas disponibles")
if not self.mcp:
logger.warning("No se ha definido el cliente MCP.")
return "No se han definido herramientas disponibles."
try:
resultado = await self.mcp.listar_tools_por_cliente()
tools_por_cliente = resultado.get("tools", {})
errores = resultado.get("errores", {})
logger.debug(f"Tools obtenidas: {list(tools_por_cliente.keys())}")
logger.debug(f"Errores detectados: {list(errores.keys())}")
herramientas = []
for name, tools in tools_por_cliente.items():
if not tools:
logger.info(f"Servidor {name} no tiene herramientas disponibles.")
continue
herramientas.append(f"\n🔌 Server: {name}")
for tool in tools:
props = tool.inputSchema.get("properties", {})
parametros = "\n ".join(f"- {k} ({v.get('type', '?')})" for k, v in props.items())
herramientas.append(f"""Nombre: {tool.name}
Descripción: {tool.description}
Parámetros:
{parametros}
""")
logger.debug(f"Herramienta agregada: {tool.name} del servidor {name}")
if errores:
herramientas.append("\n⚠️ Los siguientes servidores no están disponibles:")
for name, error in errores.items():
herramientas.append(f"- {name}: {error}")
logger.warning(f"Servidor con error: {name} -> {error}")
logger.info("Finalización de obtención de herramientas exitosamente.")
return "\n".join(herramientas) or "No hay herramientas disponibles actualmente."
except Exception as e:
logger.error(f"Error inesperado al obtener herramientas: {str(e)}", exc_info=True)
return "Se produjo un error al obtener las herramientas disponibles."
### Formatear prompt para agentes
def _formatear_prompt(self, mensajes: List[dict]) -> str:
return "\n".join([f"{msg['role']}: {msg['content']}" for msg in mensajes])
### Ejecutar codigo MCP
async def ejecutar_bloque_mcp(self, respuesta: str) -> Optional[str]:
logger.info("Iniciando ejecución de bloque MCP.")
patron = r"```mcp\s*(\{.*?\})\s*```"
match = re.search(patron, respuesta, re.DOTALL)
if not match:
patron_incorrecto = r"```[\s]*\{.*?\}[\s]*```"
if re.search(patron_incorrecto, respuesta, re.DOTALL):
logger.warning("Bloque detectado sin especificador `mcp`.")
return "Advertencia: Usaste un bloque de herramienta MCP pero olvidaste indicar el lenguaje `mcp`. Corrige el bloque a: ```mcp { ... } ```"
logger.info("No se encontró ningún bloque MCP en la respuesta.")
return None
try:
bloque_json_str = match.group(1)
logger.debug(f"Bloque MCP detectado: {bloque_json_str}")
bloque = json.loads(bloque_json_str)
server_name = bloque["server"]
tool_name = bloque["tool"]
input_args = bloque.get("input", {})
logger.info(f"Bloque MCP válido. Servidor: {server_name}, Herramienta: {tool_name}")
logger.debug(f"Parámetros de entrada: {input_args}")
except Exception as e:
logger.error(f"Error al interpretar el bloque MCP: {e}", exc_info=True)
return f"Error al interpretar el bloque MCP: {e}"
try:
cliente_mcp = self.mcp.get(server_name)
except KeyError:
logger.warning(f"No se encontró el cliente MCP para el servidor '{server_name}'.")
return f"No se encontró el cliente MCP para el servidor '{server_name}'"
try:
logger.info(f"Ejecutando herramienta '{tool_name}' en servidor '{server_name}' con argumentos: {json.dumps(input_args, ensure_ascii=False)}")
async with cliente_mcp:
resultado = await cliente_mcp.call_tool(tool_name, input_args)
logger.info(f"Ejecución completada exitosamente. Resultado: {resultado}")
return str(resultado)
except Exception as e:
logger.error(f"Error al ejecutar herramienta '{tool_name}' en servidor '{server_name}': {e}", exc_info=True)
return f"Error al ejecutar herramienta '{tool_name}' en servidor '{server_name}': {e}"
### Ejecutar VARIOS bloques MCP
async def ejecutar_multiples_bloques_mcp(self, respuesta: str) -> Optional[List[str]]:
logger.info("Buscando múltiples bloques MCP en la respuesta.")
patron = r"```mcp\s*(\{.*?\})\s*```"
matches = re.finditer(patron, respuesta, re.DOTALL)
resultados = []
hubo_bloques = False
for match in matches:
hubo_bloques = True
bloque_json_str = match.group(1)
try:
bloque = json.loads(bloque_json_str)
server_name = bloque["server"]
tool_name = bloque["tool"]
input_args = bloque.get("input", {})
logger.info(f"Ejecutando bloque MCP: servidor={server_name}, herramienta={tool_name}")
try:
cliente_mcp = self.mcp.get(server_name)
except KeyError:
msg = f"No se encontró el cliente MCP para el servidor '{server_name}'"
logger.warning(msg)
resultados.append(msg)
continue
async with cliente_mcp:
resultado = await cliente_mcp.call_tool(tool_name, input_args)
resultado_str = f"[{server_name}.{tool_name}] → {resultado}"
resultados.append(resultado_str)
except Exception as e:
error_msg = f"Error al procesar bloque MCP: {str(e)}"
logger.error(error_msg, exc_info=True)
resultados.append(error_msg)
if not hubo_bloques:
logger.info("No se encontró ningún bloque MCP en la respuesta.")
return None
return resultados
###----------- Funcion para interactuar
async def interactuar(self, prompt: str, stream: bool = False) -> Union[str, AsyncGenerator[str, None]]:
mensaje_usuario = await self.construir_prompt_usuario(prompt)
contexto = [{"role": "user", "content": mensaje_usuario}]
prompt_final = self._formatear_prompt(contexto)
respuesta = await self.modelo.responder(
prompt=prompt_final,
system_prompt=await self.generar_system_prompt(),
stream=stream
)
return respuesta
###----------- Funcion para interactuar en bucle
async def interactuar_en_bucle(self, prompt: str, stream: bool = False) -> Union[List[str], AsyncGenerator[str, None]]:
respuestas = [] if not stream else None
respuesta_anterior = ""
resultado_mcp_anterior = None # <-- Guarda último resultado del MCP
iteration = 0
prompt_original = prompt.strip()
async def generador():
nonlocal iteration, respuesta_anterior, resultado_mcp_anterior
while self.max_iterations == 0 or iteration < self.max_iterations:
instruccion_fin = (
"\n\nIMPORTANTE: Cuando hayas respondido completamente a la pregunta original del usuario y no requieras más pasos, "
"escribe <END> para indicar que has terminado."
)
if iteration == 0:
prompt_actual = prompt_original + instruccion_fin
else:
prompt_actual = (
f"Esta es la pregunta original:\n{prompt_original}\n\n"
f"Esto fue lo último que dijiste:\n{respuesta_anterior}\n\n"
f"{instruccion_fin}"
)
if resultado_mcp_anterior:
prompt_actual += (
"\n\nEsta fue la salida de la herramienta que usaste:\n"
f"{resultado_mcp_anterior}\n\n"
"Úsala para seguir resolviendo el problema o tomar una nueva decisión."
)
mensaje_usuario = await self.construir_prompt_usuario(prompt_actual)
contexto = [{"role": "user", "content": mensaje_usuario}]
prompt_final = self._formatear_prompt(contexto)
respuesta = await self.modelo.responder(
prompt=prompt_final,
system_prompt=await self.generar_system_prompt(),
stream=stream
)
if stream:
buffer_respuesta = ""
async for token in respuesta:
buffer_respuesta += token
yield token
respuesta_anterior = buffer_respuesta
else:
respuestas.append(respuesta)
respuesta_anterior = respuesta
# Revisar y ejecutar bloque MCP si existe
resultado_mcp_anterior = None
if "```mcp" in respuesta_anterior:
resultados_mcp = await self.ejecutar_multiples_bloques_mcp(respuesta_anterior)
if resultados_mcp:
resultado_mcp_anterior = "\n".join(resultados_mcp)
if stream:
yield "\n" + resultado_mcp_anterior
else:
respuestas.append(resultado_mcp_anterior)
# Guardar historial si hay memoria
if self.memoria:
self.memoria.guardar_turno("user", prompt_actual)
self.memoria.guardar_turno("assistant", respuesta_anterior)
self.numero_interacciones += 1
self.updated_at = datetime.now()
if "<end>" in respuesta_anterior.lower() and "```mcp" not in respuesta_anterior.lower():
break
iteration += 1
return generador() if stream else await generador_to_list(generador())
# Helper para consumir generador asincrónico si no es stream
async def generador_to_list(gen: AsyncGenerator[str, None]) -> List[str]:
buffer = ""
async for chunk in gen:
buffer += chunk
return [buffer]
+13
View File
@@ -0,0 +1,13 @@
from abc import ABC, abstractmethod
from typing import List
class EmbedderABC(ABC):
@abstractmethod
def encoder(self, text: str) -> List[float]:
"""Genera los embeddings para un texto dado."""
pass
@abstractmethod
def dimension_number(self) -> int:
"""Devuelve la dimensión del modelo de embedding."""
pass
+32
View File
@@ -0,0 +1,32 @@
from typing import List
from domains.Llms.Embedders.Base_Embedder import EmbedderABC # Asegúrate de que EmbedderABC esté en este módulo
from domains.ApiKeys.openai_apikey import OpenAICredencial
from domains.ConexionApis.OpenAi_conexion import OpenAICliente
from domains.Security.GenerarIDs import GeneradorIDUnico
class OpenAIEmbedder(EmbedderABC):
def __init__(self, credencial: OpenAICredencial,
model: str,
id: str = None):
self.model = model
self.client = OpenAICliente(credencial)
self._dimension = None # Lazy loading
self.id = id if id is not None else GeneradorIDUnico("OAMB").generar()
def encoder(self, text: str) -> List[float]:
"""
Genera los embeddings para un texto dado utilizando el modelo de OpenAI.
"""
response = self.client.embedding(model=self.model, input=text)
embedding = response.data[0].embedding
if self._dimension is None:
self._dimension = len(embedding)
return embedding
def dimension_number(self) -> int:
"""
Devuelve la dimensión del modelo de embedding, generando un embedding si no se ha calculado aún.
"""
if self._dimension is None:
_ = self.encoder("dimension_check")
return self._dimension
@@ -0,0 +1,96 @@
import os
from dotenv import load_dotenv
from sqlalchemy import Column, String
from sqlalchemy import Column, String, ForeignKey
from domains.ArquitectureLayer.Mapper import Mapper_base
from domains.ArquitectureLayer.Model import Model_base
from domains.ArquitectureLayer.Repo import Repo_base
from domains.ConexionSql.Base_conexion import ConexionBase
from domains.base import Base
from domains.Security.GenerarIDs import GeneradorIDUnico
from domains.Llms.Embedders.Openai_embedder import OpenAIEmbedder
from domains.ApiKeys.openai_apikey import OpenAICredencial
# ----------------------
# Cargar configuración desde .env si se requiere
# ----------------------
from entrypoint import ENV_PATH
load_dotenv(ENV_PATH)
# ----------------------
# MODELO (SQLAlchemy)
# ----------------------
class OpenAIEmbedderModel(Base, Model_base):
__tablename__ = "openai_embedders"
id = Column(String, primary_key=True)
api_key_id = Column(String, ForeignKey("openai_credenciales.id"), nullable=False)
model = Column(String, nullable=False)
# ----------------------
# MAPPER
# ----------------------
class OpenAIEmbedderMapper(Mapper_base[OpenAIEmbedder, OpenAIEmbedderModel]):
@staticmethod
def to_model(obj: OpenAIEmbedder) -> OpenAIEmbedderModel:
return OpenAIEmbedderModel(
id=obj.id,
api_key_id=obj.client.credencial.id,
model=obj.model
)
@staticmethod
def from_model(model: OpenAIEmbedderModel, credencial: OpenAICredencial) -> OpenAIEmbedder:
return OpenAIEmbedder(
id=model.id,
credencial=credencial,
model=model.model
)
@staticmethod
def to_dict(obj: OpenAIEmbedder) -> dict:
return {
"id": obj.id,
"api_key_id": obj.client.credencial.id,
"model": obj.model
}
@staticmethod
def from_dict(data: dict, credencial: OpenAICredencial) -> OpenAIEmbedder:
return OpenAIEmbedder(
id=data["id"],
credencial=credencial,
model=data["model"]
)
# ----------------------
# REPO
# ----------------------
class OpenAIEmbedderRepo(Repo_base[OpenAIEmbedderModel, OpenAIEmbedder]):
def __init__(self, conexion: ConexionBase):
super().__init__(
session=conexion.get_session(),
modelo=OpenAIEmbedderModel,
mapper=OpenAIEmbedderMapper
)
def get_by_id(self, id_: str, credencial: OpenAICredencial) -> OpenAIEmbedder | None:
model = self.session.get(self.Modelo, id_)
return self.Mapper.from_model(model, credencial) if model else None
def get_all(self, credencial_loader: callable) -> list[OpenAIEmbedder]:
"""
:param credencial_loader: función que recibe un api_key_id y devuelve una instancia de OpenAICredencial
"""
models = self.session.query(self.Modelo).all()
return [
self.Mapper.from_model(m, credencial_loader(m.api_key_id))
for m in models
]
+100
View File
@@ -0,0 +1,100 @@
from pathlib import Path
from typing import Any, Optional, Union
from pydantic import AnyUrl
from fastmcp.client import Client
from fastmcp.client.transports import (
StreamableHttpTransport,
PythonStdioTransport,
ClientTransport,
)
from mcp.types import *
from fastmcp.exceptions import ClientError
import asyncio
class MCPClient:
def __init__(self, name: str, client: Client):
self.name = name
self.client = client
def __repr__(self) -> str:
return f"<ClientWrapper(name={self.name})>"
@classmethod
def from_http(cls, name: str, url: str | AnyUrl) -> "MCPClient":
transport = StreamableHttpTransport(url=str(url))
client = Client(transport=transport)
return cls(name=name, client=client)
@classmethod
def from_stdio(
cls,
name: str,
script_path: Union[str, Path],
args: Optional[list[str]] = None,
cwd: Optional[Union[str, Path]] = None,
env: Optional[dict[str, str]] = None,
) -> "MCPClient":
transport = PythonStdioTransport(
script_path=script_path, args=args, cwd=cwd, env=env
)
client = Client(transport=transport)
return cls(name=name, client=client)
def is_connected(self) -> bool:
return self.client.is_connected()
async def __aenter__(self):
await self.client.__aenter__()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.client.__aexit__(exc_type, exc_val, exc_tb)
# Delegación MCP
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> list[TextContent | ImageContent | EmbeddedResource]:
try:
return await asyncio.wait_for(
self.client.call_tool(name, arguments), timeout=10
)
except asyncio.TimeoutError:
raise RuntimeError(f"Timeout al ejecutar herramienta '{name}'")
async def get_prompt(
self, name: str, arguments: dict[str, str] | None = None
) -> GetPromptResult:
return await self.client.get_prompt(name, arguments)
async def list_tools(self) -> list[Tool]:
return await self.client.list_tools()
async def list_prompts(self) -> list[Prompt]:
return await self.client.list_prompts()
async def list_resources(self) -> list[Resource]:
return await self.client.list_resources()
async def list_resource_templates(self) -> list[ResourceTemplate]:
return await self.client.list_resource_templates()
async def read_resource(
self, uri: AnyUrl | str
) -> list[TextResourceContents | BlobResourceContents]:
return await self.client.read_resource(uri)
async def complete(
self,
ref: ResourceReference | PromptReference,
argument: dict[str, str],
) -> Completion:
return await self.client.complete(ref, argument)
async def ping(self) -> bool:
return await self.client.ping()
async def set_logging_level(self, level: LoggingLevel) -> None:
return await self.client.set_logging_level(level)
async def send_roots_list_changed(self) -> None:
return await self.client.send_roots_list_changed()
+56
View File
@@ -0,0 +1,56 @@
from domains.Llms.MCPs.McpClient import MCPClient
from typing import Any
class ClientRegistry:
def __init__(self):
self._clients: dict[str, MCPClient] = {}
def add(self, name: str, wrapper: MCPClient) -> None:
self._clients[name] = wrapper
def get(self, name: str) -> MCPClient:
if name not in self._clients:
raise KeyError(f"Cliente '{name}' no encontrado en el registro.")
return self._clients[name]
def all(self) -> dict[str, MCPClient]:
return self._clients
def list_names(self) -> list[str]:
return list(self._clients.keys())
def __contains__(self, name: str) -> bool:
return name in self._clients
async def listar_tools_por_cliente(self) -> dict[str, Any]:
resultado = {"tools": {}, "errores": {}}
for name, wrapper in self._clients.items():
try:
async with wrapper:
resultado["tools"][name] = await wrapper.list_tools()
except Exception as e:
resultado["errores"][name] = str(e)
resultado["tools"][name] = []
return resultado
async def listar_prompts_por_cliente(self) -> dict[str, Any]:
resultado = {"prompts": {}, "errores": {}}
for name, wrapper in self._clients.items():
try:
async with wrapper:
resultado["prompts"][name] = await wrapper.list_prompts()
except Exception as e:
resultado["errores"][name] = str(e)
resultado["prompts"][name] = []
return resultado
async def listar_resources_por_cliente(self) -> dict[str, Any]:
resultado = {"resources": {}, "errores": {}}
for name, wrapper in self._clients.items():
try:
async with wrapper:
resultado["resources"][name] = await wrapper.list_resources()
except Exception as e:
resultado["errores"][name] = str(e)
resultado["resources"][name] = []
return resultado
+48
View File
@@ -0,0 +1,48 @@
# server_runner.py
import subprocess
import asyncio
import socket
import re
from pathlib import Path
async def wait_for_port(host: str, port: int, timeout: float = 10.0):
for _ in range(int(timeout * 10)):
try:
with socket.create_connection((host, port), timeout=0.5):
return True
except (OSError, ConnectionRefusedError):
await asyncio.sleep(0.1)
raise TimeoutError(f"No se pudo conectar al servidor en {host}:{port}")
class MCPServerRunner:
def __init__(self, server_script_path: str, python_path: str = "python"):
self.server_script_path = server_script_path
self.python_path = python_path
self.port: int = self._extraer_puerto()
self.process: subprocess.Popen | None = None
def _extraer_puerto(self) -> int:
contenido = Path(self.server_script_path).read_text(encoding="utf-8")
coincidencias = re.findall(r"port\s*=\s*(\d+)", contenido)
if not coincidencias:
raise ValueError(f"No se pudo detectar el puerto en {self.server_script_path}")
return int(coincidencias[0])
async def start(self):
if self.process is None or self.process.poll() is not None:
self.process = subprocess.Popen(
[self.python_path, self.server_script_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
await wait_for_port("127.0.0.1", self.port)
print(f"🟢 Servidor MCP iniciado en puerto {self.port}")
async def stop(self):
if self.process and self.process.poll() is None:
self.process.terminate()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
print("🔴 Servidor MCP detenido")
@@ -0,0 +1,133 @@
from fastmcp import FastMCP
from pathlib import Path
import shutil
from datetime import datetime
# Directorio base seguro
SANDBOX_DIR = Path("./sandbox").resolve()
SANDBOX_DIR.mkdir(parents=True, exist_ok=True)
def safe_path(requested_path: str) -> Path:
"""Siempre interpreta la ruta como relativa al SANDBOX_DIR, incluso si empieza con '/'."""
# Normaliza la ruta quitando el primer '/'
normalized = requested_path.strip().lstrip("/")
full_path = (SANDBOX_DIR / normalized).resolve()
if not full_path.is_relative_to(SANDBOX_DIR):
raise ValueError("Ruta fuera del directorio permitido.")
return full_path
mcp = FastMCP()
@mcp.tool(description="Lee y devuelve el contenido de un archivo de texto ubicado en el sistema de archivos seguro. El archivo debe estar dentro del sandbox.")
def read_file(path: str) -> str:
try:
file_path = safe_path(path)
if not file_path.is_file():
raise FileNotFoundError(f"Archivo '{path}' no encontrado.")
return file_path.read_text(encoding="utf-8")
except Exception as e:
return f"⚠️ Error al leer archivo '{path}': {str(e)}"
@mcp.tool(description="Escribe contenido de texto en un archivo dentro del sandbox. Si el archivo ya existe, será sobrescrito.")
def write_file(path: str, content: str) -> str:
file_path = safe_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return "Archivo guardado correctamente."
@mcp.tool(description="Elimina de forma segura un archivo ubicado dentro del sandbox.")
def delete_file(path: str) -> str:
file_path = safe_path(path)
if not file_path.is_file():
raise FileNotFoundError("Archivo no encontrado.")
file_path.unlink()
return "Archivo eliminado."
@mcp.tool(description="Crea una carpeta (y sus carpetas padre si es necesario) dentro del sandbox.")
def create_folder(path: str) -> str:
folder_path = safe_path(path)
folder_path.mkdir(parents=True, exist_ok=True)
return "Carpeta creada."
@mcp.tool(description="Lista archivos y carpetas dentro de una ruta del sandbox.")
def list_directory(path: str = ".") -> list[str]:
folder = safe_path(path)
if not folder.is_dir():
raise NotADirectoryError("Ruta no corresponde a una carpeta.")
return sorted(str(p.relative_to(SANDBOX_DIR)) for p in folder.iterdir())
@mcp.tool(description="Muestra la estructura de carpetas y archivos como un árbol, desde una ruta dentro del sandbox.")
def tree(path: str = ".", depth: int = 3) -> str:
base = safe_path(path)
if not base.is_dir():
raise NotADirectoryError("Ruta no corresponde a una carpeta.")
tree_output = []
def walk(dir_path: Path, prefix: str = "", level: int = 0):
if level > depth:
return
entries = sorted(dir_path.iterdir())
for i, entry in enumerate(entries):
connector = "└── " if i == len(entries) - 1 else "├── "
tree_output.append(f"{prefix}{connector}{entry.name}")
if entry.is_dir():
extension = " " if i == len(entries) - 1 else ""
walk(entry, prefix + extension, level + 1)
tree_output.append(f"{base.name}/")
walk(base)
return "\n".join(tree_output)
@mcp.tool(description="Devuelve información detallada sobre un archivo: tamaño en bytes, fecha de modificación y tipo.")
def file_info(path: str) -> dict:
fpath = safe_path(path)
if not fpath.exists():
raise FileNotFoundError("Archivo no encontrado.")
return {
"nombre": fpath.name,
"tipo": "carpeta" if fpath.is_dir() else "archivo",
"tamaño_bytes": fpath.stat().st_size,
"última_modificación": datetime.fromtimestamp(fpath.stat().st_mtime).isoformat(),
"relativo": str(fpath.relative_to(SANDBOX_DIR))
}
@mcp.tool(description="Copia un archivo o carpeta dentro del sandbox a otra ruta.")
def copy_file(src: str, dest: str) -> str:
src_path = safe_path(src)
dest_path = safe_path(dest)
if src_path.is_dir():
shutil.copytree(src_path, dest_path, dirs_exist_ok=True)
else:
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dest_path)
return "Copia completada."
@mcp.tool(description="Mueve o renombra un archivo o carpeta dentro del sandbox.")
def move_file(src: str, dest: str) -> str:
src_path = safe_path(src)
dest_path = safe_path(dest)
dest_path.parent.mkdir(parents=True, exist_ok=True)
src_path.rename(dest_path)
return "Movimiento completado."
@mcp.tool(description="Elimina todos los archivos y subcarpetas dentro de una carpeta del sandbox.")
def clear_folder(path: str) -> str:
folder_path = safe_path(path)
if not folder_path.is_dir():
raise NotADirectoryError("La ruta no es una carpeta.")
for item in folder_path.iterdir():
if item.is_file() or item.is_symlink():
item.unlink()
elif item.is_dir():
shutil.rmtree(item)
return "Carpeta vaciada."
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="127.0.0.1", port=4201, path="/fs")
@@ -0,0 +1,92 @@
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool(description="Suma dos números enteros.")
def add(a: int, b: int) -> int:
return a + b
@mcp.tool(description="Resta dos números enteros.")
def subtract(a: int, b: int) -> int:
return a - b
@mcp.tool(description="Multiplica dos números enteros.")
def multiply(a: int, b: int) -> int:
return a * b
@mcp.tool(description="Divide dos números y devuelve el resultado flotante.")
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("No se puede dividir entre cero.")
return a / b
@mcp.tool(description="Calcula el módulo de dos números enteros.")
def modulo(a: int, b: int) -> int:
return a % b
@mcp.tool(description="Concatena dos cadenas de texto.")
def concat(a: str, b: str) -> str:
return a + b
@mcp.tool(description="Devuelve la longitud de una cadena.")
def string_length(s: str) -> int:
return len(s)
@mcp.tool(description="Convierte una cadena a mayúsculas.")
def to_upper(s: str) -> str:
return s.upper()
@mcp.tool(description="Convierte una cadena a minúsculas.")
def to_lower(s: str) -> str:
return s.lower()
@mcp.tool(description="Devuelve la suma de todos los elementos en una lista de enteros.")
def sum_list(numbers: list[int]) -> int:
return sum(numbers)
@mcp.tool(description="Devuelve el valor máximo en una lista de enteros.")
def max_in_list(numbers: list[int]) -> int:
return max(numbers)
@mcp.tool(description="Verifica si un número es par.")
def is_even(n: int) -> bool:
return n % 2 == 0
@mcp.tool(description="Verifica si una cadena es un palíndromo.")
def is_palindrome(s: str) -> bool:
return s == s[::-1]
@mcp.tool(description="Calcula el factorial de un número entero positivo.")
def factorial(n: int) -> int:
if n < 0:
raise ValueError("El factorial no está definido para negativos.")
if n == 0:
return 1
result = 1
for i in range(1, n + 1):
result *= i
return result
@mcp.tool(description="Devuelve los primeros n números de Fibonacci.")
def fibonacci(n: int) -> list[int]:
if n <= 0:
return []
seq = [0, 1]
while len(seq) < n:
seq.append(seq[-1] + seq[-2])
return seq[:n]
@mcp.tool(description="Devuelve si un número es primo.")
def is_prime(n: int) -> bool:
if n <= 1:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="127.0.0.1", port=4200, path="/math")
# mcp.run(transport="stdio")
@@ -0,0 +1,69 @@
from fastmcp import FastMCP
import uuid
import datetime
import socket
import platform
import os
mcp = FastMCP()
@mcp.tool(description="Genera un UUID versión 4.")
def generate_uuid() -> str:
return str(uuid.uuid4())
@mcp.tool(description="Devuelve la fecha y hora actuales en formato ISO 8601.")
def current_datetime() -> str:
return datetime.datetime.now().isoformat()
@mcp.tool(description="Devuelve solo la fecha actual.")
def current_date() -> str:
return datetime.date.today().isoformat()
@mcp.tool(description="Devuelve el nombre del host actual.")
def get_hostname() -> str:
return socket.gethostname()
@mcp.tool(description="Devuelve el sistema operativo actual.")
def get_os() -> str:
return platform.system()
@mcp.tool(description="Devuelve el nombre del usuario actual del sistema.")
def get_current_user() -> str:
return os.getlogin()
@mcp.tool(description="Invierte un valor booleano.")
def invert_boolean(flag: bool) -> bool:
return not flag
# @mcp.tool(description="Devuelve los archivos y carpetas del directorio actual.")
# def list_current_directory() -> list[str]:
# return os.listdir()
# @mcp.tool(description="Crea un archivo con un nombre dado.")
# def create_file(filename: str) -> str:
# with open(filename, "w") as f:
# f.write("")
# return f"Archivo '{filename}' creado."
# @mcp.tool(description="Lee el contenido de un archivo de texto dado.")
# def read_file(filename: str) -> str:
# with open(filename, "r") as f:
# return f.read()
# @mcp.tool(description="Escribe contenido a un archivo, sobrescribiéndolo.")
# def write_file(filename: str, content: str) -> str:
# with open(filename, "w") as f:
# f.write(content)
# return f"Contenido escrito en '{filename}'."
@mcp.tool(description="Devuelve el número de CPUs disponibles en el sistema.")
def get_cpu_count() -> int:
return os.cpu_count()
@mcp.tool(description="Devuelve el timestamp actual (UNIX).")
def current_timestamp() -> float:
return datetime.datetime.now().timestamp()
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="127.0.0.1", port=4300, path="/tools")
@@ -1,9 +1,9 @@
from sqlalchemy import Table, Column, Integer, String, MetaData, insert, select, delete from sqlalchemy import Table, Column, Integer, String, MetaData, insert, select, delete
from typing import Literal from typing import Literal
from src.Credenciales.postgres_credencial import PostgresCredencial from domains.Credenciales.postgres_credencial import PostgresCredencial
from src.ConexionSql.Postgres_conexion import PostgresConexion # Usamos la clase específica from domains.ConexionSql.Postgres_conexion import PostgresConexion # Usamos la clase específica
from src.Llms.Memory.Base_MemoryConv import MemoryConvABC from domains.Llms.Memory.Base_MemoryConv import MemoryConvABC
class MemoryConvPostgres(MemoryConvABC): class MemoryConvPostgres(MemoryConvABC):
@@ -27,7 +27,7 @@ class MemoryConvPostgres(MemoryConvABC):
) )
# Crea la tabla si no existe # Crea la tabla si no existe
self.metadata.create_all(self.conexion.engine) self.metadata.create_all(self.conexion._engine)
def guardar_turno(self, rol: Literal["user", "assistant"], contenido: str) -> None: def guardar_turno(self, rol: Literal["user", "assistant"], contenido: str) -> None:
stmt = insert(self.tabla).values(rol=rol, contenido=contenido) stmt = insert(self.tabla).values(rol=rol, contenido=contenido)
+67
View File
@@ -0,0 +1,67 @@
from domains.Llms.Modelos.Base_model import ModeloABC
from domains.Security.GenerarIDs import GeneradorIDUnico
from typing import AsyncGenerator, Union
from domains.ConexionApis.Ollama_cliente import OllamaCliente # Asegúrate de importar correctamente
import asyncio
class ModeloOllama(ModeloABC):
def __init__(
self,
cliente: OllamaCliente,
model: str = "llama3",
id: str = None,
temperature: float = 0.7,
top_p: float = 1.0,
top_k: int = None,
frecuencia_penalizacion: float = 0.0,
num_tokens_maximos: int = 512
):
if not isinstance(cliente, OllamaCliente):
raise TypeError("El parámetro 'cliente' debe ser una instancia de OllamaCliente")
self.id = id if id else GeneradorIDUnico("MOOL").generar()
super().__init__(
model=model,
temperature=temperature,
top_p=top_p,
top_k=top_k,
frecuencia_penalizacion=frecuencia_penalizacion,
num_tokens_maximos=num_tokens_maximos
)
self.cliente = cliente
async def responder(
self,
prompt: str,
system_prompt: str = "",
stream: bool = False,
**kwargs
) -> Union[str, AsyncGenerator[str, None]]:
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
def sync_call():
return self.cliente.chat_completion(
model=self.model,
messages=messages,
temperature=self.temperature,
top_p=self.top_p,
max_tokens=self.num_tokens_maximos,
frequency_penalty=self.frecuencia_penalizacion,
stream=stream,
**kwargs
)
loop = asyncio.get_event_loop()
resultado = await loop.run_in_executor(None, sync_call)
if stream:
async def generador():
for token in resultado:
yield token
return generador()
else:
return resultado.choices[0].message.content
@@ -1,5 +1,6 @@
from src.Llms.Modelos.Base_model import ModeloABC from domains.Llms.Modelos.Base_model import ModeloABC
from src.ConexionApis.OpenAi_conexion import OpenAICliente from domains.ConexionApis.OpenAi_conexion import OpenAICliente
from domains.Security.GenerarIDs import GeneradorIDUnico
import asyncio import asyncio
from typing import AsyncGenerator, Union from typing import AsyncGenerator, Union
@@ -8,6 +9,7 @@ class ModeloOpenAI(ModeloABC):
self, self,
cliente: OpenAICliente, cliente: OpenAICliente,
model: str = "gpt-4o", model: str = "gpt-4o",
id: str = None,
temperature: float = 0.7, temperature: float = 0.7,
top_p: float = 1.0, top_p: float = 1.0,
top_k: int = None, top_k: int = None,
@@ -15,6 +17,10 @@ class ModeloOpenAI(ModeloABC):
num_tokens_maximos: int = 512, num_tokens_maximos: int = 512,
use_legacy: bool = False use_legacy: bool = False
): ):
# Generar ID con prefijo MOPA si no fue proporcionado
self.id = id if id is not None else GeneradorIDUnico("MOPA").generar()
# Inicializar resto del modelo base
super().__init__( super().__init__(
model=model, model=model,
temperature=temperature, temperature=temperature,
@@ -23,6 +29,8 @@ class ModeloOpenAI(ModeloABC):
frecuencia_penalizacion=frecuencia_penalizacion, frecuencia_penalizacion=frecuencia_penalizacion,
num_tokens_maximos=num_tokens_maximos num_tokens_maximos=num_tokens_maximos
) )
# Asignar cliente e indicadores adicionales
self.cliente = cliente self.cliente = cliente
self.use_legacy = use_legacy self.use_legacy = use_legacy
@@ -75,8 +83,8 @@ class ModeloOpenAI(ModeloABC):
if stream: if stream:
async def generador(): async def generador():
for token in resultado: # ya es un generador del cliente for token in resultado:
yield token yield token
return generador() return generador()
else: else:
return resultado.choices[0].message.content return resultado.choices[0].message.content
+122
View File
@@ -0,0 +1,122 @@
import os
from dotenv import load_dotenv
from sqlalchemy import Column, Integer, String, Float, Boolean
from domains.ArquitectureLayer.Mapper import Mapper_base
from domains.ArquitectureLayer.Model import Model_base
from domains.ArquitectureLayer.Repo import Repo_base
from typing import Optional
from domains.ConexionSql.Base_conexion import ConexionBase
from domains.base import Base
from domains.Llms.Modelos.Openai_model import ModeloOpenAI # Clase real de lógica
# ----------------------
# Cargar clave maestra
# ----------------------
from entrypoint import ENV_PATH
load_dotenv(ENV_PATH)
pssword = os.getenv('MASTER_PASSWORD')
if pssword is None:
raise ValueError("MASTER_PASSWORD no está definida en el archivo .env")
# ----------------------
# MODELO (SQLAlchemy)
# ----------------------
class ModeloOpenAIConfigModel(Base, Model_base):
__tablename__ = 'modelo_openai_configs'
id = Column(String, primary_key=True)
model = Column(String, nullable=False)
temperature = Column(Float, default=0.7, nullable=False)
top_p = Column(Float, default=1.0, nullable=False)
top_k = Column(Integer, nullable=True)
frecuencia_penalizacion = Column(Float, default=0.0, nullable=False)
num_tokens_maximos = Column(Integer, default=512, nullable=False)
use_legacy = Column(Boolean, default=False, nullable=False)
# ----------------------
# MAPPER
# ----------------------
class ModeloOpenAIConfigMapper(Mapper_base[ModeloOpenAI, ModeloOpenAIConfigModel]):
@staticmethod
def to_model(obj: ModeloOpenAI) -> ModeloOpenAIConfigModel:
return ModeloOpenAIConfigModel(
id=obj.id,
model=obj.model,
temperature=obj.temperature,
top_p=obj.top_p,
top_k=obj.top_k,
frecuencia_penalizacion=obj.frecuencia_penalizacion,
num_tokens_maximos=obj.num_tokens_maximos,
use_legacy=obj.use_legacy
)
@staticmethod
def from_model(model: ModeloOpenAIConfigModel, cliente: Optional[object] = None) -> ModeloOpenAI:
return ModeloOpenAI(
id=model.id,
cliente=cliente,
model=model.model,
temperature=model.temperature,
top_p=model.top_p,
top_k=model.top_k,
frecuencia_penalizacion=model.frecuencia_penalizacion,
num_tokens_maximos=model.num_tokens_maximos,
use_legacy=model.use_legacy
)
@staticmethod
def to_dict(obj: ModeloOpenAI) -> dict:
return {
"id": obj.id,
"model": obj.model,
"temperature": obj.temperature,
"top_p": obj.top_p,
"top_k": obj.top_k,
"frecuencia_penalizacion": obj.frecuencia_penalizacion,
"num_tokens_maximos": obj.num_tokens_maximos,
"use_legacy": obj.use_legacy
}
@staticmethod
def from_dict(data: dict, cliente: Optional[object] = None) -> ModeloOpenAI:
return ModeloOpenAI(
id=data["id"],
cliente=cliente,
model=data["model"],
temperature=data["temperature"],
top_p=data["top_p"],
top_k=data["top_k"],
frecuencia_penalizacion=data["frecuencia_penalizacion"],
num_tokens_maximos=data["num_tokens_maximos"],
use_legacy=data["use_legacy"]
)
# ----------------------
# REPO
# ----------------------
class ModeloOpenAIConfigRepo(Repo_base[ModeloOpenAIConfigModel, ModeloOpenAI]):
def __init__(self, conexion: ConexionBase, cliente: object):
super().__init__(
session=conexion.get_session(),
modelo=ModeloOpenAIConfigModel,
mapper=ModeloOpenAIConfigMapper
)
self.cliente = cliente # Necesario para construir el dominio con lógica
def get_by_id(self, id_: str) -> ModeloOpenAI | None:
model = self.session.get(self.Modelo, id_)
return self.Mapper.from_model(model, self.cliente) if model else None
def get_all(self) -> list[ModeloOpenAI]:
models = self.session.query(self.Modelo).all()
return [self.Mapper.from_model(m, self.cliente) for m in models]
+62
View File
@@ -0,0 +1,62 @@
from loguru import logger
from sqlalchemy import Column, Integer, String, Text, TIMESTAMP
from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import SQLAlchemyError
from domains.ArquitectureLayer.Model import Model_base
from domains.ConexionSql.Postgres_conexion import PostgresConexion
from domains.Credenciales.postgres_credencial import PostgresCredencial
class LoggerDB:
_sink_removido = False # ← evita múltiples remove() si se crean varias instancias
def __init__(self, credencial: PostgresCredencial, nombre_tabla: str, created_by: str = None):
# 🔥 Elimina todos los sinks activos, incluso los automáticos
logger.remove()
self.conexion = PostgresConexion(credencial)
self.engine = self.conexion.get_engine()
self.Session = sessionmaker(bind=self.engine)
self.nombre_tabla = nombre_tabla
self.created_by = created_by
self.modelo_logger = self._generar_modelo_logger()
self._crear_tabla_si_no_existe()
logger.add(self._sink, level="DEBUG")
def _generar_modelo_logger(self):
class LoggerTable(Model_base):
__tablename__ = self.nombre_tabla
__table_args__ = {'extend_existing': True} # 👈 Esta línea evita el error
id = Column(Integer, primary_key=True)
nivel = Column(String, nullable=False)
mensaje = Column(Text, nullable=False)
fecha = Column(TIMESTAMP(timezone=True), nullable=False)
modulo = Column(String, nullable=True)
funcion = Column(String, nullable=True)
linea = Column(Integer, nullable=True)
return LoggerTable
def _crear_tabla_si_no_existe(self):
self.modelo_logger.__table__.create(self.engine, checkfirst=True)
def _sink(self, message):
record = message.record
try:
session = self.Session()
log_entry = self.modelo_logger(
nivel=record["level"].name,
mensaje=record["message"],
fecha=record["time"],
modulo=record["module"],
funcion=record["function"],
linea=record["line"],
sys_created_by=self.created_by
)
session.add(log_entry)
session.commit()
session.close()
except SQLAlchemyError as e:
print(f"[LoggerDB] Error guardando log en BD: {e}")
+190
View File
@@ -0,0 +1,190 @@
from typing import TYPE_CHECKING, Optional
import random
import asyncio
import json
if TYPE_CHECKING:
from .Tab import Tab
class ElementoWeb:
def __init__(self, tab: "Tab", object_id: Optional[str]):
self.tab = tab
self.object_id = object_id
self._node_id = None # Lazy resolved
@classmethod
def from_node(cls, tab: "Tab", node_id: int) -> "ElementoWeb":
inst = cls(tab, object_id=None)
inst._node_id = node_id
return inst
async def _asegurar_object_id(self):
if not self.object_id and self._node_id:
try:
resolved = await self.tab._enviar("DOM.resolveNode", {"nodeId": self._node_id})
self.object_id = resolved["object"]["objectId"]
except Exception as e:
print(f"⚠️ No se pudo resolver objectId desde nodeId: {e}")
async def scroll_into_view(self):
try:
await self._asegurar_object_id()
await self.tab._enviar("Runtime.callFunctionOn", {
"objectId": self.object_id,
"functionDeclaration": "function() { this.scrollIntoView({block: 'center'}); }",
"awaitPromise": True
})
if self.tab.verbose:
print("📜 Elemento desplazado a la vista.")
except Exception as e:
print(f"⚠️ Error al hacer scroll hacia el elemento: {e}")
async def click(self):
try:
await self.scroll_into_view()
await self._asegurar_object_id()
if not self.object_id:
raise ValueError("No se puede obtener objectId del elemento para hacer click.")
# Intenta obtener coordenadas del nodo
node_result = await self.tab._enviar("DOM.describeNode", {
"objectId": self.object_id
})
node_id = node_result["node"]["nodeId"]
try:
box_model = await self.tab._enviar("DOM.getBoxModel", {"nodeId": node_id})
content = box_model["model"]["content"]
x = (content[0] + content[2]) / 2
y = (content[1] + content[5]) / 2
except:
quads_result = await self.tab._enviar("DOM.getContentQuads", {"nodeId": node_id})
quad = quads_result["quads"][0]
x = (quad[0] + quad[4]) / 2
y = (quad[1] + quad[5]) / 2
# 🧠 Enfocar el elemento antes de clickear
await self.tab._enviar("DOM.focus", {
"objectId": self.object_id
})
# 🎯 Movimiento humanoide opcional
start_x, start_y = x + random.uniform(-100, 100), y + random.uniform(-100, 100)
steps = random.randint(5, 12)
for i in range(1, steps + 1):
curr_x = start_x + (x - start_x) * i / steps + random.uniform(-1, 1)
curr_y = start_y + (y - start_y) * i / steps + random.uniform(-1, 1)
await self.tab._enviar("Input.dispatchMouseEvent", {
"type": "mouseMoved",
"x": curr_x,
"y": curr_y,
})
await asyncio.sleep(random.uniform(0.01, 0.05))
# 👆 Mouse Down
await self.tab._enviar("Input.dispatchMouseEvent", {
"type": "mousePressed",
"x": x,
"y": y,
"button": "left",
"clickCount": 1
})
await asyncio.sleep(random.uniform(0.05, 0.15))
# 👇 Mouse Up
await self.tab._enviar("Input.dispatchMouseEvent", {
"type": "mouseReleased",
"x": x,
"y": y,
"button": "left",
"clickCount": 1
})
await asyncio.sleep(random.uniform(0.01, 0.05))
# 🖱️ Click manual adicional
await self.tab._enviar("Input.dispatchMouseEvent", {
"type": "mouseClicked",
"x": x,
"y": y,
"button": "left",
"clickCount": 1
})
if self.tab.verbose:
print(f"🖱️ Click humano simulado en ({x:.1f}, {y:.1f})")
except Exception as e:
print(f"⚠️ Error al hacer click físico: {e}")
print("🧪 Intentando fallback con JavaScript click()...")
await self.click_js()
async def click_js(self):
try:
await self._asegurar_object_id()
if not self.object_id:
print("⚠️ No se puede hacer click JS: objectId no disponible.")
return
await self.tab._enviar("Runtime.callFunctionOn", {
"objectId": self.object_id,
"functionDeclaration": "function() { this.click(); }",
"awaitPromise": True
})
if self.tab.verbose:
print("🖱️ Click simulado por JavaScript (element.click())")
except Exception as e:
print(f"⚠️ Error al ejecutar click en JS: {e}")
async def obtener_texto(self) -> Optional[str]:
try:
await self._asegurar_object_id()
result = await self.tab._enviar("Runtime.callFunctionOn", {
"objectId": self.object_id,
"functionDeclaration": "function() { return this.textContent; }",
"returnByValue": True
})
return result.get("result", {}).get("value")
except Exception as e:
print(f"⚠️ Error al obtener texto del elemento: {e}")
return None
async def escribir_texto(self, texto: str):
try:
await self._asegurar_object_id()
await self.tab._enviar("Runtime.callFunctionOn", {
"objectId": self.object_id,
"functionDeclaration": f"function() {{ this.value = {json.dumps(texto)}; this.dispatchEvent(new Event('input')); }}",
"awaitPromise": True
})
if self.tab.verbose:
print(f"⌨️ Texto escrito en elemento: '{texto}'")
except Exception as e:
print(f"⚠️ Error al escribir texto: {e}")
async def encontrar_hijo_clickeable(self) -> Optional["ElementoWeb"]:
try:
await self._asegurar_object_id()
resultado = await self.tab._enviar("Runtime.callFunctionOn", {
"objectId": self.object_id,
"functionDeclaration": """
function() {
const candidatos = this.querySelectorAll("span, div, a, button");
for (const el of candidatos) {
const style = window.getComputedStyle(el);
const visible = style.display !== "none" && style.visibility !== "hidden";
const interactivo = style.pointerEvents !== "none";
if (visible && interactivo) return el;
}
return this;
}
""",
"returnByValue": False
})
if "result" in resultado and "objectId" in resultado["result"]:
return ElementoWeb(self.tab, resultado["result"]["objectId"])
except Exception as e:
print(f"⚠️ No se pudo encontrar hijo clickeable: {e}")
return None
+193
View File
@@ -0,0 +1,193 @@
import asyncio
import os
import signal
import subprocess
import json
from typing import Optional
import aiohttp
class Navegador:
def __init__(self,
chrome_path: str,
user_data_dir: str,
id: Optional[int] = None,
download_dir: Optional[str] = None,
debugging_port: int = 9222,
headless: bool = False,
user_agent: Optional[str] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"):
self.chrome_path = chrome_path
self.user_data_dir = user_data_dir
self.id = id
self.download_dir = download_dir or os.path.join(self.user_data_dir, "downloads")
self.debugging_port = debugging_port
self.headless = headless
self.user_agent = user_agent
self.chrome_process: Optional[subprocess.Popen] = None
async def _esperar_debugger(self, timeout=10):
url = f"http://127.0.0.1:{self.debugging_port}/json"
for _ in range(timeout * 10): # 10 intentos por segundo
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
print("✅ Chrome listo para debugging.")
return
except Exception:
pass
await asyncio.sleep(0.1)
raise RuntimeError("❌ Chrome no respondió en el puerto de debugging.")
def _preconfigurar_preferencias(self):
prefs_path = os.path.join(self.user_data_dir, "Default", "Preferences")
os.makedirs(os.path.dirname(prefs_path), exist_ok=True)
os.makedirs(self.download_dir, exist_ok=True)
prefs = {
"profile": {
"exit_type": "Normal",
"exited_cleanly": True
},
"browser": {
"has_seen_welcome_page": True
},
"distribution": {
"skip_first_run_ui": True
},
"download": {
"default_directory": self.download_dir,
"prompt_for_download": False,
"directory_upgrade": True,
"extensions_to_open": ""
},
"savefile": {
"default_directory": self.download_dir
}
}
if os.path.exists(prefs_path):
try:
with open(prefs_path, "r", encoding="utf-8") as f:
existing = json.load(f)
existing.update(prefs)
prefs = existing
except Exception:
pass
with open(prefs_path, "w", encoding="utf-8") as f:
json.dump(prefs, f, indent=2)
def _build_args(self):
os.makedirs(self.user_data_dir, exist_ok=True)
self._preconfigurar_preferencias()
args = [
f"--remote-debugging-port={self.debugging_port}",
f"--user-data-dir={self.user_data_dir}",
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
# "--disable-web-security",
# "--disable-extensions",
# "--disable-dev-shm-usage",
"--disable-infobars",
"--disable-popup-blocking",
"--disable-default-apps",
"--mute-audio",
"--window-size=1024,1024",
"--no-first-run",
"--no-default-browser-check",
"--disable-features=DefaultBrowserPrompt",
"--disable-component-update",
"--disable-background-networking",
"--disable-sync",
"--disable-translate",
"--disable-background-timer-throttling",
"--disable-client-side-phishing-detection",
"--disable-component-extensions-with-background-pages",
"--metrics-recording-only",
"--safebrowsing-disable-auto-update",
]
if self.headless:
args.append("--headless=new")
if self.user_agent:
args.append(f"--user-agent={self.user_agent}")
return args
async def inyectar_spoof_chrome(self):
script = """
window.chrome = {
app: {
isInstalled: false,
InstallState: {
DISABLED: 'disabled',
INSTALLED: 'installed',
NOT_INSTALLED: 'not_installed'
},
RunningState: {
CANNOT_RUN: 'cannot_run',
READY_TO_RUN: 'ready_to_run',
RUNNING: 'running'
}
},
runtime: {
PlatformOs: { MAC: 'mac', WIN: 'win', ANDROID: 'android', CROS: 'cros', LINUX: 'linux', OPENBSD: 'openbsd' },
PlatformArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
PlatformNaclArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
RequestUpdateCheckStatus: { THROTTLED: 'throttled', NO_UPDATE: 'no_update', UPDATE_AVAILABLE: 'update_available' },
OnInstalledReason: { INSTALL: 'install', UPDATE: 'update', CHROME_UPDATE: 'chrome_update', SHARED_MODULE_UPDATE: 'shared_module_update' },
OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' }
}
};
"""
url = f"http://127.0.0.1:{self.debugging_port}/json"
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
targets = await resp.json()
for target in targets:
if "webSocketDebuggerUrl" not in target:
continue
target_id = target["id"]
async with session.post(
f"http://127.0.0.1:{self.debugging_port}/json/protocol",
json={"targetId": target_id}
):
pass # CDP protocol fetch optional
async with session.post(
f"http://127.0.0.1:{self.debugging_port}/json/send",
json={
"id": 1,
"method": "Page.addScriptToEvaluateOnNewDocument",
"params": {"source": script}
}
) as inject_resp:
if inject_resp.status == 200:
print("✅ chrome.* spoof inyectado.")
async def iniciar(self):
args = self._build_args()
self.chrome_process = subprocess.Popen([self.chrome_path] + args)
print(f"Chrome iniciado (headless={self.headless}). Esperando disponibilidad del debugger...")
await self._esperar_debugger()
await self.inyectar_spoof_chrome()
async def cerrar(self):
if self.chrome_process and self.chrome_process.poll() is None:
self.chrome_process.terminate()
try:
await asyncio.wait_for(asyncio.to_thread(self.chrome_process.wait), timeout=5)
except asyncio.TimeoutError:
self.chrome_process.kill()
print("🛑 Chrome cerrado correctamente.")
+138
View File
@@ -0,0 +1,138 @@
import aiohttp
import websockets
import json
import asyncio
from .Tab import Tab
from typing import Optional
class Scrapper:
def __init__(self, debugging_url: str = "http://127.0.0.1:9222"):
self.debugging_url = debugging_url
self.tabs: list[Tab] = []
async def _crear_tab_websocket_url(self, target_url: str = "about:blank") -> str:
"""
Crea una nueva pestaña usando el método oficial Target.createTarget
y devuelve su WebSocketDebuggerUrl.
"""
# 1. Obtener el WebSocket general del browser (root)
async with aiohttp.ClientSession() as session:
async with session.get(f"{self.debugging_url}/json/version") as resp:
if resp.status != 200:
raise RuntimeError("No se pudo obtener información del navegador")
data = await resp.json()
browser_ws_url = data["webSocketDebuggerUrl"]
# 2. Conectarse al WebSocket del browser
async with websockets.connect(browser_ws_url) as websocket:
# 3. Enviar comando para crear target
msg_id = 1
await websocket.send(json.dumps({
"id": msg_id,
"method": "Target.createTarget",
"params": {
"url": target_url,
"newWindow": False
}
}))
# 4. Esperar respuesta con el targetId
while True:
respuesta = await websocket.recv()
data = json.loads(respuesta)
if data.get("id") == msg_id:
target_id = data["result"]["targetId"]
break
# 5. Esperar a que el target aparezca en /json
for _ in range(30): # máximo ~3 segundos
await asyncio.sleep(0.1)
async with aiohttp.ClientSession() as session:
async with session.get(f"{self.debugging_url}/json") as resp:
if resp.status == 200:
tabs = await resp.json()
for tab in tabs:
if tab.get("id") == target_id:
return tab["webSocketDebuggerUrl"]
raise RuntimeError("No se pudo obtener el WebSocket de la nueva pestaña")
async def nueva_tab(self, url: str = "", wait_time: float = 5.0) -> Tab:
websocket_url = await self._crear_tab_websocket_url()
tab = await Tab.crear_desde_websocket(websocket_url)
self.tabs.append(tab)
if url:
print(f"🌍 Navegando a: {url}")
await tab.navegar(url, wait_time)
else:
print("⚠️ No se especificó URL. La pestaña se creó pero no se navegó a ninguna página.")
return tab
async def cerrar_todos(self):
for tab in list(self.tabs):
await tab.cerrar()
self.tabs.clear()
def get_tab(self, identifier: str) -> Optional[Tab]:
"""
Devuelve una instancia de Tab según su WebSocket URL o su ID final (extraído del WebSocket URL).
Acepta:
- ws_url completo: ws://127.0.0.1:9222/devtools/page/XYZ
- id directo: XYZ
"""
for tab in self.tabs:
# Comparar directamente contra ws_url
if tab.ws_url == identifier:
return tab
# Comparar contra el ID extraído
ws_id = tab.ws_url.rsplit("/", 1)[-1]
if ws_id == identifier:
return tab
return None
async def obtener_tabs_existentes(self) -> list[Tab]:
"""
Recupera todas las pestañas de tipo 'page' que no están ya en self.tabs,
las conecta y devuelve como lista. Muestra resumen limpio por consola.
"""
async with aiohttp.ClientSession() as session:
async with session.get(f"{self.debugging_url}/json") as resp:
if resp.status != 200:
raise RuntimeError("No se pudo obtener la lista de pestañas")
tabs_info = await resp.json()
print("\n🧾 Pestañas activas (filtradas: solo 'type': 'page'):\n")
nuevas_tabs = []
for idx, tab_info in enumerate(tabs_info, start=1):
tipo = tab_info.get("type")
if tipo != "page":
continue # Filtrar todo lo que no sea página visible
ws_url = tab_info.get("webSocketDebuggerUrl")
tab_id = tab_info.get("id")
title = tab_info.get("title", "<Sin título>")
url = tab_info.get("url", "<Sin URL>")
# Verifica si ya la tienes cargada
if any(t.ws_url == ws_url for t in self.tabs):
continue
# Conectar
try:
tab = await Tab.crear_desde_websocket(ws_url)
self.tabs.append(tab)
nuevas_tabs.append(tab)
except Exception as e:
print(f"⚠️ No se pudo conectar a pestaña {tab_id}: {e}")
if not nuevas_tabs:
print("⚠️ No se encontraron nuevas pestañas para agregar.\n")
return nuevas_tabs
+206
View File
@@ -0,0 +1,206 @@
import asyncio
import json
import base64
import websockets
from typing import Optional, List
from .ElementoWeb import ElementoWeb
import os
class Tab:
def __init__(self, websocket: websockets.WebSocketClientProtocol, ws_url: str, verbose: bool = True):
self.websocket = websocket
self.ws_url = ws_url
self._message_id = 0
self._pending = {}
self._load_event = asyncio.Event()
self.verbose = verbose
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
await self.cerrar()
@classmethod
async def crear_desde_websocket(cls, ws_url: str) -> "Tab":
websocket = await websockets.connect(ws_url, max_size=10 * 1024 * 1024)
tab = cls(websocket, ws_url)
asyncio.create_task(tab._recibir_eventos())
await tab._enviar("Page.enable")
await tab._enviar("Network.enable")
return tab
async def _recibir_eventos(self):
async for mensaje in self.websocket:
data = json.loads(mensaje)
if "id" in data and data["id"] in self._pending:
future = self._pending.pop(data["id"])
if "result" in data:
future.set_result(data["result"])
elif "error" in data:
future.set_exception(Exception(data["error"]))
elif data.get("method") == "Page.loadEventFired":
self._load_event.set()
async def _enviar(self, metodo: str, parametros: Optional[dict] = None, timeout: float = 10.0) -> dict:
self._message_id += 1
msg_id = self._message_id
mensaje = {
"id": msg_id,
"method": metodo,
"params": parametros or {}
}
future = asyncio.get_event_loop().create_future()
self._pending[msg_id] = future
await self.websocket.send(json.dumps(mensaje))
return await asyncio.wait_for(future, timeout=timeout)
async def navegar(self, url: str, wait_time: float = 5.0):
self._load_event.clear()
if self.verbose:
print(f"🌍 Navegando a: {url}")
await self._enviar("Page.navigate", {"url": url})
try:
await asyncio.wait_for(self._load_event.wait(), timeout=wait_time)
if self.verbose:
print("✅ Página cargada correctamente.")
except asyncio.TimeoutError:
print(f"⚠️ Tiempo de espera agotado ({wait_time}s) al cargar la página.")
async def evaluar_js(self, js_code: str) -> Optional[str]:
try:
result = await self._enviar("Runtime.evaluate", {
"expression": js_code,
"returnByValue": True
})
if "exceptionDetails" in result:
raise Exception(result["exceptionDetails"])
return result.get("result", {}).get("value")
except Exception as e:
print(f"⚠️ Error al ejecutar JS: {e}")
return None
async def inyectar_archivo_js(self, ruta_archivo: str, reemplazos: dict = None) -> Optional[str]:
if not os.path.exists(ruta_archivo):
print(f"❌ Archivo JS no encontrado: {ruta_archivo}")
return None
with open(ruta_archivo, "r", encoding="utf-8") as f:
js_code = f.read()
if reemplazos:
for key, value in reemplazos.items():
js_code = js_code.replace(f"{{{{{key}}}}}", str(value))
# 🔧 Eliminamos el `return` externo
js_code_final = f"(async () => {{\n{js_code}\n}})();"
try:
result = await self._enviar("Runtime.evaluate", {
"expression": js_code_final,
"returnByValue": True
})
if "exceptionDetails" in result:
raise Exception(result["exceptionDetails"])
return result.get("result", {}).get("value")
except Exception as e:
print(f"⚠️ Error al inyectar JS desde {ruta_archivo}: {e}")
return None
async def obtener_user_agent(self) -> Optional[str]:
return await self.evaluar_js("navigator.userAgent")
async def capturar_screenshot(self, output_path: str = "screenshot.png"):
try:
result = await self._enviar("Page.captureScreenshot")
data = result["data"]
with open(output_path, "wb") as f:
f.write(base64.b64decode(data))
if self.verbose:
print(f"📸 Screenshot guardado como {output_path}")
except Exception as e:
print(f"⚠️ Error al capturar screenshot: {e}")
async def cerrar(self):
try:
if not self.websocket.closed:
await self.websocket.close()
if self.verbose:
print("🛑 WebSocket cerrado.")
except Exception as e:
print(f"⚠️ Error al cerrar pestaña: {e}")
async def obtener_html_completo(self) -> Optional[str]:
try:
result = await self._enviar("Runtime.evaluate", {
"expression": "document.documentElement.outerHTML",
"returnByValue": True
})
return result.get("result", {}).get("value")
except Exception as e:
print(f"⚠️ Error al obtener HTML: {e}")
return None
async def obtener_dominio(self) -> Optional[str]:
try:
dominio = await self.evaluar_js("window.location.hostname")
if self.verbose and dominio:
print(f"🌐 Dominio actual: {dominio}")
return dominio
except Exception as e:
print(f"⚠️ Error al obtener dominio: {e}")
return None
async def get_element_by_selector_node(self, selector: str) -> Optional["ElementoWeb"]:
try:
doc = await self._enviar("DOM.getDocument")
root_node_id = doc["root"]["nodeId"]
result = await self._enviar("DOM.querySelector", {
"nodeId": root_node_id,
"selector": selector
})
node_id = result.get("nodeId")
if not node_id:
print(f"⚠️ Nodo no encontrado con selector: {selector}")
return None
return ElementoWeb.from_node(self, node_id=node_id)
except Exception as e:
print(f"⚠️ Error al buscar nodo desde DOM.querySelector: {e}")
return None
async def get_elements_by_css_selector(self, selector: str) -> List["ElementoWeb"]:
try:
result = await self._enviar("Runtime.evaluate", {
"expression": f'Array.from(document.querySelectorAll("{selector}"))',
"objectGroup": "grupo_elementos_css",
"includeCommandLineAPI": True,
"returnByValue": False
})
array_id = result["result"]["objectId"]
props = await self._enviar("Runtime.getProperties", {
"objectId": array_id,
"ownProperties": True
})
elementos = []
for prop in props["result"]:
if "value" in prop and "objectId" in prop["value"]:
elementos.append(ElementoWeb(self, prop["value"]["objectId"]))
if self.verbose:
print(f"🔍 Se encontraron {len(elementos)} elementos con el selector CSS '{selector}'.")
return elementos
except Exception as e:
print(f"⚠️ Error al buscar elementos por selector CSS '{selector}': {e}")
return []
async def enfocar(self):
try:
await self._enviar("Page.bringToFront")
if self.verbose:
print("🪟 Pestaña enfocada (bringToFront).")
except Exception as e:
print(f"⚠️ Error al enfocar pestaña: {e}")
+67
View File
@@ -0,0 +1,67 @@
import uuid
import datetime
import re
class GeneradorIDUnico:
def __init__(self, tipo_objeto: str):
if not re.match(r'^[A-Z]{4}$', tipo_objeto):
raise ValueError("El tipo de objeto debe tener 4 letras en mayúscula (ej: ABCD)")
self.tipo_objeto = tipo_objeto
def generar(self):
f = datetime.datetime.now().strftime('%Y%m%d')
u = uuid.uuid4().hex[:9]
n = ''.join(filter(str.isdigit, uuid.uuid4().hex))[:8]
n = n.ljust(8, '0')
t = sum(int(c) for c in f)
t += sum(int(c, 16) for c in u)
t += sum(int(c) for c in n)
c = str(t % 10)
l = t + int(c)
d = hex(l % 16)[-1]
return f"{self.tipo_objeto}{f}-{d}{u}{c}{n}"
@staticmethod
def verificar(id_: str) -> bool:
try:
f = id_[4:12]
cuerpo = id_[13:]
d = cuerpo[0]
u = cuerpo[1:10]
c = cuerpo[10]
n = cuerpo[11:19]
t = sum(int(c) for c in f)
t += sum(int(c, 16) for c in u)
t += sum(int(c) for c in n)
esd = str(t % 10)
l = t + int(esd)
esh = hex(l % 16)[-1]
return (
d.lower() == esh.lower() and
c == esd
)
except Exception:
return False
@staticmethod
def tamaño_bytes_bits(id_: str):
"""Devuelve el tamaño del ID en bytes y bits (UTF-8)"""
bytes_ = len(id_.encode('utf-8'))
return bytes_, bytes_ * 8
if __name__ == "__main__":
# Ejemplo de uso
generador = GeneradorIDUnico("FACT")
nuevo_id = generador.generar()
print(f"Nuevo ID generado: {nuevo_id}")
# Verificación del ID
es_valido = GeneradorIDUnico.verificar(nuevo_id)
print(f"El ID es válido: {es_valido}")
# Tamaño del ID
bytes_, bits_ = GeneradorIDUnico.tamaño_bytes_bits(nuevo_id)
print(f"Tamaño del ID: {bytes_} bytes / {bits_} bits")
+59
View File
@@ -0,0 +1,59 @@
from domains.Security.GenerarIDs import GeneradorIDUnico
from domains.Llms.Embedders.Base_Embedder import EmbedderABC # Asegúrate de que esta ruta sea correcta
from typing import List, Optional
from domains.ConexionSql.Base_conexion import ConexionBase
from sqlalchemy import MetaData # Asegúrate de importar esto
from domains.TextManager.notas_mmr import generar_tabla_nota_para_biblioteca # Ajusta si es necesario
from sqlalchemy import inspect
from domains.base import Base
class Biblioteca:
def __init__(
self,
nombre: str,
descripcion: str = "",
id: Optional[str] = None,
embedder: Optional[EmbedderABC] = None,
vector_dim: Optional[int] = None
):
"""
Clase que representa una biblioteca de notas de texto.
:param nombre: Nombre de la biblioteca.
:param descripcion: Breve descripción de la biblioteca.
:param id: ID único opcional. Si no se proporciona, se genera automáticamente.
:param embedder: Objeto que implementa EmbedderABC para generar el vector del nombre.
:param vector_dim: Dimensión del vector si no se proporciona un embedder.
"""
self.id = id if id is not None else GeneradorIDUnico("BBLI").generar()
self.nombre = nombre if "biblio" in nombre else f"biblio_{nombre}"
self.descripcion = descripcion
self.embedder = embedder
if self.embedder is not None:
self.vector_dim = self.embedder.dimension_number()
elif vector_dim is not None:
self.vector_dim = vector_dim
else:
raise ValueError("Debes proporcionar un 'embedder' o un 'vector_dim' explícito.")
def generar_modelo_notas(self, conexion: ConexionBase):
nombre_tabla = f"{self.nombre}"
print(f"[Notas] Generando tabla: {nombre_tabla}")
engine = conexion.get_engine()
inspector = inspect(engine)
if inspector.has_table(nombre_tabla):
print(f"[Notas] ❌ Ya existe la tabla {nombre_tabla}")
raise ValueError(f"Ya existe una tabla con el nombre '{nombre_tabla}' en la base de datos.")
print("[Notas] Generando definición SQL...")
tabla, NotaModel = generar_tabla_nota_para_biblioteca(nombre_tabla, self.vector_dim, Base.metadata)
print("[Notas] Creando tabla en base de datos...")
Base.metadata.create_all(engine)
print("[Notas] ✔️ Tabla creada")
return NotaModel
+112
View File
@@ -0,0 +1,112 @@
import os
import base64
from dotenv import load_dotenv
from sqlalchemy import Column, String, Integer
from domains.ArquitectureLayer.Mapper import Mapper_base
from domains.ArquitectureLayer.Model import Model_base
from domains.ArquitectureLayer.Repo import Repo_base
from domains.ConexionSql.Base_conexion import ConexionBase
from domains.base import Base
from domains.Security.Encriptar import Encriptar_fernet
from domains.Security.GenerarIDs import GeneradorIDUnico
from domains.Llms.Embedders.Base_Embedder import EmbedderABC
from domains.TextManager.biblioteca import Biblioteca # Suponiendo que defines la clase lógica Biblioteca aquí
# ----------------------
# Cargar clave maestra
# ----------------------
from entrypoint import ENV_PATH
load_dotenv(ENV_PATH)
pssword = os.getenv('MASTER_PASSWORD')
if pssword is None:
raise ValueError("MASTER_PASSWORD no está definida en el archivo .env")
# ----------------------
# MODELO (SQLAlchemy)
# ----------------------
class BibliotecaModel(Base, Model_base):
__tablename__ = "bibliotecas"
id = Column(String, primary_key=True, unique=True)
nombre = Column(String, nullable=False, unique=True)
descripcion = Column(String, nullable=False, default="")
vector_dim = Column(Integer, nullable=False)
embedder_info = Column(String, nullable=True) # Nombre de clase, ID de configuración, o info encriptada del embedder
# ----------------------
# MAPPER
# ----------------------
class BibliotecaMapper(Mapper_base[Biblioteca, BibliotecaModel]):
@staticmethod
def to_model(obj: Biblioteca) -> BibliotecaModel:
return BibliotecaModel(
id=obj.id,
nombre=obj.nombre,
descripcion=obj.descripcion,
vector_dim=obj.vector_dim
# embedder no se serializa en el modelo, se maneja por separado
)
@staticmethod
def from_model(model: BibliotecaModel) -> Biblioteca:
return Biblioteca(
id=model.id,
nombre=model.nombre,
descripcion=model.descripcion,
vector_dim=model.vector_dim,
embedder=None # se deja para inyección posterior si es necesario
)
@staticmethod
def to_dict(obj: Biblioteca) -> dict:
embedder_info = type(obj.embedder).__name__ if obj.embedder else None
return {
"id": obj.id,
"nombre": obj.nombre,
"descripcion": obj.descripcion,
"vector_dim": obj.vector_dim,
"embedder_info": embedder_info
}
@staticmethod
def from_dict(data: dict) -> Biblioteca:
return Biblioteca(
id=data["id"],
nombre=data["nombre"],
descripcion=data["descripcion"],
vector_dim=data["vector_dim"],
embedder=None # inyección manual si se desea
)
# ----------------------
# REPO
# ----------------------
class BibliotecaRepo(Repo_base[BibliotecaModel, Biblioteca]):
def __init__(self, conexion: ConexionBase):
super().__init__(
session=conexion.get_session(),
modelo=BibliotecaModel,
mapper=BibliotecaMapper
)
def add(self, biblioteca: Biblioteca, created_by: str = None, notes: str = None) -> str:
# Lógica personalizada: prevenir duplicados por nombre
existente = self.session.query(self.Modelo).filter_by(nombre=biblioteca.nombre).first()
if existente:
raise ValueError(f"Ya existe una biblioteca con el nombre '{biblioteca.nombre}'")
return super().add(biblioteca, created_by=created_by, notes=notes)
def get_by_nombre(self, nombre: str) -> Biblioteca | None:
model = self.session.query(self.Modelo).filter_by(nombre=nombre, sys_deleted_at=None).first()
return self.Mapper.from_model(model) if model else None
+41
View File
@@ -0,0 +1,41 @@
from domains.Security.GenerarIDs import GeneradorIDUnico
from typing import List
class Nota:
def __init__(
self,
titulo: str,
tags: List[str] = None,
conexiones: List[str] = None,
texto: str = "",
vector: List[float] = None,
resumen: str = "",
vector_resumen: List[float] = None,
id: str = None
):
"""
Clase que representa una nota de texto con estructura semántica.
:param titulo: Título de la nota.
:param tags: Lista de etiquetas asociadas.
:param conexiones: Lista de identificadores relacionados.
:param vector: Embedding vectorial de la nota.
:param resumen: Texto resumen de la nota.
:param vector_resumen: Embedding del resumen.
:param id: Identificador único (si no se proporciona, se genera automáticamente).
"""
self.id = id if id is not None else GeneradorIDUnico("NOTA").generar()
self.titulo = titulo
self.tags = tags if tags is not None else []
self.conexiones = conexiones if conexiones is not None else []
self.texto = texto
self.vector = vector
self.resumen = resumen
self.vector_resumen = vector_resumen
def __repr__(self):
return (
f"<Nota id={self.id}, titulo='{self.titulo}', tags={len(self.tags)}, "
f"conexiones={len(self.conexiones)}, vector_dim={len(self.vector)}, "
f"resumen_len={len(self.resumen)}, vector_resumen_dim={len(self.vector_resumen)}>"
)
+196
View File
@@ -0,0 +1,196 @@
import os
from dotenv import load_dotenv
from sqlalchemy import Table, Column, String, Text, MetaData
from pgvector.sqlalchemy import Vector
from sqlalchemy.orm import registry, Session
from domains.TextManager.nota import Nota
from domains.ConexionSql.Base_conexion import ConexionBase
from typing import Tuple
import re
from domains.ArquitectureLayer.Mapper import Mapper_base
from domains.ArquitectureLayer.Model import Model_base
from domains.ArquitectureLayer.Repo import Repo_base
from domains.Credenciales.postgres_credencial import PostgresCredencial # Asegúrate de tener esta clase implementada correctamente
titulo = os.getenv('DB_TITLE')
usuario = os.getenv('DB_USER')
passwrd = os.getenv('DB_PASSWORD')
host = os.getenv('DB_HOST')
port = os.getenv('DB_PORT')
db_name = os.getenv('DB_NAME')
db_credencial = PostgresCredencial(
titulo=titulo,
user=usuario,
password=passwrd,
host=host,
port=port,
dbname=db_name
)
# from entrypoint.init_db import db_credencial
from domains.Logger.logger_db import LoggerDB, logger
LoggerDB(db_credencial, "logger_textos", created_by="sistema")
from domains.base import Base # Este es tu declarative_base()
# ----------------------
# Cargar .env
# ----------------------
from entrypoint import ENV_PATH
load_dotenv(ENV_PATH)
# ----------------------
# REGISTRO DINÁMICO PARA TABLAS
# ----------------------
mapper_registry = registry()
# ----------------------
# FUNCIONES AUXILIARES
# ----------------------
def generar_tabla_nota_para_biblioteca(biblioteca_nombre: str, vector_dim: int, metadata: MetaData) -> Tuple[Table, type]:
"""
Genera una tabla dinámica y modelo ORM para una biblioteca dada, con campos vectoriales y campos del sistema.
"""
try:
logger.info(f"Generando tabla para biblioteca: '{biblioteca_nombre}' con dimensión de vector: {vector_dim}")
# Nombre SQL-safe
nombre_tabla = re.sub(r"[^a-zA-Z0-9_]", "_", biblioteca_nombre.strip().lower())
logger.debug(f"Nombre de tabla SQL-safe: '{nombre_tabla}'")
# Modelo ORM dinámico
class NotaModel(Base, Model_base):
__tablename__ = nombre_tabla
__table_args__ = {"extend_existing": True}
id = Column(String, primary_key=True)
titulo = Column(String, nullable=False)
tags = Column(String)
conexiones = Column(String)
texto = Column(Text)
resumen = Column(Text)
vector = Column(Vector(vector_dim), nullable=True)
vector_resumen = Column(Vector(vector_dim), nullable=True)
logger.info(f"Modelo ORM 'NotaModel' creado para la tabla '{nombre_tabla}'")
logger.debug(f"Columnas del modelo: {[c.name for c in NotaModel.__table__.columns]}")
logger.debug(f"Tipos de columnas: {[str(c.type) for c in NotaModel.__table__.columns]}")
logger.debug(f"Claves primarias: {[c.name for c in NotaModel.__table__.primary_key]}")
return NotaModel.__table__, NotaModel
except Exception as e:
logger.error(f"Error al generar la tabla y modelo ORM para '{biblioteca_nombre}': {e}")
raise
# ----------------------
# MAPPER
# ----------------------
class NotaMapper(Mapper_base[Nota, object]): # Usa `object` si el modelo es dinámico
@staticmethod
def to_model(nota: Nota):
return {
"id": nota.id,
"titulo": nota.titulo,
"tags": ",".join(nota.tags),
"conexiones": ",".join(nota.conexiones),
"texto": nota.texto,
"resumen": nota.resumen,
"vector": nota.vector,
"vector_resumen": nota.vector_resumen
}
@staticmethod
def from_model(model) -> Nota:
return Nota(
id=model.id,
titulo=model.titulo,
tags=model.tags.split(",") if model.tags else [],
conexiones=model.conexiones.split(",") if model.conexiones else [],
texto=model.texto,
resumen=model.resumen,
vector=list(model.vector) if model.vector is not None else None,
vector_resumen=list(model.vector_resumen) if model.vector_resumen is not None else None
)
@staticmethod
def to_dict(nota: Nota) -> dict:
return {
"id": nota.id,
"titulo": nota.titulo,
"tags": ",".join(nota.tags),
"conexiones": ",".join(nota.conexiones),
"texto": nota.texto,
"resumen": nota.resumen,
"vector": nota.vector,
"vector_resumen": nota.vector_resumen
}
@staticmethod
def from_dict(data: dict) -> Nota:
return Nota(
id=data["id"],
titulo=data["titulo"],
tags=data["tags"].split(",") if data.get("tags") else [],
conexiones=data["conexiones"].split(",") if data.get("conexiones") else [],
texto=data["texto"],
resumen=data["resumen"],
vector=data.get("vector"),
vector_resumen=data.get("vector_resumen")
)
# ----------------------
# REPO
# ----------------------
class NotaRepo(Repo_base):
def __init__(self, session: Session, modelo_nota: type):
if modelo_nota is None:
raise ValueError("No se puede instanciar NotaRepo sin un modelo válido de nota.")
super().__init__(session=session, modelo=modelo_nota, mapper=NotaMapper)
# ------------------------
# Métodos personalizados
# ------------------------
def get_by_tag(self, tag: str) -> list[Nota]:
query = self.session.query(self.Modelo).filter(self.Modelo.tags.contains([tag]))
models = query.all()
return self.Mapper.from_model_list(models)
def search_by_text(self, texto: str) -> list[Nota]:
query = self.session.query(self.Modelo).filter(self.Modelo.texto.ilike(f'%{texto}%'))
models = query.all()
return self.Mapper.from_model_list(models)
def get_paginated(self, offset: int = 0, limit: int = 10) -> list[Nota]:
models = self.session.query(self.Modelo).offset(offset).limit(limit).all()
return self.Mapper.from_model_list(models)
def update(self, id_: str, nota_actualizada: Nota) -> bool:
model = self.session.get(self.Modelo, id_)
if not model:
return False
# Campos de dominio
model.titulo = nota_actualizada.titulo
model.texto = nota_actualizada.texto
model.tags = nota_actualizada.tags
model.conexiones = nota_actualizada.conexiones
model.resumen = nota_actualizada.resumen
# Actualización de campos de sistema
model.sys_version = (model.sys_version or 1) + 1
self.session.commit()
return True
+15
View File
@@ -0,0 +1,15 @@
class Usuario:
def __init__(self, id: int, nombre: str, email: str, activo: bool = True):
self.id = id
self.nombre = nombre
self.email = email
self.activo = activo
def activar(self):
self.activo = True
def desactivar(self):
self.activo = False
def __repr__(self):
return f"Usuario(id={self.id}, nombre='{self.nombre}', email='{self.email}', activo={self.activo})"
+82
View File
@@ -0,0 +1,82 @@
from sqlalchemy import Column, Integer, String, Boolean
from domains.ArquitectureLayer.Model import Model_base
from domains.ArquitectureLayer.Mapper import Mapper_base
from domains.ArquitectureLayer.Repo import Repo_base
from domains.Usuario.usuario import Usuario
# ----------------------
# MODELO (SQLAlchemy)
# ----------------------
class UsuarioModel(Model_base):
__tablename__ = 'usuarios'
id = Column(Integer, primary_key=True, autoincrement=True)
nombre = Column(String, nullable=False)
email = Column(String, unique=True, nullable=False)
activo = Column(Boolean, default=True, nullable=False)
# ----------------------
# MAPPER
# ----------------------
class UsuarioMapper(Mapper_base[Usuario, UsuarioModel]):
@staticmethod
def to_model(obj: Usuario) -> UsuarioModel:
return UsuarioModel(
id=obj.id,
nombre=obj.nombre,
email=obj.email,
activo=obj.activo
)
@staticmethod
def from_model(model: UsuarioModel) -> Usuario:
return Usuario(
id=model.id,
nombre=model.nombre,
email=model.email,
activo=model.activo
)
@staticmethod
def to_dict(obj: Usuario) -> dict:
return {
'id': obj.id,
'nombre': obj.nombre,
'email': obj.email,
'activo': obj.activo
}
@staticmethod
def from_dict(data: dict) -> Usuario:
return Usuario(
id=data['id'],
nombre=data['nombre'],
email=data['email'],
activo=data.get('activo', True)
)
@staticmethod
def from_model_list(models: list[UsuarioModel]) -> list[Usuario]:
return [UsuarioMapper.from_model(m) for m in models]
# ----------------------
# REPO
# ----------------------
class UsuarioRepo(Repo_base[UsuarioModel, Usuario]):
def __init__(self, session):
super().__init__(
session=session,
modelo=UsuarioModel,
mapper=UsuarioMapper
)
def get_by_email(self, email: str) -> Usuario | None:
model = (
self.session.query(self.Modelo)
.filter_by(email=email, sys_deleted_at=None)
.first()
)
return self.Mapper.from_model(model) if model else None
View File
+10 -7
View File
@@ -1,12 +1,15 @@
# entrypoint/init_db.py # entrypoint/init_db.py
from src.base import Base from domains.base import Base
from src.ConexionSql.Postgres_conexion import PostgresConexion # Asegúrate de tener esta clase implementada correctamente from domains.ConexionSql.Postgres_conexion import PostgresConexion # Asegúrate de tener esta clase implementada correctamente
from src.Credenciales.postgres_credencial import PostgresCredencial # Asegúrate de tener esta clase implementada correctamente from domains.Credenciales.postgres_credencial import PostgresCredencial # Asegúrate de tener esta clase implementada correctamente
from domains.Credenciales.postgres_credencial_mmr import PostgresCredencialModel
from domains.ApiKeys.openai_apikey_mmr import OpenAICredencialModel
from domains.Llms.Modelos.Openai_model_mmr import ModeloOpenAIConfigModel
from domains.Llms.Embedders.Openai_embedder_mmr import OpenAIEmbedderModel
from domains.TextManager.biblioteca_mmr import BibliotecaModel
from src.Credenciales.postgres_credencial_mmr import PostgresCredencialModel
from src.ApiKeys.openai_apikey_mmr import OpenAICredencialModel
from src.Llms.Modelos.Openai_model_mmr import ModeloOpenAIConfigModel
from dotenv import load_dotenv from dotenv import load_dotenv
import os import os
@@ -41,7 +44,7 @@ def init_db():
# Crear engine desde la clase de conexión PostgreSQL # Crear engine desde la clase de conexión PostgreSQL
conexion = PostgresConexion(db_credencial) conexion = PostgresConexion(db_credencial)
engine = conexion.engine # Recuperamos el engine directamente engine = conexion.get_engine() # Recuperamos el engine directamente
print("Creando tablas...") print("Creando tablas...")
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
+3140 -55
View File
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -20,14 +20,25 @@
"storybook:build": "storybook build" "storybook:build": "storybook build"
}, },
"dependencies": { "dependencies": {
"@mantine/core": "8.0.0", "@cycjimmy/jsmpeg-player": "^6.1.2",
"@mantine/hooks": "8.0.0", "@mantine/core": "^8.0.1",
"@mantine/hooks": "^8.0.1",
"@mantine/tiptap": "^8.0.1",
"@react-three/fiber": "^9.1.2", "@react-three/fiber": "^9.1.2",
"@tabler/icons": "^3.31.0", "@tabler/icons": "^3.31.0",
"@tabler/icons-react": "^3.31.0", "@tiptap/react": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@uiw/react-markdown-preview": "^5.1.4",
"@uiw/react-md-editor": "^4.0.7",
"axios": "^1.9.0",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.2",
"marked": "^15.0.12",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.4.0" "react-rnd": "^10.5.2",
"react-router-dom": "^7.4.0",
"turndown": "^7.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.23.0", "@eslint/js": "^9.23.0",
@@ -42,6 +53,7 @@
"@types/react": "^19.0.12", "@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/three": "^0.176.0", "@types/three": "^0.176.0",
"@types/turndown": "^5.0.5",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.23.0", "eslint": "^9.23.0",
"eslint-config-mantine": "^4.0.3", "eslint-config-mantine": "^4.0.3",
+70 -7
View File
@@ -1,22 +1,85 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { HomePage } from './pages/Home.page'; import { HomePage } from './frontend_domains/Home/Home.page';
import { Consulta_API } from './pages/Consulta_api'; import { Consulta_API } from './frontend_domains/Experiments/Consulta_api';
import { Error_404 } from './pages/404'; // Ajusta si está en otra carpeta import { Error_404 } from './frontend_domains/FitzStudio/404/404'; // Ajusta si está en otra carpeta
import { Prueba_appshell } from './pages/Prueba_appshell'; // Ajusta si está en otra carpeta import { Grid_Dashboard } from './frontend_domains/Experiments/Grid_dashboard'; // Ajusta si está en otra carpeta
import { Biblioteca } from './frontend_domains/TextEditor/Biblioteca';
import { VisualizacionesRandom } from './frontend_domains/Experiments/Visualizaciones_Random';
import { Camara_noir } from './frontend_domains/CamaraNoir/Camaras_noir';
import EditorTest from "./frontend_domains/TextEditor/Editor_Test";
import { ChatPage } from './frontend_domains/Llms/Chat/ChatPage';
import { LoginPage } from './frontend_domains/Usuarios/Login.page';
const router = createBrowserRouter([ const router = createBrowserRouter([
// Home Principal
{ {
path: '/', path: '/',
element: <HomePage />, element: <HomePage />,
}, },
// Biblioteca
{ {
path: '/Consulta_API', path: '/bibliot/Biblioteca',
element: <Biblioteca />,
},
{
path: '/bibliot/editortest',
element: <EditorTest />,
},
// Chat LLM
{
path: '/llms/chat',
element: <ChatPage />,
},
// CamaraNoir
{
path: '/camara/principal',
element: <Camara_noir />,
},
// Experimentos
{
path: '/experiments/Consulta_API',
element: <Consulta_API />, element: <Consulta_API />,
}, },
{ {
path: '/prueba_appshell', path: '/experiments/Grid_Dashboard',
element: <Prueba_appshell />, element: <Grid_Dashboard />,
}, },
{
path: '/experiments/Visualizaciones_Random',
element: <VisualizacionesRandom />,
},
// Login
{
path: '/login',
element: <LoginPage />,
},
// FitzStudio Pages -------------------------------------------------------
// Error 404
{ {
path: '*', path: '*',
element: <Error_404 />, element: <Error_404 />,
+6 -1
View File
@@ -1,3 +1,5 @@
// https://tabler.io/icons
// OUTLINED // OUTLINED
export { default as IconArrowLeft } from './outlined/arrow-left.svg?react'; export { default as IconArrowLeft } from './outlined/arrow-left.svg?react';
export { default as IconHomeOutline } from './outlined/home.svg?react'; export { default as IconHomeOutline } from './outlined/home.svg?react';
@@ -11,7 +13,10 @@ export { default as IconSettings } from './outlined/settings.svg?react';
export { default as IconArrowBarLeft } from './outlined/arrow-bar-left.svg?react'; export { default as IconArrowBarLeft } from './outlined/arrow-bar-left.svg?react';
export { default as IconArrowBarRight } from './outlined/arrow-bar-right.svg?react'; export { default as IconArrowBarRight } from './outlined/arrow-bar-right.svg?react';
export { default as IconCheck } from './outlined/check.svg?react'; export { default as IconCheck } from './outlined/check.svg?react';
export { default as CameraPlus } from './outlined/camera-plus.svg?react';
export { default as Flask } from './outlined/flask.svg?react';
export { default as Users } from './outlined/users.svg?react';
export { default as IconNotebook } from './outlined/notebook.svg?react';
// FILLED // FILLED
export { default as IconHomeFilled } from './filled/home.svg?react'; export { default as IconHomeFilled } from './filled/home.svg?react';
@@ -1,151 +0,0 @@
import {
AppShell,
Burger,
Group,
Skeleton,
Tooltip,
UnstyledButton,
ActionIcon,
Title,
} from '@mantine/core';
import { default as LogoIcon } from '../../assets/icons/favicon'; // ruta relativa ajusta según tu estructura
import { useMantineTheme } from '@mantine/core';
import { mainLinksdata } from '../../data/navigationsLinks_1'; // ajusta la ruta
import { submenuLinks } from '../../data/submenuLinks_1'; // ajusta la ruta según tu estructura
import { useDisclosure, useMediaQuery } from '@mantine/hooks';
import { useEffect, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import classes from './Appshell.module.css';
type AppShellWithMenuProps = {
children?: React.ReactNode; // <- ahora es opcional
};
export function AppShellWithMenu({ children }: AppShellWithMenuProps) {
const theme = useMantineTheme();
const location = useLocation();
const isMobile = useMediaQuery('(max-width: 768px)');
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = useDisclosure(false);
const [desktopOpened, { toggle: toggleDesktop, open: openDesktop }] = useDisclosure(true);
const isCollapsed = useMemo(() => (isMobile ? !mobileOpened : !desktopOpened), [isMobile, mobileOpened, desktopOpened]);
const [manualActiveTab, setManualActiveTab] = useState<string | null>(null);
const matchedMain = Object.entries(submenuLinks).find(([mainKey, items]) =>
items.some((item) => location.pathname.startsWith(item.to))
);
const routeBasedActive = matchedMain?.[0] ?? 'Home';
const active = manualActiveTab ?? routeBasedActive;
const activeLink = submenuLinks[active as keyof typeof submenuLinks]?.find((item) => location.pathname === item.to)?.label ?? '';
const mainLinks = mainLinksdata.map((link) => (
<Tooltip
label={link.label}
position="right"
withArrow
transitionProps={{ duration: 0 }}
key={link.label}
>
<UnstyledButton
onClick={() => {
setManualActiveTab(link.label);
}}
className={classes.mainLink}
data-active={link.label === active || undefined}
>
<link.icon />
</UnstyledButton>
</Tooltip>
));
const links = (submenuLinks[active as keyof typeof submenuLinks] || []).map((item) => (
<Link
className={classes.link}
data-active={activeLink === item.label || undefined}
to={item.to}
key={item.label}
style={{ display: isCollapsed ? 'none' : 'block' }}
onClick={() => {
if (isMobile) closeMobile();
}}
>
{item.label}
</Link>
));
useEffect(() => {
setManualActiveTab(null);
}, [location.pathname]);
useEffect(() => {
if (!isMobile) openDesktop();
}, [isMobile, openDesktop]);
return (
<AppShell
header={{ height: 60 }}
navbar={{
width: isCollapsed ? 60 : 300,
breakpoint: 'sm',
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
padding="md"
>
{/* Header */}
<AppShell.Header>
<Group h="100%" px="sm">
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
<LogoIcon
style={{ width: 30, height: 30 }}
circleFill={theme.colors.brand[9]}
pathFill={theme.colors.secondary[2]}
/>
</Group>
</AppShell.Header>
{/* Navbar */}
<AppShell.Navbar>
<div className={classes.wrapper}>
<div className={classes.aside}>
<div className={classes.topSection}>
{mainLinks}
</div>
</div>
<div className={classes.main}>
{!isCollapsed && <Title order={4} className={classes.title}>{active}</Title>}
{links}
</div>
</div>
</AppShell.Navbar>
{/* Main Content */}
<AppShell.Main>
{children}
</AppShell.Main>
</AppShell>
);
}
+8 -5
View File
@@ -4,17 +4,20 @@ import {
IconDeviceDesktopAnalytics, IconDeviceDesktopAnalytics,
IconFingerprint, IconFingerprint,
IconGauge, IconGauge,
IconNotebook,
IconHome2, IconHome2,
IconSettings, IconSettings,
IconUserOutline as IconUser, IconUserOutline as IconUser,
CameraPlus,
Flask,
Users
} from '../assets/icons'; // ajusta según tu estructura de proyecto } from '../assets/icons'; // ajusta según tu estructura de proyecto
export const mainLinksdata = [ export const mainLinksdata = [
{ icon: IconHome2, label: 'Home' }, { icon: IconHome2, label: 'Home' },
{ icon: IconGauge, label: 'Dashboard' }, { icon: IconNotebook, label: 'Biblioteca' },
{ icon: IconDeviceDesktopAnalytics, label: 'Analytics' }, { icon: Users, label: 'AgentesLLMs' },
{ icon: IconCalendarStats, label: 'Releases' }, { icon: CameraPlus, label: 'CameraNoir' },
{ icon: IconUser, label: 'Account' }, { icon: Flask, label: 'Experimentos' },
{ icon: IconFingerprint, label: 'Security' },
{ icon: IconSettings, label: 'Settings' }, { icon: IconSettings, label: 'Settings' },
]; ];
+36 -20
View File
@@ -1,33 +1,49 @@
// src/data/submenuLinks.ts // src/data/submenuLinks.ts
import { Biblioteca } from "@/frontend_domains/TextEditor/Biblioteca";
export const submenuLinks = { export const submenuLinks = {
// Home Principal
Home: [ Home: [
{ label: 'Inicio', to: '/' }, { label: 'Inicio', to: '/' },
{ label: 'Consulta Api', to: '/Consulta_API' },
{ label: 'Prueba_appshell', to: '/prueba_appshell' },
], ],
Dashboard: [
{ label: 'Resumen', to: '/dashboard/resumen' }, // Biblioteca
{ label: 'Estadísticas', to: '/dashboard/estadisticas' },
{ label: 'Usuarios', to: '/dashboard/usuarios' }, Biblioteca: [
{ label: 'Biblioteca', to: '/bibliot/Biblioteca' },
{ label: 'test', to: '/bibliot/editortest' },
], ],
Analytics: [
{ label: 'Conversiones', to: '/analytics/conversiones' },
{ label: 'Tráfico', to: '/analytics/trafico' },
{ label: 'Tendencias', to: '/analytics/tendencias' }, // Experimentos
Experimentos: [
{ label: 'Consulta Api', to: '/experiments/Consulta_API' },
{ label: 'Visualizaciones_Random', to: '/experiments/Visualizaciones_Random' },
{ label: 'Grid_Dashboard', to: '/experiments/Grid_Dashboard' },
], ],
Releases: [
{ label: 'Notas de versión', to: '/releases/notas-de-version' }, // Camara
{ label: 'Historial', to: '/releases/historial' }, CameraNoir: [
{ label: 'Camara_principal', to: '/camara/principal' },
], ],
Account: [
{ label: 'Perfil', to: '/account/perfil' }, // LLms
{ label: 'Suscripciones', to: '/account/suscripciones' },
], AgentesLLMs: [
Security: [ { label: 'LLMs', to: '/llms' },
{ label: 'Contraseña', to: '/security/contraseña' }, { label: 'Chat', to: '/llms/chat' },
{ label: '2FA', to: '/security/2fa' }, { label: 'Documentos', to: '/llms/documentos' },
], ],
// Settings
Settings: [ Settings: [
{ label: 'Preferencias', to: '/settings/preferencias' }, { label: 'Preferencias', to: '/settings/preferencias' },
{ label: 'Notificaciones', to: '/settings/notificaciones' }, { label: 'Notificaciones', to: '/settings/notificaciones' },
@@ -0,0 +1,39 @@
import { AppShellWithMenu } from '../FitzStudio/Appshell/Appshell';
import { Card, Text, Container } from '@mantine/core';
export function Camara_noir() {
return (
<AppShellWithMenu>
<Container
size="lg"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
}}
>
<Card shadow="sm" padding="xl" radius="md" withBorder>
<Text size="lg" mb="md">
Cámara Noir en Vivo
</Text>
<img
src="http://10.8.0.9:8000/video"
alt="Stream MJPEG en vivo desde Raspberry Pi"
style={{
width: '640px',
height: '480px',
borderRadius: '8px',
border: '1px solid #ccc',
objectFit: 'cover',
}}
/>
<Text size="sm" color="dimmed" mt="sm">
Transmisión MJPEG en vivo vía FastAPI / libcamera-vid
</Text>
</Card>
</Container>
</AppShellWithMenu>
);
}
@@ -1,6 +1,6 @@
import { LlamadorAPI } from '../components/LlamadorAPI'; import { LlamadorAPI } from './LlamadorAPI';
import { AppShellWithMenu } from '../components/Appshell/Appshell'; import { AppShellWithMenu } from '../FitzStudio/Appshell/Appshell';
export function Consulta_API() { export function Consulta_API() {
@@ -0,0 +1,14 @@
import { Grid } from '@mantine/core';
import { AppShellWithMenu } from '../FitzStudio/Appshell/Appshell';
import { GridDashboard } from './Grid_dashboard_component';
export function Grid_Dashboard() {
return (
<AppShellWithMenu>
<GridDashboard></GridDashboard>
</AppShellWithMenu>
);
}
@@ -0,0 +1,114 @@
import { Card, Text, Switch, Group, useMantineTheme, useComputedColorScheme } from '@mantine/core';
import { Rnd } from 'react-rnd';
import { useState } from 'react';
const GRID_SIZE = 30;
function hexToRgba(hex: string, alpha: number): string {
const sanitized = hex.replace('#', '');
const bigint = parseInt(sanitized, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
interface CardData {
id: string;
x: number;
y: number;
width: number;
height: number;
}
const initialCards: CardData[] = [
{ id: '1', x: 0, y: 0, width: GRID_SIZE * 2, height: GRID_SIZE * 2 },
{ id: '2', x: GRID_SIZE * 2, y: 0, width: GRID_SIZE * 3, height: GRID_SIZE * 2 },
{ id: '3', x: GRID_SIZE * 3, y: 0, width: GRID_SIZE * 3, height: GRID_SIZE * 2 },
];
export const GridDashboard = () => {
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme(); // ✅ directamente 'light' o 'dark'
const isDark = colorScheme === 'dark';
// Color de la rejilla adaptado al modo del tema
const gridBaseColor = isDark ? theme.colors.dark[4] : theme.colors.gray[3];
const gridColor = hexToRgba(gridBaseColor, 0.25); // Ajusta la opacidad aquí
const [cards, setCards] = useState<CardData[]>(initialCards);
const [showGrid, setShowGrid] = useState(true);
const updateCard = (id: string, updates: Partial<CardData>) => {
setCards((prev) =>
prev.map((card) => (card.id === id ? { ...card, ...updates } : card))
);
};
return (
<>
<Group mb="xs">
<Switch
checked={showGrid}
onChange={(event) => setShowGrid(event.currentTarget.checked)}
label="Mostrar cuadrícula"
/>
</Group>
<div
style={{
width: '100%',
height: '700px',
backgroundSize: `${GRID_SIZE}px ${GRID_SIZE}px`,
backgroundImage: showGrid
? `linear-gradient(to right, ${gridColor} 1px, transparent 1px),
linear-gradient(to bottom, ${gridColor} 1px, transparent 1px)`
: 'none',
position: 'relative',
}}
>
{cards.map((card) => (
<Rnd
key={card.id}
size={{
width: Math.round(card.width / GRID_SIZE) * GRID_SIZE,
height: Math.round(card.height / GRID_SIZE) * GRID_SIZE,
}}
position={{ x: card.x, y: card.y }}
minWidth={GRID_SIZE * 8}
minHeight={GRID_SIZE * 8}
bounds="parent"
grid={[GRID_SIZE, GRID_SIZE]}
onDragStop={(_, d) =>
updateCard(card.id, {
x: Math.round(d.x / GRID_SIZE) * GRID_SIZE,
y: Math.round(d.y / GRID_SIZE) * GRID_SIZE,
})
}
onResizeStop={(_, __, ref, ___, pos) =>
updateCard(card.id, {
width: Math.round(ref.offsetWidth / GRID_SIZE) * GRID_SIZE,
height: Math.round(ref.offsetHeight / GRID_SIZE) * GRID_SIZE,
x: Math.round(pos.x / GRID_SIZE) * GRID_SIZE,
y: Math.round(pos.y / GRID_SIZE) * GRID_SIZE,
})
}
>
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{ height: '100%', userSelect: 'none' }}
>
<Text fw={500}>Card {card.id}</Text>
<Text size="sm">Mueve o redimensiona</Text>
</Card>
</Rnd>
))}
</div>
</>
);
};
@@ -13,7 +13,7 @@ import { MetodoSelect } from './MetodoSelect';
import { useMantineTheme } from '@mantine/core'; import { useMantineTheme } from '@mantine/core';
export function LlamadorAPI() { export function LlamadorAPI() {
const [direccion, setDireccion] = useState('http://localhost:8000/api/saludo'); const [direccion, setDireccion] = useState('http://localhost:8000/api/v1/ping/');
const [metodo, setMetodo] = useState('GET'); const [metodo, setMetodo] = useState('GET');
const [contenido, setContenido] = useState(''); const [contenido, setContenido] = useState('');
const [respuesta, setRespuesta] = useState(''); const [respuesta, setRespuesta] = useState('');
@@ -1,5 +1,5 @@
import { Select, Group } from '@mantine/core'; import { Select, Group } from '@mantine/core';
import { IconCheck } from '../assets/icons'; import { IconCheck } from '../../assets/icons';
interface MetodoSelectProps { interface MetodoSelectProps {
metodo: string; metodo: string;
@@ -0,0 +1,53 @@
import { AppShellWithMenu } from '../FitzStudio/Appshell/Appshell';
import { Card, Grid, Title, Loader } from '@mantine/core';
import { useEffect, useState } from 'react';
import ReactECharts from 'echarts-for-react';
type ChartOption = any;
function useChartOption(endpoint: string) {
const [option, setOption] = useState<ChartOption | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/v1/charts/${endpoint}`)
.then((res) => res.json())
.then((json) => setOption(json))
.catch(console.error)
.finally(() => setLoading(false));
}, [endpoint]);
return { option, loading };
}
export function VisualizacionesRandom() {
const charts = [
{ title: 'Gráfico de barras', endpoint: 'bar' },
{ title: 'Gráfico de líneas', endpoint: 'line' },
{ title: 'Gráfico de pastel', endpoint: 'pie' },
{ title: 'Scatter plot', endpoint: 'scatter' },
];
return (
<AppShellWithMenu>
<Grid>
{charts.map(({ title, endpoint }, idx) => {
const { option, loading } = useChartOption(endpoint);
return (
<Grid.Col span={{ base: 12, sm: 6, md: 6, lg: 3 }} key={idx}>
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Title order={4}>{title}</Title>
{loading || !option ? (
<Loader mt="md" />
) : (
<ReactECharts option={option} style={{ height: 250, marginTop: 16 }} />
)}
</Card>
</Grid.Col>
);
})}
</Grid>
</AppShellWithMenu>
);
}
@@ -1,9 +1,10 @@
import { Box, Title, Text, Button, Group, Stack, Image, Center } from '@mantine/core'; import { Box, Title, Text, Button, Group, Stack, Image, Center } from '@mantine/core';
import { useMantineTheme } from '@mantine/core'; import { useMantineTheme } from '@mantine/core';
import { IconArrowLeft } from '../assets/icons'; import { IconArrowLeft } from '../../../assets/icons';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { MantineCardWithShader } from '../components/HoloShader'; // Ajusta ruta si es necesario import { MantineCardWithShader } from './HoloShader_404'; // Ajusta ruta si es necesario
import { AppShellWithMenu } from '../components/Appshell/Appshell'; import { AppShellWithMenu } from '../Appshell/Appshell';
export function Error_404() { export function Error_404() {
@@ -16,9 +16,9 @@
var(--mantine-font-family); var(--mantine-font-family);
margin-bottom: var(--mantine-spacing-sm); margin-bottom: var(--mantine-spacing-sm);
background-color: var(--mantine-color-body); background-color: var(--mantine-color-body);
padding: var(--mantine-spacing-md); padding: var(--mantine-spacing-xs);
padding-top: 18px; padding-top: 15px;
height: px; height: 50px;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-7)); border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-7));
} }
@@ -97,7 +97,7 @@
&, &,
&:hover { &:hover {
background-color: var(--mantine-color-brand-7); background-color: var(--mantine-color-brand-7);
color: linear-gradient(90deg, var(--mantine-color-brand-7), var(--mantine-color-brand-4)); color: var(--mantine-color-brand-2);
} }
} }
} }
@@ -0,0 +1,191 @@
import {
AppShell,
Burger,
Group,
Tooltip,
UnstyledButton,
Title,
useMantineTheme,
} from '@mantine/core';
import { useDisclosure, useMediaQuery } from '@mantine/hooks';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { default as LogoIcon } from '../../../assets/icons/favicon';
import { mainLinksdata } from '../../../data/navigationsLinks_1';
import { submenuLinks } from '../../../data/submenuLinks_1';
import classes from './Appshell.module.css';
type AppShellWithMenuProps = {
children?: React.ReactNode;
};
// Persistencia en localStorage
const STORAGE_KEY = 'lastSubmenuRoutes';
function getLastSubmenuRoute(section: string): string | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return parsed[section] ?? null;
} catch {
return null;
}
}
function setLastSubmenuRoute(section: string, route: string) {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
parsed[section] = route;
localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed));
} catch {
// fallback silencioso
}
}
export function AppShellWithMenu({ children }: AppShellWithMenuProps) {
const theme = useMantineTheme();
const location = useLocation();
const navigate = useNavigate();
const isMobile = useMediaQuery('(max-width: 768px)');
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = useDisclosure(false);
const [desktopOpened, { toggle: toggleDesktop, open: openDesktop }] = useDisclosure(true);
const isCollapsed = useMemo(
() => (isMobile ? !mobileOpened : !desktopOpened),
[isMobile, mobileOpened, desktopOpened]
);
// Estado para el main link activo
const [activeMain, setActiveMain] = useState<string>('Home');
// Ref para saber si el usuario ha hecho clic manualmente en el main link
const userClickedMainRef = useRef(false);
useEffect(() => {
const currentPath = location.pathname.toLowerCase().replace(/\/$/, '');
let matchedMain: string | null = null;
let maxMatchLength = 0;
Object.entries(submenuLinks).forEach(([main, items]) => {
items.forEach((item) => {
const itemPath = item.to.toLowerCase().replace(/\/$/, '');
if (
currentPath === itemPath ||
currentPath.startsWith(itemPath + '/')
) {
if (itemPath.length > maxMatchLength) {
matchedMain = main;
maxMatchLength = itemPath.length;
}
}
});
});
if (matchedMain) {
setActiveMain(matchedMain);
}
}, [location.pathname]);
const activeLink =
submenuLinks[activeMain as keyof typeof submenuLinks]?.find(
(item) => item.to === location.pathname
)?.label ?? '';
const mainLinks = mainLinksdata.map((link) => (
<Tooltip
label={link.label}
position="right"
withArrow
transitionProps={{ duration: 0 }}
key={link.label}
>
<UnstyledButton
onClick={() => {
userClickedMainRef.current = true;
setActiveMain(link.label);
const remembered = getLastSubmenuRoute(link.label);
const fallback = submenuLinks[link.label as keyof typeof submenuLinks]?.[0]?.to;
if (isCollapsed && (remembered || fallback)) {
navigate(remembered ?? fallback);
}
}}
className={classes.mainLink}
data-active={link.label === activeMain || undefined}
>
<link.icon />
</UnstyledButton>
</Tooltip>
));
const links = (submenuLinks[activeMain as keyof typeof submenuLinks] || []).map((item) => (
<Link
className={classes.link}
data-active={activeLink === item.label || undefined}
to={item.to}
key={item.label}
style={{ display: isCollapsed ? 'none' : 'block' }}
onClick={() => {
setLastSubmenuRoute(activeMain, item.to);
if (isMobile) closeMobile();
}}
>
{item.label}
</Link>
));
useEffect(() => {
if (!isMobile) openDesktop();
}, [isMobile, openDesktop]);
return (
<AppShell
header={{ height: 60 }}
navbar={{
width: isCollapsed ? 60 : 300,
breakpoint: 'sm',
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
padding="md"
>
{/* Header */}
<AppShell.Header>
<Group h="100%" px="sm">
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
<LogoIcon
style={{ width: 30, height: 30 }}
circleFill={theme.colors.brand[9]}
pathFill={theme.colors.secondary[2]}
/>
</Group>
</AppShell.Header>
{/* Navbar */}
<AppShell.Navbar>
<div className={classes.wrapper}>
<div className={classes.aside}>
<div className={classes.topSection}>{mainLinks}</div>
</div>
<div className={classes.main}>
{!isCollapsed && (
<Title order={4} className={classes.title}>
{activeMain}
</Title>
)}
{links}
</div>
</div>
</AppShell.Navbar>
{/* Main Content */}
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
);
}
@@ -1,4 +1,4 @@
import { AppShellWithMenu } from '../components/Appshell/Appshell'; import { AppShellWithMenu } from './Appshell/Appshell';
export function Plantilla() { export function Plantilla() {
@@ -11,7 +11,7 @@ export function Welcome() {
<Title className={classes.title} ta="center" mt={100}> <Title className={classes.title} ta="center" mt={100}>
Hola! {' '} Hola! {' '}
<Text inherit variant="gradient" component="span" gradient={{ from: theme.colors.brand[7], to: theme.colors.secondary[4], }} style={{ letterSpacing: '1px' }}> <Text inherit variant="gradient" component="span" gradient={{ from: theme.colors.brand[7], to: theme.colors.secondary[4], }} style={{ letterSpacing: '1px' }}>
Egutierrez Holooooo
</Text> </Text>
</Title> </Title>
<Text c="dimmed" ta="left" size="lg" maw={580} mx="auto" mt="xl"> <Text c="dimmed" ta="left" size="lg" maw={580} mx="auto" mt="xl">
@@ -0,0 +1,22 @@
import { AppShellWithMenu } from '../FitzStudio/Appshell/Appshell';
import { Welcome } from '@/frontend_domains/FitzStudio/Welcome/Welcome';
import { ColorSchemeToggle } from '@/frontend_domains/FitzStudio/ColorSchemeToggle/ColorSchemeToggle';
export function HomePage() {
return (
<AppShellWithMenu>
<Welcome />
<div style={{ padding: '20px' }}>
<h1>Welcome to the Home Page</h1>
<p>This is the home page content.</p>
</div>
<ColorSchemeToggle></ColorSchemeToggle>
</AppShellWithMenu>
);
}

Some files were not shown because too many files have changed in this diff Show More