✅ 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user