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 GeminiStreamingResponse { [JsonPropertyName("candidates")] public StreamingCandidate[] Candidates { 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 }
|
||||
|
||||
namespace ChatbotApi.Controllers
|
||||
@@ -213,8 +218,34 @@ namespace ChatbotApi.Controllers
|
||||
case IntentType.Homepage:
|
||||
default:
|
||||
_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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
var web = new HtmlWeb();
|
||||
var doc = await web.LoadFromWebAsync(url);
|
||||
|
||||
var articleNodes = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]");
|
||||
|
||||
if (articleNodes == null || !articleNodes.Any())
|
||||
{
|
||||
_logger.LogWarning("No se encontraron nodos de <article> en la URL {Url}", url);
|
||||
return string.Empty;
|
||||
}
|
||||
if (articleNodes == null) return newsList;
|
||||
|
||||
var contextBuilder = new StringBuilder();
|
||||
contextBuilder.AppendLine("Lista de noticias principales extraídas de la página:");
|
||||
var urlsProcesadas = new HashSet<string>();
|
||||
int count = 0;
|
||||
|
||||
foreach (var articleNode in articleNodes)
|
||||
{
|
||||
if (newsList.Count >= cantidad) break;
|
||||
|
||||
var linkNode = articleNode.SelectSingleNode(".//a[@href]");
|
||||
var titleNode = articleNode.SelectSingleNode(".//h2");
|
||||
|
||||
if (linkNode != null && titleNode != null)
|
||||
{
|
||||
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;
|
||||
|
||||
contextBuilder.AppendLine($"- Título: \"{cleanTitle}\", URL: {fullUrl}");
|
||||
urlsProcesadas.Add(relativeUrl);
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count >= cantidad)
|
||||
newsList.Add(new NewsArticleLink
|
||||
{
|
||||
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)
|
||||
{
|
||||
_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 [isStreaming, setIsStreaming] = useState(false);
|
||||
const messagesEndRef = useRef<null | HTMLDivElement>(null);
|
||||
const inputRef = useRef<null | HTMLInputElement>(null);
|
||||
const [activeArticle, setActiveArticle] = useState<{ url: string; title: string; } | null>(() => {
|
||||
try {
|
||||
// 1. Intentamos obtener el contexto del artículo guardado.
|
||||
@@ -105,6 +106,24 @@ const Chatbot: React.FC = () => {
|
||||
}
|
||||
}, [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 handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -292,6 +311,7 @@ const Chatbot: React.FC = () => {
|
||||
<form className="input-form" onSubmit={handleSendMessage}>
|
||||
<div className="input-container">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={isLoading ? "Esperando respuesta..." : "Escribe tu consulta..."}
|
||||
value={inputValue}
|
||||
|
||||
Reference in New Issue
Block a user