Fix: Focus de Textbox y Recolocación de Contexto de Nota
This commit is contained in:
@@ -33,6 +33,11 @@ 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 class NewsArticleLink
|
||||||
|
{
|
||||||
|
public required string Title { get; set; }
|
||||||
|
public required string Url { get; set; }
|
||||||
|
}
|
||||||
public enum IntentType { Article, KnowledgeBase, Homepage }
|
public enum IntentType { Article, KnowledgeBase, Homepage }
|
||||||
|
|
||||||
namespace ChatbotApi.Controllers
|
namespace ChatbotApi.Controllers
|
||||||
@@ -213,8 +218,34 @@ namespace ChatbotApi.Controllers
|
|||||||
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);
|
|
||||||
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.";
|
// 1. Obtenemos la lista de artículos de la portada.
|
||||||
|
var articles = await GetWebsiteNewsAsync(_siteUrl, 25);
|
||||||
|
|
||||||
|
// 2. Usamos la IA para encontrar el mejor artículo.
|
||||||
|
var bestMatch = await FindBestMatchingArticleAsync(userMessage, articles);
|
||||||
|
|
||||||
|
if (bestMatch != null)
|
||||||
|
{
|
||||||
|
// 3. SI ENCONTRAMOS UN ARTÍCULO: Scrapeamos su contenido y preparamos el prompt de síntesis.
|
||||||
|
_logger.LogInformation("Artículo relevante encontrado: {Title}", bestMatch.Title);
|
||||||
|
string articleContent = await GetArticleContentAsync(bestMatch.Url) ?? "No se pudo leer el contenido del artículo.";
|
||||||
|
context = articleContent;
|
||||||
|
promptInstructions = $"La pregunta del usuario es '{userMessage}'. Basado en el CONTEXTO (el contenido de un artículo), tu tarea es:\n1. Escribir un resumen muy conciso (una o dos frases) que responda directamente a la pregunta del usuario.\n2. Incluir el título completo del artículo y su enlace en formato Markdown: '[{bestMatch.Title}]({bestMatch.Url})'.\n3. Invitar amablemente al usuario a preguntar más sobre este tema.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 4. SI NO ENCONTRAMOS NADA: Fallback al comportamiento antiguo de mostrar la lista.
|
||||||
|
_logger.LogInformation("No se encontró un artículo específico. Mostrando un resumen general de la portada.");
|
||||||
|
var homepageContextBuilder = new StringBuilder();
|
||||||
|
homepageContextBuilder.AppendLine("Lista de noticias principales extraídas de la página:");
|
||||||
|
foreach (var article in articles)
|
||||||
|
{
|
||||||
|
homepageContextBuilder.AppendLine($"- Título: \"{article.Title}\", URL: {article.Url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote en la siguiente lista de noticias de portada. Si no encuentras una respuesta directa, informa al usuario sobre los temas principales disponibles.";
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,62 +383,84 @@ namespace ChatbotApi.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetWebsiteNewsAsync(string url, int cantidad)
|
private async Task<List<NewsArticleLink>> GetWebsiteNewsAsync(string url, int cantidad)
|
||||||
{
|
{
|
||||||
|
var newsList = new List<NewsArticleLink>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var web = new HtmlWeb();
|
var web = new HtmlWeb();
|
||||||
var doc = await web.LoadFromWebAsync(url);
|
var doc = await web.LoadFromWebAsync(url);
|
||||||
|
|
||||||
var articleNodes = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]");
|
var articleNodes = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]");
|
||||||
|
|
||||||
if (articleNodes == null || !articleNodes.Any())
|
if (articleNodes == null) return newsList;
|
||||||
{
|
|
||||||
_logger.LogWarning("No se encontraron nodos de <article> en la URL {Url}", url);
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
var contextBuilder = new StringBuilder();
|
|
||||||
contextBuilder.AppendLine("Lista de noticias principales extraídas de la página:");
|
|
||||||
var urlsProcesadas = new HashSet<string>();
|
var urlsProcesadas = new HashSet<string>();
|
||||||
int count = 0;
|
|
||||||
|
|
||||||
foreach (var articleNode in articleNodes)
|
foreach (var articleNode in articleNodes)
|
||||||
{
|
{
|
||||||
|
if (newsList.Count >= cantidad) break;
|
||||||
|
|
||||||
var linkNode = articleNode.SelectSingleNode(".//a[@href]");
|
var linkNode = articleNode.SelectSingleNode(".//a[@href]");
|
||||||
var titleNode = articleNode.SelectSingleNode(".//h2");
|
var titleNode = articleNode.SelectSingleNode(".//h2");
|
||||||
|
|
||||||
if (linkNode != null && titleNode != null)
|
if (linkNode != null && titleNode != null)
|
||||||
{
|
{
|
||||||
var relativeUrl = linkNode.GetAttributeValue("href", string.Empty);
|
var relativeUrl = linkNode.GetAttributeValue("href", string.Empty);
|
||||||
|
if (!string.IsNullOrEmpty(relativeUrl) && relativeUrl != "#" && !urlsProcesadas.Contains(relativeUrl))
|
||||||
if (string.IsNullOrEmpty(relativeUrl) || relativeUrl == "#" || urlsProcesadas.Contains(relativeUrl))
|
|
||||||
{
|
{
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var cleanTitle = CleanTitleText(titleNode.InnerText);
|
|
||||||
var fullUrl = relativeUrl.StartsWith("/") ? new Uri(new Uri(url), relativeUrl).ToString() : relativeUrl;
|
var fullUrl = relativeUrl.StartsWith("/") ? new Uri(new Uri(url), relativeUrl).ToString() : relativeUrl;
|
||||||
|
newsList.Add(new NewsArticleLink
|
||||||
contextBuilder.AppendLine($"- Título: \"{cleanTitle}\", URL: {fullUrl}");
|
|
||||||
urlsProcesadas.Add(relativeUrl);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count >= cantidad)
|
|
||||||
{
|
{
|
||||||
break;
|
Title = CleanTitleText(titleNode.InnerText),
|
||||||
|
Url = fullUrl
|
||||||
|
});
|
||||||
|
urlsProcesadas.Add(relativeUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = contextBuilder.ToString();
|
|
||||||
_logger.LogInformation("Scraping de la portada exitoso. Se encontraron {Count} noticias.", count);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "No se pudo descargar o procesar la URL {Url}", url);
|
_logger.LogError(ex, "No se pudo descargar o procesar la URL {Url}", url);
|
||||||
return string.Empty;
|
}
|
||||||
|
return newsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<NewsArticleLink?> FindBestMatchingArticleAsync(string userMessage, List<NewsArticleLink> articles)
|
||||||
|
{
|
||||||
|
if (!articles.Any()) return null;
|
||||||
|
|
||||||
|
var promptBuilder = new StringBuilder();
|
||||||
|
promptBuilder.AppendLine("Tu tarea es actuar como un motor de búsqueda. Dada una PREGUNTA DE USUARIO y una LISTA DE ARTÍCULOS, debes encontrar el artículo más relevante. Responde única y exclusivamente con la URL completa del artículo elegido. Si ningún artículo es relevante, responde con 'N/A'.");
|
||||||
|
promptBuilder.AppendLine("\n--- LISTA DE ARTÍCULOS ---");
|
||||||
|
foreach (var article in articles)
|
||||||
|
{
|
||||||
|
promptBuilder.AppendLine($"- Título: \"{article.Title}\", URL: {article.Url}");
|
||||||
|
}
|
||||||
|
promptBuilder.AppendLine("\n--- PREGUNTA DE USUARIO ---");
|
||||||
|
promptBuilder.AppendLine(userMessage);
|
||||||
|
promptBuilder.AppendLine("\n--- URL 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 responseUrl = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(responseUrl) || responseUrl == "N/A") return null;
|
||||||
|
|
||||||
|
// Buscamos el artículo completo en nuestra lista original usando la URL que nos dio la IA
|
||||||
|
return articles.FirstOrDefault(a => a.Url == responseUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Excepción en FindBestMatchingArticleAsync.");
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const Chatbot: React.FC = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const messagesEndRef = useRef<null | HTMLDivElement>(null);
|
const messagesEndRef = useRef<null | HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<null | HTMLInputElement>(null);
|
||||||
const [activeArticle, setActiveArticle] = useState<{ url: string; title: string; } | null>(() => {
|
const [activeArticle, setActiveArticle] = useState<{ url: string; title: string; } | null>(() => {
|
||||||
try {
|
try {
|
||||||
// 1. Intentamos obtener el contexto del artículo guardado.
|
// 1. Intentamos obtener el contexto del artículo guardado.
|
||||||
@@ -105,6 +106,24 @@ const Chatbot: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [messages, isOpen]);
|
}, [messages, isOpen]);
|
||||||
|
|
||||||
|
// Este useEffect se encarga de gestionar el foco del campo de texto.
|
||||||
|
useEffect(() => {
|
||||||
|
// Solo aplicamos la lógica si la ventana del chat está abierta.
|
||||||
|
if (isOpen) {
|
||||||
|
// Si el bot NO está cargando, significa que el usuario puede escribir.
|
||||||
|
// Esto se cumple en dos escenarios:
|
||||||
|
// 1. Justo cuando se abre la ventana del chat.
|
||||||
|
// 2. Justo cuando el bot termina de responder (isLoading pasa de true a false).
|
||||||
|
if (!isLoading) {
|
||||||
|
// Usamos un pequeño retardo (100ms) para asegurar que el DOM se haya actualizado
|
||||||
|
// y cualquier animación de CSS haya terminado antes de intentar hacer foco.
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, isLoading]); // Las dependencias: se ejecuta si cambia `isOpen` o `isLoading`.
|
||||||
|
|
||||||
const toggleChat = () => setIsOpen(!isOpen);
|
const toggleChat = () => setIsOpen(!isOpen);
|
||||||
|
|
||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -292,6 +311,7 @@ const Chatbot: React.FC = () => {
|
|||||||
<form className="input-form" onSubmit={handleSendMessage}>
|
<form className="input-form" onSubmit={handleSendMessage}>
|
||||||
<div className="input-container">
|
<div className="input-container">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={isLoading ? "Esperando respuesta..." : "Escribe tu consulta..."}
|
placeholder={isLoading ? "Esperando respuesta..." : "Escribe tu consulta..."}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
|
|||||||
Reference in New Issue
Block a user