Initial portal commit: landing + 9 AI-powered apps
Apps: - dwg-rooms: extract room numbers from DWG/DXF - dwg-counting: count symbols in PDF drawings (OpenCV template matching) - contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback) - email-drafter: bullet notes → polished Czech/English business emails - invoice-extractor: PDF/image invoice → structured data → Excel - translator: Czech-first translator across 19 languages with tone control - vv-check: find inconsistent unit prices across VV sheets in one workbook - vv-compare: diff original vs new VV files (changes / added / removed) - feature-request: portal users submit ideas + sample files Infrastructure: - LiteLLM gateway with per-app virtual keys + budgets - Langfuse observability - Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL - Caddy reverse proxy on *.klas.chat
This commit is contained in:
442
dwg-counting/static/app.js
Normal file
442
dwg-counting/static/app.js
Normal file
@@ -0,0 +1,442 @@
|
||||
// dwg-counting frontend
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const sections = {
|
||||
upload: $("s-upload"),
|
||||
processing: $("s-processing"),
|
||||
symbols: $("s-symbols"),
|
||||
results: $("s-results"),
|
||||
};
|
||||
const show = (name) => {
|
||||
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
|
||||
};
|
||||
|
||||
let state = { jobId: null, symbols: [], selected: new Set(), results: [] };
|
||||
|
||||
// Upload handlers
|
||||
const fileInput = $("file-input");
|
||||
const dropZone = $("drop-zone");
|
||||
$("browse-btn").addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0]));
|
||||
["dragenter", "dragover"].forEach((ev) =>
|
||||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
|
||||
["dragleave", "drop"].forEach((ev) =>
|
||||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
|
||||
dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0]));
|
||||
|
||||
async function upload(file) {
|
||||
show("processing");
|
||||
$("processing-title").textContent = "Připravuji výkres…";
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
// Skip auto-detection by default — user defines symbols manually
|
||||
const r = await fetch("/api/upload?auto_detect=false", {
|
||||
method: "POST", body: fd,
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const data = await r.json();
|
||||
state.jobId = data.job_id;
|
||||
state.symbols = data.symbols || [];
|
||||
state.selected = new Set();
|
||||
renderSymbols();
|
||||
show("symbols");
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
show("upload");
|
||||
}
|
||||
}
|
||||
|
||||
$("auto-detect-btn").addEventListener("click", async () => {
|
||||
if (!state.jobId) return;
|
||||
show("processing");
|
||||
$("processing-title").textContent = "Hledám legendu pomocí AI…";
|
||||
try {
|
||||
const r = await fetch(`/api/auto-detect/${state.jobId}`, { method: "POST" });
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const data = await r.json();
|
||||
state.symbols = data.symbols || [];
|
||||
renderSymbols();
|
||||
show("symbols");
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
show("symbols");
|
||||
}
|
||||
});
|
||||
|
||||
$("add-symbol-btn").addEventListener("click", () => {
|
||||
openAddSymbolModal();
|
||||
});
|
||||
|
||||
$("upload-symbol-btn").addEventListener("click", () => {
|
||||
$("symbol-file-input").click();
|
||||
});
|
||||
$("symbol-file-input").addEventListener("change", async (e) => {
|
||||
const f = e.target.files[0];
|
||||
if (!f) return;
|
||||
const name = prompt("Název symbolu:", f.name.replace(/\.[a-z]+$/i, ""));
|
||||
if (!name) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", f);
|
||||
fd.append("description", name);
|
||||
try {
|
||||
const r = await fetch(`/api/symbols/${state.jobId}/upload`, { method: "POST", body: fd });
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const sym = await r.json();
|
||||
state.symbols.push(sym);
|
||||
renderSymbols();
|
||||
} catch (err) { alert("Chyba: " + err.message); }
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
function renderSymbols() {
|
||||
const meta = $("symbols-meta");
|
||||
if (!state.symbols.length) {
|
||||
meta.textContent = 'Klikněte „+ Přidat symbol" a vyznačte ve výkresu, co chcete počítat. Nebo zkuste „Auto-detekce z legendy" pro automatický návrh.';
|
||||
$("symbols-grid").innerHTML = "";
|
||||
return;
|
||||
}
|
||||
meta.textContent = `${state.symbols.length} symbolů. Zaškrtněte k spočítání, ✎ upravit, 🗑 smazat.`;
|
||||
const grid = $("symbols-grid");
|
||||
grid.innerHTML = "";
|
||||
for (const s of state.symbols) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "symbol-card";
|
||||
card.innerHTML = `
|
||||
<input type="checkbox" data-id="${s.id}">
|
||||
<img class="symbol-thumb" src="/api/symbol/${state.jobId}/${s.id}?v=${s._v||0}" alt=""
|
||||
onerror="this.style.visibility='hidden'">
|
||||
<div class="symbol-info">
|
||||
<div class="symbol-id">${s.id}</div>
|
||||
<div class="symbol-desc">${escapeHtml(s.description || "")}</div>
|
||||
</div>
|
||||
<button class="symbol-edit" type="button" title="Upravit výřez">✎</button>
|
||||
<button class="symbol-debug" type="button" title="Debug: zobrazit šablonu a skóre">🔍</button>
|
||||
<button class="symbol-delete" type="button" title="Smazat symbol">🗑</button>`;
|
||||
const cb = card.querySelector("input");
|
||||
cb.addEventListener("change", () => {
|
||||
if (cb.checked) { state.selected.add(s.id); card.classList.add("selected"); }
|
||||
else { state.selected.delete(s.id); card.classList.remove("selected"); }
|
||||
$("count-btn").disabled = state.selected.size === 0;
|
||||
});
|
||||
// Click on row toggles checkbox (except on edit btn / thumb)
|
||||
card.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".symbol-edit") || e.target === cb) return;
|
||||
cb.checked = !cb.checked;
|
||||
cb.dispatchEvent(new Event("change"));
|
||||
});
|
||||
card.querySelector(".symbol-edit").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openCropModal(s);
|
||||
});
|
||||
card.querySelector(".symbol-debug").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openDebugModal(s);
|
||||
});
|
||||
card.querySelector(".symbol-delete").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Smazat symbol "${s.description || s.id}"?`)) return;
|
||||
try {
|
||||
const r = await fetch(`/api/symbols/${state.jobId}/${s.id}`, { method: "DELETE" });
|
||||
if (!r.ok) throw new Error("delete failed");
|
||||
state.symbols = state.symbols.filter(x => x.id !== s.id);
|
||||
state.selected.delete(s.id);
|
||||
renderSymbols();
|
||||
$("count-btn").disabled = state.selected.size === 0;
|
||||
} catch (err) { alert("Chyba: " + err.message); }
|
||||
});
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
// Add-new-symbol modal: same crop modal, but uses full drawing image and
|
||||
// asks for a name.
|
||||
function openAddSymbolModal() {
|
||||
const modal = $("crop-modal");
|
||||
const img = $("crop-img");
|
||||
const sel = $("crop-selection");
|
||||
const wrap = $("crop-canvas-wrap");
|
||||
$("crop-modal-title").textContent = "Nový symbol — vyznačte oblast ve výkresu";
|
||||
$("crop-modal-hint").textContent =
|
||||
"DOPORUČENO: najděte symbol PŘÍMO ve výkresu (ne v legendě) — barvy a tloušťka čar tam přesně odpovídají tomu, co budeme hledat. Vyznačte těsný rámeček kolem jedné instance symbolu a pojmenujte ho.";
|
||||
$("crop-name-row").classList.remove("hidden");
|
||||
$("crop-name").value = "";
|
||||
img.src = `/api/drawing/${state.jobId}`;
|
||||
sel.style.display = "none";
|
||||
$("crop-save").disabled = true;
|
||||
cropState = { mode: "create", sym: null, bbox: null, dragging: false };
|
||||
modal.classList.remove("hidden");
|
||||
attachCropHandlers(wrap, img, sel);
|
||||
}
|
||||
|
||||
// ── Crop modal logic ──────────────────────
|
||||
let cropState = null;
|
||||
|
||||
function openCropModal(sym) {
|
||||
const modal = $("crop-modal");
|
||||
const img = $("crop-img");
|
||||
const sel = $("crop-selection");
|
||||
const wrap = $("crop-canvas-wrap");
|
||||
$("crop-modal-title").textContent = `Upravit symbol: ${sym.description || sym.id}`;
|
||||
$("crop-modal-hint").textContent =
|
||||
"Označte oblast obsahující pouze grafický symbol (bez popisu).";
|
||||
$("crop-name-row").classList.add("hidden");
|
||||
// For editing: use the full drawing so user can pick any instance
|
||||
img.src = `/api/drawing/${state.jobId}`;
|
||||
sel.style.display = "none";
|
||||
$("crop-save").disabled = true;
|
||||
cropState = { mode: "edit", sym, bbox: null, dragging: false };
|
||||
modal.classList.remove("hidden");
|
||||
attachCropHandlers(wrap, img, sel);
|
||||
return;
|
||||
}
|
||||
|
||||
function attachCropHandlers(wrap, img, sel) {
|
||||
|
||||
// Mouse coords relative to the IMAGE's top-left (in image-pixel space,
|
||||
// which is also wrap's content space since image is at native scale and
|
||||
// sits at content origin (0,0) in the relative-positioned wrap).
|
||||
function localXY(e) {
|
||||
const rect = img.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
|
||||
function onMouseDown(e) {
|
||||
const p = localXY(e);
|
||||
cropState.startX = p.x;
|
||||
cropState.startY = p.y;
|
||||
cropState.dragging = true;
|
||||
sel.style.display = "block";
|
||||
sel.style.left = p.x + "px";
|
||||
sel.style.top = p.y + "px";
|
||||
sel.style.width = "0px";
|
||||
sel.style.height = "0px";
|
||||
}
|
||||
function onMouseMove(e) {
|
||||
if (!cropState || !cropState.dragging) return;
|
||||
const p = localXY(e);
|
||||
const left = Math.min(p.x, cropState.startX);
|
||||
const top = Math.min(p.y, cropState.startY);
|
||||
const w = Math.abs(p.x - cropState.startX);
|
||||
const h = Math.abs(p.y - cropState.startY);
|
||||
sel.style.left = left + "px";
|
||||
sel.style.top = top + "px";
|
||||
sel.style.width = w + "px";
|
||||
sel.style.height = h + "px";
|
||||
}
|
||||
function onMouseUp() {
|
||||
if (!cropState) return;
|
||||
cropState.dragging = false;
|
||||
const rect = img.getBoundingClientRect();
|
||||
const dispW = rect.width, dispH = rect.height;
|
||||
const left = parseFloat(sel.style.left), top = parseFloat(sel.style.top);
|
||||
const w = parseFloat(sel.style.width), h = parseFloat(sel.style.height);
|
||||
if (w < 5 || h < 5) {
|
||||
sel.style.display = "none";
|
||||
$("crop-save").disabled = true;
|
||||
return;
|
||||
}
|
||||
cropState.bbox = {
|
||||
x: left / dispW,
|
||||
y: top / dispH,
|
||||
w: w / dispW,
|
||||
h: h / dispH,
|
||||
};
|
||||
$("crop-save").disabled = false;
|
||||
}
|
||||
|
||||
wrap.onmousedown = onMouseDown;
|
||||
wrap.onmousemove = onMouseMove;
|
||||
wrap.onmouseup = onMouseUp;
|
||||
wrap.onmouseleave = onMouseUp;
|
||||
}
|
||||
|
||||
function closeCropModal() {
|
||||
$("crop-modal").classList.add("hidden");
|
||||
cropState = null;
|
||||
}
|
||||
|
||||
async function openDebugModal(sym) {
|
||||
const modal = $("debug-modal");
|
||||
const body = $("debug-body");
|
||||
$("debug-title").textContent = `Debug: ${sym.description || sym.id}`;
|
||||
body.innerHTML = '<p style="padding:20px">Načítám diagnostiku…</p>';
|
||||
modal.classList.remove("hidden");
|
||||
try {
|
||||
const r = await fetch(`/api/debug/${state.jobId}/${sym.id}`);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const info = await r.json();
|
||||
const tmplURL = `/api/symbol/${state.jobId}/${sym.id}?v=${sym._v||0}`;
|
||||
const procURL = `/api/debug-template/${state.jobId}/${sym.id}?v=${sym._v||0}&t=${Date.now()}`;
|
||||
const drawURL = `/api/drawing/${state.jobId}`;
|
||||
const matches = info.matches_at_threshold || {};
|
||||
const maxMatchCount = Math.max(1, ...Object.values(matches));
|
||||
const threshRows = Object.entries(matches).map(([t, n]) => `
|
||||
<div>práh ${t}</div>
|
||||
<div class="debug-bar"><div class="debug-bar-fill" style="width:${Math.min(100,(n/maxMatchCount)*100)}%"></div></div>
|
||||
<div>${n} shod</div>`).join("");
|
||||
const inkPct = info.template_total_pixels
|
||||
? ((info.template_ink_pixels / info.template_total_pixels) * 100).toFixed(1)
|
||||
: "?";
|
||||
body.innerHTML = `
|
||||
<div class="debug-section">
|
||||
<h4>Obrazy</h4>
|
||||
<div class="debug-thumbs">
|
||||
<div class="debug-thumb">
|
||||
<a href="${tmplURL}" target="_blank"><img src="${tmplURL}" alt=""></a>
|
||||
<span>Šablona (uložená)</span>
|
||||
</div>
|
||||
<div class="debug-thumb">
|
||||
<a href="${procURL}" target="_blank"><img src="${procURL}" alt=""></a>
|
||||
<span>Po předzpracování<br>(co matcher vidí)</span>
|
||||
</div>
|
||||
<div class="debug-thumb">
|
||||
<a href="${drawURL}" target="_blank">otevřít celý výkres ↗</a>
|
||||
<span>${info.drawing_size.join(" × ")} px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="debug-section">
|
||||
<h4>Měření</h4>
|
||||
<div class="debug-kv">
|
||||
<strong>Šablona originál</strong><span>${info.template_size.join(" × ")} px</span>
|
||||
<strong>Šablona po ořezu</strong><span>${info.template_cropped_size.join(" × ")} px</span>
|
||||
<strong>Inkoust v šabloně</strong><span>${info.template_ink_pixels} / ${info.template_total_pixels} px (${inkPct}%)</span>
|
||||
<strong>Výkres</strong><span>${info.drawing_size.join(" × ")} px</span>
|
||||
<strong>Nejlepší skóre</strong><span>${info.max_score?.toFixed(3) ?? "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="debug-section">
|
||||
<h4>Shody při různých prazích (bez rotací, scale 1.0)</h4>
|
||||
<div class="debug-thresholds">${threshRows}</div>
|
||||
<p style="margin-top:8px; color: var(--text-tertiary); font-size:12px">
|
||||
Aktuálně používaný práh pro počítání: <strong>0.75</strong>.
|
||||
Skutečné počty (s rotacemi 0/90/180/270° a třemi scale faktory) jsou typicky nižší kvůli deduplikaci.
|
||||
</p>
|
||||
</div>
|
||||
<div class="debug-section" id="debug-interpret"></div>
|
||||
`;
|
||||
// Heuristic interpretation
|
||||
const interpret = [];
|
||||
if (info.template_ink_pixels < 30)
|
||||
interpret.push('⚠ Šablona obsahuje příliš málo „inkoustu" — pravděpodobně jste vybrali převážně bílou plochu. Vyznačte rámeček těsně kolem čar symbolu.');
|
||||
if (Math.min(...info.template_cropped_size) < 12)
|
||||
interpret.push("⚠ Šablona je velmi malá (<12 px). Malé šablony hlásí spoustu falešných shod nebo žádné. Zkuste přidat trochu okolí.");
|
||||
if ((info.max_score ?? 0) < 0.5)
|
||||
interpret.push("⚠ Nejlepší skóre v celém výkresu je velmi nízké — symbol vypadá v plánu jinak než v šabloně. Zkuste vyznačit symbol přímo z plánu (ne z legendy), nebo zkuste jinou variantu/orientaci.");
|
||||
else if ((info.max_score ?? 0) < 0.7)
|
||||
interpret.push("ℹ Nejlepší skóre ≈ " + info.max_score.toFixed(2) + ". Práh 0.75 je nad maximem — zkuste snížit práh nebo upravit šablonu těsněji.");
|
||||
else
|
||||
interpret.push("✓ Symbol se v plánu vyskytuje. Pokud nejsou shody, může jít o problém s rotacemi nebo měřítkem.");
|
||||
$("debug-interpret").innerHTML = "<h4>Interpretace</h4>" +
|
||||
interpret.map(t => `<p>${t}</p>`).join("");
|
||||
} catch (err) {
|
||||
body.innerHTML = `<p style="color:#dc2626; padding:12px">Chyba: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
$("debug-close").addEventListener("click", () => $("debug-modal").classList.add("hidden"));
|
||||
$("debug-ok").addEventListener("click", () => $("debug-modal").classList.add("hidden"));
|
||||
|
||||
$("crop-close").addEventListener("click", closeCropModal);
|
||||
$("crop-cancel").addEventListener("click", closeCropModal);
|
||||
$("crop-save").addEventListener("click", async () => {
|
||||
if (!cropState || !cropState.bbox) return;
|
||||
try {
|
||||
if (cropState.mode === "create") {
|
||||
const name = ($("crop-name").value || "").trim();
|
||||
if (!name) { alert("Zadejte název symbolu."); return; }
|
||||
const r = await fetch(`/api/symbols/${state.jobId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bbox: cropState.bbox,
|
||||
description: name,
|
||||
source: "drawing",
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const newSym = await r.json();
|
||||
state.symbols.push(newSym);
|
||||
} else {
|
||||
const r = await fetch(`/api/symbol/${state.jobId}/${cropState.sym.id}/recrop`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bbox: cropState.bbox }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
cropState.sym._v = (cropState.sym._v || 0) + 1;
|
||||
}
|
||||
renderSymbols();
|
||||
closeCropModal();
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Threshold slider live readout
|
||||
const thrSlider = $("threshold-slider");
|
||||
const thrValue = $("threshold-value");
|
||||
thrSlider.addEventListener("input", () => {
|
||||
thrValue.textContent = parseFloat(thrSlider.value).toFixed(2);
|
||||
});
|
||||
|
||||
$("count-btn").addEventListener("click", async () => {
|
||||
show("processing");
|
||||
$("processing-title").textContent = `Počítám ${state.selected.size} symbolů…`;
|
||||
try {
|
||||
const r = await fetch(`/api/count/${state.jobId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
symbol_ids: [...state.selected],
|
||||
threshold: parseFloat(thrSlider.value),
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const data = await r.json();
|
||||
state.results = data.results || [];
|
||||
renderResults();
|
||||
show("results");
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
show("symbols");
|
||||
}
|
||||
});
|
||||
|
||||
function renderResults() {
|
||||
const total = state.results.reduce((a, r) => a + (r.count || 0), 0);
|
||||
$("results-meta").textContent = `Celkem ${total} symbolů ve ${state.results.length} kategoriích.`;
|
||||
const tb = $("results-tbody");
|
||||
tb.innerHTML = "";
|
||||
for (const r of state.results) {
|
||||
const tr = document.createElement("tr");
|
||||
const conf = r.confidence || "";
|
||||
tr.innerHTML = `
|
||||
<td><img class="symbol-thumb" style="width:40px;height:40px" src="/api/symbol/${state.jobId}/${r.id}" alt=""></td>
|
||||
<td>${escapeHtml(r.description || "")}</td>
|
||||
<td><strong>${r.count}</strong></td>
|
||||
<td class="confidence-${conf}">${conf || "—"}</td>
|
||||
<td>${escapeHtml(r.notes || "")}</td>`;
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
$("export-btn").addEventListener("click", () => {
|
||||
window.location.href = `/api/export/${state.jobId}`;
|
||||
});
|
||||
$("pdf-btn").addEventListener("click", () => {
|
||||
window.location.href = `/api/export-pdf/${state.jobId}`;
|
||||
});
|
||||
|
||||
$("back-btn").addEventListener("click", () => show("symbols"));
|
||||
$("reset-btn").addEventListener("click", () => {
|
||||
state = { jobId: null, symbols: [], selected: new Set(), results: [] };
|
||||
fileInput.value = "";
|
||||
show("upload");
|
||||
});
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user