Files
photon---photo-intelligence/index.html
drjones adbe4d8f24 Initial commit of PHOTON photo intelligence app
Ollama-based photo tagging with exiftool metadata writes.
2026-07-18 09:21:10 -07:00

1030 lines
49 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PHOTON // photo intelligence console</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#03050a; --panel:#080d1a; --panel2:#0c1328; --line:#162447;
--cyan:#22d3ee; --cyan-dim:#0891b2; --mag:#e879f9; --mag-dim:#c084fc;
--grn:#4ade80; --grn-dim:#16a34a; --amber:#fbbf24; --red:#f87171;
--txt:#d1e2ff; --dim:#627a9f;
--font-sans:'Outfit', sans-serif;
--font-mono:'JetBrains Mono', monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--bg); color:var(--txt); font-family:var(--font-sans);
font-size:13px; min-height:100vh; overflow-x:hidden;
background-image:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(34,211,238,.07), transparent),
repeating-linear-gradient(0deg, transparent 0 2px, rgba(255,255,255,.004) 2px 4px);
}
.wrap{max-width:1600px;margin:0 auto;padding:14px 20px 30px}
/* ---------- header & tabs ---------- */
header{display:flex;align-items:center;gap:18px;padding:10px 4px 16px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin-bottom:14px}
.logo{font-size:28px;font-weight:800;letter-spacing:6px;color:#fff;font-family:var(--font-sans);
text-shadow:0 0 12px var(--cyan),0 0 40px rgba(34,211,238,.3)}
.logo span{color:var(--cyan)}
.sub{color:var(--dim);letter-spacing:1px;font-size:10px;text-transform:uppercase;margin-top:2px}
.badge{margin-left:auto;padding:5px 14px;border:1px solid var(--line);border-radius:4px;
letter-spacing:2px;font-size:11px;text-transform:uppercase;color:var(--dim);font-family:var(--font-mono);font-weight:bold}
.badge.running{color:var(--grn);border-color:var(--grn);box-shadow:0 0 12px rgba(74,222,128,.2);animation:pulse 1.6s infinite}
.badge.paused{color:var(--amber);border-color:var(--amber)}
.badge.scanning{color:var(--cyan);border-color:var(--cyan)}
.badge.done{color:var(--mag);border-color:var(--mag)}
@keyframes pulse{50%{opacity:.55}}
.tabs{display:flex;gap:4px;margin-bottom:16px;background:rgba(8,13,26,0.6);padding:4px;border-radius:6px;border:1px solid var(--line);width:fit-content}
.tab-btn{
background:transparent;border:none;color:var(--dim);padding:8px 18px;font-family:var(--font-sans);
font-size:13px;font-weight:600;letter-spacing:1px;border-radius:4px;cursor:pointer;transition:.15s;
}
.tab-btn:hover{color:#fff;background:rgba(255,255,255,0.03)}
.tab-btn.active{color:var(--cyan);background:var(--panel2);box-shadow:inset 0 0 8px rgba(34,211,238,0.1);border:1px solid var(--line)}
.tab-content{display:none}
.tab-content.active{display:block}
/* ---------- general components ---------- */
.panel{background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden;margin-bottom:14px;box-shadow:0 8px 24px rgba(0,0,0,0.3)}
.panel h2{font-size:11px;letter-spacing:2px;text-transform:uppercase;color:var(--dim);
padding:10px 14px;border-bottom:1px solid var(--line);background:var(--panel2);font-weight:600;display:flex;align-items:center;justify-content:between}
.panel h2 b{color:var(--cyan);font-weight:600}
button{
font-family:var(--font-sans);font-size:11px;letter-spacing:1.5px;text-transform:uppercase;font-weight:600;
background:var(--panel2);color:var(--txt);border:1px solid var(--line);
padding:8px 18px;border-radius:4px;cursor:pointer;transition:.15s;
}
button:hover:not(:disabled){border-color:var(--cyan);color:var(--cyan);box-shadow:0 0 12px rgba(34,211,238,0.2)}
button:disabled{opacity:.3;cursor:not-allowed}
.btn-primary{border-color:var(--grn);color:var(--grn)}
.btn-primary:hover:not(:disabled){box-shadow:0 0 14px rgba(74,222,128,0.25)}
.btn-danger{border-color:var(--red);color:var(--red)}
.btn-danger:hover:not(:disabled){box-shadow:0 0 14px rgba(248,113,113,0.25)}
/* ---------- console view layout ---------- */
.controls{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
.folderRow{display:flex;gap:10px;flex:1;min-width:340px}
.folderRow input{flex:1;background:var(--panel);border:1px solid var(--line);color:var(--txt);
font-family:var(--font-mono);font-size:12px;padding:9px 12px;border-radius:4px}
.folderRow input:focus{outline:none;border-color:var(--cyan)}
.grid{display:grid;grid-template-columns:330px 1fr;gap:14px}
.grid-three{display:grid;grid-template-columns:330px 1fr 320px;gap:14px}
@media(max-width:1200px){.grid,.grid-three{grid-template-columns:1fr}}
.photoBox{position:relative;aspect-ratio:1;background:#000;display:flex;align-items:center;justify-content:center}
.photoBox img{max-width:100%;max-height:100%;object-fit:contain}
.photoBox .scan{position:absolute;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--cyan),transparent);
box-shadow:0 0 10px var(--cyan);opacity:0;top:0}
.photoBox.active .scan{opacity:1;animation:scanline 2.2s linear infinite}
@keyframes scanline{0%{top:0}100%{top:100%}}
.photoBox .corner{position:absolute;width:16px;height:16px;border:2px solid var(--cyan);opacity:.5}
.c1{top:6px;left:6px;border-right:0;border-bottom:0}.c2{top:6px;right:6px;border-left:0;border-bottom:0}
.c3{bottom:6px;left:6px;border-right:0;border-top:0}.c4{bottom:6px;right:6px;border-left:0;border-top:0}
.photoMeta{padding:12px;border-top:1px solid var(--line);min-height:115px}
.photoName{color:var(--cyan);font-size:11px;font-family:var(--font-mono);word-break:break-all;margin-bottom:6px}
.photoDesc{color:var(--txt);font-size:12px;line-height:1.5;margin-bottom:8px}
.photoCat{display:inline-block;border:1px solid var(--mag);color:var(--mag);font-size:9px;
letter-spacing:1.5px;padding:3px 9px;border-radius:3px;text-transform:uppercase;font-weight:600}
.term{height:390px;overflow-y:auto;padding:10px 12px;font-family:var(--font-mono);font-size:11px;line-height:1.6;
background:linear-gradient(rgba(34,211,238,.01),transparent)}
.term::-webkit-scrollbar{width:6px}
.term::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}
.ln{white-space:pre-wrap;word-break:break-word;margin-bottom:4px}
.ln .t{color:var(--dim)}
.ln.info{color:var(--cyan)} .ln.ok{color:var(--grn)}
.ln.warn{color:var(--amber)} .ln.error{color:var(--red)}
.cursor{display:inline-block;width:6px;height:12px;background:var(--cyan);animation:cursorPulse 1s infinite;vertical-align:text-bottom}
@keyframes cursorPulse{50%{opacity:0}}
/* ---------- recent results strip ---------- */
.recent-strip{display:flex;gap:8px;padding:10px;overflow-x:auto;background:var(--panel2);min-height:96px}
.recent-item{width:76px;height:76px;border:1px solid var(--line);border-radius:4px;overflow:hidden;cursor:pointer;position:relative;flex-shrink:0;transition:.15s}
.recent-item:hover{border-color:var(--cyan);transform:scale(1.04)}
.recent-item img{width:100%;height:100%;object-fit:cover}
.recent-item .cat-badge{position:absolute;bottom:2px;right:2px;font-size:8px;background:rgba(0,0,0,0.8);color:var(--mag);padding:1px 3px;border-radius:2px;font-weight:bold}
/* ---------- parameters & settings ---------- */
.set{padding:14px}
.set label{display:block;font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--dim);margin:14px 0 5px;font-weight:600}
.set label:first-child{margin-top:0}
.set select,.set input[type=number]{width:100%;background:var(--panel2);border:1px solid var(--line);
color:var(--txt);font-family:var(--font-sans);font-size:12px;padding:8px 10px;border-radius:4px}
.set select:focus,.set input:focus{outline:none;border-color:var(--cyan)}
.seg{display:flex;gap:4px}
.seg button{flex:1;padding:6px 2px;font-size:10px;letter-spacing:1px}
.seg button.on{border-color:var(--cyan);color:var(--cyan);background:rgba(34,211,238,.08)}
.tog{display:flex;justify-content:space-between;align-items:center;margin-top:12px}
.tog span{font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--dim);font-weight:600}
.sw{width:38px;height:18px;border:1px solid var(--line);border-radius:10px;position:relative;cursor:pointer;background:var(--panel2)}
.sw i{position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:var(--dim);transition:.15s}
.sw.on{border-color:var(--grn)} .sw.on i{left:22px;background:var(--grn);box-shadow:0 0 8px var(--grn)}
.hint{font-size:9px;color:var(--dim);margin-top:3px;letter-spacing:.3px;line-height:1.4}
/* ---------- stats & counters ---------- */
.statsRow{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:14px}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:12px;text-align:center}
.stat .v{font-size:24px;color:#fff;text-shadow:0 0 8px rgba(34,211,238,.4);font-family:var(--font-mono);font-weight:bold}
.stat .k{font-size:9px;letter-spacing:2px;text-transform:uppercase;color:var(--dim);margin-top:4px}
.progressWrap{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:12px 14px;margin-bottom:14px}
.pbar{height:12px;background:var(--panel2);border:1px solid var(--line);border-radius:3px;overflow:hidden;position:relative}
.pbar .fill{height:100%;width:0%;background:linear-gradient(90deg,var(--cyan-dim),var(--cyan));
box-shadow:0 0 10px var(--cyan);transition:width .4s}
.pbar .glint{position:absolute;inset:0;background:repeating-linear-gradient(-45deg,transparent 0 8px,rgba(255,255,255,.04) 8px 16px)}
.plabel{display:flex;justify-content:space-between;font-size:10px;color:var(--dim);letter-spacing:1px;margin-top:6px}
/* ---------- search interface ---------- */
.search-grid{display:grid;grid-template-columns:1fr 400px;gap:14px;height:700px}
@media(max-width:960px){.search-grid{grid-template-columns:1fr;height:auto}}
.search-left{display:flex;flex-direction:column;height:100%}
.search-bar-row{display:flex;gap:10px;padding:12px;background:var(--panel2);border-bottom:1px solid var(--line)}
.search-bar-row input{flex:1;background:var(--panel);border:1px solid var(--line);color:var(--txt);padding:8px 12px;border-radius:4px;font-family:var(--font-sans)}
.search-bar-row input:focus{outline:none;border-color:var(--cyan)}
.search-results{flex:1;overflow-y:auto;padding:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;align-content:start}
.search-results::-webkit-scrollbar{width:6px}
.search-results::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}
.result-card{background:var(--panel2);border:1px solid var(--line);border-radius:6px;overflow:hidden;display:flex;gap:10px;padding:8px;cursor:pointer;transition:.15s}
.result-card:hover{border-color:var(--cyan);transform:translateY(-1px)}
.result-card img{width:70px;height:70px;object-fit:cover;border-radius:4px;background:#000;flex-shrink:0}
.result-info{display:flex;flex-direction:column;justify-content:space-between;overflow:hidden}
.result-name{font-size:11px;color:var(--cyan);font-family:var(--font-mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.result-desc{font-size:11px;color:var(--txt);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.result-cat{font-size:9px;color:var(--mag);text-transform:uppercase;letter-spacing:1px;font-weight:600}
.editor-panel{display:flex;flex-direction:column;height:100%;padding:14px;background:var(--panel2);border-left:1px solid var(--line)}
.editor-img{width:100%;aspect-ratio:1.5;background:#000;border:1px solid var(--line);border-radius:4px;display:flex;align-items:center;justify-content:center;overflow:hidden;margin-bottom:12px}
.editor-img img{max-width:100%;max-height:100%;object-fit:contain}
.editor-fields{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:10px}
.editor-fields label{font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--dim);font-weight:600}
.editor-fields textarea{background:var(--panel);border:1px solid var(--line);color:var(--txt);padding:8px 10px;border-radius:4px;font-family:var(--font-sans);font-size:12px;resize:none;height:100px}
.editor-fields textarea:focus{outline:none;border-color:var(--cyan)}
.editor-fields select{background:var(--panel);border:1px solid var(--line);color:var(--txt);padding:8px 10px;border-radius:4px;font-size:12px}
.editor-fields select:focus{outline:none;border-color:var(--cyan)}
.editor-path{font-family:var(--font-mono);font-size:10px;color:var(--dim);word-break:break-all;background:rgba(0,0,0,0.15);padding:8px;border-radius:4px;border:1px solid rgba(255,255,255,0.02)}
.editor-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
/* ---------- blind audit view ---------- */
.audit-container{max-width:900px;margin:30px auto;min-height:500px;display:flex;flex-direction:column;justify-content:center}
.audit-start-card{text-align:center;padding:50px 30px;background:var(--panel);border:1px solid var(--line);border-radius:8px}
.audit-start-card h3{font-size:24px;color:#fff;margin-bottom:14px;letter-spacing:1px}
.audit-start-card p{font-size:13px;color:var(--dim);margin-bottom:24px;line-height:1.6;max-width:500px;margin-left:auto;margin-right:auto}
.audit-card{display:grid;grid-template-columns:1fr 1fr;gap:20px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:20px;box-shadow:0 12px 36px rgba(0,0,0,0.4)}
@media(max-width:768px){.audit-card{grid-template-columns:1fr}}
.audit-img-box{aspect-ratio:1;background:#000;border:1px solid var(--line);border-radius:6px;display:flex;align-items:center;justify-content:center;overflow:hidden}
.audit-img-box img{max-width:100%;max-height:100%;object-fit:contain}
.audit-qa-box{display:flex;flex-direction:column;justify-content:space-between;height:100%}
.audit-qa-content{display:flex;flex-direction:column;gap:14px}
.audit-label{font-size:9px;color:var(--dim);letter-spacing:1.5px;text-transform:uppercase;font-weight:600}
.audit-text{font-size:14px;color:#fff;line-height:1.5;background:var(--panel2);padding:12px;border-radius:4px;border-left:3px solid var(--cyan)}
.audit-cat-tag{align-self:start;border:1px solid var(--mag);color:var(--mag);font-size:10px;padding:3px 10px;border-radius:3px;text-transform:uppercase;font-weight:bold;letter-spacing:1px}
.audit-actions{margin-top:20px}
.audit-grade-buttons{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px}
.audit-notes{background:var(--panel2);border:1px solid var(--line);color:var(--txt);padding:8px 10px;border-radius:4px;width:100%;resize:none;font-family:var(--font-sans);font-size:12px;height:60px}
.audit-progress-bar{height:6px;background:var(--panel2);border-radius:3px;overflow:hidden;margin-top:14px}
.audit-progress-fill{height:100%;width:0%;background:var(--cyan);transition:width .2s}
.audit-results-card{padding:30px;background:var(--panel);border:1px solid var(--line);border-radius:8px;text-align:center}
.audit-score-circle{width:140px;height:140px;border-radius:50%;border:4px solid var(--cyan);display:flex;flex-direction:column;align-items:center;justify-content:center;margin:20px auto;box-shadow:0 0 20px rgba(34,211,238,0.2)}
.audit-score-circle .score-num{font-size:36px;font-weight:800;color:#fff;font-family:var(--font-mono)}
.audit-score-circle .score-lbl{font-size:9px;color:var(--dim);text-transform:uppercase;letter-spacing:1px}
.audit-results-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin:24px 0;text-align:left}
.audit-res-item{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:12px}
.audit-res-item .val{font-size:18px;color:#fff;font-weight:600}
.audit-res-item .lbl{font-size:9px;color:var(--dim);text-transform:uppercase;letter-spacing:1px;margin-top:2px}
/* ---------- categories manager ---------- */
.cat-edit-row{display:flex;gap:8px;margin-bottom:14px}
.cat-edit-row input{flex:1;background:var(--panel2);border:1px solid var(--line);color:var(--txt);padding:8px 12px;border-radius:4px;font-family:var(--font-sans)}
.cat-edit-row input:focus{outline:none;border-color:var(--cyan)}
.cat-list{display:flex;flex-direction:column;gap:6px;max-height:300px;overflow-y:auto;border:1px solid var(--line);border-radius:4px;padding:8px;background:var(--panel2)}
.cat-list-item{display:flex;justify-content:space-between;align-items:center;background:var(--panel);padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,0.02)}
.cat-list-item span{font-family:var(--font-mono);font-size:12px}
.cat-list-item button{padding:4px 10px;font-size:9px}
/* ---------- safety panel ---------- */
.safety-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:768px){.safety-grid{grid-template-columns:1fr}}
/* ---------- CSS Charts ---------- */
.chart-container{padding:14px;background:var(--panel2);border-radius:4px;border:1px solid rgba(255,255,255,0.02)}
.bar-chart{display:flex;flex-direction:column;gap:8px;margin-top:10px}
.bar-row{display:flex;align-items:center;gap:10px}
.bar-lbl{width:110px;font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:0.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar-track{flex:1;height:8px;background:rgba(255,255,255,0.02);border-radius:4px;overflow:hidden;position:relative}
.bar-fill{height:100%;background:linear-gradient(90deg,var(--cyan-dim),var(--cyan));border-radius:4px;width:0%;transition:width 0.4s}
.bar-val{width:30px;text-align:right;font-size:10px;color:var(--cyan);font-family:var(--font-mono)}
/* ---------- matrices & categories ---------- */
.cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:6px;padding:12px}
.cat{border:1px solid var(--line);border-radius:3px;padding:8px;display:flex;justify-content:space-between;align-items:center;transition:.2s}
.cat .n{font-size:9px;letter-spacing:1px;text-transform:uppercase;color:var(--dim);font-weight:600}
.cat .c{font-size:14px;color:var(--cyan);font-family:var(--font-mono)}
.cat.hot{border-color:var(--mag);box-shadow:0 0 8px rgba(232,121,249,.2)}
.cat.hot .n{color:var(--mag)}
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<div class="logo">PH<span>O</span>TON</div>
<div class="sub">local photo intelligence console · pipelined vision engine</div>
</div>
<div class="badge" id="badge">IDLE</div>
</header>
<div class="tabs">
<button class="tab-btn active" id="btn-tab-console" onclick="showTab('console')">Console</button>
<button class="tab-btn" id="btn-tab-search" onclick="showTab('search')">Search & Edit</button>
<button class="tab-btn" id="btn-tab-audit" onclick="showTab('audit')">Blind Audit</button>
<button class="tab-btn" id="btn-tab-settings" onclick="showTab('settings')">Settings & Safety</button>
</div>
<!-- ==================== CONSOLE TAB ==================== -->
<div id="tab-console" class="tab-content active">
<div class="controls">
<div class="folderRow">
<input id="folder" spellcheck="false" value="/Volumes/sanD/allphotos from phone">
<button id="btnScan">◈ Scan Directory</button>
</div>
<button id="btnStart" class="btn-primary" disabled>▶ Start Pipeline</button>
<button id="btnPause" disabled>❚❚ Pause</button>
<button id="btnStop" disabled>■ Stop</button>
</div>
<div class="statsRow">
<div class="stat"><div class="v" id="sCount">0 / 0</div><div class="k">processed</div></div>
<div class="stat"><div class="v" id="sRate">—</div><div class="k">photos / min</div></div>
<div class="stat"><div class="v" id="sAvg">—</div><div class="k">sec / photo</div></div>
<div class="stat"><div class="v" id="sEta">—</div><div class="k">eta</div></div>
<div class="stat"><div class="v" id="sFail">0</div><div class="k">failed queue</div></div>
<div class="stat"><div class="v" id="sTotalDb">0</div><div class="k">tagged total</div></div>
</div>
<div class="progressWrap">
<div class="pbar"><div class="fill" id="pfill"></div><div class="glint"></div></div>
<div class="plabel"><span id="plabelL">awaiting scan</span><span id="plabelR">0%</span></div>
</div>
<div class="grid-three">
<!-- Current Optical Feed -->
<div class="panel">
<h2>◉ <b>optical feed</b> — pipeline frame</h2>
<div class="photoBox" id="photoBox">
<div class="corner c1"></div><div class="corner c2"></div>
<div class="corner c3"></div><div class="corner c4"></div>
<img id="preview" alt="" style="display:none">
<div class="scan"></div>
<div id="noSignal" style="color:var(--dim);letter-spacing:3px;font-size:10px">NO SIGNAL</div>
</div>
<div class="photoMeta">
<div class="photoName" id="pName">—</div>
<div class="photoDesc" id="pDesc">model output will appear here</div>
<span class="photoCat" id="pCat" style="display:none"></span>
</div>
</div>
<!-- Live Logs -->
<div class="panel">
<h2>▚ <b>engine log</b> — telemetry stream</h2>
<div class="term" id="term"><div class="ln info"><span class="t">--:--:--</span> PHOTON pipeline ready <span class="cursor"></span></div></div>
</div>
<!-- Live Category distribution chart -->
<div class="panel">
<h2>◫ <b>category density</b></h2>
<div class="chart-container" style="height: 480px; overflow-y: auto;">
<div class="bar-chart" id="densityChart"></div>
</div>
</div>
</div>
<!-- Recent activity strip -->
<div class="panel" style="margin-top:14px">
<h2>◸ <b>recent results</b> — last 8 photos (click to inspect/edit)</h2>
<div class="recent-strip" id="recentStrip">
<div style="color:var(--dim); padding: 30px; font-size:11px; text-transform:uppercase; letter-spacing:1px; width:100%; text-align:center;">No recent activity yet. Start the pipeline to populate.</div>
</div>
</div>
<div class="panel" style="margin-top:14px">
<h2>◫ <b>classification matrix</b> — categories total</h2>
<div class="cats" id="cats"></div>
</div>
</div>
<!-- ==================== SEARCH & EDIT TAB ==================== -->
<div id="tab-search" class="tab-content">
<div class="panel">
<h2>⌕ <b>journal search & meta inspector</b></h2>
<div class="search-grid">
<div class="search-left">
<div class="search-bar-row">
<input id="searchInput" placeholder="Search by name, category, or description keywords..." spellcheck="false">
<button id="btnSearch" class="btn-primary">Search</button>
<button id="btnSyncMeta">⟳ Sync Metadata</button>
</div>
<div class="search-results" id="searchResults">
<div style="grid-column:1/-1; text-align:center; padding:50px; color:var(--dim); text-transform:uppercase; letter-spacing:1px">Type a search query or press Search to list all tagged photos.</div>
</div>
</div>
<!-- Inspector / Editor -->
<div class="editor-panel">
<h3 style="font-size:11px; text-transform:uppercase; letter-spacing:1px; color:var(--dim); margin-bottom:12px; border-bottom:1px solid var(--line); padding-bottom:6px">photo inspector</h3>
<div class="editor-img">
<img id="editPreview" style="display:none">
<div id="editNoSignal" style="color:var(--dim); font-size:10px; letter-spacing:2px">SELECT A PHOTO</div>
</div>
<div class="editor-fields">
<label>File path</label>
<div class="editor-path" id="editPath">—</div>
<label>category</label>
<select id="editCat"></select>
<label>description</label>
<textarea id="editDesc" placeholder="Photo description..."></textarea>
</div>
<div class="editor-actions">
<button id="btnSaveSingle" class="btn-primary" disabled>✓ Save</button>
<button id="btnRedoSingle" disabled>⌁ AI Redo</button>
<button id="btnRevealSingle" style="grid-column:1/-1" disabled>Reveal in Finder</button>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== BLIND AUDIT TAB ==================== -->
<div id="tab-audit" class="tab-content">
<div class="audit-container">
<!-- Start View -->
<div class="audit-start-card" id="auditStartCard">
<h3>Blind Accuracy Grader</h3>
<p>This mode selects up to 100 random tagged images from your database. You will grade their description and category accuracy blindly to calculate a quality score for your prompt/model configurations.</p>
<button id="btnStartAudit" class="btn-primary">▶ Start Accuracy Audit</button>
</div>
<!-- Grader View -->
<div class="audit-card" id="auditCard" style="display:none">
<div class="audit-img-box">
<img id="auditImg">
</div>
<div class="audit-qa-box">
<div class="audit-qa-content">
<div class="audit-label">Model Description</div>
<div class="audit-text" id="auditDescText">—</div>
<div class="audit-label">Model Category</div>
<div class="audit-cat-tag" id="auditCatText">—</div>
<div class="audit-label">Audit Notes & Corrections (Optional)</div>
<textarea class="audit-notes" id="auditNotes" placeholder="Write any critiques or correct description here..."></textarea>
</div>
<div class="audit-actions">
<div class="audit-grade-buttons">
<button id="btnAuditPass" class="btn-primary" style="height:44px">✓ Correct (Pass)</button>
<button id="btnAuditFail" class="btn-danger" style="height:44px">✗ Incorrect (Fail)</button>
</div>
<div style="display:flex; justify-content:between; font-size:10px; color:var(--dim)">
<span id="auditCountLabel">Photo 0 of 0</span>
<span id="auditPercentLabel">0% Complete</span>
</div>
<div class="audit-progress-bar">
<div class="audit-progress-fill" id="auditProgressFill"></div>
</div>
</div>
</div>
</div>
<!-- Results View -->
<div class="audit-results-card" id="auditResultsCard" style="display:none">
<h3>Audit Results</h3>
<div class="audit-score-circle">
<div class="score-num" id="auditScoreVal">0%</div>
<div class="score-lbl">accuracy</div>
</div>
<p style="color:var(--dim); font-size:12px; margin-bottom:20px" id="auditSummaryText">0 of 0 photos graded correctly.</p>
<div class="audit-results-grid">
<div class="audit-res-item">
<div class="val" id="auditTotalGraded">0</div>
<div class="lbl">Total Graded</div>
</div>
<div class="audit-res-item">
<div class="val" id="auditPassedCount">0</div>
<div class="lbl">Passed (Correct)</div>
</div>
<div class="audit-res-item">
<div class="val" id="auditFailedCount">0</div>
<div class="lbl">Failed (Incorrect)</div>
</div>
</div>
<button id="btnRestartAudit">Start New Audit</button>
</div>
</div>
</div>
<!-- ==================== SETTINGS & SAFETY TAB ==================== -->
<div id="tab-settings" class="tab-content">
<div class="grid">
<!-- Engine Options -->
<div class="panel">
<h2>⚙ <b>engine configurations</b></h2>
<div class="set">
<label>vision model</label>
<select id="model"></select>
<div class="hint">qwen3.5:9b has best quality (thinking disabled). glm-ocr is fastest scout classifier.</div>
<div class="tog"><span>smart router</span><div class="sw" id="swRouter"><i></i></div></div>
<div class="hint">scout classifies screenshot vs real photo, routing to specialized model branches.</div>
<div id="routerCfg" style="display:none; margin-top:8px; border-left:1px solid var(--line); padding-left:10px">
<label>scout (classifier)</label>
<select id="routerModel"></select>
<label>screenshot describer</label>
<select id="shotModel"></select>
<label>photo describer</label>
<select id="photoModel"></select>
<div class="tog"><span>ocr text embed</span><div class="sw on" id="swOcr"><i></i></div></div>
<div class="hint">glm-ocr also transcribes the screenshot text directly into the description.</div>
</div>
<label>image feed resolution</label>
<div class="seg" id="segPx">
<button data-v="512">512</button>
<button data-v="896" class="on">896</button>
<button data-v="1280">1280</button>
<button data-v="0">FULL</button>
</div>
<div class="hint">photos downscaled before analysis. lower = faster, original image files untouched.</div>
<label>description length</label>
<div class="seg" id="segLen">
<button data-v="brief">brief</button>
<button data-v="standard" class="on">std</button>
<button data-v="detailed">detail</button>
</div>
<label>temperature</label>
<input type="number" id="temperature" value="0.1" min="0" max="1" step="0.1">
<div class="hint">0 = deterministic classification, higher = more descriptive variety.</div>
<label>model keep-alive</label>
<select id="keepAlive">
<option value="10m" selected>10 min</option>
<option value="30m">30 min</option>
<option value="-1">forever (fastest)</option>
</select>
<div class="tog"><span>skip already tagged</span><div class="sw on" id="swSkip"><i></i></div></div>
<div class="tog"><span>preserve file dates</span><div class="sw on" id="swDate"><i></i></div></div>
<div class="tog"><span>keep _original backups</span><div class="sw" id="swBackup"><i></i></div></div>
<div class="hint">off = atomic metadata write. on = copies originals (doubles disk usage).</div>
<div class="tog"><span>dry run (no writes)</span><div class="sw" id="swDry"><i></i></div></div>
</div>
</div>
<!-- Custom categories & Safety tools -->
<div style="display:flex; flex-direction:column; gap:14px">
<!-- Categories Manager -->
<div class="panel">
<h2>◫ <b>custom categories manager</b></h2>
<div class="set">
<div class="cat-edit-row">
<input id="newCatInput" placeholder="Add custom category..." spellcheck="false">
<button id="btnAddCat" class="btn-primary">Add</button>
</div>
<div class="cat-list" id="catList"></div>
<button id="btnSaveCats" class="btn-primary" style="margin-top:12px; width:100%">✓ Save Category Structure</button>
</div>
</div>
<!-- Safety Panel -->
<div class="panel">
<h2>🛡 <b>trust & safety console</b></h2>
<div class="set">
<div class="tog">
<span>verify pixel integrity</span>
<div class="sw" id="swIntegrity"><i></i></div>
</div>
<div class="hint" style="margin-bottom:14px">checks image pixels pre/post write via double-BMP double-hash. Restores backup on mismatch. 100% corruption proof.</div>
<div class="tog">
<span>organize aliases</span>
<div class="sw" id="swOrganize"><i></i></div>
</div>
<div class="hint" style="margin-bottom:20px">creates Portable Relative Symlinks inside folder categorized in <code>_organized/</code>. Originals remain completely untouched.</div>
<div style="border-top: 1px solid var(--line); padding-top:14px">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px">
<span style="font-size:10px; font-weight:600; color:var(--dim); text-transform:uppercase; letter-spacing:1px">telemetry failures</span>
<span id="failBadge" style="background:rgba(248,113,113,0.1); color:var(--red); padding:2px 8px; border-radius:3px; font-family:var(--font-mono); font-weight:bold">0 FAILED</span>
</div>
<button id="btnRetryFailed" style="width:100%; margin-bottom:12px" disabled>⟳ Retry Failed Queue</button>
</div>
<div style="border-top: 1px solid var(--line); padding-top:14px">
<label style="color:var(--red); font-weight:bold; margin-top:0">destructive rollbacks</label>
<p class="hint" style="margin-bottom:10px">Removes all embedded PHOTON descriptions, categories, and keyword markers recursively. Clears organized symlinks and journals.</p>
<button id="btnUndoAll" class="btn-danger" style="width:100%">☣ Remove All PHOTON Tags</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const $=id=>document.getElementById(id);
let CATS=[], state="idle", scanned=false, currentEditFile=null;
let recentFiles=[];
/* ---------- Tab Management ---------- */
function showTab(tabId){
document.querySelectorAll(".tab-content").forEach(el=>el.classList.remove("active"));
document.querySelectorAll(".tab-btn").forEach(el=>el.classList.remove("active"));
$("tab-"+tabId).classList.add("active");
$("btn-tab-"+tabId).classList.add("active");
if(tabId==='search') doSearch();
}
/* ---------- helpers ---------- */
function seg(el){ el.querySelectorAll("button").forEach(b=>b.onclick=()=>{
el.querySelectorAll("button").forEach(x=>x.classList.remove("on")); b.classList.add("on");});}
seg($("segPx")); seg($("segLen"));
document.querySelectorAll(".sw").forEach(s=>s.onclick=()=>{
s.classList.toggle("on");
if(s.id==="swRouter") $("routerCfg").style.display = s.classList.contains("on") ? "block" : "none";
});
const segVal=el=>el.querySelector("button.on").dataset.v;
const swOn=id=>$(id).classList.contains("on");
function fmtEta(s){ if(s==null)return "—";
if(s<3600)return Math.floor(s/60)+"m "+(s%60)+"s";
const h=Math.floor(s/3600); return h+"h "+Math.floor((s%3600)/60)+"m"; }
function addLog(e){
const t=$("term");
const d=document.createElement("div");
d.className="ln "+e.level;
d.innerHTML='<span class="t">'+e.ts+'</span> '+escapeHtml(e.msg);
t.appendChild(d);
while(t.children.length>350)t.removeChild(t.firstChild);
t.scrollTop=t.scrollHeight;
}
function escapeHtml(s){return s.replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]))}
function setBadge(st){
state=st;
const b=$("badge"); b.textContent=st.toUpperCase(); b.className="badge "+st;
$("btnStart").disabled = !(scanned && (st==="idle"||st==="done"));
$("btnPause").disabled = !(st==="running"||st==="paused");
$("btnStop").disabled = !(st==="running"||st==="paused");
$("btnPause").textContent = st==="paused" ? "▶ Resume Pipeline" : "❚❚ Pause";
$("photoBox").classList.toggle("active", st==="running");
}
function renderCats(counts){
const box=$("cats");
const densityBox=$("densityChart");
box.innerHTML="";
densityBox.innerHTML="";
// Calculate max count for charts scaling
let maxCount = 0;
for(const c in counts){
if(counts[c] > maxCount) maxCount = counts[c];
}
CATS.forEach(c=>{
const cnt = counts[c] ?? 0;
// Grid box item
const d=document.createElement("div"); d.className="cat"; d.dataset.cat=c;
d.innerHTML='<span class="n">'+c+'</span><span class="c">'+cnt+'</span>';
box.appendChild(d);
// Bar row item for chart
const pct = maxCount ? (cnt / maxCount * 100) : 0;
const br = document.createElement("div"); br.className="bar-row";
br.innerHTML=`<div class="bar-lbl" title="${c}">${c}</div>
<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
<div class="bar-val">${cnt}</div>`;
densityBox.appendChild(br);
});
}
function applyStats(s){
$("sCount").textContent = (s.processed + (s.alreadyDone||0)) + " / " + s.total;
$("sRate").textContent = s.perMin || "—";
$("sAvg").textContent = s.avgSec || "—";
$("sEta").textContent = fmtEta(s.etaSec);
$("sFail").textContent = s.failed;
$("failBadge").textContent = s.failed + " FAILED";
$("btnRetryFailed").disabled = (s.failed === 0 || state === "running");
$("sTotalDb").textContent = s.journalTotal;
const done = s.processed + (s.alreadyDone||0);
const pct = s.total ? Math.min(100, done/s.total*100) : 0;
$("pfill").style.width = pct+"%";
$("plabelR").textContent = pct.toFixed(1)+"%";
$("plabelL").textContent = s.total ? done+" of "+s.total+" frames" : "awaiting scan";
renderCats(s.catCounts||{});
}
/* ---------- Recent results strip ---------- */
function addToRecentStrip(photo){
// Check if photo is already in recent strip, remove it
recentFiles = recentFiles.filter(p=>p.path !== photo.path);
recentFiles.unshift(photo);
if(recentFiles.length > 8) recentFiles.pop();
renderRecentStrip();
}
function renderRecentStrip(){
const strip = $("recentStrip");
if(recentFiles.length === 0){
strip.innerHTML = `<div style="color:var(--dim); padding: 30px; font-size:11px; text-transform:uppercase; letter-spacing:1px; width:100%; text-align:center;">No recent activity yet. Start the pipeline to populate.</div>`;
return;
}
strip.innerHTML = "";
recentFiles.forEach(photo=>{
const item = document.createElement("div");
item.className = "recent-item";
item.onclick = () => {
showTab("search");
inspectPhoto(photo);
};
item.innerHTML = `<img src="/api/thumbnail?path=${encodeURIComponent(photo.path)}">
<div class="cat-badge">${photo.category}</div>`;
strip.appendChild(item);
});
}
/* ---------- API calls ---------- */
async function api(path, body){
const r = await fetch(path, body?{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}:{});
const j = await r.json().catch(()=>({}));
if(!r.ok) addLog({level:"error",msg:j.error||("http "+r.status),ts:new Date().toTimeString().slice(0,8)});
return j;
}
$("btnScan").onclick = async ()=>{
const j = await api("/api/scan",{folder:$("folder").value});
if(j.images!=null){ scanned=true; setBadge("idle"); }
};
$("btnStart").onclick = ()=>{
api("/api/start",{
model: $("model").value,
router: swOn("swRouter"),
routerModel: $("routerModel").value,
shotModel: $("shotModel").value,
photoModel: $("photoModel").value,
ocrText: swOn("swOcr"),
ocrModel: "glm-ocr:latest",
maxPx: +segVal($("segPx")),
length: segVal($("segLen")),
temperature: +$("temperature").value,
keepAlive: $("keepAlive").value,
skipDone: swOn("swSkip"),
preserveDate: swOn("swDate"),
keepBackup: swOn("swBackup"),
dryRun: swOn("swDry"),
organize: swOn("swOrganize"),
integrity: swOn("swIntegrity")
});
};
$("btnPause").onclick = ()=>api("/api/pause");
$("btnStop").onclick = ()=>api("/api/stop");
$("btnRetryFailed").onclick = async ()=>{
const j = await api("/api/retry_failed");
if(j.ok) addLog({level:"info",msg:"Retrying failures...",ts:new Date().toTimeString().slice(0,8)});
};
$("btnUndoAll").onclick = async ()=>{
if(confirm("☣ WARNING ☣\nThis will remove all PHOTON embedded descriptions/categories/keywords from ALL files in the scanned folder. It will also clear organized symlinks, reset the database and delete the failures log.\n\nTHIS ACTION CANNOT BE UNDONE.\n\nDo you want to proceed?")){
$("btnUndoAll").disabled = true;
const j = await api("/api/undo_all");
$("btnUndoAll").disabled = false;
if(j.ok){
recentFiles = [];
renderRecentStrip();
showTab("console");
}
}
};
/* ---------- Category List Editor ---------- */
function renderCategoryListEditor(){
const list = $("catList");
list.innerHTML = "";
CATS.forEach(c=>{
const item = document.createElement("div");
item.className = "cat-list-item";
item.innerHTML = `<span>${c}</span>
<button class="btn-danger" onclick="removeCategory('${c}')">Delete</button>`;
list.appendChild(item);
});
}
$("btnAddCat").onclick = ()=>{
const val = $("newCatInput").value.trim();
if(!val) return;
if(CATS.includes(val)){
alert("Category already exists."); return;
}
CATS.push(val);
$("newCatInput").value = "";
renderCategoryListEditor();
};
function removeCategory(cat){
if(confirm(`Remove "${cat}" category? (This won't untag existing photos until rewritten, but removes it from lists).`)){
CATS = CATS.filter(c=>c !== cat);
renderCategoryListEditor();
}
}
$("btnSaveCats").onclick = async ()=>{
const j = await api("/api/categories", {categories: CATS});
if(j.ok){
alert("Categories updated and synced with database!");
CATS = j.categories;
populateCategoryDropdowns();
renderCategoryListEditor();
}
};
function populateCategoryDropdowns(){
const editCat = $("editCat");
editCat.innerHTML = "";
CATS.forEach(c=>{
const opt = document.createElement("option");
opt.value = c; opt.textContent = c;
editCat.appendChild(opt);
});
}
/* ---------- Search and Inspector ---------- */
async function doSearch(){
const q = $("searchInput").value;
const results = $("searchResults");
results.innerHTML = `<div style="grid-column:1/-1; text-align:center; padding:30px; color:var(--dim)">Searching...</div>`;
const j = await api("/api/search?q=" + encodeURIComponent(q));
if(j.results){
renderSearchResults(j.results);
}
}
$("btnSearch").onclick = doSearch;
$("searchInput").onkeydown = e => { if(e.key === "Enter") doSearch(); };
$("btnSyncMeta").onclick = async ()=>{
if(confirm("Re-read all embedded metadata in directory? This will update your journal file with any manually updated photos.")){
$("btnSyncMeta").disabled = true;
const j = await api("/api/search?re_read=1&q=" + encodeURIComponent($("searchInput").value));
$("btnSyncMeta").disabled = false;
if(j.results) renderSearchResults(j.results);
}
};
function renderSearchResults(recs){
const box = $("searchResults");
box.innerHTML = "";
if(recs.length === 0){
box.innerHTML = `<div style="grid-column:1/-1; text-align:center; padding:50px; color:var(--dim)">No results found matching your query.</div>`;
return;
}
recs.forEach(rec=>{
const card = document.createElement("div");
card.className = "result-card";
card.onclick = () => inspectPhoto(rec);
card.innerHTML = `<img src="/api/thumbnail?path=${encodeURIComponent(rec.path)}">
<div class="result-info">
<div class="result-name" title="${rec.name}">${rec.name}</div>
<div class="result-desc" title="${rec.desc}">${rec.desc}</div>
<div class="result-cat">${rec.category}</div>
</div>`;
box.appendChild(card);
});
}
function inspectPhoto(rec){
currentEditFile = rec.path;
$("editPreview").src = "/api/thumbnail?path=" + encodeURIComponent(rec.path);
$("editPreview").style.display = "block";
$("editNoSignal").style.display = "none";
$("editPath").textContent = rec.path;
$("editDesc").value = rec.desc;
$("editCat").value = rec.category;
$("btnSaveSingle").disabled = false;
$("btnRedoSingle").disabled = false;
$("btnRevealSingle").disabled = false;
}
$("btnSaveSingle").onclick = async ()=>{
if(!currentEditFile) return;
$("btnSaveSingle").disabled = true;
const j = await api("/api/write_single", {
path: currentEditFile,
desc: $("editDesc").value,
category: $("editCat").value
});
$("btnSaveSingle").disabled = false;
if(j.ok){
addLog({level:"ok",msg:`Updated ${os_basename(currentEditFile)} metadata manually.`,ts:new Date().toTimeString().slice(0,8)});
// Refresh search results
doSearch();
}
};
$("btnRedoSingle").onclick = async ()=>{
if(!currentEditFile) return;
if(confirm("Request AI model re-tagging for this photo?")){
$("btnRedoSingle").disabled = true;
const j = await api("/api/redo_single", {
path: currentEditFile,
settings: {
model: $("model").value,
router: swOn("swRouter"),
routerModel: $("routerModel").value,
shotModel: $("shotModel").value,
photoModel: $("photoModel").value,
ocrText: swOn("swOcr"),
ocrModel: "glm-ocr:latest",
maxPx: +segVal($("segPx")),
length: segVal($("segLen")),
temperature: +$("temperature").value,
keepAlive: $("keepAlive").value,
keepBackup: swOn("swBackup"),
preserveDate: swOn("swDate"),
organize: swOn("swOrganize")
}
});
$("btnRedoSingle").disabled = false;
if(j.ok){
$("editDesc").value = j.desc;
$("editCat").value = j.category;
addLog({level:"ok",msg:`AI Re-evaluated ${os_basename(currentEditFile)} successfully.`,ts:new Date().toTimeString().slice(0,8)});
doSearch();
}
}
};
$("btnRevealSingle").onclick = async ()=>{
if(!currentEditFile) return;
api("/api/reveal", {path: currentEditFile});
};
function os_basename(path){
return path.replace(/\\/g, '/').replace(/.*\//, '');
}
/* ---------- Blind Accuracy Grader ---------- */
let auditList = [];
let auditIndex = 0;
let auditPassed = 0;
$("btnStartAudit").onclick = async ()=>{
const j = await api("/api/audit/start");
if(j.results && j.results.length > 0){
auditList = j.results;
auditIndex = 0;
auditPassed = 0;
$("auditStartCard").style.display = "none";
$("auditResultsCard").style.display = "none";
$("auditCard").style.display = "grid";
loadAuditPhoto();
} else {
alert("No photos in the database to audit. Run the pipeline first!");
}
};
function loadAuditPhoto(){
if(auditIndex >= auditList.length){
finishAudit(); return;
}
const rec = auditList[auditIndex];
$("auditImg").src = "/api/thumbnail?path=" + encodeURIComponent(rec.path);
$("auditDescText").textContent = rec.desc;
$("auditCatText").textContent = rec.category;
$("auditNotes").value = "";
$("auditCountLabel").textContent = `Photo ${auditIndex + 1} of ${auditList.length}`;
const pct = (auditIndex / auditList.length * 100).toFixed(0);
$("auditPercentLabel").textContent = `${pct}% Complete`;
$("auditProgressFill").style.width = pct + "%";
}
async function gradeAuditPhoto(passed){
const rec = auditList[auditIndex];
if(passed) auditPassed++;
await api("/api/audit/grade", {
path: rec.path,
grade: passed ? "pass" : "fail",
notes: $("auditNotes").value
});
auditIndex++;
loadAuditPhoto();
}
$("btnAuditPass").onclick = () => gradeAuditPhoto(true);
$("btnAuditFail").onclick = () => gradeAuditPhoto(false);
function finishAudit(){
$("auditCard").style.display = "none";
$("auditResultsCard").style.display = "block";
const score = auditList.length ? (auditPassed / auditList.length * 100).toFixed(1) : 0;
$("auditScoreVal").textContent = score + "%";
$("auditSummaryText").textContent = `${auditPassed} of ${auditList.length} photos graded correct.`;
$("auditTotalGraded").textContent = auditList.length;
$("auditPassedCount").textContent = auditPassed;
$("auditFailedCount").textContent = auditList.length - auditPassed;
}
$("btnRestartAudit").onclick = ()=>{
$("auditResultsCard").style.display = "none";
$("auditStartCard").style.display = "block";
};
/* ---------- SSE ---------- */
function connect(){
const es = new EventSource("/api/events");
es.onmessage = ev=>{
const m = JSON.parse(ev.data);
if(m.type==="log") addLog(m.data);
else if(m.type==="stats") applyStats(m.data);
else if(m.type==="state") setBadge(m.data.status);
else if(m.type==="preview"){
const img=$("preview");
img.src="/api/preview?"+m.data.seq;
img.style.display="block"; $("noSignal").style.display="none";
}
else if(m.type==="photo_start"){
$("pName").textContent = m.data.idx+"/"+m.data.total+" · "+m.data.name;
$("pDesc").textContent = "analyzing…"; $("pCat").style.display="none";
}
else if(m.type==="photo_done"){
$("pDesc").textContent = m.data.desc;
const c=$("pCat");
c.textContent=(m.data.route?("⌁"+m.data.route+" · "):"")+m.data.category+" · "+m.data.sec+"s";
c.style.display="inline-block";
// Update recent results strip
addToRecentStrip(m.data);
}
};
es.onerror = ()=>{ es.close(); setTimeout(connect, 2000); };
}
/* ---------- init ---------- */
(async ()=>{
const st = await api("/api/state");
CATS = st.categories||[];
renderCats(st.catCounts||{});
(st.log||[]).forEach(addLog);
if(st.folder) $("folder").value = st.folder;
applyStats(st);
setBadge(st.status||"idle");
if(st.total>0) scanned=true, setBadge(st.status||"idle");
const mj = await api("/api/models");
const sel=$("model");
const visionModels=(mj.models||[]).filter(m=>m.vision && m.name!=="qwen3.5:4b-mlx"); // mlx build: vision is broken
[["model","qwen3.5:9b"],["routerModel","glm-ocr:latest"],
["shotModel","qwen3.5:4b"],["photoModel","qwen3.5:4b"]].forEach(([id,pref])=>{
const s=$(id);
visionModels.forEach(m=>{
const o=document.createElement("option");
o.value=m.name; o.textContent=m.name+" ("+m.sizeGB+" GB)";
s.appendChild(o);
});
if([...s.options].some(o=>o.value===pref)) s.value=pref;
});
if(!sel.children.length){
addLog({level:"error",msg:"no vision-capable ollama models found — pull one, e.g. `ollama pull qwen3.5:4b`",ts:"--:--:--"});
}
populateCategoryDropdowns();
renderCategoryListEditor();
connect();
})();
</script>
</body>
</html>