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:
@@ -13,17 +13,27 @@
|
||||
* vBook(), getBookStats(), vAiProgressBar()
|
||||
*/
|
||||
|
||||
/* exported pluginsByCategory, pluginsByTarget, isLoading, vPluginBtn, vBatchBtn, vAiIndicator,
|
||||
candidateSugRows, vApp, mainTitle, mainHeaderBtns, _STATUS_BADGE,
|
||||
getBookStats, vAiProgressBar, walkTree, removeNode, findNode */
|
||||
|
||||
// ── Plugin helpers ───────────────────────────────────────────────────────────
|
||||
function pluginsByCategory(cat) { return _plugins.filter(p => p.category === cat); }
|
||||
function pluginsByTarget(cat, target) { return _plugins.filter(p => p.category === cat && p.target === target); }
|
||||
function isLoading(pluginId, entityId) { return !!S._loading[`${pluginId}:${entityId}`]; }
|
||||
function pluginsByCategory(cat) {
|
||||
return _plugins.filter((p) => p.category === cat);
|
||||
}
|
||||
function pluginsByTarget(cat, target) {
|
||||
return _plugins.filter((p) => p.category === cat && p.target === target);
|
||||
}
|
||||
function isLoading(pluginId, entityId) {
|
||||
return !!S._loading[`${pluginId}:${entityId}`];
|
||||
}
|
||||
|
||||
function vPluginBtn(plugin, entityId, entityType, extraDisabled = false) {
|
||||
const loading = isLoading(plugin.id, entityId);
|
||||
const label = loading ? '⏳' : esc(plugin.name);
|
||||
return `<button class="btn btn-s" style="padding:2px 7px;font-size:.78rem;min-height:0"
|
||||
data-a="run-plugin" data-plugin="${plugin.id}" data-id="${entityId}"
|
||||
data-etype="${entityType}"${(loading||extraDisabled)?' disabled':''}
|
||||
data-etype="${entityType}"${loading || extraDisabled ? ' disabled' : ''}
|
||||
title="${esc(plugin.name)}">${label}</button>`;
|
||||
}
|
||||
|
||||
@@ -34,21 +44,36 @@ function vBatchBtn() {
|
||||
return `<button class="hbtn" data-a="batch-start" title="Analyze all unidentified books">🔄</button>`;
|
||||
}
|
||||
|
||||
// ── AI active indicator ───────────────────────────────────────────────────────
|
||||
function vAiIndicator(count) {
|
||||
return `<span class="ai-indicator" title="${count} AI request${count === 1 ? '' : 's'} running"><span class="ai-dot"></span>${count}</span>`;
|
||||
}
|
||||
|
||||
// ── Candidate suggestion rows ────────────────────────────────────────────────
|
||||
const SOURCE_LABELS = {
|
||||
vlm: 'VLM', ai: 'AI', openlibrary: 'OpenLib',
|
||||
rsl: 'РГБ', rusneb: 'НЭБ', alib: 'Alib', nlr: 'НЛР', shpl: 'ШПИЛ',
|
||||
vlm: 'VLM',
|
||||
ai: 'AI',
|
||||
openlibrary: 'OpenLib',
|
||||
rsl: 'РГБ',
|
||||
rusneb: 'НЭБ',
|
||||
alib: 'Alib',
|
||||
nlr: 'НЛР',
|
||||
shpl: 'ШПИЛ',
|
||||
};
|
||||
|
||||
function getSourceLabel(source) {
|
||||
if (SOURCE_LABELS[source]) return SOURCE_LABELS[source];
|
||||
const p = _plugins.find(pl => pl.id === source);
|
||||
const p = _plugins.find((pl) => pl.id === source);
|
||||
return p ? p.name : source;
|
||||
}
|
||||
|
||||
function parseCandidates(json) {
|
||||
if (!json) return [];
|
||||
try { return JSON.parse(json) || []; } catch { return []; }
|
||||
try {
|
||||
return JSON.parse(json) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function candidateSugRows(b, field, inputId) {
|
||||
@@ -61,7 +86,7 @@ function candidateSugRows(b, field, inputId) {
|
||||
const v = (c[field] || '').trim();
|
||||
if (!v) continue;
|
||||
const key = v.toLowerCase();
|
||||
if (!byVal.has(key)) byVal.set(key, {display: v, sources: []});
|
||||
if (!byVal.has(key)) byVal.set(key, { display: v, sources: [] });
|
||||
const entry = byVal.get(key);
|
||||
if (!entry.sources.includes(c.source)) entry.sources.push(c.source);
|
||||
}
|
||||
@@ -69,17 +94,17 @@ function candidateSugRows(b, field, inputId) {
|
||||
const aiVal = (b[`ai_${field}`] || '').trim();
|
||||
if (aiVal) {
|
||||
const key = aiVal.toLowerCase();
|
||||
if (!byVal.has(key)) byVal.set(key, {display: aiVal, sources: []});
|
||||
if (!byVal.has(key)) byVal.set(key, { display: aiVal, sources: [] });
|
||||
const entry = byVal.get(key);
|
||||
if (!entry.sources.includes('ai')) entry.sources.unshift('ai');
|
||||
}
|
||||
|
||||
return [...byVal.entries()]
|
||||
.filter(([k]) => k !== userVal.toLowerCase())
|
||||
.map(([, {display, sources}]) => {
|
||||
const badges = sources.map(s =>
|
||||
`<span class="src-badge src-${esc(s)}">${esc(getSourceLabel(s))}</span>`
|
||||
).join(' ');
|
||||
.map(([, { display, sources }]) => {
|
||||
const badges = sources
|
||||
.map((s) => `<span class="src-badge src-${esc(s)}">${esc(getSourceLabel(s))}</span>`)
|
||||
.join(' ');
|
||||
const val = esc(display);
|
||||
return `<div class="ai-sug">
|
||||
${badges} <em>${val}</em>
|
||||
@@ -90,34 +115,41 @@ function candidateSugRows(b, field, inputId) {
|
||||
data-a="dismiss-field" data-id="${b.id}" data-field="${field}"
|
||||
data-value="${val}" title="Dismiss">✗</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
// ── App shell ────────────────────────────────────────────────────────────────
|
||||
function vApp() {
|
||||
return `<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="hdr"><h1 data-a="deselect" style="cursor:pointer" title="Back to overview">📚 Bookshelf</h1></div>
|
||||
<div class="sidebar-body">
|
||||
${vTreeBody()}
|
||||
<button class="add-root" data-a="add-room">+ Add Room</button>
|
||||
</div>
|
||||
const running = (_aiLog || []).filter((e) => e.status === 'running').length;
|
||||
return `<div class="page-wrap">
|
||||
<div class="hdr">
|
||||
<h1 data-a="deselect" style="cursor:pointer;flex:1" title="Back to overview">📚 Bookshelf</h1>
|
||||
<div id="hdr-ai-indicator">${running > 0 ? vAiIndicator(running) : ''}</div>
|
||||
<div id="main-hdr-batch">${vBatchBtn()}</div>
|
||||
</div>
|
||||
<div class="main-panel">
|
||||
<div class="main-hdr" id="main-hdr">
|
||||
<h2 id="main-title">${mainTitle()}</h2>
|
||||
<div id="main-hdr-batch">${vBatchBtn()}</div>
|
||||
<div id="main-hdr-btns">${mainHeaderBtns()}</div>
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-body">
|
||||
${vTreeBody()}
|
||||
<button class="add-root" data-a="add-room">+ Add Room</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-panel">
|
||||
<div class="main-hdr" id="main-hdr">
|
||||
<h2 id="main-title">${mainTitle()}</h2>
|
||||
<div id="main-hdr-btns">${mainHeaderBtns()}</div>
|
||||
</div>
|
||||
<div class="main-body" id="main-body">${vDetailBody()}</div>
|
||||
</div>
|
||||
<div class="main-body" id="main-body">${vDetailBody()}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function mainTitle() {
|
||||
if (!S.selected) return '<span style="opacity:.7">Select a room, cabinet or shelf</span>';
|
||||
if (!S.selected) return '📚 Bookshelf';
|
||||
const n = findNode(S.selected.id);
|
||||
const {type, id} = S.selected;
|
||||
const { type, id } = S.selected;
|
||||
if (type === 'book') {
|
||||
return `<span>${esc(n?.title || 'Untitled book')}</span>`;
|
||||
}
|
||||
@@ -127,7 +159,7 @@ function mainTitle() {
|
||||
|
||||
function mainHeaderBtns() {
|
||||
if (!S.selected) return '';
|
||||
const {type, id} = S.selected;
|
||||
const { type, id } = S.selected;
|
||||
if (type === 'room') {
|
||||
return `<div style="display:flex;gap:2px">
|
||||
<button class="hbtn" data-a="add-cabinet" data-id="${id}" title="Add cabinet">+</button>
|
||||
@@ -171,18 +203,22 @@ function vRoom(r) {
|
||||
const exp = S.expanded.has(r.id);
|
||||
const sel = S.selected?.id === r.id;
|
||||
return `<div class="node" data-id="${r.id}" data-type="room">
|
||||
<div class="nrow nrow-room${sel?' sel':''}" data-a="select" data-type="room" data-id="${r.id}">
|
||||
<div class="nrow nrow-room${sel ? ' sel' : ''}" data-a="select" data-type="room" data-id="${r.id}">
|
||||
<span class="drag-h">⠿</span>
|
||||
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="room" data-id="${r.id}">▾</button>
|
||||
<button class="tbtn ${exp ? '' : 'col'}" data-a="toggle" data-type="room" data-id="${r.id}">▾</button>
|
||||
<span class="nname" data-type="room" data-id="${r.id}">🏠 ${esc(r.name)}</span>
|
||||
<div class="nacts">
|
||||
<button class="ibtn" data-a="add-cabinet" data-id="${r.id}" title="Add cabinet">+</button>
|
||||
<button class="ibtn" data-a="del-room" data-id="${r.id}" title="Delete">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="cabinets" data-parent="${r.id}">
|
||||
${
|
||||
exp
|
||||
? `<div class="nchildren"><div class="sortable-list" data-type="cabinets" data-parent="${r.id}">
|
||||
${r.cabinets.map(vCabinet).join('')}
|
||||
</div></div>` : ''}
|
||||
</div></div>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -190,9 +226,9 @@ function vCabinet(c) {
|
||||
const exp = S.expanded.has(c.id);
|
||||
const sel = S.selected?.id === c.id;
|
||||
return `<div class="node" data-id="${c.id}" data-type="cabinet">
|
||||
<div class="nrow nrow-cabinet${sel?' sel':''}" data-a="select" data-type="cabinet" data-id="${c.id}">
|
||||
<div class="nrow nrow-cabinet${sel ? ' sel' : ''}" data-a="select" data-type="cabinet" data-id="${c.id}">
|
||||
<span class="drag-h">⠿</span>
|
||||
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="cabinet" data-id="${c.id}">▾</button>
|
||||
<button class="tbtn ${exp ? '' : 'col'}" data-a="toggle" data-type="cabinet" data-id="${c.id}">▾</button>
|
||||
${c.photo_filename ? `<img src="/images/${c.photo_filename}" style="width:26px;height:32px;object-fit:cover;border-radius:2px;flex-shrink:0" alt="">` : ''}
|
||||
<span class="nname" data-type="cabinet" data-id="${c.id}">📚 ${esc(c.name)}</span>
|
||||
<div class="nacts">
|
||||
@@ -202,9 +238,13 @@ function vCabinet(c) {
|
||||
${!isDesktop() ? `<button class="ibtn" data-a="del-cabinet" data-id="${c.id}" title="Delete">🗑</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="shelves" data-parent="${c.id}">
|
||||
${
|
||||
exp
|
||||
? `<div class="nchildren"><div class="sortable-list" data-type="shelves" data-parent="${c.id}">
|
||||
${c.shelves.map(vShelf).join('')}
|
||||
</div></div>` : ''}
|
||||
</div></div>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -212,9 +252,9 @@ function vShelf(s) {
|
||||
const exp = S.expanded.has(s.id);
|
||||
const sel = S.selected?.id === s.id;
|
||||
return `<div class="node" data-id="${s.id}" data-type="shelf">
|
||||
<div class="nrow nrow-shelf${sel?' sel':''}" data-a="select" data-type="shelf" data-id="${s.id}">
|
||||
<div class="nrow nrow-shelf${sel ? ' sel' : ''}" data-a="select" data-type="shelf" data-id="${s.id}">
|
||||
<span class="drag-h">⠿</span>
|
||||
<button class="tbtn ${exp?'':'col'}" data-a="toggle" data-type="shelf" data-id="${s.id}">▾</button>
|
||||
<button class="tbtn ${exp ? '' : 'col'}" data-a="toggle" data-type="shelf" data-id="${s.id}">▾</button>
|
||||
<span class="nname" data-type="shelf" data-id="${s.id}">${esc(s.name)}</span>
|
||||
<div class="nacts">
|
||||
${!isDesktop() ? `<button class="ibtn" data-a="photo" data-type="shelf" data-id="${s.id}" title="Photo">📷</button>` : ''}
|
||||
@@ -223,14 +263,18 @@ function vShelf(s) {
|
||||
${!isDesktop() ? `<button class="ibtn" data-a="del-shelf" data-id="${s.id}" title="Delete">🗑</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${exp ? `<div class="nchildren"><div class="sortable-list" data-type="books" data-parent="${s.id}">
|
||||
${
|
||||
exp
|
||||
? `<div class="nchildren"><div class="sortable-list" data-type="books" data-parent="${s.id}">
|
||||
${s.books.map(vBook).join('')}
|
||||
</div></div>` : ''}
|
||||
</div></div>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const _STATUS_BADGE = {
|
||||
unidentified: ['s-unid', '?'],
|
||||
unidentified: ['s-unid', '?'],
|
||||
ai_identified: ['s-aiid', 'AI'],
|
||||
user_approved: ['s-appr', '✓'],
|
||||
};
|
||||
@@ -240,7 +284,7 @@ function vBook(b) {
|
||||
const sub = [b.author, b.year].filter(Boolean).join(' · ');
|
||||
const sel = S.selected?.id === b.id;
|
||||
return `<div class="node" data-id="${b.id}" data-type="book">
|
||||
<div class="nrow nrow-book${sel?' sel':''}" data-a="select" data-type="book" data-id="${b.id}">
|
||||
<div class="nrow nrow-book${sel ? ' sel' : ''}" data-a="select" data-type="book" data-id="${b.id}">
|
||||
<span class="drag-h">⠿</span>
|
||||
<span class="sbadge ${sc}" title="${b.identification_status ?? 'unidentified'}">${sl}</span>
|
||||
${b.image_filename ? `<img src="/images/${b.image_filename}" class="bthumb" alt="">` : `<div class="bthumb-ph">📖</div>`}
|
||||
@@ -248,10 +292,14 @@ function vBook(b) {
|
||||
<div class="bttl">${esc(b.title || '—')}</div>
|
||||
${sub ? `<div class="bsub">${esc(sub)}</div>` : ''}
|
||||
</div>
|
||||
${!isDesktop() ? `<div class="nacts">
|
||||
${
|
||||
!isDesktop()
|
||||
? `<div class="nacts">
|
||||
<button class="ibtn" data-a="photo" data-type="book" data-id="${b.id}" title="Upload photo">📷</button>
|
||||
<button class="ibtn" data-a="del-book" data-id="${b.id}" title="Delete">🗑</button>
|
||||
</div>` : ''}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -260,26 +308,29 @@ function vBook(b) {
|
||||
function getBookStats(node, type) {
|
||||
const books = [];
|
||||
function collect(n, t) {
|
||||
if (t==='book') { books.push(n); return; }
|
||||
if (t==='room') (n.cabinets||[]).forEach(c => collect(c,'cabinet'));
|
||||
if (t==='cabinet') (n.shelves||[]).forEach(s => collect(s,'shelf'));
|
||||
if (t==='shelf') (n.books||[]).forEach(b => collect(b,'book'));
|
||||
if (t === 'book') {
|
||||
books.push(n);
|
||||
return;
|
||||
}
|
||||
if (t === 'room') (n.cabinets || []).forEach((c) => collect(c, 'cabinet'));
|
||||
if (t === 'cabinet') (n.shelves || []).forEach((s) => collect(s, 'shelf'));
|
||||
if (t === 'shelf') (n.books || []).forEach((b) => collect(b, 'book'));
|
||||
}
|
||||
collect(node, type);
|
||||
return {
|
||||
total: books.length,
|
||||
approved: books.filter(b=>b.identification_status==='user_approved').length,
|
||||
ai: books.filter(b=>b.identification_status==='ai_identified').length,
|
||||
unidentified: books.filter(b=>b.identification_status==='unidentified').length,
|
||||
approved: books.filter((b) => b.identification_status === 'user_approved').length,
|
||||
ai: books.filter((b) => b.identification_status === 'ai_identified').length,
|
||||
unidentified: books.filter((b) => b.identification_status === 'unidentified').length,
|
||||
};
|
||||
}
|
||||
|
||||
function vAiProgressBar(stats) {
|
||||
const {total, approved, ai, unidentified} = stats;
|
||||
const { total, approved, ai, unidentified } = stats;
|
||||
if (!total || approved === total) return '';
|
||||
const pA = (approved/total*100).toFixed(1);
|
||||
const pI = (ai/total*100).toFixed(1);
|
||||
const pU = (unidentified/total*100).toFixed(1);
|
||||
const pA = ((approved / total) * 100).toFixed(1);
|
||||
const pI = ((ai / total) * 100).toFixed(1);
|
||||
const pU = ((unidentified / total) * 100).toFixed(1);
|
||||
return `<div style="margin-bottom:10px;background:white;border-radius:8px;padding:10px;box-shadow:0 1px 3px rgba(0,0,0,.07)">
|
||||
<div style="display:flex;gap:8px;font-size:.7rem;margin-bottom:5px">
|
||||
<span style="color:#15803d">✓ ${approved} approved</span><span style="color:#94a3b8">·</span>
|
||||
@@ -297,10 +348,13 @@ function vAiProgressBar(stats) {
|
||||
// ── Tree helpers ─────────────────────────────────────────────────────────────
|
||||
function walkTree(fn) {
|
||||
if (!S.tree) return;
|
||||
for (const r of S.tree) { fn(r,'room');
|
||||
for (const c of r.cabinets) { fn(c,'cabinet');
|
||||
for (const s of c.shelves) { fn(s,'shelf');
|
||||
for (const b of s.books) fn(b,'book');
|
||||
for (const r of S.tree) {
|
||||
fn(r, 'room');
|
||||
for (const c of r.cabinets) {
|
||||
fn(c, 'cabinet');
|
||||
for (const s of c.shelves) {
|
||||
fn(s, 'shelf');
|
||||
for (const b of s.books) fn(b, 'book');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,14 +362,20 @@ function walkTree(fn) {
|
||||
|
||||
function removeNode(type, id) {
|
||||
if (!S.tree) return;
|
||||
if (type==='room') S.tree = S.tree.filter(r=>r.id!==id);
|
||||
if (type==='cabinet') S.tree.forEach(r=>r.cabinets=r.cabinets.filter(c=>c.id!==id));
|
||||
if (type==='shelf') S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves=c.shelves.filter(s=>s.id!==id)));
|
||||
if (type==='book') S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>s.books=s.books.filter(b=>b.id!==id))));
|
||||
if (type === 'room') S.tree = S.tree.filter((r) => r.id !== id);
|
||||
if (type === 'cabinet') S.tree.forEach((r) => (r.cabinets = r.cabinets.filter((c) => c.id !== id)));
|
||||
if (type === 'shelf')
|
||||
S.tree.forEach((r) => r.cabinets.forEach((c) => (c.shelves = c.shelves.filter((s) => s.id !== id))));
|
||||
if (type === 'book')
|
||||
S.tree.forEach((r) =>
|
||||
r.cabinets.forEach((c) => c.shelves.forEach((s) => (s.books = s.books.filter((b) => b.id !== id)))),
|
||||
);
|
||||
}
|
||||
|
||||
function findNode(id) {
|
||||
let found = null;
|
||||
walkTree(n => { if (n.id===id) found=n; });
|
||||
walkTree((n) => {
|
||||
if (n.id === id) found = n;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user