Implementar conversación con HOOKS #111 #120

Merged
roberto.silva merged 1 commits from issue/111-implementar-conversacin-con-hooks into main 2026-02-18 21:42:23 +00:00
3 changed files with 432 additions and 166 deletions

View File

@@ -1,81 +1,177 @@
import { invokeEdge } from "../supabase/invokeEdge";
import type { InteraccionIA, UUID } from "../types/domain";
import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from '../supabase/invokeEdge'
import type { InteraccionIA, UUID } from '../types/domain'
const EDGE = {
ai_plan_improve: "ai_plan_improve",
ai_plan_chat: "ai_plan_chat",
ai_subject_improve: "ai_subject_improve",
ai_subject_chat: "ai_subject_chat",
ai_plan_improve: 'ai_plan_improve',
ai_plan_chat: 'ai_plan_chat',
ai_subject_improve: 'ai_subject_improve',
ai_subject_chat: 'ai_subject_chat',
library_search: "library_search",
} as const;
library_search: 'library_search',
} as const
export async function ai_plan_improve(payload: {
planId: UUID;
sectionKey: string; // ej: "perfil_de_egreso" o tu key interna
prompt: string;
context?: Record<string, any>;
planId: UUID
sectionKey: string // ej: "perfil_de_egreso" o tu key interna
prompt: string
context?: Record<string, any>
fuentes?: {
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_plan_improve, payload);
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
EDGE.ai_plan_improve,
payload,
)
}
export async function ai_plan_chat(payload: {
planId: UUID;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
planId: UUID
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
fuentes?: {
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_plan_chat, payload);
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
EDGE.ai_plan_chat,
payload,
)
}
export async function ai_subject_improve(payload: {
subjectId: UUID;
sectionKey: string;
prompt: string;
context?: Record<string, any>;
subjectId: UUID
sectionKey: string
prompt: string
context?: Record<string, any>
fuentes?: {
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_subject_improve, payload);
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
EDGE.ai_subject_improve,
payload,
)
}
export async function ai_subject_chat(payload: {
subjectId: UUID;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
subjectId: UUID
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
fuentes?: {
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_subject_chat, payload);
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
EDGE.ai_subject_chat,
payload,
)
}
/** Biblioteca (Edge; adapta a tu API real) */
export type LibraryItem = {
id: string;
titulo: string;
autor?: string;
isbn?: string;
citaSugerida?: string;
disponibilidad?: string;
};
export async function library_search(payload: { query: string; limit?: number }): Promise<LibraryItem[]> {
return invokeEdge<LibraryItem[]>(EDGE.library_search, payload);
id: string
titulo: string
autor?: string
isbn?: string
citaSugerida?: string
disponibilidad?: string
}
export async function library_search(payload: {
query: string
limit?: number
}): Promise<Array<LibraryItem>> {
return invokeEdge<Array<LibraryItem>>(EDGE.library_search, payload)
}
export async function create_conversation(planId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/conversations',
{
method: 'POST',
body: {
plan_estudio_id: planId, // O el nombre que confirmamos que funciona
instanciador: 'alex',
},
},
)
if (error) throw error
// LOG de depuración: Mira qué estructura trae 'data'
console.log('Respuesta creación conv:', data)
// Si data es { id: "..." }, devolvemos data.
// Si data viene envuelto, asegúrate de retornar el objeto con el id.
return data
}
export async function get_chat_history(conversacionId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${conversacionId}/messages`,
{ method: 'GET' },
)
if (error) throw error
return data // Retorna Array de mensajes
}
export async function archive_conversation(conversacionId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${conversacionId}/archive`,
{ method: 'DELETE' },
)
if (error) throw error
return data
}
// Modificamos la función de chat para que use la ruta de mensajes
export async function ai_plan_chat_v2(payload: {
conversacionId: string
content: string
campos?: Array<string>
}): Promise<{ reply: string; meta?: any }> {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${payload.conversacionId}/messages`,
{
method: 'POST',
body: {
content: payload.content,
campos: payload.campos || [],
},
},
)
if (error) throw error
return data
}
export async function getConversationByPlan(planId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_plan')
.select('*')
.eq('plan_estudio_id', planId)
.eq('estado', 'ACTIVA')
.order('creado_en', { ascending: true }) // Añade un orden para que el último sea el más nuevo
if (error) throw error
return data ?? [] // Devuelve un array vacío en lugar de null para evitar el "undefined"
}

View File

@@ -1,29 +1,94 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ai_plan_chat,
ai_plan_chat_v2,
ai_plan_improve,
ai_subject_chat,
ai_subject_improve,
archive_conversation,
create_conversation,
get_chat_history,
getConversationByPlan,
library_search,
} from "../api/ai.api";
} from '../api/ai.api'
export function useAIPlanImprove() {
return useMutation({ mutationFn: ai_plan_improve });
return useMutation({ mutationFn: ai_plan_improve })
}
export function useAIPlanChat() {
return useMutation({ mutationFn: ai_plan_chat });
return useMutation({
mutationFn: async (payload: {
planId: UUID
content: string
campos?: Array<string>
conversacionId?: string
}) => {
let currentId = payload.conversacionId
// 1. Si no hay ID, creamos la conversación
if (!currentId) {
const response = await create_conversation(payload.planId)
// CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola
currentId = response.conversation_plan.id
console.log('Nuevo ID extraído:', currentId)
}
// 2. Ahora enviamos el mensaje con el ID garantizado
const result = await ai_plan_chat_v2({
conversacionId: currentId!,
content: payload.content,
campos: payload.campos,
})
// Retornamos el resultado del chat y el ID para el estado del componente
return { ...result, conversacionId: currentId }
},
})
}
export function useChatHistory(conversacionId?: string) {
return useQuery({
queryKey: ['chat-history', conversacionId],
queryFn: async () => {
console.log('--- EJECUTANDO QUERY FN ---')
console.log('ID RECIBIDO:', conversacionId)
return get_chat_history(conversacionId!)
},
// Simplificamos el enabled para probar
enabled: Boolean(conversacionId),
})
}
export function useArchiveConversation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => archive_conversation(id),
onSuccess: () => {
// Opcional: limpiar datos viejos de la caché
queryClient.invalidateQueries({ queryKey: ['chat-history'] })
},
})
}
export function useConversationByPlan(planId: string | null) {
return useQuery({
queryKey: ['conversation-by-plan', planId],
queryFn: () => getConversationByPlan(planId!),
enabled: !!planId, // solo ejecuta si existe planId
})
}
export function useAISubjectImprove() {
return useMutation({ mutationFn: ai_subject_improve });
return useMutation({ mutationFn: ai_subject_improve })
}
export function useAISubjectChat() {
return useMutation({ mutationFn: ai_subject_chat });
return useMutation({ mutationFn: ai_subject_chat })
}
export function useLibrarySearch() {
return useMutation({ mutationFn: library_search });
return useMutation({ mutationFn: library_search })
}

View File

@@ -1,5 +1,6 @@
/* 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,
@@ -24,6 +25,12 @@ 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 {
useAIPlanChat,
useArchiveConversation,
useChatHistory,
useConversationByPlan,
} from '@/data'
import { usePlan } from '@/data/hooks/usePlans'
const PRESETS = [
@@ -67,9 +74,21 @@ export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
function RouteComponent() {
const { planId } = Route.useParams()
const { data } = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f')
const { data } = usePlan(planId)
const routerState = useRouterState()
const [openIA, setOpenIA] = useState(false)
const [conversacionId, setConversacionId] = useState<string | null>(null)
const { mutateAsync: sendChat, isLoading } = useAIPlanChat()
const { mutate: archiveChatMutation } = useArchiveConversation()
const [activeChatId, setActiveChatId] = useState<string | undefined>(
undefined,
)
const { data: historyMessages, isLoading: isLoadingHistory } =
useChatHistory(activeChatId)
const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId)
// archivos
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[],
@@ -79,25 +98,18 @@ function RouteComponent() {
>([])
const [uploadedFiles, setUploadedFiles] = useState<Array<UploadedFile>>([])
const [messages, setMessages] = useState<Array<any>>([
{
id: '1',
role: 'assistant',
content:
'¡Hola! Soy tu asistente de IA. ¿Qué campos deseas mejorar? Puedes escribir ":" para seleccionar uno.',
},
])
const [messages, setMessages] = useState<Array<any>>([])
const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// const [isLoading, setIsLoading] = useState(false)
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const queryClient = useQueryClient()
const scrollRef = useRef<HTMLDivElement>(null)
const [activeChatId, setActiveChatId] = useState('1')
const [chatHistory, setChatHistory] = useState([
{ id: '1', title: 'Chat inicial' },
])
const chatHistory = useMemo(() => {
return lastConversation || []
}, [lastConversation])
const [showArchived, setShowArchived] = useState(false)
const [archivedHistory, setArchivedHistory] = useState<Array<any>>([])
const [allMessages, setAllMessages] = useState<{ [key: string]: Array<any> }>(
@@ -111,36 +123,76 @@ function RouteComponent() {
],
},
)
const createNewChat = () => {
const newId = Date.now().toString()
const newChat = { id: newId, title: `Nuevo chat ${chatHistory.length + 1}` }
useEffect(() => {
if (isLoadingHistory) return
setChatHistory([newChat, ...chatHistory])
setAllMessages({
...allMessages,
[newId]: [
{
id: '1',
role: 'assistant',
content: '¡Nuevo chat creado! ¿En qué puedo ayudarte?',
},
],
const messagesToProcess = historyMessages?.items || historyMessages
if (activeChatId && Array.isArray(messagesToProcess)) {
const flattened = messagesToProcess.map((msg) => {
let content = msg.content
// Tu lógica de parseo existente...
if (typeof content === 'object' && content !== null) {
content = content['ai-message'] || JSON.stringify(content)
}
return { ...msg, content }
})
setActiveChatId(newId)
setMessages(flattened.reverse())
} else if (!activeChatId) {
setMessages([
{
id: 'welcome',
role: 'assistant',
content:
'¡Hola! Soy tu asistente de IA. ¿Qué campos deseas mejorar? Usa ":" para seleccionar.',
},
])
}
}, [historyMessages, activeChatId, isLoadingHistory])
useEffect(() => {
// Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
if (
!activeChatId &&
lastConversation &&
lastConversation.length > 0 &&
!isCreationMode
) {
setActiveChatId(lastConversation[0].id)
}
}, [lastConversation, activeChatId])
const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
setMessages([
{
id: 'welcome',
role: 'assistant',
content: 'Iniciando una nueva conversación. ¿En qué puedo ayudarte?',
},
])
setInput('')
setSelectedFields([])
}
const archiveChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
e.stopPropagation() // Evita que se seleccione el chat al intentar archivarlo
const chatToArchive = chatHistory.find((chat) => chat.id === id)
if (chatToArchive) {
setArchivedHistory([chatToArchive, ...archivedHistory])
const newHistory = chatHistory.filter((chat) => chat.id !== id)
setChatHistory(newHistory)
if (activeChatId === id && newHistory.length > 0) {
setActiveChatId(newHistory[0].id)
}
archiveChatMutation(id, {
onSuccess: () => {
// 1. Invalidamos las listas para que desaparezca de activos y aparezca en archivados
queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId],
})
// 2. Si el chat archivado era el que tenías abierto, limpia la pantalla
if (activeChatId === id) {
setActiveChatId(undefined)
setMessages([])
}
},
})
}
const unarchiveChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
@@ -185,13 +237,8 @@ function RouteComponent() {
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value
setInput(val)
// Si el último carácter es ':', mostramos sugerencias
if (val.endsWith(':')) {
setShowSuggestions(true)
} else {
setShowSuggestions(false)
}
// Solo abrir si termina en ":"
setShowSuggestions(val.endsWith(':'))
}
const injectFieldsIntoInput = (
@@ -209,83 +256,136 @@ function RouteComponent() {
}
const toggleField = (field: SelectedField) => {
// 1. Actualizamos los campos seleccionados (para los badges y la lógica de la IA)
let isAdding = false
setSelectedFields((prev) => {
const isSelected = prev.find((f) => f.key === field.key)
return isSelected ? prev : [...prev, field]
if (isSelected) {
return prev.filter((f) => f.key !== field.key)
} else {
isAdding = true
return [...prev, field]
}
})
// 2. Insertamos el nombre del campo en el texto y quitamos el ":"
setInput((prevInput) => {
// Buscamos la última posición del ":"
const lastColonIndex = prevInput.lastIndexOf(':')
if (isAdding) {
setInput((prev) => {
// 1. Eliminamos TODOS los ":" que existan en el texto actual
// 2. Quitamos espacios en blanco extra al final
const cleanPrev = prev.replace(/:/g, '').trim()
if (lastColonIndex !== -1) {
// Tomamos lo que está antes del ":" y le concatenamos el nombre del campo
const textBefore = prevInput.substring(0, lastColonIndex)
const textAfter = prevInput.substring(lastColonIndex + 1)
// Retornamos el texto con el nombre del campo (puedes añadir espacio si prefieres)
return `${textBefore} ${field.label}${textAfter}`
// 3. Si el input resultante está vacío, solo ponemos la frase
if (cleanPrev === '') {
return `${field.label} `
}
return prevInput
// 4. Si ya había algo, lo concatenamos con un espacio
// Usamos un espacio simple al final para que el usuario pueda seguir escribiendo
return `${cleanPrev} ${field.label} `
})
}
setShowSuggestions(false)
}
const buildPrompt = (userInput: string) => {
// Si no hay campos, enviamos solo el texto
if (selectedFields.length === 0) return userInput
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
// Si no hay campos, enviamos el texto tal cual
if (fields.length === 0) return userInput
const fieldsText = selectedFields
.map(
(f) =>
`### CAMPO: ${f.label}\nCONTENIDO ACTUAL: ${f.value || '(vacío)'}`,
)
.join('\n\n')
// Si hay campos, creamos un bloque de contexto superior
const fieldsContext = fields
.map((f) => `[CAMPO SELECCIONADO: ${f.label}]`)
.join(' ')
return `Instrucción del usuario: ${userInput || 'Mejora los campos seleccionados.'}
A continuación se detallan los campos a procesar:
${fieldsText}`.trim()
return `${fieldsContext}\n\nInstrucción del usuario: ${userInput}`
}
const handleSend = async (promptOverride?: string) => {
const rawText = promptOverride || input
if (!rawText.trim() && selectedFields.length === 0) return
const finalPrompt = buildPrompt(rawText)
const currentFields = [...selectedFields]
const finalPrompt = buildPrompt(rawText, currentFields)
const userMsg = {
id: Date.now().toString(),
role: 'user',
content: finalPrompt,
content: rawText,
}
setMessages((prev) => [...prev, userMsg])
setInput('')
setIsLoading(true)
setSelectedArchivoIds([])
setSelectedRepositorioIds([])
setUploadedFiles([])
setTimeout(() => {
const suggestions = selectedFields.map((field) => ({
key: field.key,
label: field.label,
newValue: field.value,
// setSelectedFields([])
try {
const payload: any = {
planId: planId,
content: finalPrompt,
conversacionId: activeChatId || undefined,
}
if (currentFields.length > 0) {
payload.campos = currentFields.map((f) => f.key)
}
const response = await sendChat(payload)
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<any> = []
if (response.raw) {
try {
// Parseamos el string JSON que viene en 'raw'
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',
type: 'improvement-card',
content:
'He analizado los campos seleccionados. Aquí tienes mis sugerencias de mejora:',
content: aiText,
type: suggestions.length > 0 ? 'improvement-card' : 'text',
suggestions: suggestions,
},
])
setIsLoading(false)
}, 1200)
} 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.',
},
])
}
}
const totalReferencias = useMemo(() => {
@@ -417,7 +517,8 @@ ${fieldsText}`.trim()
<div className="mx-auto max-w-3xl space-y-6 p-6">
{messages.map((msg) => (
<div
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
key={msg.id}
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'ml-auto items-end' : 'items-start'}`}
>
<div
className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${
@@ -426,19 +527,23 @@ ${fieldsText}`.trim()
: 'rounded-tl-none border bg-white text-slate-700'
}`}
>
{/* Contenido de texto normal */}
{msg.content}
{msg.type === 'improvement-card' && (
{/* Si el mensaje tiene sugerencias (ImprovementCard) */}
{msg.suggestions && msg.suggestions.length > 0 && (
<div className="mt-4">
<ImprovementCard
suggestions={msg.suggestions}
onApply={(key, val) => {
console.log(`Aplicando ${val} al campo ${key}`)
setSelectedFields((prev) =>
prev.filter((f) => f.key !== key),
)
console.log(`Aplicando ${val} al campo ${key}`)
// Aquí llamarías a tu función de actualización de datos real
// Aquí llamarías a tu mutación de actualizar el plan
}}
/>
</div>
)}
</div>
</div>