rag-service/RAG/public/playground/app.js

618 lines
23 KiB
JavaScript

const healthButton = document.getElementById("healthButton");
const ingestButton = document.getElementById("ingestButton");
const cleanupButton = document.getElementById("cleanupButton");
const bootstrapButton = document.getElementById("bootstrapButton");
const replaceBootstrapButton = document.getElementById("replaceBootstrapButton");
const clearBootstrapButton = document.getElementById("clearBootstrapButton");
const sendChatButton = document.getElementById("sendChatButton");
const clearChatButton = document.getElementById("clearChatButton");
const manualLogButton = document.getElementById("manualLogButton");
const presetDocs = document.getElementById("presetDocs");
const presetRagDocs = document.getElementById("presetRagDocs");
const presetCode = document.getElementById("presetCode");
const healthResult = document.getElementById("healthResult");
const ingestResult = document.getElementById("ingestResult");
const cleanupResult = document.getElementById("cleanupResult");
const bootstrapResult = document.getElementById("bootstrapResult");
const mainResult = document.getElementById("mainResult");
const compareResult = document.getElementById("compareResult");
const bootstrapContextResult = document.getElementById("bootstrapContextResult");
const chatMessages = document.getElementById("chatMessages");
const contextIndicator = document.getElementById("contextIndicator");
const contextStatusText = document.getElementById("contextStatusText");
const contextScopeText = document.getElementById("contextScopeText");
const logsResult = document.getElementById("logsResult");
const logCounterValue = document.getElementById("logCounterValue");
const ingestSourceType = document.getElementById("ingestSourceType");
const ingestScopeMode = document.getElementById("ingestScopeMode");
const ingestSourceIdWrapper = document.getElementById("ingestSourceIdWrapper");
const ingestSourceId = document.getElementById("ingestSourceId");
const ingestSourceRef = document.getElementById("ingestSourceRef");
const ingestUploadFile = document.getElementById("ingestUploadFile");
const ingestUploadFolder = document.getElementById("ingestUploadFolder");
const btnUploadFile = document.getElementById("btnUploadFile");
const btnUploadFolder = document.getElementById("btnUploadFolder");
const uploadStatusText = document.getElementById("uploadStatusText");
const ingestMode = document.getElementById("ingestMode");
const ingestTags = document.getElementById("ingestTags");
const ingestModeHint = document.getElementById("ingestModeHint");
const cleanupScopeSelect = document.getElementById("cleanupScopeSelect");
const cleanupSourceId = document.getElementById("cleanupSourceId");
const cleanupSourceRef = document.getElementById("cleanupSourceRef");
const cleanupTags = document.getElementById("cleanupTags");
const bootstrapQuery = document.getElementById("bootstrapQuery");
const bootstrapMode = document.getElementById("bootstrapMode");
const answerModel = document.getElementById("answerModel");
const useModelInRetrieve = document.getElementById("useModelInRetrieve");
const reuseBootstrapContext = document.getElementById("reuseBootstrapContext");
const allowAdditionalRetrieve = document.getElementById("allowAdditionalRetrieve");
const scopePresetSelect = document.getElementById("scopePresetSelect");
const selectedSourceId = document.getElementById("selectedSourceId");
const scopeSourceRef = document.getElementById("scopeSourceRef");
const scopeTags = document.getElementById("scopeTags");
const scopeEditMode = document.getElementById("scopeEditMode");
const compareWithoutRag = document.getElementById("compareWithoutRag");
const chatMode = document.getElementById("chatMode");
const chatScopeInfo = document.getElementById("chatScopeInfo");
const chatInput = document.getElementById("chatInput");
const manualLogNote = document.getElementById("manualLogNote");
let lastBootstrapContext = null;
let lastBootstrapMeta = null;
let chatHistory = [];
let availableScopes = [];
let lastInteraction = null;
let currentUploadType = null; // 'file' o 'folder'
function format(value) {
return JSON.stringify(value, null, 2);
}
function splitTags(value) {
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
}
function buildScopeLabel(scope) {
const modes = scope.chunkModes.join(", ") || "sin modo";
return `${scope.sourceRef} [${modes}]`;
}
function updateIngestUiState() {
const hasFile = Boolean(ingestUploadFile.files && ingestUploadFile.files.length > 0);
const hasFolder = Boolean(ingestUploadFolder.files && ingestUploadFolder.files.length > 0);
const hasUpload = hasFile || hasFolder;
ingestSourceType.value = hasUpload ? "file" : ingestSourceType.value;
ingestSourceType.disabled = hasUpload;
ingestSourceRef.disabled = hasUpload;
ingestSourceIdWrapper.style.display = ingestScopeMode.value === "custom" ? "block" : "none";
if (hasFile) {
uploadStatusText.textContent = `Archivo seleccionado: ${ingestUploadFile.files[0].name}`;
ingestModeHint.textContent = `Upload directo activo: se ingerira el archivo local "${ingestUploadFile.files[0].name}" y se ignorara la ruta remota.`;
ingestModeHint.classList.add("strong");
} else if (hasFolder) {
// Al seleccionar carpeta mostramos el nombre del primer archivo padre y cuantos ficheros hay
const firstPath = ingestUploadFolder.files[0].webkitRelativePath || "";
const folderName = firstPath.split('/')[0] || "Carpeta";
uploadStatusText.textContent = `Carpeta seleccionada: ${folderName} (${ingestUploadFolder.files.length} archivos totales, se filtraran ignorados)`;
ingestModeHint.textContent = `Upload directo activo: se comprimira y subira la carpeta local "${folderName}" y se ignorara la ruta remota.`;
ingestModeHint.classList.add("strong");
} else {
uploadStatusText.textContent = "Ningun elemento seleccionado";
ingestModeHint.textContent = "Si seleccionas un archivo o carpeta local, el playground lo subira directamente y podras aislarlo con un `sourceId` propio.";
ingestModeHint.classList.remove("strong");
}
}
function applySelectedScope(scope) {
selectedSourceId.value = scope?.sourceId || "";
scopeSourceRef.value = scope?.sourceRef || "";
scopeTags.value = (scope?.tags || []).join(", ");
chatScopeInfo.value = [scope?.sourceId, scope?.sourceRef].filter(Boolean).join(" | ");
if (scope?.chunkModes?.includes("codigo")) {
bootstrapMode.value = "codigo";
chatMode.value = "codigo";
} else if (scope?.chunkModes?.includes("documental")) {
bootstrapMode.value = "documental";
chatMode.value = "documental";
}
}
function applySelectedCleanupScope(scope) {
cleanupSourceId.value = scope?.sourceId || "";
cleanupSourceRef.value = scope?.sourceRef || "";
cleanupTags.value = (scope?.tags || []).join(", ");
}
function updateScopeEditState() {
const locked = scopeEditMode.value !== "manual";
scopeSourceRef.readOnly = locked;
scopeTags.readOnly = locked;
}
function request(url, payload, method = "POST") {
return fetch(url, {
method,
headers: {
"Content-Type": "application/json"
},
body: payload ? JSON.stringify(payload) : undefined
}).then(async (response) => {
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
});
}
function renderBootstrapContext() {
if (!lastBootstrapContext) {
bootstrapContextResult.textContent = "Aun no hay bootstrap cargado.";
contextIndicator.className = "indicator indicator-off";
contextStatusText.textContent = "Sin contexto cargado";
contextScopeText.textContent = "No hay bootstrap activo.";
chatScopeInfo.value = [selectedSourceId.value, scopeSourceRef.value].filter(Boolean).join(" | ") || "Sin scope seleccionado";
return;
}
bootstrapContextResult.textContent = format(lastBootstrapContext);
contextIndicator.className = "indicator indicator-on";
contextStatusText.textContent = "Contexto bootstrap activo";
contextScopeText.textContent = lastBootstrapContext.scope?.sourceRef || "Scope no especificado";
chatScopeInfo.value = [lastBootstrapContext.scope?.sourceId, lastBootstrapContext.scope?.sourceRef].filter(Boolean).join(" | ");
}
function renderChatHistory() {
if (chatHistory.length === 0) {
chatMessages.innerHTML = '<p class="empty-chat">Aun no hay conversacion. Carga un bootstrap y empieza a preguntar.</p>';
return;
}
chatMessages.innerHTML = chatHistory.map((entry) => `
<article class="message ${entry.role}">
<header>${entry.role === "user" ? "Usuario" : "Modelo"}</header>
<p>${entry.content.replace(/</g, "&lt;")}</p>
</article>
`).join("");
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function buildScopeFromInputs() {
if (scopeEditMode.value !== "manual" && scopePresetSelect.value) {
const scope = JSON.parse(scopePresetSelect.value);
return {
sourceId: scope.sourceId,
sourceRef: scope.sourceRef,
tags: scope.tags || []
};
}
return {
sourceId: selectedSourceId.value || undefined,
sourceRef: scopeSourceRef.value,
tags: splitTags(scopeTags.value)
};
}
function applyPreset(mode, query, sourceRef, useRetrieveModel = false) {
bootstrapMode.value = mode;
chatMode.value = mode;
bootstrapQuery.value = query;
scopeSourceRef.value = sourceRef;
useModelInRetrieve.checked = useRetrieveModel;
}
async function loadScopes() {
try {
const scopes = await fetch("/sources").then((response) => response.json());
availableScopes = scopes;
scopePresetSelect.innerHTML = "";
cleanupScopeSelect.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Selecciona un scope disponible";
scopePresetSelect.appendChild(placeholder.cloneNode(true));
cleanupScopeSelect.appendChild(placeholder.cloneNode(true));
for (const scope of scopes) {
const option = document.createElement("option");
option.value = JSON.stringify(scope);
option.textContent = buildScopeLabel(scope);
scopePresetSelect.appendChild(option.cloneNode(true));
cleanupScopeSelect.appendChild(option.cloneNode(true));
}
if (scopes.length === 0) {
const emptyMsg = "No hay scopes detectados";
scopePresetSelect.options[0].textContent = emptyMsg;
cleanupScopeSelect.options[0].textContent = emptyMsg;
} else if (!scopePresetSelect.value) {
scopePresetSelect.value = JSON.stringify(scopes[0]);
cleanupScopeSelect.value = JSON.stringify(scopes[0]);
applySelectedScope(scopes[0]);
applySelectedCleanupScope(scopes[0]);
}
} catch (error) {
scopePresetSelect.innerHTML = `<option value="">Error cargando scopes: ${error}</option>`;
}
}
async function loadAnswerModels() {
try {
const payload = await fetch("/models/answer").then((response) => response.json());
answerModel.innerHTML = "";
for (const model of payload.models || []) {
const option = document.createElement("option");
option.value = model;
option.textContent = model;
answerModel.appendChild(option);
}
if (payload.defaultModel) {
answerModel.value = payload.defaultModel;
}
} catch {
answerModel.innerHTML = '<option value="openai/gpt-4.1-mini">openai/gpt-4.1-mini</option>';
}
}
async function loadRecentLogs() {
try {
const logs = await fetch("/logs/recent").then((response) => response.json());
logsResult.textContent = format(logs);
logCounterValue.textContent = Array.isArray(logs) ? String(logs.length) : "0";
} catch (error) {
logsResult.textContent = String(error);
logCounterValue.textContent = "0";
}
}
document.querySelectorAll(".tab-button").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll(".tab-button").forEach((entry) => entry.classList.remove("active"));
document.querySelectorAll(".tab-panel").forEach((panel) => panel.classList.remove("active"));
button.classList.add("active");
document.getElementById(`tab-${button.dataset.tab}`).classList.add("active");
});
});
scopePresetSelect.addEventListener("change", () => {
if (!scopePresetSelect.value) {
return;
}
const scope = JSON.parse(scopePresetSelect.value);
applySelectedScope(scope);
});
cleanupScopeSelect.addEventListener("change", () => {
if (!cleanupScopeSelect.value) {
applySelectedCleanupScope(null);
return;
}
const scope = JSON.parse(cleanupScopeSelect.value);
applySelectedCleanupScope(scope);
});
btnUploadFile.addEventListener("click", () => {
ingestUploadFolder.value = "";
currentUploadType = 'file';
ingestUploadFile.click();
});
btnUploadFolder.addEventListener("click", () => {
ingestUploadFile.value = "";
currentUploadType = 'folder';
ingestUploadFolder.click();
});
ingestUploadFile.addEventListener("change", updateIngestUiState);
ingestUploadFolder.addEventListener("change", updateIngestUiState);
ingestScopeMode.addEventListener("change", updateIngestUiState);
scopeEditMode.addEventListener("change", updateScopeEditState);
healthButton.addEventListener("click", async () => {
healthResult.textContent = "Comprobando...";
try {
const data = await fetch("/health").then((response) => response.json());
healthResult.textContent = format(data);
} catch (error) {
healthResult.textContent = String(error);
}
});
ingestButton.addEventListener("click", async () => {
ingestResult.textContent = "Ejecutando ingesta...";
try {
let data;
if (currentUploadType === 'file' && ingestUploadFile.files && ingestUploadFile.files[0]) {
const formData = new FormData();
formData.append("file", ingestUploadFile.files[0]);
formData.append("mode", ingestMode.value);
formData.append("tags", splitTags(ingestTags.value).join(","));
if (ingestScopeMode.value === "custom" && ingestSourceId.value.trim()) {
formData.append("sourceId", ingestSourceId.value.trim());
}
const response = await fetch("/ingest/upload", {
method: "POST",
body: formData
});
data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
} else if (currentUploadType === 'folder' && ingestUploadFolder.files && ingestUploadFolder.files.length > 0) {
ingestResult.textContent = "Empaquetando carpeta local (esto puede tardar unos segundos)...";
const zip = new JSZip();
let addedCount = 0;
for (const file of ingestUploadFolder.files) {
// webkitRelativePath format: "FolderName/path/to/file.ext"
// Le quitamos el primer segmento (FolderName) para la ruta interna
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
// Filtro nativo simple sin depender de la libreria ignore
const isIgnored =
relativePath.startsWith('node_modules/') ||
relativePath.startsWith('.git/') ||
relativePath.startsWith('.venv/') ||
relativePath.startsWith('dist/') ||
relativePath.startsWith('build/') ||
relativePath.includes('/node_modules/') ||
relativePath.includes('/.git/');
if (relativePath && !isIgnored) {
zip.file(relativePath, file);
addedCount++;
}
}
if (addedCount === 0) {
throw new Error("La carpeta esta vacia o todos sus archivos fueron ignorados (.gitignore, node_modules, etc).");
}
ingestResult.textContent = `Subiendo paquete comprimido con ${addedCount} archivos...`;
const zipBlob = await zip.generateAsync({ type: "blob", compression: "STORE" });
const folderName = ingestUploadFolder.files[0].webkitRelativePath.split('/')[0] || "upload";
const formData = new FormData();
formData.append("file", zipBlob, `${folderName}.zip`);
formData.append("isZipFolder", "true");
formData.append("mode", ingestMode.value);
formData.append("tags", splitTags(ingestTags.value).join(","));
if (ingestScopeMode.value === "custom" && ingestSourceId.value.trim()) {
formData.append("sourceId", ingestSourceId.value.trim());
}
const response = await fetch("/ingest/upload", {
method: "POST",
body: formData
});
data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
} else {
data = await request("/ingest", {
sourceId: ingestScopeMode.value === "custom" ? (ingestSourceId.value.trim() || undefined) : undefined,
sourceType: ingestSourceType.value,
sourceRef: ingestSourceRef.value,
mode: ingestMode.value,
tags: splitTags(ingestTags.value)
});
}
ingestResult.textContent = format(data);
await loadScopes();
await loadRecentLogs();
updateIngestUiState();
} catch (error) {
ingestResult.textContent = String(error);
}
});
cleanupButton.addEventListener("click", async () => {
if (!cleanupScopeSelect.value) {
cleanupResult.textContent = "Error: Debes seleccionar un scope primero.";
return;
}
const scope = JSON.parse(cleanupScopeSelect.value);
const identifier = scope.sourceId || scope.sourceRef || "scope desconocido";
if (!window.confirm(`¿Seguro que quieres eliminar TODO el contexto de:\n\n${identifier}\n\nEsto borrara todos los vectores de Qdrant asociados y no se puede deshacer.`)) {
return;
}
cleanupResult.textContent = "Eliminando contexto...";
try {
const data = await request("/cleanup", { scope });
cleanupResult.textContent = format(data);
await loadScopes();
} catch (error) {
cleanupResult.textContent = String(error);
}
});
async function executeBootstrap() {
bootstrapResult.textContent = "Cargando bootstrap...";
try {
const data = await request("/retrieve", {
mode: bootstrapMode.value,
intent: "bootstrap",
query: bootstrapQuery.value,
model: answerModel.value,
useModelInRetrieve: useModelInRetrieve.checked,
scope: buildScopeFromInputs()
});
lastBootstrapContext = data;
lastBootstrapMeta = {
query: bootstrapQuery.value,
mode: bootstrapMode.value,
scope: buildScopeFromInputs(),
model: answerModel.value,
usedModelSummary: useModelInRetrieve.checked
};
bootstrapResult.textContent = format(data);
renderBootstrapContext();
lastInteraction = {
operation: "retrieve",
query: bootstrapQuery.value,
mode: bootstrapMode.value,
intent: "bootstrap",
model: answerModel.value,
scope: buildScopeFromInputs(),
usedBootstrapContext: false,
bootstrapMeta: lastBootstrapMeta,
usedAdditionalRetrieve: useModelInRetrieve.checked,
responseSummary: data.modelSummary || data.summary,
retrievedItems: data.items || []
};
await loadRecentLogs();
} catch (error) {
bootstrapResult.textContent = String(error);
}
}
bootstrapButton.addEventListener("click", executeBootstrap);
replaceBootstrapButton.addEventListener("click", executeBootstrap);
clearBootstrapButton.addEventListener("click", () => {
lastBootstrapContext = null;
lastBootstrapMeta = null;
renderBootstrapContext();
});
sendChatButton.addEventListener("click", async () => {
const message = chatInput.value.trim();
if (!message) {
return;
}
chatHistory.push({ role: "user", content: message });
renderChatHistory();
mainResult.textContent = "Consultando...";
compareResult.textContent = compareWithoutRag.checked ? "Comparando..." : "Desactivada.";
try {
const response = await request("/chat", {
message,
history: chatHistory,
mode: chatMode.value,
model: answerModel.value,
preloadedContext: reuseBootstrapContext.checked && lastBootstrapContext
? (lastBootstrapContext.modelSummary || lastBootstrapContext.summary || "")
: undefined,
bootstrapMeta: reuseBootstrapContext.checked ? lastBootstrapMeta : undefined,
allowAdditionalRetrieve: allowAdditionalRetrieve.checked,
scope: buildScopeFromInputs()
});
chatHistory.push({ role: "assistant", content: response.answer });
renderChatHistory();
mainResult.textContent = format(response);
lastInteraction = {
operation: "chat",
query: message,
mode: chatMode.value,
intent: "specific",
model: answerModel.value,
scope: buildScopeFromInputs(),
usedBootstrapContext: response.usedBootstrapContext,
bootstrapMeta: reuseBootstrapContext.checked ? lastBootstrapMeta : undefined,
usedAdditionalRetrieve: response.usedAdditionalRetrieve,
responseSummary: response.answer,
retrievedItems: response.retrieved?.items || []
};
await loadRecentLogs();
if (compareWithoutRag.checked) {
const comparison = await request("/answer/direct", {
query: message,
model: answerModel.value,
preloadedContext: reuseBootstrapContext.checked && lastBootstrapContext
? (lastBootstrapContext.modelSummary || lastBootstrapContext.summary || "")
: undefined,
bootstrapMeta: reuseBootstrapContext.checked ? lastBootstrapMeta : undefined
});
compareResult.textContent = format(comparison);
}
chatInput.value = "";
} catch (error) {
mainResult.textContent = String(error);
compareResult.textContent = compareWithoutRag.checked ? String(error) : "Desactivada.";
}
});
clearChatButton.addEventListener("click", () => {
chatHistory = [];
renderChatHistory();
mainResult.textContent = "Sin ejecutar aun.";
compareResult.textContent = "Desactivada.";
});
manualLogButton.addEventListener("click", async () => {
if (!lastInteraction) {
mainResult.textContent = "No hay una consulta previa para registrar en logs.";
return;
}
try {
const entry = await request("/logs/manual", {
...lastInteraction,
reason: "manual_review_requested",
note: manualLogNote.value.trim() || undefined
});
logsResult.textContent = format(entry);
await loadRecentLogs();
} catch (error) {
logsResult.textContent = String(error);
}
});
presetDocs.addEventListener("click", () => {
applyPreset(
"documental",
"dame un mapa inicial del workspace, sus lineas de trabajo principales, reglas, documentacion base y puntos importantes a tener presentes",
"/home/pancho/Documentos/Empresa/Desarrollo/IA/docs",
true
);
});
presetRagDocs.addEventListener("click", () => {
applyPreset(
"documental",
"dame un mapa inicial del modulo RAG, su arquitectura, decisiones, estado actual y documentos clave",
"/home/pancho/Documentos/Empresa/Desarrollo/IA/RAG/docs",
true
);
});
presetCode.addEventListener("click", () => {
applyPreset(
"codigo",
"dame un mapa inicial del codigo del modulo RAG, sus modulos principales, flujo interno y piezas clave",
"/home/pancho/Documentos/Empresa/Desarrollo/IA/RAG/src",
true
);
});
loadScopes();
loadAnswerModels();
loadRecentLogs();
renderBootstrapContext();
renderChatHistory();
updateIngestUiState();
updateScopeEditState();