diff --git a/src/components/planes/detalle/Ia/ImprovementCard.tsx b/src/components/planes/detalle/Ia/ImprovementCard.tsx index 67114b2..b27164c 100644 --- a/src/components/planes/detalle/Ia/ImprovementCard.tsx +++ b/src/components/planes/detalle/Ia/ImprovementCard.tsx @@ -2,21 +2,25 @@ import { Check, Loader2 } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui/button' -import { useUpdatePlanFields } from '@/data' // Tu hook existente +import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data' // Tu hook existente export const ImprovementCard = ({ suggestions, onApply, planId, // Necesitamos el ID currentDatos, // Necesitamos los datos actuales para no sobrescribir todo el JSON + activeChatId, }: { suggestions: Array onApply?: (key: string, value: string) => void planId: string currentDatos: any + activeChatId: any }) => { const [appliedFields, setAppliedFields] = useState>([]) + const [localApplied, setLocalApplied] = useState>([]) const updatePlan = useUpdatePlanFields() + const updateAppliedStatus = useUpdateRecommendationApplied() const handleApply = (key: string, newValue: string) => { if (!currentDatos) return @@ -52,6 +56,14 @@ export const ImprovementCard = ({ setAppliedFields((prev) => [...prev, key]) if (onApply) onApply(key, newValue) console.log(`Campo ${key} guardado exitosamente`) + if (activeChatId) { + updateAppliedStatus.mutate({ + conversacionId: activeChatId, + campoAfectado: key, + }) + } + + if (onApply) onApply(key, newValue) }, }, ) @@ -60,7 +72,7 @@ export const ImprovementCard = ({ return (
{suggestions.map((sug) => { - const isApplied = appliedFields.includes(sug.key) + const isApplied = sug.applied === true || localApplied.includes(sug.key) const isUpdating = updatePlan.isPending && updatePlan.variables.patch.datos?.[sug.key] !== undefined diff --git a/src/data/api/ai.api.ts b/src/data/api/ai.api.ts index ca0c080..0977490 100644 --- a/src/data/api/ai.api.ts +++ b/src/data/api/ai.api.ts @@ -181,3 +181,65 @@ export async function getConversationByPlan(planId: string) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return data ?? [] } + +export async function update_conversation_title( + conversacionId: string, + nuevoTitulo: string, +) { + const supabase = supabaseBrowser() + + const { data, error } = await supabase + .from('conversaciones_plan') + .update({ nombre: nuevoTitulo }) // Asegúrate que la columna se llame 'title' o 'nombre' + .eq('id', conversacionId) + .select() + .single() + + if (error) throw error + return data +} + +export async function update_recommendation_applied_status( + conversacionId: string, + campoAfectado: string, +) { + const supabase = supabaseBrowser() + + // 1. Obtener el estado actual del JSON + const { data: conv, error: fetchError } = await supabase + .from('conversaciones_plan') + .select('conversacion_json') + .eq('id', conversacionId) + .single() + + if (fetchError) throw fetchError + if (!conv?.conversacion_json) + throw new Error('No se encontró la conversación') + + // 2. Transformar el JSON para marcar como aplicada la recomendación específica + // Usamos una transformación inmutable para evitar efectos secundarios + const nuevoJson = (conv.conversacion_json as Array).map((msg) => { + if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) { + return { + ...msg, + recommendations: msg.recommendations.map((rec: any) => + rec.campo_afectado === campoAfectado + ? { ...rec, aplicada: true } + : rec, + ), + } + } + return msg + }) + + // 3. Actualizar la base de datos con el nuevo JSON + const { data, error: updateError } = await supabase + .from('conversaciones_plan') + .update({ conversacion_json: nuevoJson }) + .eq('id', conversacionId) + .select() + .single() + + if (updateError) throw updateError + return data +} diff --git a/src/data/hooks/useAI.ts b/src/data/hooks/useAI.ts index 5338a52..0aa655e 100644 --- a/src/data/hooks/useAI.ts +++ b/src/data/hooks/useAI.ts @@ -10,6 +10,7 @@ import { getConversationByPlan, library_search, update_conversation_status, + update_recommendation_applied_status, } from '../api/ai.api' // eslint-disable-next-line node/prefer-node-protocol @@ -91,6 +92,31 @@ export function useConversationByPlan(planId: string | null) { }) } +export function useUpdateRecommendationApplied() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ + conversacionId, + campoAfectado, + }: { + conversacionId: string + campoAfectado: string + }) => update_recommendation_applied_status(conversacionId, campoAfectado), + + onSuccess: (_, variables) => { + // Invalidamos la query para que useConversationByPlan refresque el JSON + qc.invalidateQueries({ queryKey: ['conversation-by-plan'] }) + console.log( + `Recomendación ${variables.campoAfectado} marcada como aplicada.`, + ) + }, + onError: (error) => { + console.error('Error al actualizar el estado de la recomendación:', error) + }, + }) +} + export function useAISubjectImprove() { return useMutation({ mutationFn: ai_subject_improve }) } diff --git a/src/routes/planes/$planId/_detalle/iaplan.tsx b/src/routes/planes/$planId/_detalle/iaplan.tsx index a8d6049..3e7c0f0 100644 --- a/src/routes/planes/$planId/_detalle/iaplan.tsx +++ b/src/routes/planes/$planId/_detalle/iaplan.tsx @@ -27,7 +27,6 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Textarea } from '@/components/ui/textarea' import { useAIPlanChat, - useChatHistory, useConversationByPlan, useUpdateConversationStatus, } from '@/data' @@ -84,8 +83,9 @@ function RouteComponent() { undefined, ) - const { data: historyMessages, isLoading: isLoadingHistory } = - useChatHistory(activeChatId) + /* const { data: historyMessages, isLoading: isLoadingHistory } = + useChatHistory(activeChatId) */ + const { data: lastConversation, isLoading: isLoadingConv } = useConversationByPlan(planId) // archivos @@ -106,61 +106,48 @@ function RouteComponent() { const scrollRef = useRef(null) const [showArchived, setShowArchived] = useState(false) + const [editingChatId, setEditingChatId] = useState(null) + const editableRef = useRef(null) + const { mutate: updateTitleMutation } = useUpdateConversationTitle() - useEffect(() => { - // 1. Si no hay ID o está cargando el historial, no hacemos nada - if (!activeChatId || isLoadingHistory) return + const availableFields = useMemo(() => { + if (!data?.estructuras_plan?.definicion?.properties) return [] + return Object.entries(data.estructuras_plan.definicion.properties).map( + ([key, value]) => ({ + key, + label: value.title, + value: String(value.description || ''), + }), + ) + }, [data]) + const activeChatData = useMemo(() => { + return lastConversation?.find((chat: any) => chat.id === activeChatId) + }, [lastConversation, activeChatId]) - const messagesFromApi = historyMessages?.items || historyMessages + const conversacionJson = activeChatData?.conversacion_json || [] + const chatMessages = useMemo(() => { + const json = activeChatData?.conversacion_json + if (!Array.isArray(json)) return [] - if (Array.isArray(messagesFromApi)) { - const flattened = messagesFromApi.map((msg) => { - let content = msg.content - let suggestions: Array = [] + return json.map((msg: any, index: number) => { + const isAssistant = msg.user === 'assistant' - if (typeof content === 'object' && content !== null) { - suggestions = Object.entries(content) - .filter(([key]) => key !== 'ai-message') - .map(([key, value]) => ({ - key, - label: key.replace(/_/g, ' '), - newValue: value as string, - })) - - content = content['ai-message'] || JSON.stringify(content) - } - // Si el content es un string que parece JSON (caso común en respuestas RAW) - else if (typeof content === 'string' && content.startsWith('{')) { - try { - const parsed = JSON.parse(content) - suggestions = Object.entries(parsed) - .filter(([key]) => key !== 'ai-message') - .map(([key, value]) => ({ - key, - label: key.replace(/_/g, ' '), - newValue: value as string, + return { + id: `${activeChatId}-${index}-${msg.timestamp}`, // ID estable + role: isAssistant ? 'assistant' : 'user', + content: isAssistant ? msg.message : msg.prompt, + suggestions: + isAssistant && msg.recommendations + ? msg.recommendations.map((rec: any) => ({ + key: rec.campo_afectado, + label: rec.campo_afectado.replace(/_/g, ' '), + newValue: rec.texto_mejora, + applied: rec.aplicada, })) - content = parsed['ai-message'] || content - } catch (e) { - /* no es json */ - } - } - - return { - ...msg, - content, - suggestions, - type: suggestions.length > 0 ? 'improvement-card' : 'text', - } - }) - - // Solo actualizamos si no estamos esperando la respuesta de un POST - // para evitar saltos visuales - if (!isLoading) { - setMessages(flattened.reverse()) + : [], } - } - }, [historyMessages, activeChatId, isLoadingHistory, isLoading]) + }) + }, [activeChatData, activeChatId]) useEffect(() => { // Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes @@ -321,13 +308,6 @@ function RouteComponent() { const currentFields = [...selectedFields] const finalPrompt = buildPrompt(rawText, currentFields) - const userMsg = { - id: Date.now().toString(), - role: 'user', - content: rawText, - } - - setMessages((prev) => [...prev, userMsg]) setInput('') // setSelectedFields([]) @@ -346,58 +326,14 @@ function RouteComponent() { if (response.conversacionId && response.conversacionId !== activeChatId) { setActiveChatId(response.conversacionId) - - // Esto obliga a 'useConversationByPlan' a buscar en la DB el nuevo chat creado - queryClient.invalidateQueries({ - queryKey: ['conversation-by-plan', planId], - }) } - // --- NUEVA LÓGICA DE PARSEO --- - let aiText = 'Sin respuesta del asistente' - let suggestions: Array = [] - - if (response.raw) { - try { - const rawData = JSON.parse(response.raw) - - // Extraemos el mensaje conversacional - aiText = rawData['ai-message'] || 'Cambios aplicados con éxito.' - - // Filtramos todo lo que no sea el mensaje para crear las sugerencias - suggestions = Object.entries(rawData) - .filter(([key]) => key !== 'ai-message') - .map(([key, value]) => ({ - key, - label: key.replace(/_/g, ' '), - newValue: value as string, - })) - } catch (e) { - console.error('Error parseando el campo raw:', e) - aiText = response.raw // Fallback si no es JSON - } - } - - setMessages((prev) => [ - ...prev, - { - id: Date.now().toString(), - role: 'assistant', - content: aiText, - type: suggestions.length > 0 ? 'improvement-card' : 'text', - suggestions: suggestions, - }, - ]) + await queryClient.invalidateQueries({ + queryKey: ['conversation-by-plan', planId], + }) } catch (error) { console.error('Error en el chat:', error) - setMessages((prev) => [ - ...prev, - { - id: 'error', - role: 'assistant', - content: 'Lo siento, hubo un error al procesar tu solicitud.', - }, - ]) + // Aquí sí podrías usar un toast o un mensaje de error temporal } } @@ -543,7 +479,7 @@ function RouteComponent() {
- {messages.map((msg) => ( + {chatMessages.map((msg) => (
{ - // Esto es opcional, si quieres hacer algo más en la UI del chat - console.log( - 'Evento onApply disparado desde el chat', - ) - }} + planId={planId} + currentDatos={data?.datos} + activeChatId={activeChatId} + // Puedes pasar una prop nueva si tu ImprovementCard la soporta: + // isReadOnly={msg.suggestions.every(s => s.applied)} />
)}