Feat Gestion de Fuentes URLs
This commit is contained in:
@@ -86,5 +86,65 @@ namespace ChatbotApi.Controllers
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(logs);
|
return Ok(logs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ENDPOINTS PARA FUENTES DE CONTEXTO (URLs)
|
||||||
|
[HttpGet("fuentes")]
|
||||||
|
public async Task<IActionResult> GetAllFuentes()
|
||||||
|
{
|
||||||
|
var fuentes = await _context.FuentesDeContexto.OrderBy(f => f.Nombre).ToListAsync();
|
||||||
|
return Ok(fuentes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("fuentes")]
|
||||||
|
public async Task<IActionResult> CreateFuente([FromBody] FuenteContexto fuente)
|
||||||
|
{
|
||||||
|
_context.FuentesDeContexto.Add(fuente);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return CreatedAtAction(nameof(GetAllFuentes), new { id = fuente.Id }, fuente);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("fuentes/{id}")]
|
||||||
|
public async Task<IActionResult> UpdateFuente(int id, [FromBody] FuenteContexto fuente)
|
||||||
|
{
|
||||||
|
if (id != fuente.Id)
|
||||||
|
{
|
||||||
|
return BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.Entry(fuente).State = EntityState.Modified;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
if (!_context.FuentesDeContexto.Any(e => e.Id == id))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("fuentes/{id}")]
|
||||||
|
public async Task<IActionResult> DeleteFuente(int id)
|
||||||
|
{
|
||||||
|
var fuente = await _context.FuentesDeContexto.FindAsync(id);
|
||||||
|
if (fuente == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.FuentesDeContexto.Remove(fuente);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ public class GeminiResponse { [JsonPropertyName("candidates")] public Candidate[
|
|||||||
public class Candidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
|
public class Candidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
|
||||||
public class GeminiStreamingResponse { [JsonPropertyName("candidates")] public StreamingCandidate[] Candidates { get; set; } = default!; }
|
public class GeminiStreamingResponse { [JsonPropertyName("candidates")] public StreamingCandidate[] Candidates { get; set; } = default!; }
|
||||||
public class StreamingCandidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
|
public class StreamingCandidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
|
||||||
public enum IntentType { Article, Database, Homepage }
|
public enum IntentType { Article, Database, Homepage, ExternalSource }
|
||||||
|
|
||||||
namespace ChatbotApi.Controllers
|
namespace ChatbotApi.Controllers
|
||||||
{
|
{
|
||||||
@@ -102,14 +102,34 @@ namespace ChatbotApi.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IntentType> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary)
|
private async Task<(IntentType intent, string? data)> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary, Dictionary<string, ContextoItem> knowledgeBase)
|
||||||
{
|
{
|
||||||
var promptBuilder = new StringBuilder();
|
var promptBuilder = new StringBuilder();
|
||||||
promptBuilder.AppendLine("Tu tarea es actuar como un router de intenciones. Basado en la PREGUNTA DEL USUARIO, decide qué herramienta es la más apropiada para encontrar la respuesta. Responde única y exclusivamente con una de las siguientes tres opciones: [ARTICULO_ACTUAL], [BASE_DE_DATOS], [NOTICIAS_PORTADA].");
|
promptBuilder.AppendLine("Tu tarea es actuar como un router de intenciones...");
|
||||||
|
promptBuilder.AppendLine("- [ARTICULO_ACTUAL]");
|
||||||
|
promptBuilder.AppendLine("- [NOTICIAS_PORTADA]");
|
||||||
|
promptBuilder.AppendLine("- [BASE_DE_DATOS:CLAVE_SELECCIONADA]");
|
||||||
|
|
||||||
|
// --- LÓGICA DINÁMICA ---
|
||||||
|
List<FuenteContexto> fuentesExternas;
|
||||||
|
using (var scope = _serviceProvider.CreateScope())
|
||||||
|
{
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
|
||||||
|
fuentesExternas = await dbContext.FuentesDeContexto.Where(f => f.Activo).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var fuente in fuentesExternas)
|
||||||
|
{
|
||||||
|
promptBuilder.AppendLine($"[FUENTE_EXTERNA:{fuente.Url}]: Úsala si la pregunta trata sobre: {fuente.DescripcionParaIA}");
|
||||||
|
}
|
||||||
|
|
||||||
promptBuilder.AppendLine("\n--- DESCRIPCIÓN DE HERRAMIENTAS ---");
|
promptBuilder.AppendLine("\n--- DESCRIPCIÓN DE HERRAMIENTAS ---");
|
||||||
promptBuilder.AppendLine("[ARTICULO_ACTUAL]: Úsala si la pregunta es una continuación directa de la conversación y trata sobre el artículo que se está discutiendo.");
|
promptBuilder.AppendLine("[ARTICULO_ACTUAL]: Úsala si la pregunta es una continuación directa sobre el artículo que se está discutiendo.");
|
||||||
promptBuilder.AppendLine("[BASE_DE_DATOS]: Úsala si la pregunta es sobre información específica y general del diario, como datos de contacto (teléfono, dirección), publicidad o suscripciones.");
|
promptBuilder.AppendLine("[NOTICIAS_PORTADA]: Úsala para preguntas generales sobre noticias actuales o eventos.");
|
||||||
promptBuilder.AppendLine("[NOTICIAS_PORTADA]: Úsala para preguntas generales sobre noticias actuales, eventos, o si ninguna de las otras herramientas parece adecuada.");
|
promptBuilder.AppendLine("[BASE_DE_DATOS:CLAVE_SELECCIONADA]: Úsala para preguntas sobre información específica del diario. DEBES reemplazar 'CLAVE_SELECCIONADA' con la clave más relevante de la siguiente lista:");
|
||||||
|
|
||||||
|
var dbKeys = string.Join(", ", knowledgeBase.Keys);
|
||||||
|
promptBuilder.AppendLine($" - Claves disponibles: {dbKeys}");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(conversationSummary))
|
if (!string.IsNullOrWhiteSpace(conversationSummary))
|
||||||
{
|
{
|
||||||
@@ -119,13 +139,13 @@ namespace ChatbotApi.Controllers
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(activeArticleContent))
|
if (!string.IsNullOrEmpty(activeArticleContent))
|
||||||
{
|
{
|
||||||
promptBuilder.AppendLine("\n--- CONVERSACIÓN ACTUAL (Contexto del artículo) ---");
|
promptBuilder.AppendLine("\n--- CONTEXTO DEL ARTÍCULO ACTUAL ---");
|
||||||
promptBuilder.AppendLine(new string(activeArticleContent.Take(500).ToArray()) + "...");
|
promptBuilder.AppendLine(new string(activeArticleContent.Take(500).ToArray()) + "...");
|
||||||
}
|
}
|
||||||
|
|
||||||
promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---");
|
promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---");
|
||||||
promptBuilder.AppendLine(userMessage);
|
promptBuilder.AppendLine(userMessage);
|
||||||
promptBuilder.AppendLine("\n--- HERRAMIENTA SELECCIONADA ---");
|
promptBuilder.AppendLine("\n--- HERRAMIENTA Y DATOS SELECCIONADOS ---");
|
||||||
|
|
||||||
var finalPrompt = promptBuilder.ToString();
|
var finalPrompt = promptBuilder.ToString();
|
||||||
var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
|
var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
|
||||||
@@ -134,24 +154,36 @@ namespace ChatbotApi.Controllers
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
|
var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
|
||||||
if (!response.IsSuccessStatusCode) return IntentType.Homepage;
|
if (!response.IsSuccessStatusCode) return (IntentType.Homepage, null);
|
||||||
|
|
||||||
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
|
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
|
||||||
var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "";
|
var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "";
|
||||||
|
|
||||||
_logger.LogInformation("Intención detectada: {Intent}", responseText);
|
_logger.LogInformation("Intención y datos detectados: {Response}", responseText);
|
||||||
|
|
||||||
if (responseText.Contains("ARTICULO_ACTUAL")) return IntentType.Article;
|
if (responseText.Contains("ARTICULO_ACTUAL")) return (IntentType.Article, null);
|
||||||
if (responseText.Contains("BASE_DE_DATOS")) return IntentType.Database;
|
if (responseText.Contains("NOTICIAS_PORTADA")) return (IntentType.Homepage, null);
|
||||||
return IntentType.Homepage;
|
if (responseText.Contains("BASE_DE_DATOS:"))
|
||||||
|
{
|
||||||
|
var key = responseText.Split(new[] { ':' }, 2)[1].TrimEnd(']');
|
||||||
|
return (IntentType.Database, key);
|
||||||
|
}
|
||||||
|
if (responseText.Contains("FUENTE_EXTERNA:"))
|
||||||
|
{
|
||||||
|
var url = responseText.Split(new[] { ':' }, 2, StringSplitOptions.None)[1].TrimEnd(']');
|
||||||
|
return (IntentType.ExternalSource, url); // Necesitaremos un nuevo IntentType
|
||||||
|
}
|
||||||
|
|
||||||
|
return (IntentType.Homepage, null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage.");
|
_logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage.");
|
||||||
return IntentType.Homepage;
|
return (IntentType.Homepage, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpPost("stream-message")]
|
[HttpPost("stream-message")]
|
||||||
[EnableRateLimiting("fixed")]
|
[EnableRateLimiting("fixed")]
|
||||||
public async IAsyncEnumerable<string> StreamMessage(
|
public async IAsyncEnumerable<string> StreamMessage(
|
||||||
@@ -178,8 +210,11 @@ namespace ChatbotApi.Controllers
|
|||||||
articleContext = await GetArticleContentAsync(request.ContextUrl);
|
articleContext = await GetArticleContentAsync(request.ContextUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le pasamos el resumen al router de intenciones
|
var knowledgeBase = await GetKnowledgeAsync();
|
||||||
intent = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary);
|
|
||||||
|
// --- CORRECCIÓN 2: El código que llama a GetIntentAsync debe esperar una tupla ---
|
||||||
|
var (detectedIntent, intentData) = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary, knowledgeBase);
|
||||||
|
intent = detectedIntent;
|
||||||
|
|
||||||
switch (intent)
|
switch (intent)
|
||||||
{
|
{
|
||||||
@@ -190,17 +225,38 @@ namespace ChatbotApi.Controllers
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case IntentType.Database:
|
case IntentType.Database:
|
||||||
_logger.LogInformation("Ejecutando intención: Base de Datos.");
|
// --- CORRECCIÓN 3: La lógica aquí debe manejar la clave recibida ---
|
||||||
var knowledgeBase = await GetKnowledgeAsync();
|
_logger.LogInformation("Ejecutando intención: Base de Datos con clave '{Key}'.", intentData);
|
||||||
context = await FindBestDbItemAsync(userMessage, request.ConversationSummary, knowledgeBase) ?? "No se encontró información relevante en la base de datos.";
|
if (intentData != null && knowledgeBase.TryGetValue(intentData, out var dbItem))
|
||||||
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene información específica (contacto, suscripciones, etc.).";
|
{
|
||||||
|
// Ahora dbItem es un objeto ContextoItem, no un string.
|
||||||
|
var dbContextBuilder = new StringBuilder();
|
||||||
|
dbContextBuilder.AppendLine("Aquí tienes la información solicitada:");
|
||||||
|
dbContextBuilder.AppendLine($"- PREGUNTA: {dbItem.Descripcion}");
|
||||||
|
dbContextBuilder.AppendLine($" RESPUESTA: {dbItem.Valor}");
|
||||||
|
context = dbContextBuilder.ToString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context = "No se encontró información relevante para la clave solicitada.";
|
||||||
|
_logger.LogWarning("La clave '{Key}' devuelta por la IA no es válida.", intentData);
|
||||||
|
}
|
||||||
|
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene la pregunta y respuesta encontrada.";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case IntentType.Homepage:
|
case IntentType.Homepage:
|
||||||
default:
|
default:
|
||||||
_logger.LogInformation("Ejecutando intención: Noticias de Portada.");
|
_logger.LogInformation("Ejecutando intención: Noticias de Portada.");
|
||||||
context = await GetWebsiteNewsAsync(_siteUrl, 25);
|
context = await GetWebsiteNewsAsync(_siteUrl, 25);
|
||||||
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene una lista de noticias de portada. Si encuentras una noticia relevante, proporciona su enlace en formato Markdown: '[título](URL)'.";
|
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene una lista de noticias de portada.";
|
||||||
|
break;
|
||||||
|
case IntentType.ExternalSource:
|
||||||
|
_logger.LogInformation("Ejecutando intención: Fuente Externa con URL '{Url}'.", intentData);
|
||||||
|
if (!string.IsNullOrEmpty(intentData))
|
||||||
|
{
|
||||||
|
context = await ScrapeUrlContentAsync(intentData);
|
||||||
|
}
|
||||||
|
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene el texto de una página web.";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,55 +394,7 @@ namespace ChatbotApi.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> FindBestDbItemAsync(string userMessage, string? conversationSummary, Dictionary<string, string> knowledgeBase)
|
|
||||||
{
|
|
||||||
if (knowledgeBase == null || !knowledgeBase.Any()) return null;
|
|
||||||
|
|
||||||
var availableKeys = string.Join(", ", knowledgeBase.Keys);
|
|
||||||
|
|
||||||
var promptBuilder = new StringBuilder();
|
|
||||||
promptBuilder.AppendLine("Tu tarea es actuar como un buscador semántico. Usa el RESUMEN para entender el contexto de la conversación. Basado en la PREGUNTA DEL USUARIO, elige la CLAVE más relevante de la lista. Responde única y exclusivamente con la clave que elijas.");
|
|
||||||
|
|
||||||
// Añadimos el resumen al prompt del buscador
|
|
||||||
if (!string.IsNullOrWhiteSpace(conversationSummary))
|
|
||||||
{
|
|
||||||
promptBuilder.AppendLine("\n--- RESUMEN DE LA CONVERSACIÓN ---");
|
|
||||||
promptBuilder.AppendLine(conversationSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
promptBuilder.AppendLine("\n--- LISTA DE CLAVES DISPONIBLES ---");
|
|
||||||
promptBuilder.AppendLine(availableKeys);
|
|
||||||
promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---");
|
|
||||||
promptBuilder.AppendLine(userMessage);
|
|
||||||
promptBuilder.AppendLine("\n--- CLAVE MÁS RELEVANTE ---");
|
|
||||||
|
|
||||||
var finalPrompt = promptBuilder.ToString();
|
|
||||||
var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
|
|
||||||
var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
|
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
|
||||||
|
|
||||||
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
|
|
||||||
var bestKey = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
|
|
||||||
|
|
||||||
if (bestKey != null && knowledgeBase.TryGetValue(bestKey, out var contextValue))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Búsqueda en DB por IA: La pregunta '{userMessage}' se asoció con la clave '{bestKey}'.", userMessage, bestKey);
|
|
||||||
return contextValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogWarning("Búsqueda en DB por IA: La clave devuelta '{bestKey}' no es válida.", bestKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Excepción en FindBestDbItemAsync.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> GetWebsiteNewsAsync(string url, int cantidad)
|
private async Task<string> GetWebsiteNewsAsync(string url, int cantidad)
|
||||||
{
|
{
|
||||||
@@ -462,7 +470,7 @@ namespace ChatbotApi.Controllers
|
|||||||
return textoDecodificado;
|
return textoDecodificado;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Dictionary<string, string>> GetKnowledgeAsync()
|
private async Task<Dictionary<string, ContextoItem>> GetKnowledgeAsync()
|
||||||
{
|
{
|
||||||
return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry =>
|
return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry =>
|
||||||
{
|
{
|
||||||
@@ -471,13 +479,14 @@ namespace ChatbotApi.Controllers
|
|||||||
using (var scope = _serviceProvider.CreateScope())
|
using (var scope = _serviceProvider.CreateScope())
|
||||||
{
|
{
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
|
||||||
|
// Usamos ToDictionaryAsync para obtener el objeto ContextoItem completo.
|
||||||
var knowledge = await dbContext.ContextoItems
|
var knowledge = await dbContext.ContextoItems
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.ToDictionaryAsync(item => item.Clave, item => item.Valor);
|
.ToDictionaryAsync(item => item.Clave, item => item);
|
||||||
_logger.LogInformation($"Caché actualizada con {knowledge.Count} items.");
|
_logger.LogInformation($"Caché actualizada con {knowledge.Count} items.");
|
||||||
return knowledge;
|
return knowledge;
|
||||||
}
|
}
|
||||||
}) ?? new Dictionary<string, string>();
|
}) ?? new Dictionary<string, ContextoItem>();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> GetArticleContentAsync(string url)
|
private async Task<string?> GetArticleContentAsync(string url)
|
||||||
@@ -514,5 +523,28 @@ namespace ChatbotApi.Controllers
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> ScrapeUrlContentAsync(string url)
|
||||||
|
{
|
||||||
|
// Usamos la URL como clave de caché para no scrapear la misma página una y otra vez.
|
||||||
|
return await _cache.GetOrCreateAsync(url, async entry =>
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", url);
|
||||||
|
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); // Cachear por 1 hora
|
||||||
|
|
||||||
|
var web = new HtmlWeb();
|
||||||
|
var doc = await web.LoadFromWebAsync(url);
|
||||||
|
|
||||||
|
// Selector genérico que intenta obtener el contenido principal
|
||||||
|
// Esto puede necesitar ajustes dependiendo de la estructura de las páginas
|
||||||
|
var mainContentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body");
|
||||||
|
|
||||||
|
if (mainContentNode == null) return string.Empty;
|
||||||
|
|
||||||
|
// Podríamos hacer esto mucho más inteligente, buscando <p>, <h2>, <li>, etc.
|
||||||
|
// pero para empezar, InnerText es un buen punto de partida.
|
||||||
|
return WebUtility.HtmlDecode(mainContentNode.InnerText);
|
||||||
|
}) ?? string.Empty;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,5 +9,6 @@ namespace ChatbotApi.Data.Models
|
|||||||
|
|
||||||
public DbSet<ContextoItem> ContextoItems { get; set; } = null!;
|
public DbSet<ContextoItem> ContextoItems { get; set; } = null!;
|
||||||
public DbSet<ConversacionLog> ConversacionLogs { get; set; } = null!;
|
public DbSet<ConversacionLog> ConversacionLogs { get; set; } = null!;
|
||||||
|
public DbSet<FuenteContexto> FuentesDeContexto { get; set; } = null!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
22
ChatbotApi/Data/Models/FuenteContexto.cs
Normal file
22
ChatbotApi/Data/Models/FuenteContexto.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Data/Models/FuenteContexto.cs
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
public class FuenteContexto
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string Nombre { get; set; } = null!; // Ej: "FAQs de Suscripción"
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(1000)]
|
||||||
|
public string Url { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string DescripcionParaIA { get; set; } = null!; // ¡La parte más importante!
|
||||||
|
|
||||||
|
public bool Activo { get; set; } = true;
|
||||||
|
}
|
||||||
364
ChatbotApi/Migrations/20251121141306_AddFuentesDeContextoTable.Designer.cs
generated
Normal file
364
ChatbotApi/Migrations/20251121141306_AddFuentesDeContextoTable.Designer.cs
generated
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using ChatbotApi.Data.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ChatbotApi.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppContexto))]
|
||||||
|
[Migration("20251121141306_AddFuentesDeContextoTable")]
|
||||||
|
partial class AddFuentesDeContextoTable
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.0")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("ChatbotApi.Data.Models.ContextoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Clave")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Descripcion")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("FechaActualizacion")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Valor")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("ContextoItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ChatbotApi.Data.Models.ConversacionLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("BotRespuesta")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Fecha")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UsuarioMensaje")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("ConversacionLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FuenteContexto", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("Activo")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("DescripcionParaIA")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Nombre")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("FuentesDeContexto");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex")
|
||||||
|
.HasFilter("[NormalizedName] IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("RoleId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex")
|
||||||
|
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("RoleId")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ChatbotApi.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFuentesDeContextoTable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FuentesDeContexto",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Nombre = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Url = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||||
|
DescripcionParaIA = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||||
|
Activo = table.Column<bool>(type: "bit", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FuentesDeContexto", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FuentesDeContexto");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,37 @@ namespace ChatbotApi.Migrations
|
|||||||
b.ToTable("ConversacionLogs");
|
b.ToTable("ConversacionLogs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FuenteContexto", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("Activo")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("DescripcionParaIA")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Nombre")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("FuentesDeContexto");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
// src/components/AdminPanel.tsx
|
// EN: src/components/AdminPanel.tsx
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Box, Typography, AppBar, Toolbar, IconButton, Tabs, Tab } from '@mui/material';
|
import { Box, Typography, AppBar, Toolbar, IconButton, Tabs, Tab } from '@mui/material';
|
||||||
import LogoutIcon from '@mui/icons-material/Logout';
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
|
|
||||||
// Importamos los dos componentes que mostraremos en las pestañas
|
import ContextManager from './ContextManager';
|
||||||
import ContextManager from './ContextManager'; // Renombraremos el AdminPanel original
|
|
||||||
import LogsViewer from './LogsViewer';
|
import LogsViewer from './LogsViewer';
|
||||||
|
import SourceManager from './SourceManager';
|
||||||
|
|
||||||
interface AdminPanelProps {
|
interface AdminPanelProps {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// El componente se convierte en un contenedor con pestañas
|
|
||||||
const AdminPanel: React.FC<AdminPanelProps> = ({ onLogout }) => {
|
const AdminPanel: React.FC<AdminPanelProps> = ({ onLogout }) => {
|
||||||
const [currentTab, setCurrentTab] = useState(0);
|
const [currentTab, setCurrentTab] = useState(0);
|
||||||
|
|
||||||
@@ -33,12 +32,13 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ onLogout }) => {
|
|||||||
<Tabs value={currentTab} onChange={handleTabChange} textColor="inherit" indicatorColor="secondary">
|
<Tabs value={currentTab} onChange={handleTabChange} textColor="inherit" indicatorColor="secondary">
|
||||||
<Tab label="Gestor de Contexto" />
|
<Tab label="Gestor de Contexto" />
|
||||||
<Tab label="Historial de Conversaciones" />
|
<Tab label="Historial de Conversaciones" />
|
||||||
|
<Tab label="Gestor de Fuentes" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
{/* Mostramos el componente correspondiente a la pestaña activa */}
|
|
||||||
{currentTab === 0 && <ContextManager onAuthError={onLogout} />}
|
{currentTab === 0 && <ContextManager onAuthError={onLogout} />}
|
||||||
{currentTab === 1 && <LogsViewer onAuthError={onLogout} />}
|
{currentTab === 1 && <LogsViewer onAuthError={onLogout} />}
|
||||||
|
{currentTab === 2 && <SourceManager onAuthError={onLogout} />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
208
chatbot-admin/src/components/SourceManager.tsx
Normal file
208
chatbot-admin/src/components/SourceManager.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// EN: src/components/SourceManager.tsx
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { DataGrid, GridActionsCellItem } from '@mui/x-data-grid';
|
||||||
|
import type { GridColDef } from '@mui/x-data-grid';
|
||||||
|
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Alert, TextField, Chip, Switch, FormControlLabel } from '@mui/material';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import apiClient from '../api/apiClient';
|
||||||
|
|
||||||
|
interface FuenteContexto {
|
||||||
|
id: number;
|
||||||
|
nombre: string;
|
||||||
|
url: string;
|
||||||
|
descripcionParaIA: string;
|
||||||
|
activo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceManagerProps {
|
||||||
|
onAuthError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SourceManager: React.FC<SourceManagerProps> = ({ onAuthError }) => {
|
||||||
|
const [rows, setRows] = useState<FuenteContexto[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
|
const [currentRow, setCurrentRow] = useState<Partial<FuenteContexto>>({});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [itemToDelete, setItemToDelete] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// --- ENDPOINT ---
|
||||||
|
const response = await apiClient.get('/api/admin/fuentes');
|
||||||
|
setRows(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('No se pudieron cargar las fuentes de contexto.');
|
||||||
|
if (axios.isAxiosError(err) && err.response?.status === 401) {
|
||||||
|
onAuthError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onAuthError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleOpen = (item?: FuenteContexto) => {
|
||||||
|
if (item) {
|
||||||
|
setIsEdit(true);
|
||||||
|
setCurrentRow(item);
|
||||||
|
} else {
|
||||||
|
// --- ESTADO INICIAL ---
|
||||||
|
setIsEdit(false);
|
||||||
|
setCurrentRow({ nombre: '', url: '', descripcionParaIA: '', activo: true });
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => setOpen(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await apiClient.put(`/api/admin/fuentes/${currentRow.id}`, currentRow);
|
||||||
|
} else {
|
||||||
|
await apiClient.post('/api/admin/fuentes', currentRow);
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
handleClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al guardar la fuente.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (id: number) => {
|
||||||
|
setItemToDelete(id);
|
||||||
|
setConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmClose = () => {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
setItemToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (itemToDelete !== null) {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/admin/fuentes/${itemToDelete}`);
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al eliminar la fuente.');
|
||||||
|
} finally {
|
||||||
|
handleConfirmClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DEFINICIÓN DE COLUMNAS ---
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{ field: 'nombre', headerName: 'Nombre', width: 200 },
|
||||||
|
{ field: 'url', headerName: 'URL', width: 350 },
|
||||||
|
{ field: 'descripcionParaIA', headerName: 'Descripción para IA', flex: 1 },
|
||||||
|
{
|
||||||
|
field: 'activo',
|
||||||
|
headerName: 'Activo',
|
||||||
|
width: 100,
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Chip label={params.value ? 'Sí' : 'No'} color={params.value ? 'success' : 'default'} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'actions',
|
||||||
|
type: 'actions',
|
||||||
|
width: 100,
|
||||||
|
getActions: (params) => [
|
||||||
|
<GridActionsCellItem
|
||||||
|
icon={<EditIcon />}
|
||||||
|
label="Editar"
|
||||||
|
onClick={() => handleOpen(params.row as FuenteContexto)}
|
||||||
|
/>,
|
||||||
|
<GridActionsCellItem
|
||||||
|
icon={<DeleteIcon />}
|
||||||
|
label="Eliminar"
|
||||||
|
onClick={() => handleDeleteClick(params.id as number)}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 4 }}>
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
<Button startIcon={<AddIcon />} variant="contained" onClick={() => handleOpen()} sx={{ mb: 2 }}>
|
||||||
|
Añadir Nueva Fuente
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ height: 600, width: '100%' }}>
|
||||||
|
<DataGrid rows={rows} columns={columns} pageSizeOptions={[10, 25, 50]} />
|
||||||
|
</Box>
|
||||||
|
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="md">
|
||||||
|
<DialogTitle>{isEdit ? 'Editar Fuente' : 'Añadir Nueva Fuente'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Nombre"
|
||||||
|
fullWidth
|
||||||
|
value={currentRow.nombre || ''}
|
||||||
|
onChange={(e) => setCurrentRow({ ...currentRow, nombre: e.target.value })}
|
||||||
|
helperText="Un nombre corto y descriptivo (ej: FAQs de Suscripción)."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="URL"
|
||||||
|
fullWidth
|
||||||
|
value={currentRow.url || ''}
|
||||||
|
onChange={(e) => setCurrentRow({ ...currentRow, url: e.target.value })}
|
||||||
|
helperText="La URL completa de la página que el bot debe leer."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Descripción para la IA"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={currentRow.descripcionParaIA || ''}
|
||||||
|
onChange={(e) => setCurrentRow({ ...currentRow, descripcionParaIA: e.target.value })}
|
||||||
|
helperText="¡Crucial! Describe en una frase para qué sirve esta fuente. Ej: 'Usar para responder preguntas sobre cómo registrarse, iniciar sesión o por qué es obligatorio el registro'."
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={currentRow.activo ?? true}
|
||||||
|
onChange={(e) => setCurrentRow({ ...currentRow, activo: e.target.checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Fuente activa"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose}>Cancelar</Button>
|
||||||
|
<Button onClick={handleSave} variant="contained">Guardar</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog
|
||||||
|
open={confirmOpen}
|
||||||
|
onClose={handleConfirmClose}
|
||||||
|
>
|
||||||
|
<DialogTitle>Confirmar Eliminación</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContent>
|
||||||
|
¿Estás seguro de que quieres eliminar este item? Esta acción no se puede deshacer.
|
||||||
|
</DialogContent>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleConfirmClose}>Cancelar</Button>
|
||||||
|
<Button onClick={handleConfirmDelete} color="error" variant="contained">
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SourceManager;
|
||||||
Reference in New Issue
Block a user