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:
Ondřej Glaser
2026-05-13 15:25:04 +02:00
commit 48cef99257
139 changed files with 20171 additions and 0 deletions

209
dwg-rooms/static/app.js Normal file
View File

@@ -0,0 +1,209 @@
"use strict";
const $ = id => document.getElementById(id);
const sUpload = $("s-upload");
const sProcessing = $("s-processing");
const sResults = $("s-results");
const dropZone = $("drop-zone");
const fileInput = $("file-input");
const browseBtn = $("browse-btn");
const stepsList = $("steps-list");
const roomsTbody = $("rooms-tbody");
const resultsMeta = $("results-meta");
const exportBtn = $("export-btn");
const resetBtn = $("reset-btn");
const examplesList = $("examples-list");
const examplesForm = $("examples-form");
const exampleInput = $("example-input");
let jobId = null;
let rooms = [];
let examples = [];
// ── Examples management ───────────────────────────
async function loadDefaults() {
try {
const resp = await fetch("/api/defaults");
const data = await resp.json();
examples = Array.isArray(data.examples) ? data.examples : [];
renderExamples();
} catch (e) {
console.warn("Could not load defaults:", e);
}
}
function renderExamples() {
examplesList.innerHTML = "";
examples.forEach((ex, i) => {
const chip = document.createElement("span");
chip.className = "example-chip";
chip.innerHTML = `
${esc(ex)}
<button class="example-chip-remove" type="button" data-i="${i}" aria-label="Odebrat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</button>
`;
examplesList.appendChild(chip);
});
examplesList.querySelectorAll(".example-chip-remove").forEach(btn => {
btn.addEventListener("click", () => {
examples.splice(parseInt(btn.dataset.i, 10), 1);
renderExamples();
});
});
}
examplesForm.addEventListener("submit", e => {
e.preventDefault();
const v = exampleInput.value.trim();
if (!v) return;
if (!/\d/.test(v)) {
exampleInput.focus();
exampleInput.select();
return;
}
if (!examples.includes(v)) examples.push(v);
exampleInput.value = "";
renderExamples();
exampleInput.focus();
});
// ── Drag & drop ───────────────────────────────────
dropZone.addEventListener("dragover", e => { e.preventDefault(); dropZone.classList.add("drag-over"); });
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
dropZone.addEventListener("drop", e => {
e.preventDefault();
dropZone.classList.remove("drag-over");
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
});
dropZone.addEventListener("click", () => fileInput.click());
browseBtn.addEventListener("click", e => { e.stopPropagation(); fileInput.click(); });
fileInput.addEventListener("change", e => { if (e.target.files[0]) handleFile(e.target.files[0]); });
// ── Upload & process ──────────────────────────────
async function handleFile(file) {
const ext = file.name.split(".").pop().toLowerCase();
if (!["dwg", "dxf"].includes(ext)) {
alert("Podporované formáty jsou .dwg a .dxf");
return;
}
show(sProcessing); hide(sUpload); hide(sResults);
stepsList.innerHTML = "";
addStep("Nahrávání souboru…", "active");
const fd = new FormData();
fd.append("file", file);
fd.append("examples", JSON.stringify(examples));
try {
const resp = await fetch("/api/upload", { method: "POST", body: fd });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const data = await resp.json();
jobId = data.job_id;
stepsList.innerHTML = "";
for (const step of data.steps) addStep(step, "done");
const rResp = await fetch(`/api/rooms/${jobId}`);
rooms = await rResp.json();
renderResults();
} catch (err) {
stepsList.innerHTML = "";
addStep(`Chyba: ${err.message}`, "error");
setTimeout(() => { show(sUpload); hide(sProcessing); }, 4000);
}
}
function addStep(label, state) {
const el = document.createElement("div");
el.className = `step-item ${state}`;
el.innerHTML = `<span class="step-dot"></span>${esc(label)}`;
stepsList.appendChild(el);
}
// ── Render table ──────────────────────────────────
function renderResults() {
hide(sProcessing); show(sResults);
const total = rooms.length;
const llmCount = rooms.filter(r => r.source === "llm").length;
resultsMeta.textContent = `${total} místností nalezeno${llmCount ? ` (${llmCount} z AI)` : ""}`;
roomsTbody.innerHTML = "";
const sorted = [...rooms].sort((a, b) => String(a.room).localeCompare(String(b.room)));
sorted.forEach((room, i) => {
const tr = document.createElement("tr");
const pct = Math.round((room.confidence ?? 1) * 100);
const badgeCls = room.source === "llm" ? "badge-llm" : "badge-rule";
const badgeTxt = room.source === "llm" ? "AI" : "Pravidla";
tr.innerHTML = `
<td contenteditable="true" data-field="room" data-i="${i}">${esc(room.room)}</td>
<td contenteditable="true" data-field="description" data-i="${i}">${esc(room.description)}</td>
<td><span class="badge ${badgeCls}">${badgeTxt}</span></td>
<td>${pct} %</td>
`;
tr.querySelectorAll("[contenteditable]").forEach(cell => {
cell.addEventListener("blur", () => {
const idx = parseInt(cell.dataset.i, 10);
const field = cell.dataset.field;
rooms[idx][field] = cell.textContent.trim();
});
cell.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); cell.blur(); }
});
});
roomsTbody.appendChild(tr);
});
}
// ── Export ────────────────────────────────────────
exportBtn.addEventListener("click", async () => {
exportBtn.disabled = true;
exportBtn.textContent = "Ukládám…";
try {
await fetch(`/api/rooms/${jobId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(rooms),
});
window.location.href = `/api/export/${jobId}`;
} finally {
setTimeout(() => {
exportBtn.disabled = false;
exportBtn.innerHTML = `<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
</svg>Exportovat Excel`;
}, 1500);
}
});
// ── Reset ─────────────────────────────────────────
resetBtn.addEventListener("click", () => {
jobId = null; rooms = [];
fileInput.value = "";
stepsList.innerHTML = ""; roomsTbody.innerHTML = "";
show(sUpload); hide(sResults); hide(sProcessing);
});
// ── Helpers ───────────────────────────────────────
function show(el) { el.classList.remove("hidden"); }
function hide(el) { el.classList.add("hidden"); }
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ── Init ──────────────────────────────────────────
loadDefaults();