Files
bookshelf/static/js/init.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

160 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* init.js
* Application bootstrap: full render, partial detail re-render, config and
* tree loading, batch-status polling, and the initial Promise.all boot call.
*
* render() is the single source of truth for full repaints — it replaces
* #app innerHTML, re-attaches editables, reinitialises Sortable instances,
* and (on desktop) schedules the boundary canvas setup.
*
* renderDetail() does a cheaper in-place update of the right panel only,
* used during plugin runs and field edits to avoid re-rendering the sidebar.
*
* Depends on: S, _plugins, _batchState, _batchWs (state.js);
* req, toast (api.js / helpers.js); isDesktop (helpers.js);
* vApp, vDetailBody, mainTitle, mainHeaderBtns, vBatchBtn
* (tree-render.js / detail-render.js);
* attachEditables, initSortables (editing.js);
* setupDetailCanvas (canvas-boundary.js)
* Provides: render(), renderDetail(), loadConfig(), connectBatchWs(),
* loadTree()
*/
/* exported render, renderDetail, connectBatchWs, connectAiLogWs, loadTree */
// ── Full re-render ────────────────────────────────────────────────────────────
function render() {
if (document.activeElement?.contentEditable === 'true') return;
const sy = window.scrollY;
document.getElementById('app').innerHTML = vApp();
window.scrollTo(0, sy);
attachEditables();
initSortables();
if (isDesktop()) requestAnimationFrame(setupDetailCanvas);
}
// ── Right-panel partial re-render ─────────────────────────────────────────────
// Used during plugin runs and field edits to avoid re-rendering the sidebar.
function renderDetail() {
const body = document.getElementById('main-body');
if (body) body.innerHTML = vDetailBody();
const t = document.getElementById('main-title');
if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML string
const hb = document.getElementById('main-hdr-btns');
if (hb) hb.innerHTML = mainHeaderBtns();
attachEditables(); // pick up the new editable span in the header
requestAnimationFrame(setupDetailCanvas);
}
// ── Data loading ──────────────────────────────────────────────────────────────
async function loadConfig() {
try {
const cfg = await req('GET', '/api/config');
window._grabPx = cfg.boundary_grab_px ?? 14;
window._confidenceThreshold = cfg.confidence_threshold ?? 0.8;
window._aiLogMax = cfg.ai_log_max_entries ?? 100;
_plugins = cfg.plugins || [];
} catch {
window._grabPx = 14;
window._confidenceThreshold = 0.8;
window._aiLogMax = 100;
}
}
function connectBatchWs() {
if (_batchWs) {
_batchWs.close();
_batchWs = null;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws/batch`);
_batchWs = ws;
ws.onmessage = async (ev) => {
const st = JSON.parse(ev.data);
_batchState = st;
const bb = document.getElementById('main-hdr-batch');
if (bb) bb.innerHTML = vBatchBtn();
if (!st.running) {
ws.close();
_batchWs = null;
toast(`Batch: ${st.done} done, ${st.errors} errors`);
await loadTree();
}
};
ws.onerror = () => {
_batchWs = null;
};
ws.onclose = () => {
_batchWs = null;
};
}
function connectAiLogWs() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws/ai-log`);
_aiLogWs = ws;
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'snapshot') {
_aiLog = msg.entries || [];
} else if (msg.type === 'update') {
const entry = msg.entry;
const idx = _aiLog.findIndex((e) => e.id === entry.id);
if (idx >= 0) {
_aiLog[idx] = entry;
} else {
_aiLog.push(entry);
const max = window._aiLogMax ?? 100;
if (_aiLog.length > max) _aiLog.splice(0, _aiLog.length - max);
}
} else if (msg.type === 'entity_update') {
const etype = msg.entity_type.slice(0, -1); // "books" → "book"
walkTree((n) => {
if (n.id === msg.entity_id) Object.assign(n, msg.data);
});
if (S.selected && S.selected.type === etype && S.selected.id === msg.entity_id) {
renderDetail();
} else {
render(); // update sidebar badges
}
return; // skip AI indicator update — not a log entry
}
// Update header AI indicator
const hdr = document.getElementById('hdr-ai-indicator');
if (hdr) {
const running = _aiLog.filter((e) => e.status === 'running').length;
hdr.innerHTML = running > 0 ? vAiIndicator(running) : '';
}
// Update root detail panel if shown
if (!S.selected) renderDetail();
};
ws.onerror = () => {};
ws.onclose = () => {
// Reconnect after a short delay
setTimeout(connectAiLogWs, 3000);
};
}
async function loadTree() {
S.tree = await req('GET', '/api/tree');
render();
}
// ── Init ──────────────────────────────────────────────────────────────────────
// Image popup: close when clicking the overlay background or the × button.
(function () {
const popup = document.getElementById('img-popup');
const closeBtn = document.getElementById('img-popup-close');
if (popup) {
popup.addEventListener('click', (e) => {
if (e.target === popup) popup.classList.remove('open');
});
}
if (closeBtn) {
closeBtn.addEventListener('click', () => popup && popup.classList.remove('open'));
}
})();
Promise.all([loadConfig(), loadTree()]).then(() => connectAiLogWs());