Files
bookshelf/static/js/detail-render.js
Petr Polezhaev b94f222c96 Add per-request AI logging, DB batch queue, WS entity updates, and UI polish
- log_thread.py: thread-safe ContextVar bridge so executor threads can log
  individual LLM calls and archive searches back to the event loop
- ai_log.py: init_thread_logging(), notify_entity_update(); WS now pushes
  entity_update messages when book data changes after any plugin or batch run
- batch.py: replace batch_pending.json with batch_queue SQLite table;
  run_batch_consumer() reads queue dynamically so new books can be added
  while batch is running; add_to_queue() deduplicates
- migrate.py: fix _migrate_v1 (clear-on-startup bug); add _migrate_v2 for
  batch_queue table
- _client.py / archive.py / identification.py: wrap each LLM API call and
  archive search with log_thread start/finish entries
- api.py: POST /api/batch returns {already_running, added}; notify_entity_update
  after identify pipeline
- models.default.yaml: strengthen ai_identify confidence-scoring instructions;
  warn against placeholder data
- detail-render.js: book log entries show clickable ID + spine thumbnail;
  book spine/title images open full-screen popup
- events.js: batch-start handles already_running+added; open-img-popup action
- init.js: entity_update WS handler; image popup close listeners
- overlays.css / index.html: full-screen image popup overlay
- eslint.config.js: add new globals; fix no-redeclare/no-unused-vars for
  multi-file global architecture; all lint errors resolved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 12:10:54 +03:00

264 lines
14 KiB
JavaScript

