✅ Mejoras Implementadas en Extracción de PDFs con IA
He mejorado significativamente el sistema de extracción de texto de PDFs para el análisis con IA. Aquí están los cambios principales:
🎯 Problemas Resueltos
Límites muy pequeños → Aumentados significativamente según capacidad del modelo
No extraía todo el documento → Ahora procesa hasta 100k caracteres
Duplicación de contenido → Detecta y elimina páginas repetidas
Sin información de estado → Reporta páginas procesadas, truncado, etc.
This commit is contained in:
@@ -209,7 +209,74 @@ def send_completed_inspection_to_n8n(inspection, db):
|
|||||||
# No lanzamos excepción para no interrumpir el flujo normal
|
# No lanzamos excepción para no interrumpir el flujo normal
|
||||||
|
|
||||||
|
|
||||||
BACKEND_VERSION = "1.0.94"
|
# ============================================================================
|
||||||
|
# UTILIDADES PARA PROCESAMIENTO DE PDFs
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def extract_pdf_text_smart(pdf_content: bytes, max_chars: int = None) -> dict:
|
||||||
|
"""
|
||||||
|
Extrae texto de un PDF de forma inteligente, evitando duplicaciones
|
||||||
|
y manejando PDFs grandes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf_content: Contenido del PDF en bytes
|
||||||
|
max_chars: Límite máximo de caracteres (None = sin límite)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con 'text', 'pages', 'total_chars', 'truncated'
|
||||||
|
"""
|
||||||
|
from pypdf import PdfReader
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_file = BytesIO(pdf_content)
|
||||||
|
pdf_reader = PdfReader(pdf_file)
|
||||||
|
|
||||||
|
full_text = ""
|
||||||
|
pages_processed = 0
|
||||||
|
total_pages = len(pdf_reader.pages)
|
||||||
|
|
||||||
|
for page_num, page in enumerate(pdf_reader.pages, 1):
|
||||||
|
page_text = page.extract_text()
|
||||||
|
|
||||||
|
# Limpiar y validar texto de la página
|
||||||
|
if page_text and page_text.strip():
|
||||||
|
# Evitar duplicación: verificar si el texto ya existe
|
||||||
|
# (algunos PDFs pueden tener páginas repetidas)
|
||||||
|
if page_text.strip() not in full_text:
|
||||||
|
full_text += f"\n--- Página {page_num}/{total_pages} ---\n{page_text.strip()}\n"
|
||||||
|
pages_processed += 1
|
||||||
|
|
||||||
|
# Si hay límite y lo alcanzamos, detener
|
||||||
|
if max_chars and len(full_text) >= max_chars:
|
||||||
|
break
|
||||||
|
|
||||||
|
total_chars = len(full_text)
|
||||||
|
truncated = False
|
||||||
|
|
||||||
|
# Aplicar límite si se especificó
|
||||||
|
if max_chars and total_chars > max_chars:
|
||||||
|
full_text = full_text[:max_chars]
|
||||||
|
truncated = True
|
||||||
|
|
||||||
|
return {
|
||||||
|
'text': full_text,
|
||||||
|
'pages': total_pages,
|
||||||
|
'pages_processed': pages_processed,
|
||||||
|
'total_chars': total_chars,
|
||||||
|
'truncated': truncated,
|
||||||
|
'success': True
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'text': '',
|
||||||
|
'error': str(e),
|
||||||
|
'success': False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_VERSION = "1.0.95"
|
||||||
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
|
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
|
||||||
|
|
||||||
# S3/MinIO configuration
|
# S3/MinIO configuration
|
||||||
@@ -2598,7 +2665,6 @@ async def analyze_image(
|
|||||||
|
|
||||||
# Guardar archivo temporalmente y procesar según tipo
|
# Guardar archivo temporalmente y procesar según tipo
|
||||||
import base64
|
import base64
|
||||||
from pypdf import PdfReader
|
|
||||||
|
|
||||||
contents = await file.read()
|
contents = await file.read()
|
||||||
file_type = file.content_type
|
file_type = file.content_type
|
||||||
@@ -2610,34 +2676,30 @@ async def analyze_image(
|
|||||||
|
|
||||||
if is_pdf:
|
if is_pdf:
|
||||||
print("📕 Detectado PDF - extrayendo texto...")
|
print("📕 Detectado PDF - extrayendo texto...")
|
||||||
try:
|
|
||||||
from io import BytesIO
|
|
||||||
pdf_file = BytesIO(contents)
|
|
||||||
pdf_reader = PdfReader(pdf_file)
|
|
||||||
|
|
||||||
# Extraer texto de todas las páginas
|
# Usar función inteligente de extracción
|
||||||
pdf_text = ""
|
# Para análisis de imagen usamos hasta 100k caracteres (Gemini soporta mucho más)
|
||||||
for page_num, page in enumerate(pdf_reader.pages):
|
pdf_result = extract_pdf_text_smart(contents, max_chars=100000)
|
||||||
pdf_text += f"\n--- Página {page_num + 1} ---\n"
|
|
||||||
pdf_text += page.extract_text()
|
|
||||||
|
|
||||||
print(f"✅ Texto extraído: {len(pdf_text)} caracteres")
|
if not pdf_result['success']:
|
||||||
|
|
||||||
if not pdf_text.strip():
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"message": "No se pudo extraer texto del PDF. Puede ser un PDF escaneado sin OCR."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Para PDFs usamos análisis de texto, no de imagen
|
|
||||||
image_b64 = None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Error al procesar PDF: {str(e)}")
|
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Error al procesar PDF: {str(e)}"
|
"message": f"Error al procesar PDF: {pdf_result.get('error', 'Unknown')}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pdf_text = pdf_result['text']
|
||||||
|
print(f"✅ Texto extraído: {pdf_result['total_chars']} caracteres de {pdf_result['pages_processed']}/{pdf_result['pages']} páginas")
|
||||||
|
if pdf_result['truncated']:
|
||||||
|
print(f"⚠️ PDF truncado a 100k caracteres")
|
||||||
|
|
||||||
|
if not pdf_text.strip():
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "No se pudo extraer texto del PDF. Puede ser un PDF escaneado sin OCR."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Para PDFs usamos análisis de texto, no de imagen
|
||||||
|
image_b64 = None
|
||||||
else:
|
else:
|
||||||
# Es una imagen
|
# Es una imagen
|
||||||
image_b64 = base64.b64encode(contents).decode('utf-8')
|
image_b64 = base64.b64encode(contents).decode('utf-8')
|
||||||
@@ -2819,7 +2881,7 @@ NOTA:
|
|||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": f"{user_message}\n\n--- CONTENIDO DEL DOCUMENTO PDF ---\n{pdf_text[:4000]}" # Limitar a 4000 chars
|
"content": f"{user_message}\n\n--- CONTENIDO DEL DOCUMENTO PDF ({len(pdf_text)} caracteres) ---\n{pdf_text[:30000]}" # 30k chars para GPT-4
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
@@ -2860,8 +2922,8 @@ NOTA:
|
|||||||
prompt = f"{system_prompt}\n\n{user_message}"
|
prompt = f"{system_prompt}\n\n{user_message}"
|
||||||
|
|
||||||
if is_pdf:
|
if is_pdf:
|
||||||
# Para PDF, solo texto
|
# Para PDF, solo texto - Gemini puede manejar contextos muy largos (2M tokens)
|
||||||
prompt_with_content = f"{prompt}\n\n--- CONTENIDO DEL DOCUMENTO PDF ---\n{pdf_text[:4000]}"
|
prompt_with_content = f"{prompt}\n\n--- CONTENIDO DEL DOCUMENTO PDF ({len(pdf_text)} caracteres) ---\n{pdf_text[:100000]}"
|
||||||
response = model.generate_content(prompt_with_content)
|
response = model.generate_content(prompt_with_content)
|
||||||
else:
|
else:
|
||||||
# Para imagen, incluir imagen
|
# Para imagen, incluir imagen
|
||||||
@@ -3036,8 +3098,6 @@ async def chat_with_ai_assistant(
|
|||||||
attached_files_data = []
|
attached_files_data = []
|
||||||
if files:
|
if files:
|
||||||
import base64
|
import base64
|
||||||
from pypdf import PdfReader
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
file_content = await file.read()
|
file_content = await file.read()
|
||||||
@@ -3051,18 +3111,22 @@ async def chat_with_ai_assistant(
|
|||||||
|
|
||||||
# Si es PDF, extraer texto
|
# Si es PDF, extraer texto
|
||||||
if file_type == 'application/pdf' or file.filename.lower().endswith('.pdf'):
|
if file_type == 'application/pdf' or file.filename.lower().endswith('.pdf'):
|
||||||
try:
|
# Usar función inteligente - límite de 50k para chat (balance entre contexto y tokens)
|
||||||
pdf_file = BytesIO(file_content)
|
pdf_result = extract_pdf_text_smart(file_content, max_chars=50000)
|
||||||
pdf_reader = PdfReader(pdf_file)
|
|
||||||
pdf_text = ""
|
if pdf_result['success']:
|
||||||
for page in pdf_reader.pages:
|
|
||||||
pdf_text += page.extract_text()
|
|
||||||
file_info['content_type'] = 'pdf'
|
file_info['content_type'] = 'pdf'
|
||||||
file_info['text'] = pdf_text[:2000] # Limitar texto
|
file_info['text'] = pdf_result['text']
|
||||||
print(f"📄 PDF procesado: {file.filename} - {len(pdf_text)} caracteres")
|
file_info['total_chars'] = pdf_result['total_chars']
|
||||||
except Exception as e:
|
file_info['pages'] = pdf_result['pages']
|
||||||
print(f"❌ Error procesando PDF {file.filename}: {str(e)}")
|
file_info['pages_processed'] = pdf_result['pages_processed']
|
||||||
file_info['error'] = str(e)
|
file_info['truncated'] = pdf_result['truncated']
|
||||||
|
|
||||||
|
truncated_msg = " (TRUNCADO)" if pdf_result['truncated'] else ""
|
||||||
|
print(f"📄 PDF procesado: {file.filename} - {pdf_result['total_chars']} caracteres, {pdf_result['pages_processed']}/{pdf_result['pages']} páginas{truncated_msg}")
|
||||||
|
else:
|
||||||
|
print(f"❌ Error procesando PDF {file.filename}: {pdf_result.get('error', 'Unknown')}")
|
||||||
|
file_info['error'] = pdf_result.get('error', 'Error desconocido')
|
||||||
|
|
||||||
# Si es imagen, convertir a base64
|
# Si es imagen, convertir a base64
|
||||||
elif file_type.startswith('image/'):
|
elif file_type.startswith('image/'):
|
||||||
@@ -3120,9 +3184,12 @@ INFORMACIÓN DEL VEHÍCULO:
|
|||||||
attached_context = f"\n\nARCHIVOS ADJUNTOS EN ESTE MENSAJE ({len(attached_files_data)} archivos):\n"
|
attached_context = f"\n\nARCHIVOS ADJUNTOS EN ESTE MENSAJE ({len(attached_files_data)} archivos):\n"
|
||||||
for idx, file_info in enumerate(attached_files_data, 1):
|
for idx, file_info in enumerate(attached_files_data, 1):
|
||||||
if file_info.get('content_type') == 'pdf':
|
if file_info.get('content_type') == 'pdf':
|
||||||
attached_context += f"\n{idx}. PDF: {file_info['filename']}\n"
|
truncated_indicator = " ⚠️TRUNCADO" if file_info.get('truncated') else ""
|
||||||
|
pages_info = f" ({file_info.get('pages_processed', '?')}/{file_info.get('pages', '?')} páginas, {file_info.get('total_chars', '?')} caracteres{truncated_indicator})" if 'pages' in file_info else ""
|
||||||
|
attached_context += f"\n{idx}. PDF: {file_info['filename']}{pages_info}\n"
|
||||||
if 'text' in file_info:
|
if 'text' in file_info:
|
||||||
attached_context += f" Contenido: {file_info['text'][:500]}...\n"
|
# Mostrar más contexto del PDF (primeros 2000 caracteres como preview)
|
||||||
|
attached_context += f" Contenido: {file_info['text'][:2000]}...\n"
|
||||||
elif file_info.get('content_type') == 'image':
|
elif file_info.get('content_type') == 'image':
|
||||||
attached_context += f"\n{idx}. Imagen: {file_info['filename']}\n"
|
attached_context += f"\n{idx}. Imagen: {file_info['filename']}\n"
|
||||||
|
|
||||||
|
|||||||
155
docs/pdf-extraction-improvements.md
Normal file
155
docs/pdf-extraction-improvements.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Mejoras en la Extracción de PDFs con IA
|
||||||
|
|
||||||
|
## Versión Backend: 1.0.95
|
||||||
|
|
||||||
|
## Problema Original
|
||||||
|
|
||||||
|
El sistema tenía limitaciones al procesar PDFs con IA:
|
||||||
|
|
||||||
|
1. **Límites muy pequeños**: Solo extraía 2,000-4,000 caracteres
|
||||||
|
2. **Sin manejo de duplicaciones**: Páginas repetidas se procesaban múltiples veces
|
||||||
|
3. **No aprovechaba contextos largos**: Los modelos modernos soportan millones de tokens
|
||||||
|
4. **Falta de información**: No reportaba páginas procesadas o si el contenido fue truncado
|
||||||
|
|
||||||
|
## Solución Implementada
|
||||||
|
|
||||||
|
### 1. Función Centralizada de Extracción
|
||||||
|
|
||||||
|
Nueva función `extract_pdf_text_smart()` que:
|
||||||
|
- ✅ Extrae texto de forma inteligente
|
||||||
|
- ✅ Detecta y evita páginas duplicadas
|
||||||
|
- ✅ Maneja límites configurables
|
||||||
|
- ✅ Reporta estadísticas completas (páginas, caracteres, truncado)
|
||||||
|
- ✅ Manejo robusto de errores
|
||||||
|
|
||||||
|
```python
|
||||||
|
pdf_result = extract_pdf_text_smart(pdf_content, max_chars=50000)
|
||||||
|
# Retorna:
|
||||||
|
# {
|
||||||
|
# 'text': '...',
|
||||||
|
# 'pages': 10,
|
||||||
|
# 'pages_processed': 9, # Si una página estaba duplicada
|
||||||
|
# 'total_chars': 45000,
|
||||||
|
# 'truncated': False,
|
||||||
|
# 'success': True
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Límites Optimizados por Caso de Uso
|
||||||
|
|
||||||
|
| Endpoint | Límite Anterior | Límite Nuevo | Modelo Objetivo |
|
||||||
|
|----------|----------------|--------------|-----------------|
|
||||||
|
| `/api/analyze-image` (OpenAI) | 4,000 chars | 30,000 chars | GPT-4 (128k tokens) |
|
||||||
|
| `/api/analyze-image` (Gemini) | 4,000 chars | 100,000 chars | Gemini 1.5/2.0 (2M tokens) |
|
||||||
|
| `/api/ai/chat-assistant` | 2,000 chars | 50,000 chars | Equilibrado para contexto |
|
||||||
|
|
||||||
|
### 3. Detección de Duplicaciones
|
||||||
|
|
||||||
|
El sistema ahora verifica si el contenido de una página ya existe antes de agregarlo:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if page_text.strip() not in full_text:
|
||||||
|
full_text += f"\n--- Página {page_num}/{total_pages} ---\n{page_text.strip()}\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto previene:
|
||||||
|
- PDFs con páginas idénticas repetidas
|
||||||
|
- Documentos mal generados con contenido duplicado
|
||||||
|
- Uso innecesario de tokens en el análisis IA
|
||||||
|
|
||||||
|
### 4. Información Mejorada
|
||||||
|
|
||||||
|
El sistema ahora reporta:
|
||||||
|
- **Páginas totales**: Total de páginas en el PDF
|
||||||
|
- **Páginas procesadas**: Páginas únicas con contenido
|
||||||
|
- **Caracteres totales**: Tamaño real del texto extraído
|
||||||
|
- **Indicador de truncado**: Si el PDF fue limitado
|
||||||
|
|
||||||
|
Ejemplo de output:
|
||||||
|
```
|
||||||
|
📄 PDF procesado: manual-vehiculo.pdf - 87450 caracteres, 8/10 páginas (TRUNCADO)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Capacidades de Contexto por Modelo
|
||||||
|
|
||||||
|
### OpenAI GPT-4
|
||||||
|
- **Contexto**: ~128,000 tokens (~500,000 caracteres)
|
||||||
|
- **Límite aplicado**: 30,000 caracteres
|
||||||
|
- **Razón**: Balance entre contexto útil y costo
|
||||||
|
|
||||||
|
### Gemini 1.5/2.0 Pro
|
||||||
|
- **Contexto**: 2,000,000 tokens (~8,000,000 caracteres)
|
||||||
|
- **Límite aplicado**: 100,000 caracteres
|
||||||
|
- **Razón**: Aprovechar contexto masivo sin sobrecargar
|
||||||
|
|
||||||
|
### Chat Assistant
|
||||||
|
- **Límite**: 50,000 caracteres
|
||||||
|
- **Razón**: Incluye historial + contexto de fotos + PDF
|
||||||
|
|
||||||
|
## Casos de Uso Soportados
|
||||||
|
|
||||||
|
### ✅ PDFs Pequeños (1-5 páginas)
|
||||||
|
Extracción completa sin truncado
|
||||||
|
|
||||||
|
### ✅ PDFs Medianos (5-20 páginas)
|
||||||
|
Extracción completa o parcial según contenido
|
||||||
|
|
||||||
|
### ✅ PDFs Grandes (20+ páginas)
|
||||||
|
Extracción inteligente con truncado después de límite
|
||||||
|
|
||||||
|
### ✅ PDFs con Páginas Duplicadas
|
||||||
|
Detección automática y eliminación
|
||||||
|
|
||||||
|
### ✅ Múltiples PDFs en Chat
|
||||||
|
Cada uno procesado independientemente con su límite
|
||||||
|
|
||||||
|
## Indicadores de Estado
|
||||||
|
|
||||||
|
### En Logs del Servidor
|
||||||
|
```
|
||||||
|
📄 PDF procesado: documento.pdf - 25000 caracteres, 10/10 páginas
|
||||||
|
📄 PDF procesado: manual.pdf - 50000 caracteres, 15/20 páginas (TRUNCADO)
|
||||||
|
```
|
||||||
|
|
||||||
|
### En Respuesta al Cliente
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attached_files": [
|
||||||
|
{
|
||||||
|
"filename": "manual.pdf",
|
||||||
|
"type": "application/pdf",
|
||||||
|
"pages": 20,
|
||||||
|
"pages_processed": 15,
|
||||||
|
"total_chars": 75000,
|
||||||
|
"truncated": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Próximas Mejoras Potenciales
|
||||||
|
|
||||||
|
1. **Chunking Inteligente**: Para PDFs muy grandes, dividir en chunks semánticos
|
||||||
|
2. **OCR Integrado**: Detectar PDFs escaneados y aplicar OCR automático
|
||||||
|
3. **Resumen Automático**: Para PDFs grandes, generar resumen antes de análisis
|
||||||
|
4. **Cache de Extracciones**: Guardar texto extraído en DB para reutilización
|
||||||
|
|
||||||
|
## Migración
|
||||||
|
|
||||||
|
No requiere migración de base de datos. Los cambios son retrocompatibles.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Para probar las mejoras:
|
||||||
|
|
||||||
|
1. **PDF pequeño** (< 10 páginas): Debe procesarse completo
|
||||||
|
2. **PDF grande** (> 50 páginas): Debe truncarse y reportar info
|
||||||
|
3. **PDF con duplicados**: Debe eliminar páginas repetidas
|
||||||
|
4. **Múltiples PDFs**: Cada uno procesado independientemente
|
||||||
|
|
||||||
|
## Notas Técnicas
|
||||||
|
|
||||||
|
- La función `extract_pdf_text_smart()` está en `main.py` línea ~210
|
||||||
|
- Usa `pypdf.PdfReader` para extracción
|
||||||
|
- Maneja encoding UTF-8 automáticamente
|
||||||
|
- Thread-safe (usa BytesIO)
|
||||||
BIN
frontend/public/ayutec_logo.webp
Normal file
BIN
frontend/public/ayutec_logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -20,9 +20,9 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
|
|||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src="/logo192.png"
|
src="/ayutec_logo.png"
|
||||||
alt="Ayutec"
|
alt="Ayutec"
|
||||||
className="w-8 h-8 object-contain"
|
className="w-8 h-8 object-contain bg-white rounded-lg p-1"
|
||||||
/>
|
/>
|
||||||
<h2 className="text-xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Ayutec</h2>
|
<h2 className="text-xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Ayutec</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,16 +141,21 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
|
|||||||
{/* Versión */}
|
{/* Versión */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="mb-3 px-2 py-1.5 bg-indigo-900/30 rounded-lg border border-indigo-700/30">
|
<div className="mb-3 px-2 py-1.5 bg-indigo-900/30 rounded-lg border border-indigo-700/30">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<a
|
||||||
|
href="https://ayutec.es"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src="/logo192.png"
|
src="/ayutec_logo.webp"
|
||||||
alt="Ayutec"
|
alt="Ayutec"
|
||||||
className="w-4 h-4 object-contain"
|
className="w-10 h-10 object-contain bg-white rounded p-1"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-indigo-300 font-medium">
|
<p className="text-xs text-indigo-300 font-medium hover:text-indigo-200">
|
||||||
Ayutec v1.1.0
|
Ayutec v1.1.0
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user