1180 lines
44 KiB
TypeScript
1180 lines
44 KiB
TypeScript
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
|
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { createFileRoute, useRouterState } from '@tanstack/react-router'
|
|
import {
|
|
Send,
|
|
Target,
|
|
Lightbulb,
|
|
FileText,
|
|
GraduationCap,
|
|
BookOpen,
|
|
Check,
|
|
X,
|
|
MessageSquarePlus,
|
|
Archive,
|
|
Loader2,
|
|
Sparkles,
|
|
RotateCcw,
|
|
} from 'lucide-react'
|
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
|
|
|
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
|
|
|
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
|
|
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip'
|
|
import {
|
|
useAIPlanChat,
|
|
useConversationByPlan,
|
|
useMessagesByChat,
|
|
useUpdateConversationStatus,
|
|
useUpdateConversationTitle,
|
|
useUpdatePlanFields,
|
|
useUpdateRecommendationApplied,
|
|
} from '@/data'
|
|
import { usePlan } from '@/data/hooks/usePlans'
|
|
|
|
const PRESETS = [
|
|
{
|
|
id: 'objetivo',
|
|
label: 'Mejorar objetivo general',
|
|
icon: Target,
|
|
prompt: 'Mejora la redacción del objetivo general...',
|
|
},
|
|
{
|
|
id: 'perfil-egreso',
|
|
label: 'Redactar perfil de egreso',
|
|
icon: GraduationCap,
|
|
prompt: 'Genera un perfil de egreso detallado...',
|
|
},
|
|
{
|
|
id: 'competencias',
|
|
label: 'Sugerir competencias',
|
|
icon: BookOpen,
|
|
prompt: 'Genera una lista de competencias...',
|
|
},
|
|
{
|
|
id: 'pertinencia',
|
|
label: 'Justificar pertinencia',
|
|
icon: FileText,
|
|
prompt: 'Redacta una justificación de pertinencia...',
|
|
},
|
|
]
|
|
|
|
// --- Tipado y Helpers ---
|
|
interface SelectedField {
|
|
key: string
|
|
label: string
|
|
value: string
|
|
}
|
|
interface EstructuraDefinicion {
|
|
properties?: {
|
|
[key: string]: {
|
|
title: string
|
|
description?: string
|
|
}
|
|
}
|
|
}
|
|
interface ChatMessageJSON {
|
|
user: 'user' | 'assistant'
|
|
message?: string
|
|
prompt?: string
|
|
refusal?: boolean
|
|
recommendations?: Array<{
|
|
campo_afectado: string
|
|
texto_mejora: string
|
|
aplicada: boolean
|
|
}>
|
|
}
|
|
export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
|
|
component: RouteComponent,
|
|
})
|
|
|
|
function RouteComponent() {
|
|
const { planId } = Route.useParams()
|
|
const { data } = usePlan(planId)
|
|
const routerState = useRouterState()
|
|
const [openIA, setOpenIA] = useState(false)
|
|
const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat()
|
|
const { mutate: updateStatusMutation } = useUpdateConversationStatus()
|
|
const [isSyncing, setIsSyncing] = useState(false)
|
|
const [activeChatId, setActiveChatId] = useState<string | undefined>(
|
|
undefined,
|
|
)
|
|
const { data: lastConversation, isLoading: isLoadingConv } =
|
|
useConversationByPlan(planId)
|
|
const { data: mensajesDelChat, isLoading: isLoadingMessages } =
|
|
useMessagesByChat(activeChatId ?? null)
|
|
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
|
[],
|
|
)
|
|
const [selectedRepositorioIds, setSelectedRepositorioIds] = useState<
|
|
Array<string>
|
|
>([])
|
|
const [uploadedFiles, setUploadedFiles] = useState<Array<UploadedFile>>([])
|
|
|
|
const [messages, setMessages] = useState<Array<any>>([])
|
|
const [input, setInput] = useState('')
|
|
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
|
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
|
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
|
|
const queryClient = useQueryClient()
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
const isInitialLoad = useRef(true)
|
|
const [showArchived, setShowArchived] = useState(false)
|
|
const [editingChatId, setEditingChatId] = useState<string | null>(null)
|
|
const editableRef = useRef<HTMLSpanElement>(null)
|
|
const { mutate: updateTitleMutation } = useUpdateConversationTitle()
|
|
const [isSending, setIsSending] = useState(false)
|
|
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(
|
|
null,
|
|
)
|
|
const [filterQuery, setFilterQuery] = useState('')
|
|
|
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
|
const [isActionsOpen, setIsActionsOpen] = useState(false)
|
|
|
|
const [selectedImprovements, setSelectedImprovements] = useState<
|
|
Array<string>
|
|
>([])
|
|
const updatePlan = useUpdatePlanFields()
|
|
const updateAppliedStatus = useUpdateRecommendationApplied()
|
|
|
|
const availableFields = useMemo(() => {
|
|
const definicion = data?.estructuras_plan
|
|
?.definicion as EstructuraDefinicion
|
|
|
|
if (!definicion?.properties) return []
|
|
|
|
return Object.entries(definicion.properties).map(([key, value]) => ({
|
|
key,
|
|
label: value.title,
|
|
value: String(value.description || ''),
|
|
}))
|
|
}, [data])
|
|
|
|
const filteredFields = useMemo(() => {
|
|
return availableFields.filter(
|
|
(field) =>
|
|
field.label.toLowerCase().includes(filterQuery.toLowerCase()) &&
|
|
!selectedFields.some((s) => s.key === field.key),
|
|
)
|
|
}, [availableFields, filterQuery, selectedFields])
|
|
|
|
const chatMessages = useMemo(() => {
|
|
if (!activeChatId || !mensajesDelChat) return []
|
|
|
|
return mensajesDelChat.flatMap((msg: any) => {
|
|
const messages = []
|
|
|
|
messages.push({
|
|
id: `${msg.id}-user`,
|
|
role: 'user',
|
|
content: msg.mensaje,
|
|
selectedFields: msg.campos || [],
|
|
})
|
|
|
|
if (msg.respuesta) {
|
|
const rawRecommendations = msg.propuesta?.recommendations || []
|
|
|
|
messages.push({
|
|
id: `${msg.id}-ai`,
|
|
dbMessageId: msg.id,
|
|
role: 'assistant',
|
|
content: msg.respuesta,
|
|
isRefusal: msg.is_refusal,
|
|
suggestions: rawRecommendations.map((rec: any) => {
|
|
const fieldConfig = availableFields.find(
|
|
(f) => f.key === rec.campo_afectado,
|
|
)
|
|
return {
|
|
key: rec.campo_afectado,
|
|
label: fieldConfig
|
|
? fieldConfig.label
|
|
: rec.campo_afectado.replace(/_/g, ' '),
|
|
newValue: rec.texto_mejora,
|
|
applied: rec.aplicada,
|
|
}
|
|
}),
|
|
})
|
|
}
|
|
|
|
return messages
|
|
})
|
|
}, [mensajesDelChat, activeChatId, availableFields])
|
|
|
|
const handleApplyMultiple = async (
|
|
sugerencias: Array<any>,
|
|
dbMessageId: string,
|
|
) => {
|
|
if (!planId || !data?.datos || sugerencias.length === 0) return
|
|
|
|
setIsSending(true)
|
|
try {
|
|
const datosActualizados = { ...data.datos }
|
|
|
|
for (const sug of sugerencias) {
|
|
const key = sug.key
|
|
const newValue = sug.newValue
|
|
const currentValue = datosActualizados[key]
|
|
|
|
if (
|
|
typeof currentValue === 'object' &&
|
|
currentValue !== null &&
|
|
'description' in currentValue
|
|
) {
|
|
datosActualizados[key] = { ...currentValue, description: newValue }
|
|
} else {
|
|
datosActualizados[key] = newValue
|
|
}
|
|
}
|
|
|
|
await updatePlan.mutateAsync({
|
|
planId: planId as any,
|
|
patch: { datos: datosActualizados },
|
|
})
|
|
|
|
for (const sug of sugerencias) {
|
|
try {
|
|
await updateAppliedStatus.mutateAsync({
|
|
conversacionId: dbMessageId,
|
|
campoAfectado: sug.key,
|
|
})
|
|
removeSelectedField(sug.key)
|
|
} catch (err) {
|
|
console.error(
|
|
`Error al marcar aplicada la sugerencia: ${sug.key}`,
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
setSelectedImprovements([])
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['plan', planId] }),
|
|
queryClient.invalidateQueries({ queryKey: ['conversation-messages'] }),
|
|
])
|
|
} catch (error) {
|
|
console.error('Error crítico en aplicación masiva:', error)
|
|
} finally {
|
|
setIsSending(false)
|
|
}
|
|
}
|
|
|
|
const toggleImprovementSelection = (sugKey: string) => {
|
|
setSelectedImprovements((prev) =>
|
|
prev.includes(sugKey)
|
|
? prev.filter((key) => key !== sugKey)
|
|
: [...prev, sugKey],
|
|
)
|
|
}
|
|
|
|
const toggleAllFromMessage = (suggestions: Array<any>) => {
|
|
const pending = suggestions.filter((s) => !s.applied)
|
|
const allKeys = pending.map((s) => s.key)
|
|
const allSelected = allKeys.every((key) =>
|
|
selectedImprovements.includes(key),
|
|
)
|
|
|
|
if (allSelected) {
|
|
setSelectedImprovements((prev) =>
|
|
prev.filter((key) => !allKeys.includes(key)),
|
|
)
|
|
} else {
|
|
setSelectedImprovements((prev) =>
|
|
Array.from(new Set([...prev, ...allKeys])),
|
|
)
|
|
}
|
|
}
|
|
|
|
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
|
|
if (scrollRef.current) {
|
|
const scrollContainer = scrollRef.current.querySelector(
|
|
'[data-radix-scroll-area-viewport]',
|
|
)
|
|
if (scrollContainer) {
|
|
scrollContainer.scrollTo({
|
|
top: scrollContainer.scrollHeight,
|
|
behavior,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const { activeChats, archivedChats } = useMemo(() => {
|
|
const allChats = lastConversation || []
|
|
return {
|
|
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
|
|
archivedChats: allChats.filter(
|
|
(chat: any) => chat.estado === 'ARCHIVADA',
|
|
),
|
|
}
|
|
}, [lastConversation])
|
|
|
|
useEffect(() => {
|
|
if (chatMessages.length > 0) {
|
|
if (isInitialLoad.current) {
|
|
scrollToBottom('instant')
|
|
isInitialLoad.current = false
|
|
} else {
|
|
scrollToBottom('smooth')
|
|
}
|
|
}
|
|
}, [chatMessages])
|
|
|
|
useEffect(() => {
|
|
isInitialLoad.current = true
|
|
}, [activeChatId])
|
|
|
|
useEffect(() => {
|
|
if (isLoadingConv || isSending) return
|
|
|
|
const currentChatExists = activeChats.some(
|
|
(chat) => chat.id === activeChatId,
|
|
)
|
|
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
|
|
|
|
if (activeChatId && !currentChatExists && !isCreationMode) {
|
|
setActiveChatId(undefined)
|
|
setMessages([])
|
|
return
|
|
}
|
|
|
|
if (
|
|
!activeChatId &&
|
|
activeChats.length > 0 &&
|
|
!isCreationMode &&
|
|
chatMessages.length === 0
|
|
) {
|
|
setActiveChatId(activeChats[0].id)
|
|
}
|
|
}, [
|
|
activeChats,
|
|
activeChatId,
|
|
isLoadingConv,
|
|
isSending,
|
|
messages.length,
|
|
chatMessages.length,
|
|
messages,
|
|
])
|
|
|
|
useEffect(() => {
|
|
const state = routerState.location.state as any
|
|
if (!state?.campo_edit || availableFields.length === 0) return
|
|
const field = availableFields.find(
|
|
(f) =>
|
|
f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
|
|
)
|
|
if (!field) return
|
|
setSelectedFields([field])
|
|
setInput((prev) =>
|
|
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
|
|
)
|
|
}, [availableFields, routerState.location.state])
|
|
|
|
const createNewChat = () => {
|
|
setActiveChatId(undefined)
|
|
setMessages([
|
|
{
|
|
id: 'welcome',
|
|
role: 'assistant',
|
|
content: 'Iniciando una nueva conversación. ¿En qué puedo ayudarte?',
|
|
},
|
|
])
|
|
setInput('')
|
|
}
|
|
|
|
const archiveChat = (e: React.MouseEvent, id: string) => {
|
|
e.stopPropagation()
|
|
|
|
updateStatusMutation(
|
|
{ id, estado: 'ARCHIVADA' },
|
|
{
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['conversation-by-plan', planId],
|
|
})
|
|
|
|
if (activeChatId === id) {
|
|
setActiveChatId(undefined)
|
|
setMessages([])
|
|
setOptimisticMessage(null)
|
|
setInput('')
|
|
setSelectedFields([])
|
|
}
|
|
},
|
|
},
|
|
)
|
|
}
|
|
const unarchiveChat = (e: React.MouseEvent, id: string) => {
|
|
e.stopPropagation()
|
|
|
|
updateStatusMutation(
|
|
{ id, estado: 'ACTIVA' },
|
|
{
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['conversation-by-plan', planId],
|
|
})
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const val = e.target.value
|
|
const cursorPosition = e.target.selectionStart
|
|
setInput(val)
|
|
|
|
const textBeforeCursor = val.slice(0, cursorPosition)
|
|
const match = textBeforeCursor.match(/:(\w*)$/)
|
|
|
|
if (match) {
|
|
setShowSuggestions(true)
|
|
setFilterQuery(match[1])
|
|
} else {
|
|
setShowSuggestions(false)
|
|
setFilterQuery('')
|
|
}
|
|
}
|
|
|
|
const injectFieldsIntoInput = (
|
|
input: string,
|
|
fields: Array<SelectedField>,
|
|
) => {
|
|
const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim()
|
|
|
|
if (fields.length === 0) return cleaned
|
|
|
|
const fieldLabels = fields.map((f) => f.label).join(', ')
|
|
return `${cleaned}: ${fieldLabels}`
|
|
}
|
|
|
|
const toggleField = (field: SelectedField) => {
|
|
setSelectedFields((prev) => {
|
|
const isSelected = prev.find((f) => f.key === field.key)
|
|
return isSelected ? prev : [...prev, field]
|
|
})
|
|
|
|
setInput((prev) => {
|
|
const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
|
|
return nuevoTexto + ' '
|
|
})
|
|
|
|
setShowSuggestions(false)
|
|
setFilterQuery('')
|
|
}
|
|
|
|
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
|
|
if (fields.length === 0) return userInput
|
|
return ` ${userInput}`
|
|
}
|
|
|
|
const handleSend = async (promptOverride?: string) => {
|
|
const rawText = promptOverride || input
|
|
if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
|
|
|
|
const currentFields = [...selectedFields]
|
|
const finalContent = buildPrompt(rawText, currentFields)
|
|
setIsSending(true)
|
|
setOptimisticMessage(finalContent)
|
|
setInput('')
|
|
|
|
try {
|
|
const payload = {
|
|
planId: planId as any,
|
|
content: finalContent,
|
|
conversacionId: activeChatId,
|
|
campos:
|
|
currentFields.length > 0
|
|
? currentFields.map((f) => f.key)
|
|
: undefined,
|
|
}
|
|
|
|
const response = await sendChat(payload)
|
|
setIsSyncing(true)
|
|
if (response.conversacionId && response.conversacionId !== activeChatId) {
|
|
setActiveChatId(response.conversacionId)
|
|
}
|
|
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['conversation-by-plan', planId],
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['conversation-messages', response.conversacionId],
|
|
}),
|
|
])
|
|
} catch (error) {
|
|
console.error('Error:', error)
|
|
setOptimisticMessage(null)
|
|
} finally {
|
|
setIsSending(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return
|
|
|
|
const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any
|
|
|
|
if (ultimoMensajeDB?.respuesta) {
|
|
setIsSyncing(false)
|
|
setOptimisticMessage(null)
|
|
}
|
|
}, [mensajesDelChat, isSyncing])
|
|
|
|
const totalReferencias = useMemo(() => {
|
|
return (
|
|
selectedArchivoIds.length +
|
|
selectedRepositorioIds.length +
|
|
uploadedFiles.length
|
|
)
|
|
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
|
|
|
|
const removeSelectedField = (fieldKey: string) => {
|
|
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 pb-1 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden">
|
|
{/* --- HEADER MÓVIL --- */}
|
|
<div className="bg-background flex shrink-0 items-center justify-between rounded-lg border p-2 shadow-sm md:hidden">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setIsHistoryOpen(true)}
|
|
>
|
|
<Archive size={18} className="mr-2" /> Historial
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setIsActionsOpen(true)}
|
|
>
|
|
<Lightbulb size={18} className="text-primary mr-2" /> Acciones
|
|
</Button>
|
|
</div>
|
|
|
|
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
|
|
<div className="hidden w-64 flex-col border-r pr-4 md:flex">
|
|
<h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
|
|
Chats
|
|
</h2>
|
|
<Button
|
|
onClick={createNewChat}
|
|
variant="outline"
|
|
className="mt-2 mb-4 w-full justify-start gap-2"
|
|
>
|
|
<MessageSquarePlus size={18} /> Nuevo chat
|
|
</Button>
|
|
<ScrollArea className="flex-1">
|
|
<div className="space-y-1 pr-2">
|
|
{!showArchived ? (
|
|
activeChats.map((chat) => (
|
|
<div
|
|
key={chat.id}
|
|
onClick={() => setActiveChatId(chat.id)}
|
|
className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
|
|
activeChatId === chat.id
|
|
? 'bg-accent text-foreground font-medium'
|
|
: 'text-muted-foreground hover:bg-accent/50'
|
|
}`}
|
|
>
|
|
<div
|
|
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
|
|
style={{
|
|
maskImage:
|
|
'linear-gradient(to right, black 70%, transparent 95%)',
|
|
WebkitMaskImage:
|
|
'linear-gradient(to right, black 70%, transparent 95%)',
|
|
}}
|
|
>
|
|
<FileText size={16} className="shrink-0 opacity-40" />
|
|
<TooltipProvider delayDuration={400}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild className="min-w-0 flex-1">
|
|
<div className="min-w-0 flex-1">
|
|
<span
|
|
ref={
|
|
editingChatId === chat.id ? editableRef : null
|
|
}
|
|
contentEditable={editingChatId === chat.id}
|
|
suppressContentEditableWarning={true}
|
|
className={`block truncate outline-none ${
|
|
editingChatId === chat.id
|
|
? 'bg-background ring-primary max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded px-1 break-all shadow-sm ring-1'
|
|
: 'cursor-pointer'
|
|
}`}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation()
|
|
setEditingChatId(chat.id)
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
e.currentTarget.blur()
|
|
}
|
|
if (e.key === 'Escape') {
|
|
setEditingChatId(null)
|
|
e.currentTarget.textContent =
|
|
chat.nombre || ''
|
|
}
|
|
}}
|
|
onBlur={(e) => {
|
|
if (editingChatId === chat.id) {
|
|
const newTitle =
|
|
e.currentTarget.textContent.trim() || ''
|
|
if (newTitle && newTitle !== chat.nombre) {
|
|
updateTitleMutation({
|
|
id: chat.id,
|
|
nombre: newTitle,
|
|
})
|
|
}
|
|
setEditingChatId(null)
|
|
}
|
|
}}
|
|
>
|
|
{chat.nombre ||
|
|
`Chat ${chat.creado_en.split('T')[0]}`}
|
|
</span>
|
|
</div>
|
|
</TooltipTrigger>
|
|
{editingChatId !== chat.id && (
|
|
<TooltipContent
|
|
side="right"
|
|
className="max-w-[280px] break-all"
|
|
>
|
|
{chat.nombre || 'Conversación'}
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
|
|
<div
|
|
className={`absolute top-1/2 right-2 z-20 flex -translate-y-1/2 items-center gap-1 rounded-md px-1 opacity-0 transition-opacity group-hover:opacity-100 ${
|
|
activeChatId === chat.id ? 'bg-accent' : 'bg-transparent'
|
|
}`}
|
|
>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setEditingChatId(chat.id)
|
|
setTimeout(() => editableRef.current?.focus(), 50)
|
|
}}
|
|
className="text-muted-foreground hover:text-primary rounded-md p-1 transition-colors"
|
|
>
|
|
<Send size={12} className="rotate-45" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => archiveChat(e, chat.id)}
|
|
className="text-muted-foreground hover:text-destructive rounded-md p-1 transition-colors"
|
|
>
|
|
<Archive size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="animate-in fade-in slide-in-from-left-2 px-1">
|
|
<p className="text-muted-foreground mb-2 px-2 text-[10px] font-bold uppercase">
|
|
Archivados
|
|
</p>
|
|
{archivedChats.map((chat) => (
|
|
<div
|
|
key={chat.id}
|
|
className="bg-muted/50 text-muted-foreground group relative mb-1 flex w-full items-center overflow-hidden rounded-lg px-3 py-2 text-sm"
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
|
|
<Archive size={14} className="shrink-0 opacity-30" />
|
|
<span className="block truncate">
|
|
{chat.nombre ||
|
|
`Archivado ${chat.creado_en.split('T')[0]}`}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={(e) => unarchiveChat(e, chat.id)}
|
|
className="bg-accent hover:text-primary absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
|
|
>
|
|
<RotateCcw size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* --- PANEL DE CHAT PRINCIPAL --- */}
|
|
<div className="border-border/60 bg-muted/30 relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border shadow-sm md:h-full md:flex-[3]">
|
|
<div className="bg-background z-10 shrink-0 border-b p-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground text-[10px] font-bold uppercase">
|
|
Mejorar con IA
|
|
</span>
|
|
<button
|
|
onClick={() => setOpenIA(true)}
|
|
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition"
|
|
>
|
|
<Archive size={14} className="opacity-70" />
|
|
Referencias
|
|
{totalReferencias > 0 && (
|
|
<span className="bg-primary text-primary-foreground flex h-4 min-w-[16px] items-center justify-center rounded-full px-1 text-[10px]">
|
|
{totalReferencias}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
|
<ScrollArea ref={scrollRef} className="h-full w-full">
|
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
|
{!activeChatId &&
|
|
chatMessages.length === 0 &&
|
|
!optimisticMessage ? (
|
|
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
|
|
<MessageSquarePlus
|
|
size={48}
|
|
className="text-muted-foreground/50 mb-4"
|
|
/>
|
|
<h3 className="text-foreground text-lg font-medium">
|
|
No hay un chat seleccionado
|
|
</h3>
|
|
<p className="text-muted-foreground text-sm">
|
|
Selecciona un chat del historial o crea uno nuevo para
|
|
empezar.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{chatMessages.map((msg: any) => {
|
|
const isAI = msg.role === 'assistant'
|
|
const isUser = msg.role === 'user'
|
|
const isProcessing = msg.isProcessing
|
|
|
|
return (
|
|
<div
|
|
key={msg.id}
|
|
className={`flex max-w-[85%] flex-col ${
|
|
isUser ? 'ml-auto items-end' : 'items-start'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
|
|
isUser
|
|
? 'bg-primary text-primary-foreground rounded-tr-none'
|
|
: `bg-card text-card-foreground rounded-tl-none border ${
|
|
msg.isRefusal
|
|
? 'border-destructive/50 bg-destructive/10 ring-destructive/20 ring-1'
|
|
: 'border-border'
|
|
}`
|
|
}`}
|
|
>
|
|
{msg.isRefusal && (
|
|
<div className="text-destructive mb-1 flex items-center gap-1 text-[10px] font-bold uppercase">
|
|
<span>Aviso del Asistente</span>
|
|
</div>
|
|
)}
|
|
|
|
{isAI && isProcessing ? (
|
|
<div className="flex items-center gap-2 py-1">
|
|
<div className="flex gap-1">
|
|
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full" />
|
|
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]" />
|
|
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
msg.content
|
|
)}
|
|
|
|
{isAI && msg.suggestions?.length > 0 && (
|
|
<div className="border-border/60 bg-muted/50 mt-4 w-full space-y-3 rounded-xl border p-3">
|
|
<div className="flex items-center justify-between px-1">
|
|
<span className="text-muted-foreground text-[10px] font-bold uppercase">
|
|
Sugerencias de mejora
|
|
</span>
|
|
{msg.suggestions.some(
|
|
(s: any) => !s.applied,
|
|
) && (
|
|
<Button
|
|
variant="ghost"
|
|
className="text-primary hover:bg-primary/10 h-6 px-2 text-[10px]"
|
|
onClick={() =>
|
|
toggleAllFromMessage(msg.suggestions)
|
|
}
|
|
>
|
|
{msg.suggestions
|
|
.filter((s: any) => !s.applied)
|
|
.every((s: any) =>
|
|
selectedImprovements.includes(s.key),
|
|
)
|
|
? 'Desmarcar todo'
|
|
: 'Seleccionar todo'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{msg.suggestions.map((sug: any) => (
|
|
<div key={sug.key} className="flex gap-2">
|
|
{!sug.applied && (
|
|
<input
|
|
type="checkbox"
|
|
className="border-input accent-primary mt-4 h-4 w-4 shrink-0 rounded"
|
|
checked={selectedImprovements.includes(
|
|
sug.key,
|
|
)}
|
|
onChange={() =>
|
|
toggleImprovementSelection(sug.key)
|
|
}
|
|
/>
|
|
)}
|
|
<div className="flex-1">
|
|
<ImprovementCard
|
|
suggestions={[sug]}
|
|
dbMessageId={msg.dbMessageId}
|
|
planId={planId}
|
|
currentDatos={data?.datos}
|
|
activeChatId={activeChatId}
|
|
onApplySuccess={(key) =>
|
|
removeSelectedField(key)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{msg.suggestions.some((s: any) =>
|
|
selectedImprovements.includes(s.key),
|
|
) && (
|
|
<Button
|
|
size="sm"
|
|
disabled={isSending}
|
|
className="w-full py-1 text-xs font-bold"
|
|
onClick={() => {
|
|
const seleccionadas =
|
|
msg.suggestions.filter((s: any) =>
|
|
selectedImprovements.includes(s.key),
|
|
)
|
|
handleApplyMultiple(
|
|
seleccionadas,
|
|
msg.dbMessageId,
|
|
)
|
|
}}
|
|
>
|
|
{isSending ? (
|
|
<Loader2
|
|
className="mr-2 animate-spin"
|
|
size={12}
|
|
/>
|
|
) : (
|
|
<Check className="mr-2" size={12} />
|
|
)}
|
|
Aplicar seleccionadas (
|
|
{
|
|
msg.suggestions.filter((s: any) =>
|
|
selectedImprovements.includes(s.key),
|
|
).length
|
|
}
|
|
)
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{(isSending || isSyncing) && (
|
|
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
|
|
<Avatar className="bg-primary text-primary-foreground h-9 w-9 shrink-0 border shadow-sm">
|
|
<AvatarFallback>
|
|
<Sparkles size={16} className="animate-pulse" />
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex flex-col items-start gap-2">
|
|
<div className="bg-card border-border rounded-2xl rounded-tl-none border p-4 shadow-sm">
|
|
<div className="flex gap-1">
|
|
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]"></span>
|
|
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]"></span>
|
|
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full"></span>
|
|
</div>
|
|
</div>
|
|
<span className="text-muted-foreground text-[10px] font-medium italic">
|
|
La IA está analizando tu solicitud...
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{pendingSuggestion && !isLoading && (
|
|
<div className="animate-in fade-in slide-in-from-bottom-2 bg-card border-border absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border p-1.5 shadow-2xl">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setPendingSuggestion(null)}
|
|
className="h-8 rounded-full text-xs"
|
|
>
|
|
<X className="mr-1 h-3 w-3" /> Descartar
|
|
</Button>
|
|
<Button size="sm" className="h-8 rounded-full text-xs">
|
|
<Check className="mr-1 h-3 w-3" /> Aplicar cambios
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* INPUT FIJO AL FONDO */}
|
|
<div className="bg-background border-border shrink-0 border-t p-4">
|
|
<div className="relative mx-auto max-w-4xl">
|
|
{showSuggestions && (
|
|
<div className="animate-in slide-in-from-bottom-2 bg-popover border-border absolute bottom-full mb-2 w-full rounded-xl border shadow-2xl">
|
|
<div className="bg-muted text-muted-foreground border-b px-3 py-2 text-[10px] font-bold uppercase">
|
|
Resultados para "{filterQuery}"
|
|
</div>
|
|
<div className="max-h-64 overflow-y-auto p-1">
|
|
{filteredFields.length > 0 ? (
|
|
filteredFields.map((field, index) => (
|
|
<button
|
|
key={field.key}
|
|
onClick={() => toggleField(field)}
|
|
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
|
index === 0
|
|
? 'bg-primary/10 text-primary ring-primary/30 ring-1 ring-inset'
|
|
: 'hover:bg-accent'
|
|
}`}
|
|
>
|
|
<span>{field.label}</span>
|
|
{index === 0 && (
|
|
<span className="font-mono text-[10px] opacity-50">
|
|
TAB
|
|
</span>
|
|
)}
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="text-muted-foreground p-3 text-center text-xs">
|
|
No hay coincidencias
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-muted/50 focus-within:bg-background focus-within:ring-primary flex flex-col gap-2 rounded-xl border p-2 transition-all focus-within:ring-1">
|
|
{selectedFields.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 px-2 pt-1">
|
|
{selectedFields.map((field) => (
|
|
<div
|
|
key={field.key}
|
|
className="animate-in zoom-in-95 border-primary/20 bg-primary/10 text-primary flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-semibold"
|
|
>
|
|
<span className="opacity-70">Campo:</span> {field.label}
|
|
<button
|
|
onClick={() => toggleField(field)}
|
|
className="hover:bg-primary/20 ml-1 rounded-full p-0.5 transition-colors"
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-end gap-2">
|
|
<Textarea
|
|
value={input}
|
|
onChange={handleInputChange}
|
|
onKeyDown={(e) => {
|
|
if (showSuggestions) {
|
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
if (filteredFields.length > 0) {
|
|
e.preventDefault()
|
|
toggleField(filteredFields[0])
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
setShowSuggestions(false)
|
|
setFilterQuery('')
|
|
}
|
|
} else {
|
|
if (
|
|
e.key === 'Backspace' &&
|
|
input === '' &&
|
|
selectedFields.length > 0
|
|
) {
|
|
setSelectedFields((prev) => prev.slice(0, -1))
|
|
}
|
|
}
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey && !showSuggestions) {
|
|
e.preventDefault()
|
|
if (!isSending) handleSend()
|
|
}
|
|
}}
|
|
placeholder={
|
|
selectedFields.length > 0
|
|
? 'Escribe instrucciones adicionales...'
|
|
: 'Escribe tu solicitud o ":" para campos...'
|
|
}
|
|
/>
|
|
|
|
<Button
|
|
onClick={() => handleSend()}
|
|
disabled={
|
|
isSending || (!input.trim() && selectedFields.length === 0)
|
|
}
|
|
size="icon"
|
|
className="mb-1 h-9 w-9 shrink-0"
|
|
>
|
|
{isSending ? (
|
|
<Loader2 className="animate-spin" size={16} />
|
|
) : (
|
|
<Send size={16} />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS --- */}
|
|
<div className="hidden flex-[1] flex-col gap-4 overflow-y-auto md:flex">
|
|
<h4 className="text-foreground flex items-center gap-2 text-left text-sm font-bold">
|
|
<Lightbulb size={18} className="text-primary" /> Acciones rápidas
|
|
</h4>
|
|
<div className="space-y-2 p-1">
|
|
{PRESETS.map((preset) => (
|
|
<button
|
|
key={preset.id}
|
|
onClick={() => handleSend(preset.prompt)}
|
|
className="bg-card hover:border-primary hover:bg-primary/5 group flex w-full items-center gap-3 rounded-xl border p-3 text-left text-sm shadow-sm transition-all"
|
|
>
|
|
<div className="bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary rounded-lg p-2 transition-colors">
|
|
<preset.icon size={16} />
|
|
</div>
|
|
<span className="text-foreground leading-tight font-medium">
|
|
{preset.label}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* --- DRAWER: HISTORIAL (Móvil) --- */}
|
|
<Drawer open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
|
|
<DrawerContent className="h-[80vh] p-4">
|
|
<Button
|
|
onClick={() => {
|
|
createNewChat()
|
|
setIsHistoryOpen(false)
|
|
}}
|
|
className="mb-4 w-full"
|
|
>
|
|
<MessageSquarePlus size={18} className="mr-2" /> Nuevo Chat
|
|
</Button>
|
|
<ScrollArea className="flex-1">
|
|
<p className="text-muted-foreground mb-4 text-xs font-bold uppercase">
|
|
Historial Reciente
|
|
</p>
|
|
{activeChats.map((chat) => (
|
|
<div
|
|
key={chat.id}
|
|
onClick={() => {
|
|
setActiveChatId(chat.id)
|
|
setIsHistoryOpen(false)
|
|
}}
|
|
className="border-border border-b p-3 text-sm"
|
|
>
|
|
{chat.nombre || 'Chat sin nombre'}
|
|
</div>
|
|
))}
|
|
</ScrollArea>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
{/* --- DRAWER: ACCIONES RÁPIDAS (Móvil) --- */}
|
|
<Drawer open={isActionsOpen} onOpenChange={setIsActionsOpen}>
|
|
<DrawerContent className="h-[60vh] p-4">
|
|
<h4 className="mb-4 flex items-center gap-2 font-bold">
|
|
<Lightbulb size={18} className="text-primary" /> Acciones rápidas
|
|
</h4>
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
{PRESETS.map((preset) => (
|
|
<button
|
|
key={preset.id}
|
|
onClick={() => {
|
|
handleSend(preset.prompt)
|
|
setIsActionsOpen(false)
|
|
}}
|
|
className="border-border flex items-center gap-3 rounded-xl border p-4 text-left text-sm"
|
|
>
|
|
<preset.icon size={16} />
|
|
<span>{preset.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer open={openIA} onOpenChange={setOpenIA}>
|
|
<DrawerContent className="bg-background fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl">
|
|
<div className="bg-muted/50 border-border flex items-center justify-between border-b px-4 py-3">
|
|
<h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
|
|
Referencias para la IA
|
|
</h2>
|
|
<button
|
|
onClick={() => setOpenIA(false)}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
<ReferenciasParaIA
|
|
selectedArchivoIds={selectedArchivoIds}
|
|
selectedRepositorioIds={selectedRepositorioIds}
|
|
uploadedFiles={uploadedFiles}
|
|
onToggleArchivo={(id, checked) => {
|
|
setSelectedArchivoIds((prev) =>
|
|
checked ? [...prev, id] : prev.filter((a) => a !== id),
|
|
)
|
|
}}
|
|
onToggleRepositorio={(id, checked) => {
|
|
setSelectedRepositorioIds((prev) =>
|
|
checked ? [...prev, id] : prev.filter((r) => r !== id),
|
|
)
|
|
}}
|
|
onFilesChange={(files) => {
|
|
setUploadedFiles(files)
|
|
}}
|
|
/>
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
</div>
|
|
)
|
|
}
|