/*
* detail-render.js
* HTML-string generators for the right-side detail panel (desktop) and
* the selected-entity view (mobile). Covers all four entity types.
*
* Depends on: S, _bnd (state.js); esc (helpers.js);
* pluginsByCategory, pluginsByTarget, vPluginBtn, getBookStats,
* vAiProgressBar, candidateSugRows, _STATUS_BADGE (tree-render.js);
* parseBounds, parseBndPluginResults (canvas-boundary.js)
* Provides: vDetailBody(), vRoomDetail(), vCabinetDetail(),
* vShelfDetail(), vBookDetail()
*/
/* exported vDetailBody, aiBlocksShown */
// ── Room detail ──────────────────────────────────────────────────────────────
function vRoomDetail(r) {
const stats = getBookStats(r, 'room');
const totalBooks = stats.total;
return `<div>
${vAiProgressBar(stats)}
<p style="font-size:.72rem;color:#64748b">${r.cabinets.length} cabinet${r.cabinets.length !== 1 ? 's' : ''} · ${totalBooks} book${totalBooks !== 1 ? 's' : ''}</p>
</div>`;
}
// ── Root detail (no selection) ────────────────────────────────────────────────
function vAiLogEntry(entry) {
const ts = new Date(entry.ts * 1000).toLocaleTimeString();
const statusColor = entry.status === 'ok' ? '#15803d' : entry.status === 'error' ? '#dc2626' : '#b45309';
const statusLabel = entry.status === 'running' ? '⏳' : entry.status === 'ok' ? '✓' : '✗';
const dur = entry.duration_ms > 0 ? ` ${entry.duration_ms}ms` : '';
const model = entry.model
? `<span style="font-size:.68rem;color:#94a3b8;margin-left:6px">${esc(entry.model)}</span>`
: '';
const isBook = entry.entity_type === 'books';
const entityLabel = isBook
? `<button data-a="select" data-type="book" data-id="${esc(entry.entity_id)}"
style="background:none;border:none;padding:0;cursor:pointer;color:#2563eb;font-size:.75rem;text-decoration:underline"
>${esc(entry.entity_id.slice(0, 8))}</button>`
: `<span>${esc(entry.entity_id.slice(0, 8))}</span>`;
const thumb = isBook
? `<img src="/api/books/${esc(entry.entity_id)}/spine" alt=""
style="height:30px;width:auto;vertical-align:middle;border-radius:2px;margin-left:2px"
onerror="this.style.display='none'">`
: '';
return `<details class="ai-log-entry">
<summary style="display:flex;align-items:center;gap:6px;cursor:pointer;list-style:none;padding:6px 0">
<span style="color:${statusColor};font-weight:600;font-size:.78rem;width:1.2rem;text-align:center">${statusLabel}</span>
<span style="font-size:.75rem;color:#475569;flex:1;display:flex;align-items:center;gap:4px;flex-wrap:wrap">
${esc(entry.plugin_id)} · ${entityLabel}${thumb}
</span>
<span style="font-size:.68rem;color:#94a3b8;white-space:nowrap">${ts}${dur}</span>
</summary>
<div style="padding:6px 0 6px 1.8rem;font-size:.75rem;color:#475569">
${model}
${entry.request ? `<div style="margin-top:4px;color:#64748b"><strong>Request:</strong> ${esc(entry.request)}</div>` : ''}
${entry.response ? `<div style="margin-top:4px;color:#64748b"><strong>Response:</strong> ${esc(entry.response)}</div>` : ''}
</div>
</details>`;
}
function vRootDetail() {
const log = (_aiLog || []).slice().reverse(); // newest first
return `<div style="padding:0">
<div style="font-size:.72rem;font-weight:600;color:#64748b;margin-bottom:8px;text-transform:uppercase;letter-spacing:.04em">AI Request Log</div>
${
log.length === 0
? `<div style="font-size:.78rem;color:#94a3b8">No AI requests yet. Use Identify or run a plugin on a book.</div>`
: log.map(vAiLogEntry).join('<hr style="border:none;border-top:1px solid #f1f5f9;margin:0">')
}
</div>`;
}
// ── Detail body (right panel) ────────────────────────────────────────────────
function vDetailBody() {
if (!S.selected) return `<div class="det-root">${vRootDetail()}</div>`;
const { type, id } = S.selected;
const node = findNode(id);
if (!node) return '<div class="det-empty">Not found</div>';
if (type === 'room') return vRoomDetail(node);
if (type === 'cabinet') return vCabinetDetail(node);
if (type === 'shelf') return vShelfDetail(node);
if (type === 'book') return vBookDetail(node);
return '';
}
// ── Cabinet detail ───────────────────────────────────────────────────────────
function vCabinetDetail(cab) {
const bounds = parseBounds(cab.shelf_boundaries);
const hasPhoto = !!cab.photo_filename;
const stats = getBookStats(cab, 'cabinet');
const bndPlugins = pluginsByTarget('boundary_detector', 'shelves');
const pluginResults = parseBndPluginResults(cab.ai_shelf_boundaries);
const pluginIds = Object.keys(pluginResults);
const sel = _bnd?.nodeId === cab.id ? _bnd.selectedPlugin : cab.shelves.length > 0 ? null : (pluginIds[0] ?? null);
const selOpts = [
`<option value="">None</option>`,
...pluginIds.map((pid) => `<option value="${pid}"${sel === pid ? ' selected' : ''}>${pid}</option>`),
...(pluginIds.length > 1 ? [`<option value="all"${sel === 'all' ? ' selected' : ''}>All</option>`] : []),
].join('');
return `<div>
${vAiProgressBar(stats)}
${
hasPhoto
? `<div class="img-wrap" id="bnd-wrap" data-type="cabinet" data-id="${cab.id}">
<img id="bnd-img" src="/images/${cab.photo_filename}?t=${Date.now()}" alt="">
<canvas id="bnd-canvas"></canvas>
</div>`
: `<div class="empty"><div class="ei">📷</div><div>Upload a cabinet photo (📷 in header) to get started</div></div>`
}
${hasPhoto ? `<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>` : ''}
${
hasPhoto
? `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${cab.shelves.length} shelf${cab.shelves.length !== 1 ? 's' : ''} · ${bounds.length} boundar${bounds.length !== 1 ? 'ies' : 'y'}</span>` : ''}
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto">
${bndPlugins.map((p) => vPluginBtn(p, cab.id, 'cabinets')).join('')}
<select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select>
</div>
</div>`
: ''
}
</div>`;
}
// ── Shelf detail ─────────────────────────────────────────────────────────────
function vShelfDetail(shelf) {
const bounds = parseBounds(shelf.book_boundaries);
const stats = getBookStats(shelf, 'shelf');
const bndPlugins = pluginsByTarget('boundary_detector', 'books');
const pluginResults = parseBndPluginResults(shelf.ai_book_boundaries);
const pluginIds = Object.keys(pluginResults);
const sel = _bnd?.nodeId === shelf.id ? _bnd.selectedPlugin : shelf.books.length > 0 ? null : (pluginIds[0] ?? null);
const selOpts = [
`<option value="">None</option>`,
...pluginIds.map((pid) => `<option value="${pid}"${sel === pid ? ' selected' : ''}>${pid}</option>`),
...(pluginIds.length > 1 ? [`<option value="all"${sel === 'all' ? ' selected' : ''}>All</option>`] : []),
].join('');
return `<div>
${vAiProgressBar(stats)}
<div class="img-wrap" id="bnd-wrap" data-type="shelf" data-id="${shelf.id}">
<img id="bnd-img" src="/api/shelves/${shelf.id}/image?t=${Date.now()}" alt=""
onerror="this.parentElement.innerHTML='<div class=empty style=padding:40px><div class=ei>📷</div><div>No image available — upload a cabinet photo first</div></div>'">
<canvas id="bnd-canvas"></canvas>
</div>
<p style="font-size:.72rem;color:#94a3b8;margin-top:8px">Drag lines · Ctrl+Alt+Click to add · Snap to AI guides</p>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px">
${bounds.length ? `<span style="font-size:.72rem;color:#64748b">${shelf.books.length} book${shelf.books.length !== 1 ? 's' : ''} · ${bounds.length} boundary${bounds.length !== 1 ? 'ies' : ''}</span>` : ''}
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto">
${bndPlugins.map((p) => vPluginBtn(p, shelf.id, 'shelves')).join('')}
<select data-a="select-bnd-plugin" style="font-size:.72rem;padding:2px 5px;border:1px solid #e2e8f0;border-radius:4px;background:white;color:#475569">${selOpts}</select>
</div>
</div>
</div>`;
}
// ── AI blocks helpers ─────────────────────────────────────────────────────────
function parseAiBlocks(json) {
if (!json) return [];
try {
return JSON.parse(json) || [];
} catch {
return [];
}
}
function aiBlocksShown(b) {
if (b.id in _aiBlocksVisible) return _aiBlocksVisible[b.id];
return b.identification_status !== 'user_approved';
}
function vAiBlock(block, bookId) {
const score = typeof block.score === 'number' ? (block.score * 100).toFixed(0) + '%' : '';
const sources = (block.sources || []).join(', ');
const fields = [
['title', block.title],
['author', block.author],
['year', block.year],
['isbn', block.isbn],
['publisher', block.publisher],
].filter(([, v]) => v && v.trim());
const rows = fields
.map(
([k, v]) =>
`<div style="font-size:.78rem;color:#475569"><span style="color:#94a3b8;min-width:4.5rem;display:inline-block">${k}</span> ${esc(v)}</div>`,
)
.join('');
const blockData = esc(JSON.stringify(block));
return `<div class="ai-block" data-a="apply-ai-block" data-id="${bookId}" data-block="${blockData}"
style="cursor:pointer;border:1px solid #e2e8f0;border-radius:6px;padding:8px 10px;margin-bottom:6px;background:#f8fafc">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap">
${score ? `<span style="background:#dbeafe;color:#1e40af;border-radius:4px;padding:1px 6px;font-size:.72rem;font-weight:600">${score}</span>` : ''}
${sources ? `<span style="font-size:.7rem;color:#64748b">${esc(sources)}</span>` : ''}
</div>
${rows}
</div>`;
}
// ── Book detail ──────────────────────────────────────────────────────────────
function vBookDetail(b) {
const [sc, sl] = _STATUS_BADGE[b.identification_status] ?? _STATUS_BADGE.unidentified;
const isLoading_ = isLoading('identify', b.id);
const blocks = parseAiBlocks(b.ai_blocks);
const shown = aiBlocksShown(b);
const spineUrl = `/api/books/${b.id}/spine?t=${Date.now()}`;
const titleUrl = b.image_filename ? `/images/${b.image_filename}` : '';
return `<div class="book-panel">
<div>
<div class="book-img-label">Spine</div>
<div class="book-img-box">
<img src="${spineUrl}" alt="" style="cursor:pointer"
data-a="open-img-popup" data-src="${spineUrl}"
onerror="this.style.display='none'">
</div>
${
titleUrl
? `<div class="book-img-label">Title page</div>
<div class="book-img-box">
<img src="${titleUrl}" alt="" style="cursor:pointer"
data-a="open-img-popup" data-src="${titleUrl}">
</div>`
: ''
}
</div>
<div>
<div class="card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<span class="sbadge ${sc}" style="font-size:.7rem;padding:2px 7px">${sl}</span>
<span style="font-size:.72rem;color:#64748b">${b.identification_status ?? 'unidentified'}</span>
${b.analyzed_at ? `<span style="font-size:.68rem;color:#94a3b8">Identified ${b.analyzed_at.slice(0, 10)}</span>` : ''}
<button class="btn btn-s" style="padding:2px 10px;font-size:.78rem;min-height:0;margin-left:auto"
data-a="identify-book" data-id="${b.id}"${isLoading_ ? ' disabled' : ''}>
${isLoading_ ? '⏳ Identifying…' : '🔍 Identify'}
</button>
</div>
${
blocks.length
? `<div style="margin-bottom:8px">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
<span style="font-size:.72rem;font-weight:600;color:#475569">AI Results (${blocks.length})</span>
<button class="btn btn-s" style="padding:1px 7px;font-size:.72rem;min-height:0;margin-left:auto"
data-a="toggle-ai-blocks" data-id="${b.id}">${shown ? 'Hide' : 'Show'}</button>
</div>
${shown ? blocks.map((bl) => vAiBlock(bl, b.id)).join('') : ''}
</div>`
: ''
}
<div class="fgroup"><label class="flabel">Title</label>
<input class="finput" id="d-title" value="${esc(b.title ?? '')}"></div>
<div class="fgroup"><label class="flabel">Author</label>
<input class="finput" id="d-author" value="${esc(b.author ?? '')}"></div>
<div class="fgroup"><label class="flabel">Year</label>
<input class="finput" id="d-year" value="${esc(b.year ?? '')}" inputmode="numeric"></div>
<div class="fgroup"><label class="flabel">ISBN</label>
<input class="finput" id="d-isbn" value="${esc(b.isbn ?? '')}" inputmode="numeric"></div>
<div class="fgroup"><label class="flabel">Publisher</label>
<input class="finput" id="d-pub" value="${esc(b.publisher ?? '')}"></div>
<div class="fgroup"><label class="flabel">Notes</label>
<textarea class="finput" id="d-notes">${esc(b.notes ?? '')}</textarea></div>
</div>
</div>
</div>`;
}