feat: Add .env.example configuration file and integrate Redis service into Docker Compose for improved task management. Update desktop application to support document indexing and extraction workflows, enhancing user experience with a new extraction wizard and stepper components. Revise README and documentation for clarity on setup and usage.

This commit is contained in:
erik
2026-07-05 22:39:07 -05:00
parent 9aca926988
commit d95f8f6ad8
70 changed files with 9807 additions and 499 deletions
@@ -0,0 +1,347 @@
const MAX_SYNC_FILES = 50;␍
const MAX_ASYNC_FILES = 500;␍
const JOB_CHUNK_SIZE = 500;␍
const LONG_POLL_MS = 30000;␍
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);␍
␍
function $(id) {␍
return document.getElementById(id);␍
}␍
␍
function getBaseUrl() {␍
const input = $("baseUrl").value.trim();␍
return (input || window.location.origin).replace(/\/$/, "");␍
}␍
␍
function getIncludeRejected() {␍
return $("includeRejected").checked;␍
}␍
␍
function getSelectedFiles(inputId) {␍
const input = $(inputId);␍
return Array.from(input.files || []);␍
}␍
␍
function validateFiles(files, maxFiles) {␍
if (files.length === 0) {␍
throw new Error("Selecciona al menos un archivo PDF.");␍
}␍
if (files.length > maxFiles) {␍
throw new Error(`Máximo ${maxFiles} archivos por petición.`);␍
}␍
for (const file of files) {␍
if (!file.name.toLowerCase().endsWith(".pdf")) {␍
throw new Error(`"${file.name}" no es un PDF.`);␍
}␍
}␍
}␍
␍
async function parseError(res) {␍
try {␍
const data = await res.json();␍
if (data && data.error) return data.error;␍
} catch (_) {␍
/* ignore */␍
}␍
return `Error HTTP ${res.status}`;␍
}␍
␍
function buildMultipart(files) {␍
const form = new FormData();␍
for (const file of files) {␍
form.append("files", file, file.name);␍
}␍
return form;␍
}␍
␍
function setLoading(button, loading, label) {␍
button.disabled = loading;␍
button.textContent = loading ? "Procesando…" : label;␍
}␍
␍
function showError(containerId, message) {␍
const el = $(containerId);␍
el.hidden = false;␍
el.textContent = message;␍
}␍
␍
function hideError(containerId) {␍
const el = $(containerId);␍
el.hidden = true;␍
el.textContent = "";␍
}␍
␍
function formatProbability(value) {␍
if (typeof value !== "number") return "—";␍
return (value * 100).toFixed(1) + "%";␍
}␍
␍
function renderResults(container, results) {␍
container.innerHTML = "";␍
␍
if (!results || results.length === 0) {␍
container.innerHTML = '<p class="empty">Sin resultados.</p>';␍
return;␍
}␍
␍
for (const item of results) {␍
const card = document.createElement("article");␍
card.className = "result-card";␍
␍
const header = document.createElement("div");␍
header.className = "result-header";␍
header.innerHTML = `␍
<h3>${escapeHtml(item.filename)}</h3>␍
<span class="badge badge-${item.status}">${escapeHtml(item.status)}</span>␍
`;␍
card.appendChild(header);␍
␍
if (item.status === "error" && item.error) {␍
const err = document.createElement("p");␍
err.className = "result-error";␍
err.textContent = item.error;␍
card.appendChild(err);␍
} else {␍
const meta = document.createElement("p");␍
meta.className = "result-meta";␍
meta.textContent = `Servicios detectados: ${item.service_count ?? 0}`;␍
card.appendChild(meta);␍
␍
if (item.services && item.services.length > 0) {␍
const table = document.createElement("table");␍
table.innerHTML = `␍
<thead>␍
<tr>␍
<th>Texto</th>␍
<th>Probabilidad</th>␍
<th>Es servicio</th>␍
</tr>␍
</thead>␍
<tbody></tbody>␍
`;␍
const tbody = table.querySelector("tbody");␍
for (const row of item.services) {␍
const tr = document.createElement("tr");␍
const isService =␍
row.is_service === undefined || row.is_service === null␍
? "—"␍
: row.is_service␍
? "Sí"␍
: "No";␍
tr.innerHTML = `␍
<td>${escapeHtml(row.text)}</td>␍
<td>${formatProbability(row.probability)}</td>␍
<td>${isService}</td>␍
`;␍
tbody.appendChild(tr);␍
}␍
card.appendChild(table);␍
}␍
}␍
␍
container.appendChild(card);␍
}␍
}␍
␍
function escapeHtml(text) {␍
const div = document.createElement("div");␍
div.textContent = text ?? "";␍
return div.innerHTML;␍
}␍
␍
async function countSync(baseUrl, files, includeRejected) {␍
const url = new URL(`${baseUrl}/v1/count`);␍
if (includeRejected) url.searchParams.set("include_rejected", "true");␍
␍
const res = await fetch(url, {␍
method: "POST",␍
body: buildMultipart(files),␍
});␍
␍
if (!res.ok) throw new Error(await parseError(res));␍
const data = await res.json();␍
return data.results || [];␍
}␍
␍
async function createEmptyJob(baseUrl, includeRejected) {␍
const res = await fetch(`${baseUrl}/v1/jobs`, {␍
method: "POST",␍
headers: { "Content-Type": "application/json" },␍
body: JSON.stringify({ include_rejected: includeRejected }),␍
});␍
if (!res.ok) throw new Error(await parseError(res));␍
const data = await res.json();␍
return data.job_id;␍
}␍
␍
async function uploadChunk(baseUrl, jobId, files, includeRejected) {␍
const url = new URL(`${baseUrl}/v1/jobs/${jobId}/upload`);␍
if (includeRejected) url.searchParams.set("include_rejected", "true");␍
␍
const res = await fetch(url, {␍
method: "POST",␍
body: buildMultipart(files),␍
});␍
if (!res.ok) throw new Error(await parseError(res));␍
return res.json();␍
}␍
␍
async function getJobStatus(baseUrl, jobId, waitMs = 0) {␍
const url = new URL(`${baseUrl}/v1/jobs/${jobId}`);␍
if (waitMs > 0) url.searchParams.set("wait_ms", String(waitMs));␍
␍
const res = await fetch(url);␍
if (!res.ok) throw new Error(await parseError(res));␍
return res.json();␍
}␍
␍
async function fetchAllResults(baseUrl, jobId) {␍
const results = [];␍
let cursor = "";␍
let hasMore = true;␍
␍
while (hasMore) {␍
const url = new URL(`${baseUrl}/v1/jobs/${jobId}/results`);␍
url.searchParams.set("limit", "500");␍
if (cursor) url.searchParams.set("cursor", cursor);␍
␍
const res = await fetch(url);␍
if (!res.ok) throw new Error(await parseError(res));␍
const page = await res.json();␍
results.push(...(page.results || []));␍
cursor = page.next_cursor || "";␍
hasMore = Boolean(page.has_more);␍
}␍
␍
return results;␍
}␍
␍
function chunkArray(items, size) {␍
const chunks = [];␍
for (let i = 0; i < items.length; i += size) {␍
chunks.push(items.slice(i, i + size));␍
}␍
return chunks;␍
}␍
␍
async function runAsyncJob(baseUrl, files, includeRejected, onProgress) {␍
const jobId = await createEmptyJob(baseUrl, includeRejected);␍
const chunks = chunkArray(files, JOB_CHUNK_SIZE);␍
␍
for (let i = 0; i < chunks.length; i++) {␍
onProgress({␍
status: "uploading",␍
total: files.length,␍
processed: 0,␍
failed: 0,␍
uploadChunk: i + 1,␍
uploadChunks: chunks.length,␍
});␍
await uploadChunk(baseUrl, jobId, chunks[i], includeRejected);␍
}␍
␍
while (true) {␍
const status = await getJobStatus(baseUrl, jobId, LONG_POLL_MS);␍
onProgress(status);␍
if (TERMINAL_STATUSES.has(status.status)) {␍
if (status.status === "cancelled") {␍
throw new Error("El job fue cancelado.");␍
}␍
break;␍
}␍
}␍
␍
return fetchAllResults(baseUrl, jobId);␍
}␍
␍
function updateProgress(status) {␍
const bar = $("asyncProgress");␍
const label = $("asyncProgressLabel");␍
const total = status.total || 0;␍
const done = (status.processed || 0) + (status.failed || 0);␍
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;␍
␍
bar.hidden = false;␍
label.hidden = false;␍
␍
if (status.status === "uploading") {␍
const chunkPct =␍
status.uploadChunks > 0␍
? Math.round((status.uploadChunk / status.uploadChunks) * 100)␍
: 0;␍
bar.value = chunkPct;␍
label.textContent = `Subiendo lote ${status.uploadChunk}/${status.uploadChunks} (${total} archivos)…`;␍
return;␍
}␍
␍
bar.value = pct;␍
label.textContent = `Estado: ${status.status} — ${done}/${total} procesados (${status.failed || 0} fallidos)`;␍
}␍
␍
function initTabs() {␍
const tabs = document.querySelectorAll(".tab");␍
const panels = document.querySelectorAll(".panel");␍
␍
tabs.forEach((tab) => {␍
tab.addEventListener("click", () => {␍
tabs.forEach((t) => t.classList.remove("active"));␍
panels.forEach((p) => p.classList.remove("active"));␍
tab.classList.add("active");␍
document.getElementById(tab.dataset.panel).classList.add("active");␍
});␍
});␍
}␍
␍
function init() {␍
$("baseUrl").value = window.location.origin;␍
␍
initTabs();␍
␍
$("syncForm").addEventListener("submit", async (e) => {␍
e.preventDefault();␍
const btn = $("syncSubmit");␍
hideError("syncError");␍
$("syncResults").innerHTML = "";␍
␍
try {␍
const files = getSelectedFiles("syncFiles");␍
validateFiles(files, MAX_SYNC_FILES);␍
setLoading(btn, true, "Contar servicios");␍
const results = await countSync(getBaseUrl(), files, getIncludeRejected());␍
renderResults($("syncResults"), results);␍
} catch (err) {␍
showError("syncError", err.message);␍
} finally {␍
setLoading(btn, false, "Contar servicios");␍
}␍
});␍
␍
$("asyncForm").addEventListener("submit", async (e) => {␍
e.preventDefault();␍
const btn = $("asyncSubmit");␍
hideError("asyncError");␍
$("asyncResults").innerHTML = "";␍
$("asyncProgress").hidden = true;␍
$("asyncProgressLabel").hidden = true;␍
␍
try {␍
const files = getSelectedFiles("asyncFiles");␍
validateFiles(files, MAX_ASYNC_FILES);␍
setLoading(btn, true, "Iniciar job");␍
const results = await runAsyncJob(␍
getBaseUrl(),␍
files,␍
getIncludeRejected(),␍
updateProgress,␍
);␍
renderResults($("asyncResults"), results);␍
} catch (err) {␍
showError("asyncError", err.message);␍
} finally {␍
setLoading(btn, false, "Iniciar job");␍
}␍
});␍
}␍
␍
document.addEventListener("DOMContentLoaded", init);␍
␍