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:
6
dwg-rooms/.dockerignore
Normal file
6
dwg-rooms/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
libredwg-0.12.5/
|
||||
8
dwg-rooms/.env.example
Normal file
8
dwg-rooms/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# Copy to .env and fill in values
|
||||
|
||||
# LiteLLM proxy (leave LITELLM_API_KEY empty to disable LLM fallback)
|
||||
LITELLM_BASE_URL=http://host.docker.internal:4000
|
||||
LITELLM_API_KEY=
|
||||
|
||||
# Which LiteLLM model alias to use for the LLM fallback
|
||||
LLM_MODEL=gpt-4o-mini
|
||||
38
dwg-rooms/Dockerfile
Normal file
38
dwg-rooms/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# Stage 1: compile LibreDWG (provides dwg2dxf for DWG → DXF conversion)
|
||||
FROM debian:bookworm-slim AS libredwg-builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
xz-utils \
|
||||
build-essential \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY libredwg-0.12.5.tar.xz .
|
||||
RUN tar xf libredwg-0.12.5.tar.xz \
|
||||
&& cd libredwg-0.12.5 \
|
||||
&& ./configure --prefix=/opt/libredwg --disable-shared --disable-bindings \
|
||||
&& make -j"$(nproc)" \
|
||||
&& make install \
|
||||
&& cd .. \
|
||||
&& rm -rf libredwg-0.12.5 libredwg-0.12.5.tar.xz
|
||||
|
||||
# Stage 2: runtime
|
||||
FROM python:3.12-slim
|
||||
|
||||
COPY --from=libredwg-builder /opt/libredwg/bin/dwgread /usr/local/bin/dwgread
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /tmp/dwg-rooms
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
24
dwg-rooms/docker-compose.yml
Normal file
24
dwg-rooms/docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
dwg-rooms:
|
||||
build: .
|
||||
container_name: dwg-rooms
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3025:8000"
|
||||
environment:
|
||||
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000}
|
||||
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- localai
|
||||
volumes:
|
||||
- dwg-rooms-data:/tmp/dwg-rooms
|
||||
|
||||
volumes:
|
||||
dwg-rooms-data:
|
||||
|
||||
networks:
|
||||
localai:
|
||||
external: true
|
||||
48
dwg-rooms/excel_export.py
Normal file
48
dwg-rooms/excel_export.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Export room list to a styled Excel workbook."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
|
||||
def export_to_excel(rooms: list[dict], output_path: str) -> None:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Místnosti"
|
||||
|
||||
hdr_fill = PatternFill("solid", fgColor="155AEF")
|
||||
hdr_font = Font(bold=True, color="FFFFFF", size=10)
|
||||
hdr_align = Alignment(horizontal="center", vertical="center")
|
||||
row_border = Border(
|
||||
bottom=Side(style="thin", color="D0D5DC"),
|
||||
right=Side(style="thin", color="D0D5DC"),
|
||||
)
|
||||
alt_fill = PatternFill("solid", fgColor="F9FAFB")
|
||||
|
||||
headers = ["Č. místnosti", "Popis / účel", "Zdroj", "Spolehlivost"]
|
||||
widths = [16, 52, 14, 16]
|
||||
|
||||
for col, (hdr, w) in enumerate(zip(headers, widths), 1):
|
||||
c = ws.cell(row=1, column=col, value=hdr)
|
||||
c.font = hdr_font
|
||||
c.fill = hdr_fill
|
||||
c.alignment = hdr_align
|
||||
ws.column_dimensions[get_column_letter(col)].width = w
|
||||
|
||||
ws.row_dimensions[1].height = 22
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
for row_num, room in enumerate(sorted(rooms, key=lambda r: str(r.get("room", ""))), 2):
|
||||
values = [
|
||||
room.get("room", ""),
|
||||
room.get("description", ""),
|
||||
"Pravidla" if room.get("source") == "rule" else "AI",
|
||||
f"{room.get('confidence', 1.0) * 100:.0f} %",
|
||||
]
|
||||
for col, val in enumerate(values, 1):
|
||||
c = ws.cell(row=row_num, column=col, value=val)
|
||||
c.alignment = Alignment(vertical="center")
|
||||
c.border = row_border
|
||||
if row_num % 2 == 0:
|
||||
c.fill = alt_fill
|
||||
|
||||
wb.save(output_path)
|
||||
168
dwg-rooms/extractor.py
Normal file
168
dwg-rooms/extractor.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Rule-based room extraction from DXF files."""
|
||||
import math
|
||||
import re
|
||||
|
||||
import ezdxf
|
||||
|
||||
# Defaults shown to user; they can remove or replace.
|
||||
DEFAULT_EXAMPLES = ["č.m. 0301", "01024"]
|
||||
|
||||
MEASUREMENT_KW = (
|
||||
"podlaha:", "stěny:", "strop:", "m2", "m²", "povrchová", "nátěr",
|
||||
"penetrační", "vrstva", "odstín", "ral ", "chodníky:", "klenba:",
|
||||
"portály:", "epoxidový", "beton", "dlažba", "omítka", "obklad",
|
||||
)
|
||||
|
||||
|
||||
def example_to_regex(example: str) -> re.Pattern | None:
|
||||
"""
|
||||
Convert an example like '4-22408' or 'č.m. 0301' into a compiled regex.
|
||||
- digits become wildcards (\\d, exactly N digits where the example had N digits)
|
||||
- everything else is matched literally
|
||||
- an optional trailing letter is allowed (for variants like '0301a')
|
||||
"""
|
||||
if not example or not example.strip():
|
||||
return None
|
||||
s = example.strip()
|
||||
parts: list[str] = []
|
||||
run = 0
|
||||
for ch in s:
|
||||
if ch.isdigit():
|
||||
run += 1
|
||||
continue
|
||||
if run:
|
||||
parts.append(rf"\d{{{run}}}")
|
||||
run = 0
|
||||
parts.append(re.escape(ch))
|
||||
if run:
|
||||
parts.append(rf"\d{{{run}}}")
|
||||
pattern = "^(" + "".join(parts) + r"[a-zA-Z]?)$"
|
||||
try:
|
||||
return re.compile(pattern, re.IGNORECASE)
|
||||
except re.error:
|
||||
return None
|
||||
|
||||
|
||||
def compile_examples(examples: list[str] | None) -> list[re.Pattern]:
|
||||
items = examples if examples is not None else DEFAULT_EXAMPLES
|
||||
out = []
|
||||
for ex in items:
|
||||
rx = example_to_regex(ex)
|
||||
if rx is not None:
|
||||
out.append(rx)
|
||||
return out
|
||||
|
||||
|
||||
def _clean_mtext(text: str) -> str:
|
||||
text = re.sub(r"\{\\[^;]*;", "", text)
|
||||
text = re.sub(r"\\[Pp]", " ", text)
|
||||
text = text.replace("}", "")
|
||||
text = re.sub(r"\s*\d+[,\.]\d*\s*m2\s*$", "", text)
|
||||
return " ".join(text.split()).strip()
|
||||
|
||||
|
||||
def _is_measurement(text: str) -> bool:
|
||||
t = text.lower()
|
||||
return any(k in t for k in MEASUREMENT_KW)
|
||||
|
||||
|
||||
def _is_room_marker(text: str, regexes: list[re.Pattern]) -> re.Match | None:
|
||||
s = text.strip()
|
||||
for rx in regexes:
|
||||
m = rx.fullmatch(s)
|
||||
if m:
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _is_dimension(text: str, regexes: list[re.Pattern]) -> bool:
|
||||
"""
|
||||
Anything that's effectively just digits (with optional separators) and is NOT
|
||||
claimed by any active room pattern — treat as a dimension/measurement value.
|
||||
"""
|
||||
if _is_room_marker(text, regexes):
|
||||
return False
|
||||
clean = re.sub(r"[\s.,\-x×+]", "", text)
|
||||
return clean.isdigit() and len(clean) > 0
|
||||
|
||||
|
||||
def _all_text_entities(dxf_path: str) -> list[dict]:
|
||||
doc = ezdxf.readfile(dxf_path)
|
||||
msp = doc.modelspace()
|
||||
out = []
|
||||
for ent in msp:
|
||||
try:
|
||||
if ent.dxftype() == "TEXT":
|
||||
t = ent.dxf.text.strip()
|
||||
x, y = ent.dxf.insert.x, ent.dxf.insert.y
|
||||
elif ent.dxftype() == "MTEXT":
|
||||
t = _clean_mtext(ent.text)
|
||||
x, y = ent.dxf.insert.x, ent.dxf.insert.y
|
||||
else:
|
||||
continue
|
||||
if t:
|
||||
out.append({"text": t, "x": x, "y": y})
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _nearest_description(rx_x: float, ry: float, candidates: list[dict],
|
||||
regexes: list[re.Pattern], max_dist: float = 8000) -> str | None:
|
||||
best, best_d = None, max_dist
|
||||
for c in candidates:
|
||||
t = c["text"]
|
||||
if _is_measurement(t) or _is_dimension(t, regexes) or len(t.strip()) < 2:
|
||||
continue
|
||||
if _is_room_marker(t, regexes):
|
||||
continue
|
||||
d = math.hypot(c["x"] - rx_x, c["y"] - ry)
|
||||
if d < best_d:
|
||||
best_d, best = d, t
|
||||
return best
|
||||
|
||||
|
||||
def extract_rooms(dxf_path: str, examples: list[str] | None = None) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Returns (rooms, unmatched_texts).
|
||||
examples: user-provided room-number examples; None → DEFAULT_EXAMPLES.
|
||||
"""
|
||||
regexes = compile_examples(examples)
|
||||
entities = _all_text_entities(dxf_path)
|
||||
|
||||
room_markers, other = [], []
|
||||
for e in entities:
|
||||
m = _is_room_marker(e["text"], regexes)
|
||||
if m:
|
||||
room_markers.append({"room": m.group(1), "x": e["x"], "y": e["y"]})
|
||||
else:
|
||||
other.append(e)
|
||||
|
||||
used: set[str] = set()
|
||||
seen_rooms: set[str] = set()
|
||||
rooms: list[dict] = []
|
||||
for rm in room_markers:
|
||||
if rm["room"] in seen_rooms:
|
||||
continue
|
||||
seen_rooms.add(rm["room"])
|
||||
desc = _nearest_description(rm["x"], rm["y"], other, regexes)
|
||||
rooms.append({
|
||||
"room": rm["room"],
|
||||
"description": desc or "",
|
||||
"x": round(rm["x"], 1),
|
||||
"y": round(rm["y"], 1),
|
||||
"source": "rule",
|
||||
"confidence": 1.0 if desc else 0.6,
|
||||
})
|
||||
if desc:
|
||||
used.add(desc)
|
||||
|
||||
unmatched = [
|
||||
e for e in other
|
||||
if e["text"] not in used
|
||||
and not _is_measurement(e["text"])
|
||||
and not _is_dimension(e["text"], regexes)
|
||||
and len(e["text"]) > 3
|
||||
]
|
||||
|
||||
return rooms, unmatched
|
||||
BIN
dwg-rooms/libredwg-0.12.5.tar.xz
Normal file
BIN
dwg-rooms/libredwg-0.12.5.tar.xz
Normal file
Binary file not shown.
80
dwg-rooms/llm_helper.py
Normal file
80
dwg-rooms/llm_helper.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""LLM fallback: classify unmatched DXF text entities as rooms via LiteLLM."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: AsyncOpenAI | None = None
|
||||
|
||||
|
||||
def _get_client() -> AsyncOpenAI:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = AsyncOpenAI(
|
||||
base_url=os.getenv("LITELLM_BASE_URL", "http://host.docker.internal:4000"),
|
||||
api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"),
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
SYSTEM = """You are a specialist extracting room data from Czech architectural DXF floor plans.
|
||||
You receive text entities (text, x, y) that were not matched by rule-based parsing.
|
||||
|
||||
Identify pairs of room number + Czech room name/description.
|
||||
Czech room numbers: 4-6 digit codes, sometimes prefixed with "č.m.".
|
||||
Czech room names: e.g. "Chodba", "Serverovna", "Sklep", "WC", "Kancelář", etc.
|
||||
|
||||
Return ONLY a JSON array of objects:
|
||||
{"room": "XXXXX", "description": "Czech name", "confidence": 0.0-1.0}
|
||||
|
||||
Skip: measurements (m2, m²), material names (beton, dlažba), dimensions, unrelated text.
|
||||
Only include entries with confidence > 0.5."""
|
||||
|
||||
|
||||
async def enhance_with_llm(unmatched: list[dict]) -> list[dict]:
|
||||
api_key = os.getenv("LITELLM_API_KEY", "")
|
||||
if not api_key or api_key == "sk-dummy":
|
||||
logger.info("LITELLM_API_KEY not set — skipping LLM enhancement")
|
||||
return []
|
||||
|
||||
sample = unmatched[:200]
|
||||
text_block = "\n".join(
|
||||
f'- "{t["text"]}" x={t["x"]:.0f} y={t["y"]:.0f}' for t in sample
|
||||
)
|
||||
model = os.getenv("LLM_MODEL", "gpt-4o-mini")
|
||||
|
||||
try:
|
||||
resp = await _get_client().chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": SYSTEM},
|
||||
{"role": "user", "content": f"Text entities:\n{text_block}"},
|
||||
],
|
||||
temperature=0.1,
|
||||
max_tokens=2000,
|
||||
)
|
||||
raw = resp.choices[0].message.content or "[]"
|
||||
# Strip markdown code fences if present
|
||||
raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
data = data.get("rooms", data.get("result", []))
|
||||
|
||||
return [
|
||||
{
|
||||
"room": str(r["room"]),
|
||||
"description": r.get("description", ""),
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"source": "llm",
|
||||
"confidence": float(r.get("confidence", 0.7)),
|
||||
}
|
||||
for r in data
|
||||
if isinstance(r, dict) and r.get("room")
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.error("LLM enhancement failed: %s", exc)
|
||||
return []
|
||||
143
dwg-rooms/main.py
Normal file
143
dwg-rooms/main.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""FastAPI app: upload DWG/DXF → extract rooms → export Excel."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from excel_export import export_to_excel
|
||||
from extractor import DEFAULT_EXAMPLES, extract_rooms
|
||||
from llm_helper import enhance_with_llm
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="DWG Room Extractor")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/dwg-rooms"))
|
||||
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
jobs: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _dwg_to_dxf(dwg_path: Path, out_dir: Path) -> Path:
|
||||
"""Convert DWG → DXF using LibreDWG dwgread. Returns DXF path."""
|
||||
dxf_path = out_dir / f"{dwg_path.stem}.dxf"
|
||||
r = subprocess.run(
|
||||
["dwgread", "-O", "DXF", "-o", str(dxf_path), str(dwg_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if not dxf_path.exists():
|
||||
raise RuntimeError(
|
||||
f"DWG conversion failed (exit {r.returncode}): {r.stderr or r.stdout or 'no output'}"
|
||||
)
|
||||
return dxf_path
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
@app.get("/api/defaults")
|
||||
async def defaults():
|
||||
return {"examples": DEFAULT_EXAMPLES}
|
||||
|
||||
|
||||
@app.post("/api/upload")
|
||||
async def upload(
|
||||
file: UploadFile = File(...),
|
||||
examples: str = Form(default=""), # JSON array of strings
|
||||
):
|
||||
suffix = Path(file.filename or "input.dxf").suffix.lower()
|
||||
if suffix not in (".dwg", ".dxf"):
|
||||
raise HTTPException(400, "Supported formats: .dwg, .dxf")
|
||||
|
||||
try:
|
||||
ex_list = json.loads(examples) if examples else None
|
||||
if ex_list is not None and not isinstance(ex_list, list):
|
||||
ex_list = None
|
||||
except json.JSONDecodeError:
|
||||
ex_list = None
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
job_dir = WORK_DIR / job_id
|
||||
job_dir.mkdir()
|
||||
|
||||
input_path = job_dir / f"input{suffix}"
|
||||
content = await file.read()
|
||||
input_path.write_bytes(content)
|
||||
logger.info("Job %s: %s (%d bytes), examples=%r", job_id, file.filename, len(content), ex_list)
|
||||
|
||||
steps: list[str] = []
|
||||
|
||||
try:
|
||||
if suffix == ".dwg":
|
||||
steps.append("Konverze DWG → DXF")
|
||||
dxf_path = _dwg_to_dxf(input_path, job_dir)
|
||||
else:
|
||||
dxf_path = input_path
|
||||
|
||||
steps.append("Extrakce místností (pravidla)")
|
||||
rooms, unmatched = extract_rooms(str(dxf_path), examples=ex_list)
|
||||
logger.info("Job %s: %d rooms, %d unmatched texts", job_id, len(rooms), len(unmatched))
|
||||
|
||||
if unmatched and os.getenv("LITELLM_API_KEY", ""):
|
||||
steps.append("AI rozšíření")
|
||||
llm_rooms = await enhance_with_llm(unmatched)
|
||||
logger.info("Job %s: LLM added %d rooms", job_id, len(llm_rooms))
|
||||
rooms.extend(llm_rooms)
|
||||
|
||||
jobs[job_id] = {"filename": file.filename, "rooms": rooms}
|
||||
return {"job_id": job_id, "room_count": len(rooms), "steps": steps}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Job %s failed: %s", job_id, exc)
|
||||
raise HTTPException(500, str(exc))
|
||||
|
||||
|
||||
@app.get("/api/rooms/{job_id}")
|
||||
async def get_rooms(job_id: str):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
return jobs[job_id]["rooms"]
|
||||
|
||||
|
||||
@app.put("/api/rooms/{job_id}")
|
||||
async def update_rooms(job_id: str, rooms: list[dict]):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
jobs[job_id]["rooms"] = rooms
|
||||
return {"ok": True, "count": len(rooms)}
|
||||
|
||||
|
||||
@app.get("/api/export/{job_id}")
|
||||
async def export(job_id: str):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
excel_path = WORK_DIR / job_id / "rooms.xlsx"
|
||||
export_to_excel(job["rooms"], str(excel_path))
|
||||
stem = Path(job["filename"]).stem
|
||||
return FileResponse(
|
||||
str(excel_path),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
filename=f"mistnosti_{stem}.xlsx",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
9
dwg-rooms/requirements.txt
Normal file
9
dwg-rooms/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
ezdxf>=1.3
|
||||
openpyxl>=3.1
|
||||
pandas>=2.2
|
||||
python-multipart>=0.0.9
|
||||
openai>=1.50
|
||||
python-dotenv>=1.0
|
||||
aiofiles>=23.0
|
||||
209
dwg-rooms/static/app.js
Normal file
209
dwg-rooms/static/app.js
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────
|
||||
loadDefaults();
|
||||
161
dwg-rooms/static/index.html
Normal file
161
dwg-rooms/static/index.html
Normal file
@@ -0,0 +1,161 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Extrakce místností z DWG | Colsys AI</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script>
|
||||
(function () {
|
||||
var t = null;
|
||||
try {
|
||||
var p = new URL(window.location.href).searchParams.get("theme");
|
||||
if (p === "dark" || p === "light") t = p;
|
||||
} catch (e) {}
|
||||
if (!t) { try { t = localStorage.getItem("app_theme"); } catch (e) {} }
|
||||
if (!t) {
|
||||
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
|
||||
if (m) t = decodeURIComponent(m[1]);
|
||||
}
|
||||
if (t === "dark" || t === "light") {
|
||||
document.documentElement.classList.add(t);
|
||||
try { localStorage.setItem("app_theme", t); } catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px 6px 10px; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 500; color: var(--text-tertiary);
|
||||
text-decoration: none; border: 0.5px solid var(--border-default);
|
||||
background: var(--bg-primary); flex-shrink: 0;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--primary); border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
|
||||
}
|
||||
.back-link svg { opacity: 0.8; }
|
||||
@media (max-width: 640px) {
|
||||
.back-link span { display: none; }
|
||||
.back-link { padding: 6px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<a href="https://ai.klas.chat" class="brand">
|
||||
<span class="brand-icon">C</span>
|
||||
<span class="brand-name">Colsys <span class="brand-ai">AI</span></span>
|
||||
</a>
|
||||
<span class="header-crumb">Extrakce místností z DWG / DXF</span>
|
||||
<a href="https://ai.klas.chat" class="back-link" title="Zpět na portál">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
<span>Zpět na portál</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<!-- ── Upload ────────────────────────────── -->
|
||||
<section id="s-upload">
|
||||
<div class="section-intro">
|
||||
<h1 class="section-title">Extrakce místností z výkresu</h1>
|
||||
<p class="section-desc">
|
||||
Nahrajte výkres ve formátu <strong>DWG</strong> nebo <strong>DXF</strong>.
|
||||
Aplikace automaticky rozpozná čísla a názvy místností a připraví
|
||||
editovatelnou tabulku pro export do Excelu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Examples editor -->
|
||||
<div class="examples-panel">
|
||||
<div class="examples-header">
|
||||
<span class="examples-title">Vzorová čísla místností</span>
|
||||
<span class="examples-subtitle">Zadejte konkrétní příklad čísla z výkresu — cifry se nahradí libovolnými, ostatní znaky zůstanou doslovně.</span>
|
||||
</div>
|
||||
|
||||
<div id="examples-list" class="examples-list"></div>
|
||||
|
||||
<form id="examples-form" class="examples-input-row">
|
||||
<input type="text" id="example-input" class="example-input"
|
||||
placeholder="např. 4-22408 nebo č.m. 0301" autocomplete="off" inputmode="text">
|
||||
<button type="submit" class="btn btn-secondary example-add-btn">
|
||||
<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.5v15m7.5-7.5h-15"/>
|
||||
</svg>
|
||||
Přidat
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="drop-zone" class="drop-zone">
|
||||
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 16.5V4.5m0 0-3.75 3.75M12 4.5l3.75 3.75M4.5 19.5h15"/>
|
||||
</svg>
|
||||
<p class="drop-text">Přetáhněte soubor DWG nebo DXF sem</p>
|
||||
<p class="drop-or">nebo</p>
|
||||
<button class="btn btn-secondary" id="browse-btn" type="button">Vybrat soubor</button>
|
||||
<p class="drop-formats">Podporované formáty: .dwg · .dxf</p>
|
||||
<input type="file" id="file-input" accept=".dwg,.dxf" style="display:none">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Processing ────────────────────────── -->
|
||||
<section id="s-processing" class="hidden">
|
||||
<div class="processing-card">
|
||||
<div class="spinner"></div>
|
||||
<h2 class="processing-title">Zpracovávám výkres…</h2>
|
||||
<div id="steps-list" class="steps-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Results ───────────────────────────── -->
|
||||
<section id="s-results" class="hidden">
|
||||
<div class="results-header">
|
||||
<div>
|
||||
<h2 class="results-title">Nalezené místnosti</h2>
|
||||
<p class="results-meta" id="results-meta"></p>
|
||||
</div>
|
||||
<div class="results-actions">
|
||||
<button class="btn btn-secondary" id="reset-btn" type="button">Nahrát jiný soubor</button>
|
||||
<button class="btn btn-primary" id="export-btn" type="button">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:130px">Č. místnosti</th>
|
||||
<th>Popis / účel</th>
|
||||
<th style="width:110px">Zdroj</th>
|
||||
<th style="width:100px">Spolehlivost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rooms-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="table-hint">Kliknutím na buňku ji upravíte — změny se projeví v exportu.</p>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
459
dwg-rooms/static/styles.css
Normal file
459
dwg-rooms/static/styles.css
Normal file
@@ -0,0 +1,459 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f9fafb;
|
||||
--bg-tertiary: #f2f4f7;
|
||||
--text-primary: #101828;
|
||||
--text-secondary: #354052;
|
||||
--text-tertiary: #676f83;
|
||||
--text-quaternary: #98a2b2;
|
||||
--border-default: rgb(16 24 40 / 0.08);
|
||||
--border-strong: #d0d5dc;
|
||||
--border-subtle: rgb(16 24 40 / 0.04);
|
||||
--card: #ffffff;
|
||||
--primary: #155aef;
|
||||
--primary-hover: #004aeb;
|
||||
--accent-indigo: #444ce7;
|
||||
--shadow-card: 0 1px 2px rgb(16 24 40 / 0.05);
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) {
|
||||
--bg-primary: #14181f;
|
||||
--bg-secondary: #1a1f29;
|
||||
--bg-tertiary: #232936;
|
||||
--text-primary: #f5f7fa;
|
||||
--text-secondary: #c8ccd5;
|
||||
--text-tertiary: #98a2b2;
|
||||
--text-quaternary: #676f83;
|
||||
--border-default: rgb(255 255 255 / 0.08);
|
||||
--border-strong: #354052;
|
||||
--border-subtle: rgb(255 255 255 / 0.04);
|
||||
--card: #1a1f29;
|
||||
}
|
||||
}
|
||||
:root.dark {
|
||||
--bg-primary: #14181f;
|
||||
--bg-secondary: #1a1f29;
|
||||
--bg-tertiary: #232936;
|
||||
--text-primary: #f5f7fa;
|
||||
--text-secondary: #c8ccd5;
|
||||
--text-tertiary: #98a2b2;
|
||||
--text-quaternary: #676f83;
|
||||
--border-default: rgb(255 255 255 / 0.08);
|
||||
--border-strong: #354052;
|
||||
--border-subtle: rgb(255 255 255 / 0.04);
|
||||
--card: #1a1f29;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────── */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
border-bottom: 0.5px solid var(--border-default);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.header-inner { padding: 0 32px; }
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%);
|
||||
box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: -0.04em;
|
||||
color: white;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.brand-ai { color: var(--primary); }
|
||||
.header-crumb {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Main ────────────────────────────────────────── */
|
||||
.main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.header-inner { padding: 0 16px; }
|
||||
.main { padding: 24px 16px 60px; }
|
||||
}
|
||||
|
||||
/* ── Section intro ───────────────────────────────── */
|
||||
.section-intro { margin-bottom: 24px; }
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section-desc {
|
||||
color: var(--text-tertiary);
|
||||
max-width: 580px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Examples panel ──────────────────────────────── */
|
||||
.examples-panel {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.examples-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.examples-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.examples-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.examples-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.examples-list:empty::before {
|
||||
content: "Zatím žádné vzory — přidejte alespoň jeden níže";
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
font-style: italic;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.example-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 4px 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.example-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
padding: 0;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.example-chip-remove:hover {
|
||||
background: color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.example-chip-remove svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.examples-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.example-input {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.example-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
.example-add-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Drop zone ───────────────────────────────────── */
|
||||
.drop-zone {
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
padding: 56px 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.drag-over {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 4%, var(--card));
|
||||
}
|
||||
.drop-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: var(--text-quaternary);
|
||||
margin: 0 auto 18px;
|
||||
display: block;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
.drop-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.drop-or {
|
||||
font-size: 13px;
|
||||
color: var(--text-quaternary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.drop-formats {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgb(21 90 239 / 0.25);
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--border-strong); }
|
||||
.btn-icon { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
|
||||
/* ── Processing card ─────────────────────────────── */
|
||||
.processing-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 56px 32px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--border-strong);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
margin: 0 auto 22px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.processing-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
.step-item.active {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
|
||||
}
|
||||
.step-item.done { color: #17b26a; }
|
||||
.step-item.error { color: #d92d20; background: #fef3f2; }
|
||||
.step-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Results ─────────────────────────────────────── */
|
||||
.results-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.results-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.results-meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.results-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Table ───────────────────────────────────────── */
|
||||
.table-wrap {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--card);
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
min-width: 500px;
|
||||
}
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
th {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:nth-child(even) td { background: var(--bg-secondary); }
|
||||
td[contenteditable]:focus {
|
||||
outline: none;
|
||||
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
|
||||
box-shadow: inset 0 0 0 1.5px var(--primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-rule { background: #d1e0ff; color: #004aeb; }
|
||||
.badge-llm { background: #d1fae5; color: #065f46; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.badge-rule { background: #1e3a8a; color: #93c5fd; }
|
||||
.badge-llm { background: #064e3b; color: #6ee7b7; }
|
||||
}
|
||||
|
||||
.table-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
Reference in New Issue
Block a user