✅ Nuevo Tipo de Pregunta: Asistente IA (Chat) 🤖💬
Frontend (v1.0.88) QuestionTypeEditor.jsx ✅ Nuevo tipo: ai_assistant con icono 💬 ✅ Configuración completa: assistant_prompt: Define rol y comportamiento del asistente context_questions: IDs de preguntas anteriores cuyas fotos usar (o todas) assistant_instructions: Reglas específicas de diagnóstico max_messages: Límite de mensajes en el chat response_length: Corta/Media/Larga QuestionAnswerInput.jsx ✅ Mensaje informativo para tipo ai_assistant ✅ Indica que el chat se abre con botón separado App.jsx - Modal de Chat IA ✅ Modal full-screen responsive con: Header con gradiente purple/blue Área de mensajes con scroll Input de texto + botón enviar Soporte Enter para enviar Indicador de "pensando..." Timestamps en mensajes Confianza de la IA Límite de mensajes ✅ Botón "💬 Consultar Asistente IA" al lado de "Cargar Documentos" ✅ Contador de mensajes en el botón si ya hay historial ✅ Historial guardado en answers[questionId].chatHistory ✅ Auto-marca como completada cuando se abre el chat Backend (v1.0.85) Endpoint /api/ai/chat-assistant ✅ Recibe: Mensaje del usuario Historial del chat Fotos de preguntas anteriores con sus análisis Configuración del asistente Info del vehículo ✅ Contexto inteligente: Información del vehículo (marca, modelo, km, placa) Análisis previos de fotos (hasta 10 fotos más relevantes) Instrucciones personalizadas por pregunta Historial conversacional (últimos 10 mensajes) ✅ Soporte multi-proveedor: OpenAI (GPT-4, GPT-3.5) Google Gemini ✅ Respuestas adaptables: Longitud configurable (200/400/800 tokens) Temperature 0.7 para balance creatividad/precisión Cómo Funciona Configuración (Admin): - Crear pregunta tipo "Asistente IA (Chat)" - Definir prompt: "Eres experto en sistemas de frenos..." - Seleccionar preguntas anteriores: "5,8,12" (fotos de frenos) - Instrucciones: "Si pastillas < 3mm → cambio inmediato" - Longitud: Media - Max mensajes: 20 2 Durante Inspección (Mecánico): - Llega a pregunta tipo AI Assistant - Ve botón "💬 Consultar Asistente IA" - Clic → Abre chat full-screen - La IA YA vio las fotos de preguntas 5, 8, 12 - Mecánico pregunta: "¿Las pastillas están bien?" - IA responde basado en fotos previas + contexto vehículo - Conversación natural con historial - Al cerrar → se guarda automáticamente 3 Ejemplo de Conversación: Mecánico: "Vi desgaste en las pastillas, ¿qué recomiendas?" IA: "Basándome en las fotos que analicé: Pastilla delantera izquierda: ~2.5mm de espesor Pastilla delantera derecha: ~2.8mm ⚠️ CRÍTICO: Ambas pastillas están por debajo del mínimo de 3mm. Con 45,000 km, es normal este desgaste. RECOMENDACIÓN: - Cambio INMEDIATO de pastillas delanteras - Verificar estado de discos (posible rectificado) - Revisar líquido de frenos (última foto muestra nivel bajo) ¿Necesitas el código de pieza para este Toyota Corolla 2019?" Casos de Uso Diagnóstico de Frenos assistant_prompt: "Eres especialista en sistemas de frenos. Analiza desgaste, fugas, vibraciones." context_questions: "10,11,12,13" // Fotos de pastillas, discos, líquido assistant_prompt: "Experto en motores. Detecta fugas, ruidos anormales, consumo excesivo." context_questions: "5,6,7,8,9" // Motor, aceite, correa, filtros assistant_prompt: "Especialista en sistemas eléctricos y electrónicos." context_questions: "20,21,22" // Batería, luces, tablero instructions: "Siempre pedir código OBD2 si hay check engine" Ventajas ✅ Contextual: La IA ve fotos previas, no pregunta "¿puedes mostrarme?" ✅ Especializado: Un asistente POR tema (frenos, motor, eléctrico) ✅ Conversacional: El mecánico puede hacer follow-up questions ✅ Guiado: Instrucciones específicas por tipo de inspección ✅ Historial: No repite info, mantiene contexto de la conversación ✅ Móvil-friendly: Modal responsive, fácil de usar en celular
This commit is contained in:
@@ -204,7 +204,7 @@ 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.84"
|
BACKEND_VERSION = "1.0.85"
|
||||||
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
|
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
|
||||||
|
|
||||||
# S3/MinIO configuration
|
# S3/MinIO configuration
|
||||||
@@ -2849,6 +2849,175 @@ Responde en formato JSON:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/ai/chat-assistant")
|
||||||
|
async def chat_with_ai_assistant(
|
||||||
|
request: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Chat conversacional con IA usando contexto de fotos anteriores
|
||||||
|
El asistente tiene acceso a fotos de preguntas previas para dar mejor contexto
|
||||||
|
"""
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("🤖 AI CHAT ASSISTANT")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
question_id = request.get('question_id')
|
||||||
|
inspection_id = request.get('inspection_id')
|
||||||
|
user_message = request.get('user_message')
|
||||||
|
chat_history = request.get('chat_history', [])
|
||||||
|
context_photos = request.get('context_photos', [])
|
||||||
|
assistant_prompt = request.get('assistant_prompt', '')
|
||||||
|
assistant_instructions = request.get('assistant_instructions', '')
|
||||||
|
response_length = request.get('response_length', 'medium')
|
||||||
|
vehicle_info = request.get('vehicle_info', {})
|
||||||
|
|
||||||
|
print(f"📋 Question ID: {question_id}")
|
||||||
|
print(f"🚗 Inspection ID: {inspection_id}")
|
||||||
|
print(f"💬 User message: {user_message}")
|
||||||
|
print(f"📸 Context photos: {len(context_photos)} fotos")
|
||||||
|
print(f"💭 Chat history: {len(chat_history)} mensajes previos")
|
||||||
|
|
||||||
|
# Obtener configuración de IA
|
||||||
|
ai_config = db.query(models.AIConfiguration).filter(
|
||||||
|
models.AIConfiguration.is_active == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not ai_config:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"response": "No hay configuración de IA activa. Por favor configura en Settings.",
|
||||||
|
"confidence": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Construir el contexto del vehículo
|
||||||
|
vehicle_context = f"""
|
||||||
|
INFORMACIÓN DEL VEHÍCULO:
|
||||||
|
- Marca: {vehicle_info.get('brand', 'N/A')}
|
||||||
|
- Modelo: {vehicle_info.get('model', 'N/A')}
|
||||||
|
- Placa: {vehicle_info.get('plate', 'N/A')}
|
||||||
|
- Kilometraje: {vehicle_info.get('km', 'N/A')} km
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Construir el contexto de las fotos anteriores
|
||||||
|
photos_context = ""
|
||||||
|
if context_photos:
|
||||||
|
photos_context = f"\n\nFOTOS ANALIZADAS PREVIAMENTE ({len(context_photos)} imágenes):\n"
|
||||||
|
for idx, photo in enumerate(context_photos[:10], 1): # Limitar a 10 fotos
|
||||||
|
ai_analysis = photo.get('aiAnalysis', [])
|
||||||
|
if ai_analysis and len(ai_analysis) > 0:
|
||||||
|
analysis_text = ai_analysis[0].get('analysis', {})
|
||||||
|
obs = analysis_text.get('observations', 'Sin análisis')
|
||||||
|
status = analysis_text.get('status', 'unknown')
|
||||||
|
photos_context += f"\n{idx}. Pregunta ID {photo.get('questionId')}: Status={status}\n Observaciones: {obs[:200]}...\n"
|
||||||
|
|
||||||
|
# Definir la longitud de respuesta
|
||||||
|
max_tokens_map = {
|
||||||
|
'short': 200,
|
||||||
|
'medium': 400,
|
||||||
|
'long': 800
|
||||||
|
}
|
||||||
|
max_tokens = max_tokens_map.get(response_length, 400)
|
||||||
|
|
||||||
|
# Construir el system prompt
|
||||||
|
base_prompt = assistant_prompt or "Eres un experto mecánico automotriz que ayuda a diagnosticar problemas."
|
||||||
|
|
||||||
|
system_prompt = f"""{base_prompt}
|
||||||
|
|
||||||
|
{vehicle_context}
|
||||||
|
|
||||||
|
{photos_context}
|
||||||
|
|
||||||
|
INSTRUCCIONES ADICIONALES:
|
||||||
|
{assistant_instructions if assistant_instructions else "Sé técnico, claro y directo en tus respuestas."}
|
||||||
|
|
||||||
|
FORMATO DE RESPUESTA:
|
||||||
|
- Sé {response_length} en tus respuestas
|
||||||
|
- Usa lenguaje técnico pero comprensible
|
||||||
|
- Si ves algo preocupante en las fotos analizadas, menciónalo
|
||||||
|
- Proporciona recomendaciones específicas cuando sea relevante
|
||||||
|
- Si no tienes suficiente información, pide más detalles
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Construir el historial de mensajes para la IA
|
||||||
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
|
|
||||||
|
# Agregar historial previo (últimos 10 mensajes para no saturar)
|
||||||
|
for msg in chat_history[-10:]:
|
||||||
|
messages.append({
|
||||||
|
"role": msg.get('role'),
|
||||||
|
"content": msg.get('content')
|
||||||
|
})
|
||||||
|
|
||||||
|
# Agregar el mensaje actual del usuario
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": user_message
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"🔧 Enviando a {ai_config.provider} con {len(messages)} mensajes")
|
||||||
|
|
||||||
|
# Llamar a la IA según el proveedor
|
||||||
|
if ai_config.provider == 'openai':
|
||||||
|
from openai import OpenAI
|
||||||
|
client = OpenAI(api_key=ai_config.api_key)
|
||||||
|
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model=ai_config.model or "gpt-4",
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_response = response.choices[0].message.content
|
||||||
|
confidence = 0.85 # OpenAI no devuelve confidence directo
|
||||||
|
|
||||||
|
elif ai_config.provider == 'gemini':
|
||||||
|
import google.generativeai as genai
|
||||||
|
genai.configure(api_key=ai_config.api_key)
|
||||||
|
|
||||||
|
model = genai.GenerativeModel(ai_config.model or 'gemini-pro')
|
||||||
|
|
||||||
|
# Gemini maneja el chat diferente
|
||||||
|
# Convertir mensajes al formato de Gemini
|
||||||
|
chat_content = ""
|
||||||
|
for msg in messages[1:]: # Skip system message
|
||||||
|
role_label = "Usuario" if msg['role'] == 'user' else "Asistente"
|
||||||
|
chat_content += f"\n{role_label}: {msg['content']}\n"
|
||||||
|
|
||||||
|
full_prompt = f"{system_prompt}\n\nCONVERSACIÓN:\n{chat_content}\n\nAsistente:"
|
||||||
|
|
||||||
|
response = model.generate_content(full_prompt)
|
||||||
|
ai_response = response.text
|
||||||
|
confidence = 0.80
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Proveedor no soportado: {ai_config.provider}")
|
||||||
|
|
||||||
|
print(f"✅ Respuesta generada: {len(ai_response)} caracteres")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"response": ai_response,
|
||||||
|
"confidence": confidence,
|
||||||
|
"provider": ai_config.provider,
|
||||||
|
"model": ai_config.model
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error en chat IA: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"response": f"Error al comunicarse con el asistente: {str(e)}",
|
||||||
|
"confidence": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============= REPORTS =============
|
# ============= REPORTS =============
|
||||||
@app.get("/api/reports/dashboard", response_model=schemas.DashboardData)
|
@app.get("/api/reports/dashboard", response_model=schemas.DashboardData)
|
||||||
def get_dashboard_data(
|
def get_dashboard_data(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "checklist-frontend",
|
"name": "checklist-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.87",
|
"version": "1.0.88",
|
||||||
"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.0.87';
|
const CACHE_NAME = 'ayutec-v1.0.88';
|
||||||
const urlsToCache = [
|
const urlsToCache = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html'
|
'/index.html'
|
||||||
|
|||||||
@@ -4054,6 +4054,11 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
|
|||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||||||
const [aiAnalyzing, setAiAnalyzing] = useState(false)
|
const [aiAnalyzing, setAiAnalyzing] = useState(false)
|
||||||
|
|
||||||
|
// AI Assistant Chat
|
||||||
|
const [showAIChat, setShowAIChat] = useState(false)
|
||||||
|
const [aiChatMessages, setAiChatMessages] = useState([])
|
||||||
|
const [aiChatLoading, setAiChatLoading] = useState(false)
|
||||||
|
|
||||||
// Signature canvas
|
// Signature canvas
|
||||||
const mechanicSigRef = useRef(null)
|
const mechanicSigRef = useRef(null)
|
||||||
|
|
||||||
@@ -5031,6 +5036,29 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Botón para abrir chat IA (si es tipo ai_assistant) */}
|
||||||
|
{currentQuestion.options?.type === 'ai_assistant' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAIChat(true)
|
||||||
|
// Cargar historial si existe
|
||||||
|
if (answers[currentQuestion.id]?.chatHistory) {
|
||||||
|
setAiChatMessages(answers[currentQuestion.id].chatHistory)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full mt-3 px-4 py-3 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-lg hover:from-purple-700 hover:to-blue-700 transition flex items-center justify-center gap-2 font-semibold shadow-lg"
|
||||||
|
>
|
||||||
|
<span>💬</span>
|
||||||
|
<span>Consultar Asistente IA</span>
|
||||||
|
{answers[currentQuestion.id]?.chatHistory?.length > 0 && (
|
||||||
|
<span className="ml-1 px-2 py-0.5 bg-white/20 rounded-full text-xs">
|
||||||
|
{answers[currentQuestion.id].chatHistory.length} mensajes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -5171,6 +5199,262 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de Chat IA Asistente */}
|
||||||
|
{showAIChat && currentQuestion && (
|
||||||
|
<AIAssistantChatModal
|
||||||
|
question={currentQuestion}
|
||||||
|
inspection={{ id: inspectionId, ...vehicleData }}
|
||||||
|
allAnswers={answers}
|
||||||
|
messages={aiChatMessages}
|
||||||
|
setMessages={setAiChatMessages}
|
||||||
|
loading={aiChatLoading}
|
||||||
|
setLoading={setAiChatLoading}
|
||||||
|
onClose={() => {
|
||||||
|
setShowAIChat(false)
|
||||||
|
// Guardar historial del chat en la respuesta
|
||||||
|
setAnswers(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentQuestion.id]: {
|
||||||
|
...(prev[currentQuestion.id] || {}),
|
||||||
|
chatHistory: aiChatMessages,
|
||||||
|
value: 'chat_completed' // Marcar como respondida
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
saveAnswer(currentQuestion.id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente Modal de Chat IA Asistente
|
||||||
|
function AIAssistantChatModal({ question, inspection, allAnswers, messages, setMessages, loading, setLoading, onClose }) {
|
||||||
|
const [inputMessage, setInputMessage] = useState('')
|
||||||
|
const chatEndRef = useRef(null)
|
||||||
|
const config = question.options || {}
|
||||||
|
|
||||||
|
// Auto-scroll al final
|
||||||
|
useEffect(() => {
|
||||||
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// Enviar mensaje al asistente
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!inputMessage.trim() || loading) return
|
||||||
|
|
||||||
|
const userMessage = { role: 'user', content: inputMessage, timestamp: new Date().toISOString() }
|
||||||
|
setMessages(prev => [...prev, userMessage])
|
||||||
|
setInputMessage('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
// Recopilar fotos de preguntas anteriores según configuración
|
||||||
|
const contextPhotos = []
|
||||||
|
const contextQuestionIds = config.context_questions
|
||||||
|
? config.context_questions.split(',').map(id => parseInt(id.trim()))
|
||||||
|
: Object.keys(allAnswers).map(id => parseInt(id))
|
||||||
|
|
||||||
|
// Filtrar solo preguntas anteriores a la actual
|
||||||
|
const previousQuestionIds = contextQuestionIds.filter(id => id < question.id)
|
||||||
|
|
||||||
|
previousQuestionIds.forEach(qId => {
|
||||||
|
const answer = allAnswers[qId]
|
||||||
|
if (answer?.photos && answer.photos.length > 0) {
|
||||||
|
answer.photos.forEach(photoUrl => {
|
||||||
|
contextPhotos.push({
|
||||||
|
questionId: qId,
|
||||||
|
url: photoUrl,
|
||||||
|
aiAnalysis: answer.aiAnalysis
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📤 Enviando a chat IA:', payload)
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/api/ai/chat-assistant`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Error en respuesta del servidor')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('📥 Respuesta de IA:', data)
|
||||||
|
|
||||||
|
const assistantMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: data.response,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
confidence: data.confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, assistantMessage])
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al enviar mensaje:', error)
|
||||||
|
const errorMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: '❌ Error al comunicarse con el asistente. Por favor intenta nuevamente.',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
isError: true
|
||||||
|
}
|
||||||
|
setMessages(prev => [...prev, errorMessage])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100] p-2 sm:p-4">
|
||||||
|
<div className="bg-white rounded-xl w-full max-w-4xl max-h-[95vh] flex flex-col shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-purple-600 to-blue-600 text-white p-4 sm:p-6 rounded-t-xl">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-3xl">💬</span>
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold">Asistente IA</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm sm:text-base text-purple-100 line-clamp-2">
|
||||||
|
{question.text}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="px-2 py-1 bg-white/20 rounded">
|
||||||
|
{Object.values(allAnswers).filter(a => a.photos?.length > 0).length} preguntas con fotos
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-1 bg-white/20 rounded">
|
||||||
|
{messages.length} mensajes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/80 hover:text-white text-3xl flex-shrink-0"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4 bg-gray-50">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-6xl mb-4">🤖</div>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base mb-2">
|
||||||
|
¡Hola! Soy tu asistente técnico.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-xs sm:text-sm">
|
||||||
|
He analizado las fotos anteriores. ¿En qué puedo ayudarte?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] sm:max-w-[75%] rounded-lg p-3 sm:p-4 ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: msg.isError
|
||||||
|
? 'bg-red-50 border border-red-200 text-red-800'
|
||||||
|
: 'bg-white border border-gray-200 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm sm:text-base whitespace-pre-wrap break-words">
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-xs mt-2 ${
|
||||||
|
msg.role === 'user' ? 'text-blue-100' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{new Date(msg.timestamp).toLocaleTimeString('es-ES', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
{msg.confidence && (
|
||||||
|
<span className="ml-2">• Confianza: {Math.round(msg.confidence * 100)}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 text-gray-600">
|
||||||
|
<div className="animate-spin h-4 w-4 border-2 border-purple-600 border-t-transparent rounded-full"></div>
|
||||||
|
<span className="text-sm">El asistente está pensando...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={chatEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="border-t p-3 sm:p-4 bg-white rounded-b-xl">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputMessage}
|
||||||
|
onChange={(e) => setInputMessage(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()}
|
||||||
|
placeholder="Escribe tu pregunta..."
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 px-3 sm:px-4 py-2 sm:py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 text-sm sm:text-base disabled:bg-gray-100"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={!inputMessage.trim() || 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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{config.max_messages && messages.length >= config.max_messages && (
|
||||||
|
<p className="text-xs text-amber-600 mt-2">
|
||||||
|
⚠️ Has alcanzado el límite de {config.max_messages} mensajes
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,6 +292,25 @@ export function QuestionAnswerInput({ question, value, onChange, onSave }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI_ASSISTANT (Chat con IA)
|
||||||
|
if (questionType === 'ai_assistant') {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-gradient-to-r from-purple-50 to-blue-50 border-2 border-purple-200 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-2xl">💬</span>
|
||||||
|
<h4 className="font-semibold text-purple-900">Asistente IA Disponible</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-purple-700 mb-2">
|
||||||
|
Haz clic en el botón "💬 Consultar Asistente" debajo para abrir el chat con IA.
|
||||||
|
El asistente ha analizado las fotos anteriores y está listo para ayudarte.
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-purple-600 bg-white/50 rounded px-2 py-1">
|
||||||
|
ℹ️ No requiere respuesta manual - el chat se guarda automáticamente
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback para tipos desconocidos
|
// Fallback para tipos desconocidos
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ const QUESTION_TYPES = [
|
|||||||
{ value: 'number', label: '🔢 Número', icon: '#️⃣' },
|
{ value: 'number', label: '🔢 Número', icon: '#️⃣' },
|
||||||
{ value: 'date', label: '📅 Fecha', icon: '📆' },
|
{ value: 'date', label: '📅 Fecha', icon: '📆' },
|
||||||
{ value: 'time', label: '🕐 Hora', icon: '⏰' },
|
{ value: 'time', label: '🕐 Hora', icon: '⏰' },
|
||||||
{ value: 'photo_only', label: '📸 Solo Fotografía', icon: '📷' }
|
{ value: 'photo_only', label: '📸 Solo Fotografía', icon: '📷' },
|
||||||
|
{ value: 'ai_assistant', label: '🤖 Asistente IA (Chat)', icon: '💬' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
@@ -512,6 +513,121 @@ export function QuestionTypeEditor({ value, onChange, maxPoints = 1 }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI ASSISTANT (CHAT) */}
|
||||||
|
{config.type === 'ai_assistant' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-3xl">💬</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-blue-900 mb-1">Asistente IA Conversacional</h4>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
El mecánico podrá chatear con IA usando fotos de preguntas anteriores como contexto.
|
||||||
|
Configura qué preguntas anteriores usar y el comportamiento del asistente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt del asistente */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
🎯 Prompt del Asistente (Rol y Comportamiento)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={config.assistant_prompt || ''}
|
||||||
|
onChange={(e) => updateConfig({ assistant_prompt: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||||
|
rows="6"
|
||||||
|
placeholder="Ejemplo: Eres un experto mecánico especializado en sistemas de frenos. Ayuda al mecánico a diagnosticar problemas basándote en las fotos que has visto. Sé directo y técnico. Si ves algo anormal en las fotos, menciónalo proactivamente."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Define cómo debe comportarse el asistente: su rol, tono, especialidad, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preguntas de contexto */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
📸 Preguntas Anteriores para Contexto
|
||||||
|
</label>
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
|
||||||
|
<p className="text-xs text-yellow-800">
|
||||||
|
<strong>💡 Cómo funciona:</strong> El asistente verá las fotos de las preguntas que selecciones abajo.
|
||||||
|
Elige preguntas cuyas fotos sean relevantes para el diagnóstico (ej: si es asistente de frenos, selecciona preguntas sobre pastillas, discos, líquido de frenos, etc.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={config.context_questions || ''}
|
||||||
|
onChange={(e) => updateConfig({ context_questions: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder="IDs de preguntas separados por comas. Ejemplo: 5,8,12,15
|
||||||
|
Dejar vacío para usar TODAS las preguntas anteriores con fotos."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Especifica los IDs de preguntas anteriores cuyas fotos debe analizar el asistente, o déjalo vacío para usar todas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instrucciones adicionales */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
📋 Instrucciones Adicionales (Opcional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={config.assistant_instructions || ''}
|
||||||
|
onChange={(e) => updateConfig({ assistant_instructions: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Instrucciones específicas adicionales.
|
||||||
|
Ejemplo:
|
||||||
|
- Si detectas pastillas con menos de 3mm, recomienda cambio inmediato
|
||||||
|
- Siempre verifica si hay fugas de líquido
|
||||||
|
- Menciona el código OBD2 si es relevante"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Reglas o criterios específicos que el asistente debe seguir al dar consejos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuración de respuestas */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
💬 Mensajes Máximos
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
value={config.max_messages || 20}
|
||||||
|
onChange={(e) => updateConfig({ max_messages: parseInt(e.target.value) })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Límite de mensajes en el chat
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
📏 Longitud de Respuesta
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={config.response_length || 'medium'}
|
||||||
|
onChange={(e) => updateConfig({ response_length: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="short">Corta (concisa)</option>
|
||||||
|
<option value="medium">Media (balanceada)</option>
|
||||||
|
<option value="long">Larga (detallada)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user