feat: initial commit - Agent DXF Dorafort
This commit is contained in:
commit
6ccee1e952
3 changed files with 749 additions and 0 deletions
16
Dockerfile
Normal file
16
Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
fastapi==0.115.0 \
|
||||||
|
uvicorn==0.30.6 \
|
||||||
|
httpx==0.27.2 \
|
||||||
|
ezdxf==1.3.4 \
|
||||||
|
python-multipart==0.0.9
|
||||||
|
|
||||||
|
COPY main.py .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
services:
|
||||||
|
dxf-agent:
|
||||||
|
build: .
|
||||||
|
container_name: dxf-agent
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- litellm-net
|
||||||
|
- coolify
|
||||||
|
volumes:
|
||||||
|
- /tmp/dxf_uploads:/tmp/dxf_uploads
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.dxf-agent.rule=Host(`dxf-agent.91.98.39.120.sslip.io`)"
|
||||||
|
- "traefik.http.routers.dxf-agent.entrypoints=https"
|
||||||
|
- "traefik.http.routers.dxf-agent.tls=true"
|
||||||
|
- "traefik.http.routers.dxf-agent.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.dxf-agent.loadbalancer.server.port=8000"
|
||||||
|
- "traefik.http.routers.dxf-agent-http.rule=Host(`dxf-agent.91.98.39.120.sslip.io`)"
|
||||||
|
- "traefik.http.routers.dxf-agent-http.entrypoints=http"
|
||||||
|
- "traefik.http.routers.dxf-agent-http.middlewares=redirect-to-https@file"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
litellm-net:
|
||||||
|
external: true
|
||||||
|
name: zosw8ckwkg40o8cccc40wwgg
|
||||||
|
coolify:
|
||||||
|
external: true
|
||||||
|
name: coolify
|
||||||
705
main.py
Normal file
705
main.py
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Agent DXF - Dorafort
|
||||||
|
DXF upload, SVG preview, RAG chat cu qwen2.5vl:3b
|
||||||
|
"""
|
||||||
|
import json, uuid, math
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import ezdxf
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, File, UploadFile, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
app = FastAPI(title="Agent DXF - Dorafort")
|
||||||
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
|
LITELLM_URL = "http://10.0.12.4:4000"
|
||||||
|
LITELLM_KEY = "DHoa2l5FBCrhFd8DZIV1LIpn7AVyNHss"
|
||||||
|
MODEL = "llama-70b"
|
||||||
|
UPLOAD_DIR = Path("/tmp/dxf_uploads")
|
||||||
|
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
sessions: dict[str, dict] = {} # session_id -> {context, svg, analysis}
|
||||||
|
|
||||||
|
# ── LAYER STYLE MAP ───────────────────────────────────────────────────────────
|
||||||
|
LAYER_STYLE = {
|
||||||
|
"CADRU": {"color": "#e2e8f0", "width": 2.5, "dash": ""},
|
||||||
|
"STICLA": {"color": "#38bdf8", "width": 1.0, "dash": "6,3"},
|
||||||
|
"PROFIL": {"color": "#4ade80", "width": 2.0, "dash": ""},
|
||||||
|
"FERONERIE": {"color": "#fb923c", "width": 1.5, "dash": ""},
|
||||||
|
"COTE": {"color": "#fbbf24", "width": 0.8, "dash": "2,2"},
|
||||||
|
"TEXT": {"color": "#94a3b8", "width": 0.5, "dash": ""},
|
||||||
|
"0": {"color": "#64748b", "width": 0.8, "dash": ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
def layer_style(name: str) -> dict:
|
||||||
|
for key, s in LAYER_STYLE.items():
|
||||||
|
if key.upper() in name.upper():
|
||||||
|
return s
|
||||||
|
return {"color": "#94a3b8", "width": 1.0, "dash": ""}
|
||||||
|
|
||||||
|
|
||||||
|
# ── SVG RENDERER ──────────────────────────────────────────────────────────────
|
||||||
|
def dxf_to_svg(filepath: str) -> str:
|
||||||
|
doc = ezdxf.readfile(filepath)
|
||||||
|
msp = doc.modelspace()
|
||||||
|
|
||||||
|
all_x, all_y = [], []
|
||||||
|
by_layer: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
def reg(layer, el):
|
||||||
|
by_layer.setdefault(layer, []).append(el)
|
||||||
|
|
||||||
|
def fy(y): return -y # flip Y axis (DXF up → SVG down)
|
||||||
|
|
||||||
|
def arc_path(cx, cy, r, start_deg, end_deg):
|
||||||
|
s = math.radians(start_deg)
|
||||||
|
e = math.radians(end_deg)
|
||||||
|
if end_deg <= start_deg:
|
||||||
|
e += 2 * math.pi
|
||||||
|
large = 1 if (e - s) > math.pi else 0
|
||||||
|
x1 = cx + r * math.cos(s); y1 = fy(cy + r * math.sin(s))
|
||||||
|
x2 = cx + r * math.cos(e); y2 = fy(cy + r * math.sin(e))
|
||||||
|
return f"M {x1} {y1} A {r} {r} 0 {large} 0 {x2} {y2}"
|
||||||
|
|
||||||
|
for entity in msp:
|
||||||
|
etype = entity.dxftype()
|
||||||
|
layer = entity.dxf.layer
|
||||||
|
st = layer_style(layer)
|
||||||
|
col = st["color"]
|
||||||
|
sw = st["width"]
|
||||||
|
dash = f'stroke-dasharray="{st["dash"]}"' if st["dash"] else ""
|
||||||
|
attrs = f'stroke="{col}" stroke-width="{sw}" fill="none" {dash}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
if etype == "LWPOLYLINE":
|
||||||
|
pts = [(p[0], p[1]) for p in entity.get_points()]
|
||||||
|
if len(pts) < 2:
|
||||||
|
continue
|
||||||
|
all_x += [p[0] for p in pts]; all_y += [p[1] for p in pts]
|
||||||
|
pts_s = " ".join(f"{p[0]:.3f},{fy(p[1]):.3f}" for p in pts)
|
||||||
|
tag = "polygon" if entity.closed else "polyline"
|
||||||
|
reg(layer, f'<{tag} points="{pts_s}" {attrs}/>')
|
||||||
|
|
||||||
|
elif etype == "LINE":
|
||||||
|
s, e = entity.dxf.start, entity.dxf.end
|
||||||
|
all_x += [s.x, e.x]; all_y += [s.y, e.y]
|
||||||
|
reg(layer, f'<line x1="{s.x:.3f}" y1="{fy(s.y):.3f}" x2="{e.x:.3f}" y2="{fy(e.y):.3f}" {attrs}/>')
|
||||||
|
|
||||||
|
elif etype == "CIRCLE":
|
||||||
|
c, r = entity.dxf.center, entity.dxf.radius
|
||||||
|
all_x += [c.x-r, c.x+r]; all_y += [c.y-r, c.y+r]
|
||||||
|
reg(layer, f'<circle cx="{c.x:.3f}" cy="{fy(c.y):.3f}" r="{r:.3f}" {attrs}/>')
|
||||||
|
|
||||||
|
elif etype == "ARC":
|
||||||
|
c, r = entity.dxf.center, entity.dxf.radius
|
||||||
|
sa, ea = entity.dxf.start_angle, entity.dxf.end_angle
|
||||||
|
all_x += [c.x-r, c.x+r]; all_y += [c.y-r, c.y+r]
|
||||||
|
d = arc_path(c.x, c.y, r, sa, ea)
|
||||||
|
reg(layer, f'<path d="{d}" {attrs}/>')
|
||||||
|
|
||||||
|
elif etype in ("TEXT", "MTEXT"):
|
||||||
|
txt = (entity.dxf.text if etype == "TEXT" else entity.text).strip()
|
||||||
|
if not txt:
|
||||||
|
continue
|
||||||
|
ins = entity.dxf.insert
|
||||||
|
h = max(getattr(entity.dxf, "height", 10), 5)
|
||||||
|
all_x.append(ins.x); all_y.append(ins.y)
|
||||||
|
esc = txt.replace("&","&").replace("<","<").replace(">",">")
|
||||||
|
ty_svg = fy(ins.y)
|
||||||
|
# matrix(1 0 0 -1 0 2*ty_svg) flips Y around the text baseline
|
||||||
|
# without CSS transform-origin which causes mirroring artifacts
|
||||||
|
reg(layer, f'<text x="{ins.x:.3f}" y="{ty_svg:.3f}" '
|
||||||
|
f'font-size="{h:.2f}" fill="{col}" font-family="monospace" '
|
||||||
|
f'transform="matrix(1 0 0 -1 0 {2*ty_svg:.3f})">'
|
||||||
|
f'{esc}</text>')
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not all_x:
|
||||||
|
return '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><text fill="red">No geometry</text></svg>'
|
||||||
|
|
||||||
|
mn_x, mx_x = min(all_x), max(all_x)
|
||||||
|
mn_y, mx_y = min(all_y), max(all_y)
|
||||||
|
pad = max(mx_x - mn_x, mx_y - mn_y) * 0.07 + 10
|
||||||
|
|
||||||
|
vbx = mn_x - pad
|
||||||
|
vby = fy(mx_y) - pad
|
||||||
|
vbw = (mx_x - mn_x) + 2*pad
|
||||||
|
vbh = (mx_y - mn_y) + 2*pad
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||||
|
f'viewBox="{vbx:.2f} {vby:.2f} {vbw:.2f} {vbh:.2f}" '
|
||||||
|
f'id="dxf-svg" style="width:100%;height:100%;background:#080f1e">'
|
||||||
|
]
|
||||||
|
# grid
|
||||||
|
grid_step = max(100, round((vbw+vbh)/2 / 10 / 100)*100)
|
||||||
|
parts.append(f'<defs><pattern id="grid" width="{grid_step}" height="{grid_step}" '
|
||||||
|
f'patternUnits="userSpaceOnUse" patternTransform="translate(0,0)">'
|
||||||
|
f'<path d="M {grid_step} 0 L 0 0 0 {grid_step}" fill="none" '
|
||||||
|
f'stroke="#1e293b" stroke-width="0.5"/></pattern></defs>')
|
||||||
|
parts.append(f'<rect x="{vbx:.2f}" y="{vby:.2f}" width="{vbw:.2f}" height="{vbh:.2f}" fill="url(#grid)"/>')
|
||||||
|
|
||||||
|
skip = {"Defpoints", "defpoints"}
|
||||||
|
for layer, els in by_layer.items():
|
||||||
|
if layer in skip:
|
||||||
|
continue
|
||||||
|
safe = layer.replace(" ","_").replace("/","_").replace("(","").replace(")","")
|
||||||
|
col = layer_style(layer)["color"]
|
||||||
|
parts.append(f'<g id="L-{safe}" data-layer="{layer}" data-color="{col}">')
|
||||||
|
parts += els
|
||||||
|
parts.append('</g>')
|
||||||
|
|
||||||
|
parts.append('</svg>')
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ── DXF ANALYSIS ─────────────────────────────────────────────────────────────
|
||||||
|
def analyze_dxf(filepath: str) -> dict:
|
||||||
|
doc = ezdxf.readfile(filepath)
|
||||||
|
msp = doc.modelspace()
|
||||||
|
res = {"file": Path(filepath).name, "layers": [], "entities_count": {},
|
||||||
|
"texts": [], "geometry": {"polylines": [], "circles": []}, "summary": {}}
|
||||||
|
|
||||||
|
for layer in doc.layers:
|
||||||
|
res["layers"].append(layer.dxf.name)
|
||||||
|
|
||||||
|
for entity in msp:
|
||||||
|
et = entity.dxftype()
|
||||||
|
res["entities_count"][et] = res["entities_count"].get(et, 0) + 1
|
||||||
|
try:
|
||||||
|
if et == "TEXT":
|
||||||
|
v = entity.dxf.text.strip()
|
||||||
|
if v: res["texts"].append({"text": v, "layer": entity.dxf.layer})
|
||||||
|
elif et == "MTEXT":
|
||||||
|
v = entity.text.strip()
|
||||||
|
if v: res["texts"].append({"text": v, "layer": entity.dxf.layer})
|
||||||
|
elif et == "LWPOLYLINE":
|
||||||
|
pts = list(entity.get_points())
|
||||||
|
if pts:
|
||||||
|
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
|
||||||
|
w, h = max(xs)-min(xs), max(ys)-min(ys)
|
||||||
|
res["geometry"]["polylines"].append({
|
||||||
|
"layer": entity.dxf.layer,
|
||||||
|
"bbox_width_mm": round(w,2), "bbox_height_mm": round(h,2),
|
||||||
|
"area_m2": round(w*h/1e6, 4),
|
||||||
|
})
|
||||||
|
elif et == "CIRCLE":
|
||||||
|
res["geometry"]["circles"].append({
|
||||||
|
"layer": entity.dxf.layer, "radius_mm": round(entity.dxf.radius,2)
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cadru = [p for p in res["geometry"]["polylines"] if "CADRU" in p["layer"].upper()]
|
||||||
|
sticla = [p for p in res["geometry"]["polylines"] if "STICLA" in p["layer"].upper()]
|
||||||
|
if not cadru:
|
||||||
|
cadru = sorted(res["geometry"]["polylines"], key=lambda p: p["area_m2"], reverse=True)[:1]
|
||||||
|
|
||||||
|
if cadru:
|
||||||
|
f = max(cadru, key=lambda p: p["area_m2"])
|
||||||
|
res["summary"]["latime_mm"] = f["bbox_width_mm"]
|
||||||
|
res["summary"]["inaltime_mm"] = f["bbox_height_mm"]
|
||||||
|
res["summary"]["suprafata_bruta_m2"] = f["area_m2"]
|
||||||
|
res["summary"]["perimetru_m"] = round(2*(f["bbox_width_mm"]+f["bbox_height_mm"])/1000, 3)
|
||||||
|
if sticla:
|
||||||
|
g = max(sticla, key=lambda p: p["area_m2"])
|
||||||
|
res["summary"]["sticla_w_mm"] = g["bbox_width_mm"]
|
||||||
|
res["summary"]["sticla_h_mm"] = g["bbox_height_mm"]
|
||||||
|
res["summary"]["sticla_m2"] = g["area_m2"]
|
||||||
|
|
||||||
|
for t in res["texts"]:
|
||||||
|
tx = t["text"]
|
||||||
|
if "PROFIL:" in tx.upper(): res["summary"]["profil"] = tx
|
||||||
|
if "STICLA:" in tx.upper(): res["summary"]["tip_sticla"] = tx
|
||||||
|
if any(k in tx.upper() for k in ["TIP ", "FEREASTRA", "USA"]):
|
||||||
|
res["summary"]["tip"] = tx
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_context(a: dict) -> str:
|
||||||
|
s = a.get("summary", {})
|
||||||
|
return "\n".join([
|
||||||
|
f"=== DXF: {a['file']} ===",
|
||||||
|
f"Tip: {s.get('tip','?')}",
|
||||||
|
f"Dimensiuni: {s.get('latime_mm','?')} x {s.get('inaltime_mm','?')} mm",
|
||||||
|
f"Suprafata bruta: {s.get('suprafata_bruta_m2','?')} m2",
|
||||||
|
f"Perimetru: {s.get('perimetru_m','?')} m",
|
||||||
|
f"Sticla: {s.get('sticla_w_mm','?')} x {s.get('sticla_h_mm','?')} mm = {s.get('sticla_m2','?')} m2",
|
||||||
|
f"Profil: {s.get('profil','?')}",
|
||||||
|
f"Tip sticla: {s.get('tip_sticla','?')}",
|
||||||
|
"",
|
||||||
|
"Texte din desen: " + " | ".join(t["text"] for t in a.get("texts",[])),
|
||||||
|
f"Layere: {', '.join(a.get('layers',[]))}",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
# ── LLM (Nvidia via LiteLLM) ──────────────────────────────────────────────────
|
||||||
|
async def ask_ollama(context: str, message: str) -> str:
|
||||||
|
system = f"""Esti Agent DXF pentru Dorafort, producator de ferestre si usi din lemn stratificat.
|
||||||
|
Ai urmatoarea analiza a unui desen DXF:
|
||||||
|
|
||||||
|
{context}
|
||||||
|
|
||||||
|
Raspunde in romana, concis si precis. Poti calcula cantitati de profil, suprafete sticla, liste materiale."""
|
||||||
|
async with httpx.AsyncClient(timeout=60) as c:
|
||||||
|
r = await c.post(
|
||||||
|
f"{LITELLM_URL}/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
|
||||||
|
json={
|
||||||
|
"model": MODEL,
|
||||||
|
"messages": [{"role":"system","content":system},{"role":"user","content":message}],
|
||||||
|
"temperature": 0.1,
|
||||||
|
"max_tokens": 800,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── ENDPOINTS ─────────────────────────────────────────────────────────────────
|
||||||
|
@app.post("/upload")
|
||||||
|
async def upload(file: UploadFile = File(...)):
|
||||||
|
if not file.filename.lower().endswith(".dxf"):
|
||||||
|
raise HTTPException(400, "Fisierul trebuie sa fie .dxf")
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
path = UPLOAD_DIR / f"{sid}.dxf"
|
||||||
|
path.write_bytes(await file.read())
|
||||||
|
try:
|
||||||
|
analysis = analyze_dxf(str(path))
|
||||||
|
svg = dxf_to_svg(str(path))
|
||||||
|
except Exception as e:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(400, f"Eroare DXF: {e}")
|
||||||
|
sessions[sid] = {"context": fmt_context(analysis), "svg": svg,
|
||||||
|
"analysis": analysis, "filename": file.filename}
|
||||||
|
return {"session_id": sid, "analysis": analysis["summary"],
|
||||||
|
"layers": [l for l in analysis["layers"] if l not in ("Defpoints","0")],
|
||||||
|
"filename": file.filename}
|
||||||
|
|
||||||
|
@app.get("/preview/{sid}")
|
||||||
|
async def preview(sid: str):
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(404, "Sesiune invalida")
|
||||||
|
return Response(content=s["svg"], media_type="image/svg+xml")
|
||||||
|
|
||||||
|
class ChatReq(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
@app.post("/chat")
|
||||||
|
async def chat(req: ChatReq):
|
||||||
|
s = sessions.get(req.session_id)
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(404, "Sesiune invalida — re-uploadati DXF-ul")
|
||||||
|
answer = await ask_ollama(s["context"], req.message)
|
||||||
|
return {"answer": answer}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status":"ok","model":MODEL,"provider":"nvidia-nim","sessions":len(sessions)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── UI ────────────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def ui():
|
||||||
|
return r"""<!DOCTYPE html>
|
||||||
|
<html lang="ro">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Agent DXF — Dorafort</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#080f1e;--panel:#0d1b2e;--border:#1e3a5f;
|
||||||
|
--accent:#38bdf8;--accent2:#818cf8;--green:#4ade80;
|
||||||
|
--text:#e2e8f0;--muted:#64748b;--warn:#fb923c;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--text);
|
||||||
|
height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
||||||
|
|
||||||
|
/* ── TOP BAR ── */
|
||||||
|
header{display:flex;align-items:center;gap:1rem;padding:.6rem 1.25rem;
|
||||||
|
background:var(--panel);border-bottom:1px solid var(--border);
|
||||||
|
flex-shrink:0;z-index:10}
|
||||||
|
header h1{font-size:.95rem;font-weight:700;color:var(--accent);letter-spacing:.04em}
|
||||||
|
header span{font-size:.75rem;color:var(--muted)}
|
||||||
|
.dot{width:7px;height:7px;border-radius:50%;background:var(--green);
|
||||||
|
box-shadow:0 0 6px var(--green);margin-left:auto}
|
||||||
|
|
||||||
|
/* ── LAYOUT ── */
|
||||||
|
main{display:flex;flex:1;overflow:hidden}
|
||||||
|
|
||||||
|
/* ── LEFT PANEL ── */
|
||||||
|
aside{width:360px;flex-shrink:0;display:flex;flex-direction:column;
|
||||||
|
border-right:1px solid var(--border);background:var(--panel)}
|
||||||
|
|
||||||
|
.upload-area{padding:1rem;border-bottom:1px solid var(--border)}
|
||||||
|
.drop-zone{border:2px dashed var(--border);border-radius:10px;padding:1.5rem;
|
||||||
|
text-align:center;cursor:pointer;transition:.2s;position:relative}
|
||||||
|
.drop-zone:hover,.drop-zone.over{border-color:var(--accent);background:#0a1929}
|
||||||
|
.drop-zone input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
|
||||||
|
.drop-icon{font-size:2rem;margin-bottom:.4rem}
|
||||||
|
.drop-zone label{color:var(--accent);font-weight:600;font-size:.875rem;cursor:pointer}
|
||||||
|
.drop-zone p{color:var(--muted);font-size:.75rem;margin-top:.25rem}
|
||||||
|
#fname{font-size:.75rem;color:var(--accent2);margin-top:.5rem;text-align:center;min-height:1em}
|
||||||
|
|
||||||
|
/* metrics */
|
||||||
|
.metrics{padding:.75rem 1rem;border-bottom:1px solid var(--border)}
|
||||||
|
.metrics h2{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;
|
||||||
|
color:var(--muted);margin-bottom:.6rem}
|
||||||
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}
|
||||||
|
.metric{background:#0a1929;border:1px solid var(--border);border-radius:8px;
|
||||||
|
padding:.6rem .8rem;transition:.3s}
|
||||||
|
.metric .lbl{font-size:.68rem;color:var(--muted);margin-bottom:.2rem}
|
||||||
|
.metric .val{font-size:1.05rem;font-weight:700;color:var(--accent)}
|
||||||
|
.metric.green .val{color:var(--green)}
|
||||||
|
.metric.warn .val{color:var(--warn)}
|
||||||
|
.metric.purple .val{color:var(--accent2)}
|
||||||
|
|
||||||
|
/* chat */
|
||||||
|
.chat-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:.75rem 1rem}
|
||||||
|
.chat-wrap h2{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;
|
||||||
|
color:var(--muted);margin-bottom:.6rem;flex-shrink:0}
|
||||||
|
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:.6rem;
|
||||||
|
padding-right:.25rem;scrollbar-width:thin;scrollbar-color:var(--border) transparent}
|
||||||
|
.msg{padding:.6rem .9rem;border-radius:10px;font-size:.82rem;line-height:1.5;max-width:100%;animation:fadeIn .2s}
|
||||||
|
.msg.user{background:#1d4ed8;align-self:flex-end;border-radius:10px 10px 2px 10px}
|
||||||
|
.msg.agent{background:#0a1929;border:1px solid var(--border);white-space:pre-wrap}
|
||||||
|
.msg.thinking{color:var(--muted);font-style:italic}
|
||||||
|
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||||
|
.chat-input{display:flex;gap:.4rem;margin-top:.6rem;flex-shrink:0}
|
||||||
|
.chat-input input{flex:1;background:#0a1929;border:1px solid var(--border);
|
||||||
|
border-radius:7px;padding:.5rem .75rem;color:var(--text);
|
||||||
|
font-size:.82rem;outline:none;transition:.2s}
|
||||||
|
.chat-input input:focus{border-color:var(--accent)}
|
||||||
|
.chat-input input:disabled{opacity:.5}
|
||||||
|
.btn{background:var(--accent);color:#000;border:none;padding:.5rem .9rem;
|
||||||
|
border-radius:7px;font-weight:700;cursor:pointer;font-size:.8rem;
|
||||||
|
transition:.15s;white-space:nowrap}
|
||||||
|
.btn:hover{background:#7dd3fc}
|
||||||
|
.btn:disabled{background:var(--border);color:var(--muted);cursor:not-allowed}
|
||||||
|
|
||||||
|
/* ── RIGHT — PREVIEW ── */
|
||||||
|
.preview-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||||
|
|
||||||
|
.preview-toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;
|
||||||
|
background:var(--panel);border-bottom:1px solid var(--border);
|
||||||
|
flex-wrap:wrap;flex-shrink:0}
|
||||||
|
.tool-btn{background:transparent;border:1px solid var(--border);color:var(--text);
|
||||||
|
border-radius:6px;padding:.3rem .6rem;font-size:.72rem;cursor:pointer;transition:.15s}
|
||||||
|
.tool-btn:hover{background:var(--border)}
|
||||||
|
.tool-btn.active{background:var(--accent);border-color:var(--accent);color:#000;font-weight:600}
|
||||||
|
.sep{width:1px;height:18px;background:var(--border);margin:0 .25rem}
|
||||||
|
.layer-btn{border-radius:20px;padding:.25rem .7rem;font-size:.7rem}
|
||||||
|
.layer-btn.off{opacity:.35;text-decoration:line-through}
|
||||||
|
#zoom-level{font-size:.72rem;color:var(--muted);margin-left:auto}
|
||||||
|
|
||||||
|
.canvas{flex:1;position:relative;overflow:hidden;cursor:grab;background:var(--bg)}
|
||||||
|
.canvas:active{cursor:grabbing}
|
||||||
|
#svg-container{position:absolute;inset:0;transition:none;transform-origin:0 0}
|
||||||
|
#svg-container svg{display:block;width:100%;height:100%}
|
||||||
|
|
||||||
|
/* empty state */
|
||||||
|
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
|
height:100%;gap:.75rem;color:var(--muted);user-select:none}
|
||||||
|
.empty .icon{font-size:4rem;opacity:.2}
|
||||||
|
.empty p{font-size:.85rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Agent DXF</h1>
|
||||||
|
<span>Dorafort — Ferestre & Uși</span>
|
||||||
|
<div class="dot" id="status-dot"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- LEFT PANEL -->
|
||||||
|
<aside>
|
||||||
|
<div class="upload-area">
|
||||||
|
<div class="drop-zone" id="dz">
|
||||||
|
<input type="file" id="file-in" accept=".dxf">
|
||||||
|
<div class="drop-icon">📐</div>
|
||||||
|
<label for="file-in">Alege fișier DXF</label>
|
||||||
|
<p>sau trage aici</p>
|
||||||
|
</div>
|
||||||
|
<div id="fname"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metrics" id="metrics-wrap" style="display:none">
|
||||||
|
<h2>Analiza</h2>
|
||||||
|
<div class="grid" id="metrics-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-wrap">
|
||||||
|
<h2>Chat</h2>
|
||||||
|
<div class="messages" id="msgs">
|
||||||
|
<div class="msg agent">Incarca un fisier DXF pentru a incepe analiza.</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input">
|
||||||
|
<input id="user-in" placeholder="Intreaba despre desen..." disabled>
|
||||||
|
<button class="btn" id="send-btn" onclick="send()" disabled>▶</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- RIGHT PANEL — PREVIEW -->
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<div class="preview-toolbar" id="toolbar" style="display:none">
|
||||||
|
<button class="tool-btn" onclick="zoom(1.25)">+</button>
|
||||||
|
<button class="tool-btn" onclick="zoom(0.8)">−</button>
|
||||||
|
<button class="tool-btn" onclick="fitView()">⊡ Fit</button>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<span style="font-size:.7rem;color:var(--muted)">Layere:</span>
|
||||||
|
<div id="layer-btns"></div>
|
||||||
|
<span id="zoom-level">100%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="canvas" id="canvas">
|
||||||
|
<div class="empty" id="empty-state">
|
||||||
|
<div class="icon">🗂️</div>
|
||||||
|
<p>Incarca un fisier DXF<br>pentru previzualizare</p>
|
||||||
|
</div>
|
||||||
|
<div id="svg-container" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── STATE ──
|
||||||
|
let sid = null, svgEl = null;
|
||||||
|
let tx = 0, ty = 0, scale = 1;
|
||||||
|
let dragging = false, lastX = 0, lastY = 0;
|
||||||
|
let naturalW = 0, naturalH = 0;
|
||||||
|
|
||||||
|
// ── UPLOAD ──
|
||||||
|
const dz = document.getElementById('dz');
|
||||||
|
const fi = document.getElementById('file-in');
|
||||||
|
dz.addEventListener('dragover', e=>{e.preventDefault();dz.classList.add('over')});
|
||||||
|
dz.addEventListener('dragleave', ()=>dz.classList.remove('over'));
|
||||||
|
dz.addEventListener('drop', e=>{e.preventDefault();dz.classList.remove('over');
|
||||||
|
if(e.dataTransfer.files[0]) doUpload(e.dataTransfer.files[0])});
|
||||||
|
fi.addEventListener('change', ()=>{ if(fi.files[0]) doUpload(fi.files[0]) });
|
||||||
|
|
||||||
|
async function doUpload(file){
|
||||||
|
document.getElementById('fname').textContent = '⏳ ' + file.name;
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
try{
|
||||||
|
const r = await fetch('/upload',{method:'POST',body:form});
|
||||||
|
if(!r.ok){ const e=await r.json(); throw new Error(e.detail); }
|
||||||
|
const d = await r.json();
|
||||||
|
sid = d.session_id;
|
||||||
|
document.getElementById('fname').textContent = '✓ ' + d.filename;
|
||||||
|
showMetrics(d.analysis);
|
||||||
|
showLayers(d.layers);
|
||||||
|
await loadPreview(sid);
|
||||||
|
enableChat();
|
||||||
|
addMsg('agent', `Desenul <strong>${d.filename}</strong> a fost analizat ✅\nPune întrebări despre dimensiuni, cantități de profil, suprafețe de sticlă sau orice detaliu tehnic.`);
|
||||||
|
} catch(e){
|
||||||
|
document.getElementById('fname').textContent = '✗ Eroare: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── METRICS ──
|
||||||
|
function showMetrics(s){
|
||||||
|
const wrap = document.getElementById('metrics-wrap');
|
||||||
|
const grid = document.getElementById('metrics-grid');
|
||||||
|
wrap.style.display = '';
|
||||||
|
const items = [
|
||||||
|
['Lățime', s.latime_mm ? s.latime_mm+' mm' : '—', ''],
|
||||||
|
['Înălțime', s.inaltime_mm ? s.inaltime_mm+' mm' : '—', ''],
|
||||||
|
['Sup. brută', s.suprafata_bruta_m2 ? s.suprafata_bruta_m2+' m²' : '—', 'purple'],
|
||||||
|
['Sup. sticlă', s.sticla_m2 ? s.sticla_m2+' m²' : '—', 'green'],
|
||||||
|
['Perimetru', s.perimetru_m ? s.perimetru_m+' m' : '—', 'warn'],
|
||||||
|
['Tip', s.tip || '—', ''],
|
||||||
|
];
|
||||||
|
grid.innerHTML = items.map(([l,v,c])=>
|
||||||
|
`<div class="metric ${c}"><div class="lbl">${l}</div><div class="val">${v}</div></div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PREVIEW ──
|
||||||
|
async function loadPreview(sid){
|
||||||
|
const container = document.getElementById('svg-container');
|
||||||
|
const empty = document.getElementById('empty-state');
|
||||||
|
const toolbar = document.getElementById('toolbar');
|
||||||
|
|
||||||
|
const r = await fetch(`/preview/${sid}`);
|
||||||
|
const svgText = await r.text();
|
||||||
|
container.innerHTML = svgText;
|
||||||
|
svgEl = container.querySelector('svg');
|
||||||
|
|
||||||
|
// measure natural size from viewBox
|
||||||
|
const vb = svgEl.getAttribute('viewBox')?.split(' ').map(Number);
|
||||||
|
if(vb){ naturalW = vb[2]; naturalH = vb[3]; }
|
||||||
|
|
||||||
|
empty.style.display = 'none';
|
||||||
|
container.style.display = 'block';
|
||||||
|
toolbar.style.display = 'flex';
|
||||||
|
fitView();
|
||||||
|
setupPanZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LAYER TOGGLES ──
|
||||||
|
function showLayers(layers){
|
||||||
|
const COLORS = {CADRU:'#e2e8f0',STICLA:'#38bdf8',PROFIL:'#4ade80',
|
||||||
|
FERONERIE:'#fb923c',COTE:'#fbbf24',TEXT:'#94a3b8'};
|
||||||
|
const btns = document.getElementById('layer-btns');
|
||||||
|
btns.innerHTML = '';
|
||||||
|
layers.filter(l=>l!='0'&&l!='Defpoints').forEach(layer=>{
|
||||||
|
const col = Object.entries(COLORS).find(([k])=>k.toUpperCase()===layer.toUpperCase())?.[1] || '#94a3b8';
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'tool-btn layer-btn active';
|
||||||
|
b.style.borderColor = col; b.style.color = col;
|
||||||
|
b.textContent = layer;
|
||||||
|
b.dataset.layer = layer;
|
||||||
|
b.onclick = ()=>toggleLayer(b, layer, col);
|
||||||
|
btns.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLayer(btn, layer, col){
|
||||||
|
const svg = document.getElementById('svg-container').querySelector('svg');
|
||||||
|
if(!svg) return;
|
||||||
|
const safe = layer.replace(/[\s\/()]/g,'_');
|
||||||
|
const g = svg.getElementById('L-'+safe);
|
||||||
|
if(!g) return;
|
||||||
|
const isOn = btn.classList.contains('active');
|
||||||
|
btn.classList.toggle('active', !isOn);
|
||||||
|
btn.classList.toggle('off', isOn);
|
||||||
|
g.style.display = isOn ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PAN / ZOOM ──
|
||||||
|
function setupPanZoom(){
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
canvas.addEventListener('wheel', e=>{
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.deltaY < 0 ? 1.12 : 0.89;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
tx = mx - (mx - tx)*f;
|
||||||
|
ty = my - (my - ty)*f;
|
||||||
|
scale *= f;
|
||||||
|
applyTransform();
|
||||||
|
}, {passive:false});
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', e=>{dragging=true;lastX=e.clientX;lastY=e.clientY});
|
||||||
|
canvas.addEventListener('mousemove', e=>{
|
||||||
|
if(!dragging) return;
|
||||||
|
tx += e.clientX - lastX; ty += e.clientY - lastY;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
applyTransform();
|
||||||
|
});
|
||||||
|
canvas.addEventListener('mouseup', ()=>dragging=false);
|
||||||
|
canvas.addEventListener('mouseleave',()=>dragging=false);
|
||||||
|
|
||||||
|
// touch
|
||||||
|
let lastDist = 0, touches = [];
|
||||||
|
canvas.addEventListener('touchstart', e=>{
|
||||||
|
touches = Array.from(e.touches);
|
||||||
|
if(touches.length===2) lastDist = dist(touches[0],touches[1]);
|
||||||
|
});
|
||||||
|
canvas.addEventListener('touchmove', e=>{
|
||||||
|
e.preventDefault();
|
||||||
|
const t = Array.from(e.touches);
|
||||||
|
if(t.length===1&&touches.length===1){
|
||||||
|
tx += t[0].clientX-touches[0].clientX;
|
||||||
|
ty += t[0].clientY-touches[0].clientY;
|
||||||
|
} else if(t.length===2){
|
||||||
|
const d = dist(t[0],t[1]);
|
||||||
|
if(lastDist) scale *= d/lastDist;
|
||||||
|
lastDist = d;
|
||||||
|
}
|
||||||
|
touches = t; applyTransform();
|
||||||
|
},{passive:false});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dist(a,b){ return Math.hypot(a.clientX-b.clientX, a.clientY-b.clientY) }
|
||||||
|
|
||||||
|
function applyTransform(){
|
||||||
|
const c = document.getElementById('svg-container');
|
||||||
|
c.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`;
|
||||||
|
document.getElementById('zoom-level').textContent = Math.round(scale*100)+'%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoom(f){
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const cx = canvas.clientWidth/2, cy = canvas.clientHeight/2;
|
||||||
|
tx = cx - (cx-tx)*f; ty = cy - (cy-ty)*f;
|
||||||
|
scale *= f; applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitView(){
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||||||
|
if(!naturalW || !naturalH) return;
|
||||||
|
scale = Math.min(cw/naturalW, ch/naturalH) * 0.92;
|
||||||
|
tx = (cw - naturalW*scale)/2;
|
||||||
|
ty = (ch - naturalH*scale)/2;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CHAT ──
|
||||||
|
function enableChat(){
|
||||||
|
document.getElementById('user-in').disabled = false;
|
||||||
|
document.getElementById('send-btn').disabled = false;
|
||||||
|
document.getElementById('user-in').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMsg(role, html){
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'msg '+role;
|
||||||
|
div.innerHTML = html;
|
||||||
|
const msgs = document.getElementById('msgs');
|
||||||
|
msgs.appendChild(div);
|
||||||
|
msgs.scrollTop = msgs.scrollHeight;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(){
|
||||||
|
const inp = document.getElementById('user-in');
|
||||||
|
const msg = inp.value.trim();
|
||||||
|
if(!msg||!sid) return;
|
||||||
|
inp.value = '';
|
||||||
|
addMsg('user', msg);
|
||||||
|
const thinking = addMsg('agent thinking', '⏳ Se gândește...');
|
||||||
|
document.getElementById('send-btn').disabled = true;
|
||||||
|
inp.disabled = true;
|
||||||
|
try{
|
||||||
|
const r = await fetch('/chat',{method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({session_id:sid, message:msg})
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
thinking.className='msg agent';
|
||||||
|
thinking.innerHTML = (d.answer||d.detail||'Eroare').replace(/\n/g,'<br>');
|
||||||
|
} catch(e){
|
||||||
|
thinking.innerHTML = '✗ '+e.message;
|
||||||
|
}
|
||||||
|
document.getElementById('send-btn').disabled = false;
|
||||||
|
inp.disabled = false;
|
||||||
|
inp.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('user-in').addEventListener('keydown', e=>{
|
||||||
|
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}
|
||||||
|
});
|
||||||
|
|
||||||
|
// resize fit
|
||||||
|
window.addEventListener('resize', ()=>{ if(svgEl) fitView() });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
Loading…
Reference in a new issue