Chat AI Assistant con Archivos Adjuntos Implementado

🎯 Nueva Funcionalidad Completa
Se ha implementado un sistema de chat conversacional con IA que permite adjuntar archivos (imágenes y PDFs), similar a ChatGPT, con prompt personalizable y envío completo al webhook.

📋 Características Implementadas
1. Adjuntar Archivos en el Chat
 Botón 📎 para adjuntar archivos
 Soporte para imágenes (JPG, PNG, etc.) y PDFs
 Preview de archivos adjuntos antes de enviar
 Eliminación individual de archivos adjuntos
 Múltiples archivos por mensaje
 Validación de tipos de archivo
2. Procesamiento Backend de Archivos
 Endpoint modificado para recibir FormData con archivos
 PDFs: Extracción automática de texto con pypdf
 Imágenes: Conversión a base64 para Vision AI
 Análisis combinado de texto + imágenes
 Límite de 2000 caracteres por PDF para optimizar
3. Integración con IA
 OpenAI Vision: Soporte multimodal (texto + imágenes)
 Gemini: Soporte de imágenes y texto
 Contexto enriquecido con archivos adjuntos
 Prompts adaptados según tipo de archivo
4. Custom Prompt por Checklist
 Campo assistant_prompt configurable por pregunta
 Campo assistant_instructions para instrucciones adicionales
 Control de longitud de respuesta (short/medium/long)
 Contexto automático del vehículo en cada mensaje
5. Persistencia del Chat
 Nuevo campo chat_history en modelo Answer
 Migración SQL: add_chat_history_to_answers.sql
 Guardado automático del historial completo
 Restauración del chat al reabrir
6. Envío al Webhook (n8n)
 Todos los chats incluidos en send_completed_inspection_to_n8n()
 Campo chat_history en cada respuesta del webhook
 Incluye metadata de archivos adjuntos
 Tipo de pregunta identificado en webhook
 Datos completos para análisis posterior
This commit is contained in:
2025-12-02 11:22:21 -03:00
parent bf30b1a2bf
commit c374909fa8
8 changed files with 240 additions and 57 deletions

View File

