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>
This commit is contained in:
@@ -11,26 +11,76 @@
|
||||
* 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>
|
||||
<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-empty">← Select a room, cabinet or shelf from the tree</div>';
|
||||
const {type, id} = S.selected;
|
||||
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 === 'room') return vRoomDetail(node);
|
||||
if (type === 'cabinet') return vCabinetDetail(node);
|
||||
if (type === 'shelf') return vShelfDetail(node);
|
||||
if (type === 'book') return vBookDetail(node);
|
||||
if (type === 'shelf') return vShelfDetail(node);
|
||||
if (type === 'book') return vBookDetail(node);
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -42,29 +92,34 @@ function vCabinetDetail(cab) {
|
||||
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 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>`] : []),
|
||||
...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}">
|
||||
${
|
||||
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>`}
|
||||
: `<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>` : ''}
|
||||
${
|
||||
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('')}
|
||||
${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>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -75,12 +130,11 @@ function vShelfDetail(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 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>`] : []),
|
||||
...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)}
|
||||
@@ -91,72 +145,115 @@ function vShelfDetail(shelf) {
|
||||
</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>` : ''}
|
||||
${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('')}
|
||||
${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 recognizers = pluginsByCategory('text_recognizer');
|
||||
const identifiers = pluginsByCategory('book_identifier');
|
||||
const searchers = pluginsByCategory('archive_searcher');
|
||||
const hasRawText = !!(b.raw_text || '').trim();
|
||||
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="/api/books/${b.id}/spine?t=${Date.now()}" alt=""
|
||||
onerror="this.style.display='none'"></div>
|
||||
${b.image_filename
|
||||
? `<div class="book-img-label">Title page</div>
|
||||
<div class="book-img-box"><img src="/images/${b.image_filename}" alt=""></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">
|
||||
<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;margin-left:auto">Identified ${b.analyzed_at.slice(0,10)}</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>
|
||||
<div class="fgroup">
|
||||
<label class="flabel" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
Recognition
|
||||
${recognizers.map(p => vPluginBtn(p, b.id, 'books')).join('')}
|
||||
${identifiers.map(p => vPluginBtn(p, b.id, 'books', !hasRawText)).join('')}
|
||||
</label>
|
||||
<textarea class="finput" id="d-raw-text" style="height:72px;font-family:monospace;font-size:.8rem" readonly>${esc(b.raw_text ?? '')}</textarea>
|
||||
</div>
|
||||
${searchers.length ? `<div class="fgroup">
|
||||
<label class="flabel" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
Archives
|
||||
${searchers.map(p => vPluginBtn(p, b.id, 'books')).join('')}
|
||||
</label>
|
||||
</div>` : ''}
|
||||
<div class="fgroup">
|
||||
${candidateSugRows(b, 'title', 'd-title')}
|
||||
<label class="flabel">Title</label>
|
||||
${
|
||||
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">
|
||||
${candidateSugRows(b, 'author', 'd-author')}
|
||||
<label class="flabel">Author</label>
|
||||
<div class="fgroup"><label class="flabel">Author</label>
|
||||
<input class="finput" id="d-author" value="${esc(b.author ?? '')}"></div>
|
||||
<div class="fgroup">
|
||||
${candidateSugRows(b, 'year', 'd-year')}
|
||||
<label class="flabel">Year</label>
|
||||
<div class="fgroup"><label class="flabel">Year</label>
|
||||
<input class="finput" id="d-year" value="${esc(b.year ?? '')}" inputmode="numeric"></div>
|
||||
<div class="fgroup">
|
||||
${candidateSugRows(b, 'isbn', 'd-isbn')}
|
||||
<label class="flabel">ISBN</label>
|
||||
<div class="fgroup"><label class="flabel">ISBN</label>
|
||||
<input class="finput" id="d-isbn" value="${esc(b.isbn ?? '')}" inputmode="numeric"></div>
|
||||
<div class="fgroup">
|
||||
${candidateSugRows(b, 'publisher', 'd-pub')}
|
||||
<label class="flabel">Publisher</label>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user