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:
@@ -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);␍
|
||||
␍
|
||||
Reference in New Issue
Block a user