@@ -4270,7 +4270,8 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
status: status,
comment: answer.observations || null,
ai_analysis: answer.aiAnalysis || null,
is_flagged: status === 'critical'
is_flagged: status === 'critical',
chat_history: answer.chatHistory || null // Agregar historial de chat
}
const response = await fetch(`${API_URL}/api/answers`, {
@@ -5265,7 +5266,9 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
// Componente Modal de Chat IA Asistente
function AIAssistantChatModal({ question, inspection, allAnswers, messages, setMessages, loading, setLoading, onClose }) {
const [inputMessage, setInputMessage] = useState('')
const [attachedFiles, setAttachedFiles] = useState([])
const chatEndRef = useRef(null)
const fileInputRef = useRef(null)
const config = question.options || {}
// Auto-scroll al final
@@ -5273,19 +5276,66 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Manejar adjuntos de archivos
const handleFileAttach = (e) => {
const files = Array.from(e.target.files)
const validFiles = files.filter(file => {
const isImage = file.type.startsWith('image/')
const isPDF = file.type === 'application/pdf'
const isValid = isImage || isPDF
if (!isValid) {
alert(`⚠️ ${file.name}: Solo se permiten imágenes y PDFs`)
}
return isValid
})
setAttachedFiles(prev => [...prev, ...validFiles])
}
const removeAttachedFile = (index) => {
setAttachedFiles(prev => prev.filter((_, i) => i !== index))
}
// Enviar mensaje al asistente
const sendMessage = async () => {
if (!inputMessage.trim() || loading) return
if ((!inputMessage.trim() && attachedFiles.length === 0) || loading) return
const userMessage = { role: 'user', content: inputMessage, timestamp: new Date().toISOString() }
const userMessage = {
role: 'user',
content: inputMessage || '📎 Archivos adjuntos',
timestamp: new Date().toISOString(),
files: attachedFiles.map(f => ({ name: f.name, type: f.type, size: f.size }))
}
setMessages(prev => [...prev, userMessage])
const currentFiles = attachedFiles
setInputMessage('')
setAttachedFiles([])
setLoading(true)
try {
const token = localStorage.getItem('token')
const API_URL = import.meta.env.VITE_API_URL || ''
// Preparar FormData para enviar archivos
const formData = new FormData()
formData.append('question_id', question.id)
formData.append('inspection_id', inspection.id)
formData.append('user_message', inputMessage)
formData.append('chat_history', JSON.stringify(messages))
formData.append('assistant_prompt', config.assistant_prompt || '')
formData.append('assistant_instructions', config.assistant_instructions || '')
formData.append('response_length', config.response_length || 'medium')
// Adjuntar archivos
currentFiles.forEach((file, index) => {
formData.append('files', file)
})
// Adjuntar archivos
currentFiles.forEach((file, index) => {
formData.append('files', file)
})
// Recopilar fotos de preguntas anteriores según configuración
const contextPhotos = []
const contextQuestionIds = config.context_questions
@@ -5308,33 +5358,23 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
}
})
// Preparar el payload
const payload = {
question_id: question.id,
inspection_id: inspection.id,
user_message: inputMessage,
chat_history: messages,
context_photos: contextPhotos,
assistant_prompt: config.assistant_prompt || '',
assistant_instructions: config.assistant_instructions || '',
response_length: config.response_length || 'medium',
vehicle_info: {
brand: inspection.vehicle_brand,
model: inspection.vehicle_model,
plate: inspection.vehicle_plate,
km: inspection.vehicle_km
}
}
formData.append('context_photos', JSON.stringify(contextPhotos))
formData.append('vehicle_info', JSON.stringify({
brand: inspection.vehicle_brand,
model: inspection.vehicle_model,
plate: inspection.vehicle_plate,
km: inspection.vehicle_km
}))
console.log('📤 Enviando a chat IA:', payload)
console.log('📤 Enviando a chat IA con archivos:', currentFiles.length)
const response = await fetch(`${API_URL}/api/ai/chat-assistant`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
'Authorization': `Bearer ${token}`
// No incluir Content-Type, fetch lo establece automáticamente con FormData
},
body: JSON.stringify(payload)
body: formData
})
if (!response.ok) {
@@ -5430,6 +5470,18 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
<div className="text-sm sm:text-base whitespace-pre-wrap break-words">
{msg.content}
</div>
{/* Mostrar archivos adjuntos si existen */}
{msg.files && msg.files.length > 0 && (
<div className="mt-2 space-y-1">
{msg.files.map((file, fIdx) => (
<div key={fIdx} className={`text-xs flex items-center gap-1 ${msg.role === 'user' ? 'text-blue-100' : 'text-gray-600'}`}>
<span>{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
<span className="truncate">{file.name}</span>
<span>({(file.size / 1024).toFixed(1)} KB)</span>
</div>
))}
</div>
)}
<div
className={`text-xs mt-2 ${
msg.role === 'user' ? 'text-blue-100' : 'text-gray-400'
@@ -5463,7 +5515,43 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
{/* Input */}
<div className="border-t p-3 sm:p-4 bg-white rounded-b-xl">
{/* Preview de archivos adjuntos */}
{attachedFiles.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{attachedFiles.map((file, idx) => (
<div key={idx} className="flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-lg text-sm">
<span>{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
<span className="max-w-[150px] truncate">{file.name}</span>
<button
onClick={() => removeAttachedFile(idx)}
className="text-red-600 hover:text-red-800 font-bold"
type="button"
>
</button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileAttach}
accept="image/*,application/pdf"
multiple
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={loading}
className="px-3 py-2 sm:py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
title="Adjuntar archivos (imágenes o PDFs)"
type="button"
>
📎
</button>
<input
type="text"
value={inputMessage}
@@ -5475,7 +5563,7 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
/>
<button
onClick={sendMessage}
disabled={!inputMessage.trim() || loading}
disabled={(!inputMessage.trim() && attachedFiles.length === 0) || loading}
className="px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-lg hover:from-purple-700 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition text-sm sm:text-base font-semibold"
>
Enviar