✅ Backend v1.2.7 / Frontend v1.3.0
Problema de blob URLs solucionado: Cambios implementados: Backend: Las imágenes del chat ahora se suben a S3 automáticamente Se genera una URL permanente para cada imagen La respuesta incluye url para cada archivo adjunto Frontend: Las imágenes en mensajes del asistente usan URLs de S3 (permanentes) Ya no depende de blob URLs que desaparecen al refrescar Las imágenes del usuario siguen usando blob (temporal solo para preview) Las imágenes del asistente se muestran con URLs persistentes Resultado: ✅ Las imágenes del chat ahora funcionan después de refrescar ✅ Las URLs son permanentes y accesibles ✅ No más errores 404 con blob: URLs ✅ Las imágenes quedan guardadas en S3 para historial
This commit is contained in:
@@ -278,7 +278,7 @@ def extract_pdf_text_smart(pdf_content: bytes, max_chars: int = None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
BACKEND_VERSION = "1.2.6"
|
BACKEND_VERSION = "1.2.7"
|
||||||
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
|
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
|
||||||
|
|
||||||
# S3/MinIO configuration
|
# S3/MinIO configuration
|
||||||
@@ -3484,10 +3484,30 @@ async def chat_with_ai_assistant(
|
|||||||
print(f"❌ Error procesando PDF {file.filename}: {pdf_result.get('error', 'Unknown')}")
|
print(f"❌ Error procesando PDF {file.filename}: {pdf_result.get('error', 'Unknown')}")
|
||||||
file_info['error'] = pdf_result.get('error', 'Error desconocido')
|
file_info['error'] = pdf_result.get('error', 'Error desconocido')
|
||||||
|
|
||||||
# Si es imagen, convertir a base64
|
# Si es imagen, convertir a base64 Y subir a S3
|
||||||
elif file_type.startswith('image/'):
|
elif file_type.startswith('image/'):
|
||||||
file_info['content_type'] = 'image'
|
file_info['content_type'] = 'image'
|
||||||
file_info['base64'] = base64.b64encode(file_content).decode('utf-8')
|
file_info['base64'] = base64.b64encode(file_content).decode('utf-8')
|
||||||
|
|
||||||
|
# Subir imagen a S3 para que tenga URL permanente
|
||||||
|
try:
|
||||||
|
file_extension = file.filename.split('.')[-1] if '.' in file.filename else 'jpg'
|
||||||
|
unique_filename = f"chat_{inspection_id}_{uuid.uuid4()}.{file_extension}"
|
||||||
|
|
||||||
|
s3_client.upload_fileobj(
|
||||||
|
BytesIO(file_content),
|
||||||
|
app_config.settings.MINIO_IMAGE_BUCKET,
|
||||||
|
unique_filename,
|
||||||
|
ExtraArgs={'ContentType': file_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generar URL
|
||||||
|
image_url = f"{app_config.settings.MINIO_ENDPOINT}/{app_config.settings.MINIO_IMAGE_BUCKET}/{unique_filename}"
|
||||||
|
file_info['url'] = image_url
|
||||||
|
print(f"🖼️ Imagen subida a S3: {unique_filename}")
|
||||||
|
except Exception as upload_error:
|
||||||
|
print(f"⚠️ Error subiendo imagen a S3: {upload_error}")
|
||||||
|
# Continuar sin URL, usar base64
|
||||||
print(f"🖼️ Imagen procesada: {file.filename}")
|
print(f"🖼️ Imagen procesada: {file.filename}")
|
||||||
|
|
||||||
attached_files_data.append(file_info)
|
attached_files_data.append(file_info)
|
||||||
@@ -3712,7 +3732,14 @@ Longitud de respuesta: {response_length}
|
|||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"provider": ai_config.provider,
|
"provider": ai_config.provider,
|
||||||
"model": ai_config.model_name,
|
"model": ai_config.model_name,
|
||||||
"attached_files": [{'filename': f['filename'], 'type': f['type']} for f in attached_files_data]
|
"attached_files": [
|
||||||
|
{
|
||||||
|
'filename': f['filename'],
|
||||||
|
'type': f['type'],
|
||||||
|
'url': f.get('url') # URL de S3 si es imagen
|
||||||
|
}
|
||||||
|
for f in attached_files_data
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "checklist-frontend",
|
"name": "checklist-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.8",
|
"version": "1.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Service Worker para PWA con detección de actualizaciones
|
// Service Worker para PWA con detección de actualizaciones
|
||||||
// IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión
|
// IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión
|
||||||
const CACHE_NAME = 'ayutec-v1.2.8';
|
const CACHE_NAME = 'ayutec-v1.3.0';
|
||||||
const urlsToCache = [
|
const urlsToCache = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html'
|
'/index.html'
|
||||||
|
|||||||
@@ -5656,12 +5656,31 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
|
|||||||
|
|
||||||
// NUEVO: Agregar respuestas de texto (incluyendo observations)
|
// NUEVO: Agregar respuestas de texto (incluyendo observations)
|
||||||
if (answer?.value || answer?.observations) {
|
if (answer?.value || answer?.observations) {
|
||||||
// Buscar la pregunta para obtener su texto
|
// Buscar la pregunta para obtener su texto y tipo
|
||||||
const questionData = inspection.checklist.questions.find(q => q.id === qId)
|
const questionData = inspection.checklist.questions.find(q => q.id === qId)
|
||||||
|
|
||||||
|
// Formatear respuesta según el tipo de pregunta
|
||||||
|
let formattedAnswer = answer.value || ''
|
||||||
|
if (questionData?.options?.type) {
|
||||||
|
const qType = questionData.options.type
|
||||||
|
|
||||||
|
if (qType === 'boolean') {
|
||||||
|
formattedAnswer = answer.value === 'true' ? 'Sí' : answer.value === 'false' ? 'No' : formattedAnswer
|
||||||
|
} else if (qType === 'single_choice' && questionData.options.choices) {
|
||||||
|
// Buscar la opción seleccionada
|
||||||
|
const selectedChoice = questionData.options.choices.find(c => c.value === answer.value)
|
||||||
|
formattedAnswer = selectedChoice?.label || answer.value
|
||||||
|
} else if (qType === 'scale') {
|
||||||
|
formattedAnswer = `${answer.value} (escala ${questionData.options.min || 1}-${questionData.options.max || 10})`
|
||||||
|
}
|
||||||
|
// Para text, number, date, time, multiple_choice, photo_only: usar valor directo
|
||||||
|
}
|
||||||
|
|
||||||
contextAnswers.push({
|
contextAnswers.push({
|
||||||
questionId: qId,
|
questionId: qId,
|
||||||
questionText: questionData?.text || `Pregunta ${qId}`,
|
questionText: questionData?.text || `Pregunta ${qId}`,
|
||||||
answer: answer.value || '',
|
questionType: questionData?.options?.type || 'text',
|
||||||
|
answer: formattedAnswer,
|
||||||
observations: answer.observations || ''
|
observations: answer.observations || ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -5694,11 +5713,17 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('📥 Respuesta de IA:', data)
|
console.log('📥 Respuesta de IA:', data)
|
||||||
|
|
||||||
|
// Crear mensaje del asistente con archivos adjuntos (usando URLs del servidor)
|
||||||
const assistantMessage = {
|
const assistantMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: data.response,
|
content: data.response,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
confidence: data.confidence
|
confidence: data.confidence,
|
||||||
|
attachedFiles: data.attached_files?.map(f => ({
|
||||||
|
filename: f.filename,
|
||||||
|
type: f.type,
|
||||||
|
url: f.url // URL de S3, no blob
|
||||||
|
})) || []
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages(prev => [...prev, assistantMessage])
|
setMessages(prev => [...prev, assistantMessage])
|
||||||
@@ -5827,6 +5852,34 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Mostrar archivos del asistente (URLs de S3) */}
|
||||||
|
{msg.attachedFiles && msg.attachedFiles.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{msg.attachedFiles.map((file, fIdx) => (
|
||||||
|
<div key={fIdx}>
|
||||||
|
{file.type.startsWith('image/') && file.url ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={file.filename}
|
||||||
|
className="rounded-lg max-w-full h-auto max-h-64 object-contain cursor-zoom-in hover:opacity-90 transition border border-gray-300"
|
||||||
|
onClick={() => setSelectedImage({ url: file.url, name: file.filename })}
|
||||||
|
/>
|
||||||
|
<div className="text-xs flex items-center gap-1 text-gray-500">
|
||||||
|
<span>🖼️</span>
|
||||||
|
<span className="truncate">{file.filename}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs flex items-center gap-1 text-gray-600">
|
||||||
|
<span>{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
|
||||||
|
<span className="truncate">{file.filename}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={`text-xs mt-2 ${
|
className={`text-xs mt-2 ${
|
||||||
msg.role === 'user' ? 'text-blue-100' : 'text-gray-400'
|
msg.role === 'user' ? 'text-blue-100' : 'text-gray-400'
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
|
|||||||
className="w-10 h-10 object-contain bg-white rounded p-1"
|
className="w-10 h-10 object-contain bg-white rounded p-1"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-indigo-300 font-medium hover:text-indigo-200">
|
<p className="text-xs text-indigo-300 font-medium hover:text-indigo-200">
|
||||||
Ayutec v1.2.8
|
Ayutec v1.3.0
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user