Files
AI_portal/dwg-counting/static/app.js
Ondřej Glaser 48cef99257 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
2026-05-13 15:25:04 +02:00

443 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 dedupli­kaci.
</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) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();