feat(assistant): add persistent chat history, continuous learning and rating system (good/bad)
This commit is contained in:
100
src/app/api/chat/feedback/route.ts
Normal file
100
src/app/api/chat/feedback/route.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
|
||||||
|
|
||||||
|
// 1. Autenticación de usuario
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Contexto de tenant activo
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
const tenantId = ctx.tenantId;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: 'No active tenant selected' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Parsear body
|
||||||
|
let messageId = '';
|
||||||
|
let rating: 'good' | 'bad' | null = null;
|
||||||
|
let notes: string | undefined = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
messageId = typeof body.messageId === 'string' ? body.messageId.trim() : '';
|
||||||
|
if (body.rating === 'good' || body.rating === 'bad' || body.rating === null) {
|
||||||
|
rating = body.rating;
|
||||||
|
}
|
||||||
|
if (typeof body.notes === 'string') {
|
||||||
|
notes = body.notes.trim();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!messageId) {
|
||||||
|
return NextResponse.json({ error: 'messageId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Actualizar tool_calls con el rating para feedback y aprendizaje
|
||||||
|
const admin = createAdminClient(supabaseUrl, serviceKey);
|
||||||
|
|
||||||
|
const { data: currentMsg, error: fetchErr } = await admin
|
||||||
|
.from('chat_messages')
|
||||||
|
.select('id, tool_calls, tenant_id, role')
|
||||||
|
.eq('id', messageId)
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (fetchErr) {
|
||||||
|
console.error('Error fetching message for feedback:', fetchErr);
|
||||||
|
return NextResponse.json({ error: fetchErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentMsg) {
|
||||||
|
return NextResponse.json({ error: 'Message not found in active tenant' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingToolCalls = (currentMsg.tool_calls && typeof currentMsg.tool_calls === 'object')
|
||||||
|
? currentMsg.tool_calls
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const updatedToolCalls = {
|
||||||
|
...existingToolCalls,
|
||||||
|
rating,
|
||||||
|
rated_at: rating ? new Date().toISOString() : null,
|
||||||
|
rated_by: rating ? user.id : null,
|
||||||
|
notes: notes !== undefined ? notes : (existingToolCalls as any).notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error: updateErr } = await admin
|
||||||
|
.from('chat_messages')
|
||||||
|
.update({ tool_calls: updatedToolCalls })
|
||||||
|
.eq('id', messageId)
|
||||||
|
.eq('tenant_id', tenantId);
|
||||||
|
|
||||||
|
if (updateErr) {
|
||||||
|
console.error('Error updating message feedback:', updateErr);
|
||||||
|
return NextResponse.json({ error: updateErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
messageId,
|
||||||
|
rating,
|
||||||
|
updatedAt: updatedToolCalls.rated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,11 +1,90 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
import { buildClientAssistantContext } from '@/lib/assistant-context';
|
import { buildClientAssistantContext } from '@/lib/assistant-context';
|
||||||
import Anthropic from '@anthropic-ai/sdk';
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
const tenantId = ctx.tenantId;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ messages: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = createAdminClient(supabaseUrl, serviceKey);
|
||||||
|
const { data, error } = await admin
|
||||||
|
.from('chat_messages')
|
||||||
|
.select('id, role, content, tool_calls, created_at')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.order('created_at', { ascending: true })
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching chat history:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = (data || []).map((m: any) => ({
|
||||||
|
id: m.id,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
rating: m.tool_calls?.rating || null,
|
||||||
|
createdAt: m.created_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ messages });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
const tenantId = ctx.tenantId;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ ok: true, deleted: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = createAdminClient(supabaseUrl, serviceKey);
|
||||||
|
const { error } = await admin
|
||||||
|
.from('chat_messages')
|
||||||
|
.delete()
|
||||||
|
.eq('tenant_id', tenantId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error deleting chat history:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, message: 'Historial eliminado con éxito' });
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
|
||||||
@@ -99,7 +178,26 @@ Instrucciones:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Caso: Cliente específico activo -> Cargar TODO el contexto del cliente
|
// 5. Caso: Cliente específico activo -> Persistir pregunta del usuario en el historial
|
||||||
|
const admin = createAdminClient(supabaseUrl, serviceKey);
|
||||||
|
let userMessageRowId: string | null = null;
|
||||||
|
try {
|
||||||
|
const { data: uRow } = await admin
|
||||||
|
.from('chat_messages')
|
||||||
|
.insert({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: user.id,
|
||||||
|
role: 'user',
|
||||||
|
content: userMessage,
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
if (uRow?.id) userMessageRowId = uRow.id;
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('Error guardando mensaje del usuario en chat_messages:', dbErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Cargar TODO el contexto del cliente (incluyendo histórico y feedback)
|
||||||
let clientContext;
|
let clientContext;
|
||||||
try {
|
try {
|
||||||
clientContext = await buildClientAssistantContext(
|
clientContext = await buildClientAssistantContext(
|
||||||
@@ -215,7 +313,36 @@ REGLAS Y PAUTAS INMUTABLES:
|
|||||||
.map((b) => (b.type === 'text' ? b.text : ''))
|
.map((b) => (b.type === 'text' ? b.text : ''))
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
return NextResponse.json([{ text: replyText }]);
|
let assistantMessageRowId: string | null = null;
|
||||||
|
try {
|
||||||
|
const { data: aRow } = await admin
|
||||||
|
.from('chat_messages')
|
||||||
|
.insert({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: user.id,
|
||||||
|
role: 'assistant',
|
||||||
|
content: replyText,
|
||||||
|
tool_calls: {
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
user_message_id: userMessageRowId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
if (aRow?.id) assistantMessageRowId = aRow.id;
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('Error guardando respuesta del asistente en chat_messages:', dbErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json([
|
||||||
|
{
|
||||||
|
id: assistantMessageRowId,
|
||||||
|
role: 'assistant',
|
||||||
|
text: replyText,
|
||||||
|
content: replyText,
|
||||||
|
rating: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
} catch (apiErr: any) {
|
} catch (apiErr: any) {
|
||||||
console.error('Error al consultar la API de Anthropic Claude:', apiErr);
|
console.error('Error al consultar la API de Anthropic Claude:', apiErr);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Bot, Send, Sparkles, TrendingUp, Target, Filter, DollarSign, BookOpen, HelpCircle } from 'lucide-react';
|
import {
|
||||||
|
Bot,
|
||||||
|
Send,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
|
Target,
|
||||||
|
Filter,
|
||||||
|
DollarSign,
|
||||||
|
BookOpen,
|
||||||
|
HelpCircle,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
RotateCcw,
|
||||||
|
CheckCircle,
|
||||||
|
Info
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id?: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
rating?: 'good' | 'bad' | null;
|
||||||
|
ratingLoading?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function FormattedMessage({ content }: { content: string }) {
|
function FormattedMessage({ content }: { content: string }) {
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
@@ -101,30 +125,70 @@ function FormattedMessage({ content }: { content: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WELCOME_MESSAGE: ChatMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content:
|
||||||
|
'¡Hola! Soy tu **Asistente Financiero y de Crecimiento de 4Ventures** potenciado por Claude. Tengo acceso en tiempo real a toda la información de tu cuenta: finanzas, metas del mes, analítica publicitaria, embudo de ventas, plataforma de marca y calendario editorial. Además, **aprendo continuamente de tus valoraciones** (👍 / 👎) para afinar cada respuesta. ¿Qué deseas consultar hoy?'
|
||||||
|
};
|
||||||
|
|
||||||
export default function AsistentePage() {
|
export default function AsistentePage() {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([
|
const [messages, setMessages] = useState<ChatMessage[]>([DEFAULT_WELCOME_MESSAGE]);
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: '¡Hola! Soy tu **Asistente Financiero y de Crecimiento de 4Ventures** potenciado por Claude. Tengo acceso en tiempo real a toda la información de tu cuenta: finanzas, metas del mes, analítica publicitaria, embudo de ventas, plataforma de marca y calendario editorial. ¿Qué deseas consultar hoy?'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const showToast = (msg: string) => {
|
||||||
|
setToastMessage(msg);
|
||||||
|
setTimeout(() => {
|
||||||
|
setToastMessage((current) => (current === msg ? null : current));
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cargar historial de chat persistente en Supabase
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
async function fetchChatHistory() {
|
||||||
}, [messages, loading]);
|
try {
|
||||||
|
setInitialLoading(true);
|
||||||
|
const res = await fetch('/api/chat', { cache: 'no-store' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data.messages) && data.messages.length > 0) {
|
||||||
|
setMessages(data.messages);
|
||||||
|
} else {
|
||||||
|
setMessages([DEFAULT_WELCOME_MESSAGE]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error al cargar historial de chat:', e);
|
||||||
|
} finally {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchChatHistory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialLoading) {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}, [messages, loading, initialLoading]);
|
||||||
|
|
||||||
|
// Enviar mensaje
|
||||||
const sendMessage = async (textToSend?: string) => {
|
const sendMessage = async (textToSend?: string) => {
|
||||||
const text = textToSend || input;
|
const text = textToSend || input;
|
||||||
if (!text.trim() || loading) return;
|
if (!text.trim() || loading) return;
|
||||||
|
|
||||||
const newMessages = [...messages, { role: 'user', content: text }];
|
const userMsg: ChatMessage = { role: 'user', content: text };
|
||||||
|
const newMessages = [...messages, userMsg];
|
||||||
setMessages(newMessages);
|
setMessages(newMessages);
|
||||||
setInput('');
|
setInput('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -135,46 +199,187 @@ export default function AsistentePage() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: text,
|
message: text,
|
||||||
messages: newMessages
|
messages: newMessages.map((m) => ({ role: m.role, content: m.content }))
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
let assistantText = '';
|
let assistantText = '';
|
||||||
if (Array.isArray(data) && data[0]?.text) {
|
let assistantId: string | undefined = undefined;
|
||||||
assistantText = data[0].text;
|
let assistantRating: 'good' | 'bad' | null = null;
|
||||||
} else if (data && typeof data.text === 'string') {
|
|
||||||
assistantText = data.text;
|
if (Array.isArray(data) && data[0]) {
|
||||||
|
assistantText = data[0].text || data[0].content || '';
|
||||||
|
assistantId = data[0].id;
|
||||||
|
assistantRating = data[0].rating || null;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
assistantText = data.text || data.content || '';
|
||||||
|
assistantId = data.id;
|
||||||
|
assistantRating = data.rating || null;
|
||||||
} else if (typeof data === 'string') {
|
} else if (typeof data === 'string') {
|
||||||
assistantText = data;
|
assistantText = data;
|
||||||
} else {
|
} else {
|
||||||
assistantText = JSON.stringify(data);
|
assistantText = JSON.stringify(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages([...newMessages, { role: 'assistant', content: assistantText }]);
|
setMessages([
|
||||||
|
...newMessages,
|
||||||
|
{
|
||||||
|
id: assistantId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantText,
|
||||||
|
rating: assistantRating,
|
||||||
|
}
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
setMessages([...newMessages, { role: 'assistant', content: 'Lo siento, hubo un problema al procesar la consulta con las herramientas de analítica.' }]);
|
setMessages([
|
||||||
|
...newMessages,
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Lo siento, hubo un problema al procesar la consulta con las herramientas de analítica.'
|
||||||
|
}
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessages([...newMessages, { role: 'assistant', content: 'Error de conexión con el servidor.' }]);
|
setMessages([
|
||||||
|
...newMessages,
|
||||||
|
{ role: 'assistant', content: 'Error de conexión con el servidor.' }
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Valorar respuesta para aprendizaje continuo
|
||||||
|
const handleRateMessage = async (messageId: string | undefined, index: number, targetRating: 'good' | 'bad') => {
|
||||||
|
if (!messageId) return;
|
||||||
|
|
||||||
|
const currentMsg = messages[index];
|
||||||
|
const previousRating = currentMsg?.rating;
|
||||||
|
const newRating = previousRating === targetRating ? null : targetRating;
|
||||||
|
|
||||||
|
// Actualización optimista
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m, i) => (i === index ? { ...m, rating: newRating, ratingLoading: true } : m))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/chat/feedback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
messageId,
|
||||||
|
rating: newRating,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
if (newRating === 'good') {
|
||||||
|
showToast('¡Respuesta guardada! Claude tomará este ejemplo para calibrar futuras respuestas.');
|
||||||
|
} else if (newRating === 'bad') {
|
||||||
|
showToast('Feedback registrado. Claude evitará repetir este enfoque o falta de concreción.');
|
||||||
|
} else {
|
||||||
|
showToast('Valoración retirada.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Rollback
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m, i) => (i === index ? { ...m, rating: previousRating } : m))
|
||||||
|
);
|
||||||
|
showToast('No se pudo guardar la valoración.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m, i) => (i === index ? { ...m, rating: previousRating } : m))
|
||||||
|
);
|
||||||
|
showToast('Error de conexión al guardar la valoración.');
|
||||||
|
} finally {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m, i) => (i === index ? { ...m, ratingLoading: false } : m))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limpiar / Reiniciar historial
|
||||||
|
const handleClearChat = async () => {
|
||||||
|
if (clearing) return;
|
||||||
|
setClearing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/chat', { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
setMessages([DEFAULT_WELCOME_MESSAGE]);
|
||||||
|
setConfirmClear(false);
|
||||||
|
showToast('Historial reiniciado correctamente.');
|
||||||
|
} else {
|
||||||
|
showToast('Error al reiniciar el historial.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Error de conexión al limpiar el historial.');
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-10rem)] bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
<div className="flex flex-col h-[calc(100vh-10rem)] bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm relative">
|
||||||
|
{/* Toast Notification */}
|
||||||
|
{toastMessage && (
|
||||||
|
<div className="absolute top-16 left-1/2 -translate-x-1/2 z-50 bg-gray-900 text-white px-4 py-2 rounded-xl text-xs shadow-lg flex items-center gap-2 animate-in fade-in slide-in-from-top-2 border border-gray-700">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-brand-400 shrink-0" />
|
||||||
|
<span>{toastMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between bg-white">
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between bg-white shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-brand-500/20 text-brand-500 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-brand-500/20 text-brand-500 flex items-center justify-center">
|
||||||
<Bot className="w-5 h-5" />
|
<Bot className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-bold text-sm text-gray-900">Asistente IA Financiero & Growth</h3>
|
<h3 className="font-bold text-sm text-gray-900">Asistente IA Financiero & Growth</h3>
|
||||||
<p className="text-[11px] text-gray-500">Motor Claude 4.5 Sonnet · Acceso Integral a Datos del Cliente (Finanzas, KPIs, Marca y Contenidos)</p>
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
Aprendizaje Activo
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-500">Motor Claude 4.5 Sonnet · Memoria persistente y calibración continua con feedback</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{confirmClear ? (
|
||||||
|
<div className="flex items-center gap-1.5 bg-rose-50 border border-rose-200 px-2 py-1 rounded-lg">
|
||||||
|
<span className="text-xs text-rose-700 font-medium">¿Borrar historial?</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearChat}
|
||||||
|
disabled={clearing}
|
||||||
|
className="text-xs font-semibold px-2 py-0.5 bg-rose-600 text-white rounded hover:bg-rose-700 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{clearing ? 'Borrando...' : 'Sí, borrar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmClear(false)}
|
||||||
|
className="text-xs text-gray-600 px-1.5 py-0.5 hover:text-gray-900 cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmClear(true)}
|
||||||
|
title="Reiniciar conversación y limpiar historial"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-200 text-xs text-gray-600 hover:text-rose-600 hover:bg-rose-50/50 hover:border-rose-200 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span>Limpiar chat</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,7 +387,7 @@ export default function AsistentePage() {
|
|||||||
<div className="flex-1 p-6 overflow-y-auto space-y-4">
|
<div className="flex-1 p-6 overflow-y-auto space-y-4">
|
||||||
{messages.map((m, idx) => (
|
{messages.map((m, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={m.id || idx}
|
||||||
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -193,6 +398,64 @@ export default function AsistentePage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FormattedMessage content={m.content} />
|
<FormattedMessage content={m.content} />
|
||||||
|
|
||||||
|
{/* Barra de valoración para respuestas del asistente */}
|
||||||
|
{m.role === 'assistant' && (
|
||||||
|
<div className="mt-3 pt-2.5 border-t border-gray-200/80 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[11px] text-gray-400 font-medium">¿Resultado útil?</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRateMessage(m.id, idx, 'good')}
|
||||||
|
disabled={!m.id || m.ratingLoading}
|
||||||
|
title={!m.id ? 'Mensaje inicial del sistema' : 'Buen resultado — Claude aprenderá de este formato'}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs transition-all ${
|
||||||
|
!m.id
|
||||||
|
? 'opacity-40 cursor-not-allowed border-gray-200 text-gray-400'
|
||||||
|
: m.rating === 'good'
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold shadow-xs cursor-pointer'
|
||||||
|
: 'bg-white text-gray-600 border-gray-200 hover:bg-emerald-50/60 hover:text-emerald-700 hover:border-emerald-200 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ThumbsUp className={`w-3.5 h-3.5 ${m.rating === 'good' ? 'fill-emerald-600 text-emerald-600' : ''}`} />
|
||||||
|
<span>Buen resultado</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRateMessage(m.id, idx, 'bad')}
|
||||||
|
disabled={!m.id || m.ratingLoading}
|
||||||
|
title={!m.id ? 'Mensaje inicial del sistema' : 'Mal resultado — Claude evitará este enfoque'}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs transition-all ${
|
||||||
|
!m.id
|
||||||
|
? 'opacity-40 cursor-not-allowed border-gray-200 text-gray-400'
|
||||||
|
: m.rating === 'bad'
|
||||||
|
? 'bg-rose-50 text-rose-700 border-rose-300 font-semibold shadow-xs cursor-pointer'
|
||||||
|
: 'bg-white text-gray-600 border-gray-200 hover:bg-rose-50/60 hover:text-rose-700 hover:border-rose-200 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ThumbsDown className={`w-3.5 h-3.5 ${m.rating === 'bad' ? 'fill-rose-600 text-rose-600' : ''}`} />
|
||||||
|
<span>Mal resultado</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.rating === 'good' && (
|
||||||
|
<span className="text-[11px] text-emerald-600 font-medium flex items-center gap-1">
|
||||||
|
✓ Registrado para el aprendizaje de Claude
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{m.rating === 'bad' && (
|
||||||
|
<span className="text-[11px] text-rose-600 font-medium flex items-center gap-1">
|
||||||
|
✕ Enfoque descartado para calibración
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!m.rating && m.id && (
|
||||||
|
<span className="text-[10px] text-gray-400">
|
||||||
|
Valora para calibrar el modelo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -209,7 +472,7 @@ export default function AsistentePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Prompts Chips */}
|
{/* Quick Prompts Chips */}
|
||||||
<div className="px-6 py-2.5 bg-white/80 border-t border-gray-200 flex gap-2 overflow-x-auto text-xs text-gray-600">
|
<div className="px-6 py-2.5 bg-white/80 border-t border-gray-200 flex gap-2 overflow-x-auto text-xs text-gray-600 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => sendMessage('¿Qué puedo preguntar sobre el cliente?')}
|
onClick={() => sendMessage('¿Qué puedo preguntar sobre el cliente?')}
|
||||||
className="px-3 py-1.5 rounded-full bg-brand-50 text-brand-700 hover:bg-brand-100 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-brand-200 font-medium"
|
className="px-3 py-1.5 rounded-full bg-brand-50 text-brand-700 hover:bg-brand-100 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-brand-200 font-medium"
|
||||||
@@ -262,7 +525,7 @@ export default function AsistentePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input Box */}
|
{/* Input Box */}
|
||||||
<div className="p-4 border-t border-gray-200 bg-white">
|
<div className="p-4 border-t border-gray-200 bg-white shrink-0">
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export async function buildClientAssistantContext(
|
|||||||
adDailyRes,
|
adDailyRes,
|
||||||
calendarDocsRes,
|
calendarDocsRes,
|
||||||
creativesRes,
|
creativesRes,
|
||||||
auditsRes
|
auditsRes,
|
||||||
|
ratedMessagesRes
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
admin.from('tenants').select('id, name, slug, settings').eq('id', tenantId).maybeSingle(),
|
admin.from('tenants').select('id, name, slug, settings').eq('id', tenantId).maybeSingle(),
|
||||||
admin.from('documents').select('content, updated_at').eq('tenant_id', tenantId).eq('type', 'brandbook').order('updated_at', { ascending: false }).limit(1).maybeSingle(),
|
admin.from('documents').select('content, updated_at').eq('tenant_id', tenantId).eq('type', 'brandbook').order('updated_at', { ascending: false }).limit(1).maybeSingle(),
|
||||||
@@ -53,7 +54,8 @@ export async function buildClientAssistantContext(
|
|||||||
admin.from('ad_daily').select('date, platform, campaign_name, spend, impressions, clicks, platform_leads, leads, conversions').eq('tenant_id', tenantId).order('date', { ascending: false }).limit(120),
|
admin.from('ad_daily').select('date, platform, campaign_name, spend, impressions, clicks, platform_leads, leads, conversions').eq('tenant_id', tenantId).order('date', { ascending: false }).limit(120),
|
||||||
admin.from('documents').select('id, title, content, updated_at').eq('tenant_id', tenantId).eq('type', 'calendar').order('updated_at', { ascending: false }).limit(4),
|
admin.from('documents').select('id, title, content, updated_at').eq('tenant_id', tenantId).eq('type', 'calendar').order('updated_at', { ascending: false }).limit(4),
|
||||||
admin.from('creatives').select('headline, hook, angle, format, status, campaign_name, primary_text, cta').eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(20),
|
admin.from('creatives').select('headline, hook, angle, format, status, campaign_name, primary_text, cta').eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(20),
|
||||||
admin.from('documents').select('title, content, updated_at').eq('tenant_id', tenantId).eq('type', 'audit').limit(3)
|
admin.from('documents').select('title, content, updated_at').eq('tenant_id', tenantId).eq('type', 'audit').limit(3),
|
||||||
|
admin.from('chat_messages').select('id, role, content, tool_calls, created_at').eq('tenant_id', tenantId).not('tool_calls->>rating', 'is', null).order('created_at', { ascending: false }).limit(20)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tenant = tenantRes.status === 'fulfilled' ? tenantRes.value.data : null;
|
const tenant = tenantRes.status === 'fulfilled' ? tenantRes.value.data : null;
|
||||||
@@ -252,6 +254,29 @@ ${creatives.length > 0
|
|||||||
).join('\n')
|
).join('\n')
|
||||||
: '- Sin piezas creativas en pipeline.'}`;
|
: '- Sin piezas creativas en pipeline.'}`;
|
||||||
|
|
||||||
|
// 12. Historial de feedback del usuario para Aprendizaje Continuo (Ratings good/bad)
|
||||||
|
const ratedRows = ratedMessagesRes.status === 'fulfilled' ? (ratedMessagesRes.value.data || []) : [];
|
||||||
|
const goodResponses = ratedRows
|
||||||
|
.filter((r: any) => r.role === 'assistant' && r.tool_calls?.rating === 'good')
|
||||||
|
.slice(0, 4);
|
||||||
|
const badResponses = ratedRows
|
||||||
|
.filter((r: any) => r.role === 'assistant' && r.tool_calls?.rating === 'bad')
|
||||||
|
.slice(0, 4);
|
||||||
|
|
||||||
|
let feedbackSection = '';
|
||||||
|
if (goodResponses.length > 0 || badResponses.length > 0) {
|
||||||
|
feedbackSection = `
|
||||||
|
=== 🧠 APRENDIZAJE Y FEEDBACK ACUMULADO DEL USUARIO (PREFERENCIAS VALIDADAS) ===
|
||||||
|
El usuario de este cliente ha calificado respuestas anteriores del Asistente para guiar tu aprendizaje continuo. Toma muy en cuenta este refuerzo:
|
||||||
|
${goodResponses.length > 0 ? `
|
||||||
|
🟢 RESPUESTAS VALORADAS COMO EXCELENTES / BUEN RESULTADO (IMPRIME ESTE RIGOR, NIVEL DE DETALLE Y TONO DIRECTO):
|
||||||
|
${goodResponses.map((g: any, i: number) => `Ejemplo Aprobado ${i + 1}:\n"""${g.content.slice(0, 500)}${g.content.length > 500 ? '...' : ''}"""`).join('\n\n')}` : ''}
|
||||||
|
${badResponses.length > 0 ? `
|
||||||
|
🔴 RESPUESTAS VALORADAS COMO MAL RESULTADO (EVITA ESTOS PATRONES, FALTA DE CONCRECIÓN, ALUCINACIONES O AMBIGÜEDADES):
|
||||||
|
${badResponses.map((b: any, i: number) => `Ejemplo a Corregir ${i + 1}:\n"""${b.content.slice(0, 350)}${b.content.length > 350 ? '...' : ''}"""`).join('\n\n')}` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
const contextPrompt = `DATOS COMPLETOS DEL CLIENTE EN TIEMPO REAL:
|
const contextPrompt = `DATOS COMPLETOS DEL CLIENTE EN TIEMPO REAL:
|
||||||
${brandSection}
|
${brandSection}
|
||||||
${targetsSection}
|
${targetsSection}
|
||||||
@@ -260,6 +285,7 @@ ${adsSection}
|
|||||||
${kpisSection}
|
${kpisSection}
|
||||||
${calendarSection}
|
${calendarSection}
|
||||||
${creativesSection}
|
${creativesSection}
|
||||||
|
${feedbackSection}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user