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:
2026-03-11 12:10:54 +03:00
parent fd32be729f
commit b94f222c96
41 changed files with 2566 additions and 586 deletions

View File

@@ -16,10 +16,16 @@
* setupDetailCanvas(), drawBnd(), clearSegHover()
*/
/* exported parseBounds, parseBndPluginResults, setupDetailCanvas, drawBnd */
// ── Boundary parsing helpers ─────────────────────────────────────────────────
function parseBounds(json) {
if (!json) return [];
try { return JSON.parse(json) || []; } catch { return []; }
try {
return JSON.parse(json) || [];
} catch {
return [];
}
}
function parseBndPluginResults(json) {
@@ -28,39 +34,57 @@ function parseBndPluginResults(json) {
const v = JSON.parse(json);
if (Array.isArray(v) || !v || typeof v !== 'object') return {};
return v;
} catch { return {}; }
} catch {
return {};
}
}
const SEG_FILLS = ['rgba(59,130,246,.14)','rgba(16,185,129,.14)','rgba(245,158,11,.14)','rgba(239,68,68,.14)','rgba(168,85,247,.14)'];
const SEG_STROKES = ['#3b82f6','#10b981','#f59e0b','#ef4444','#a855f7'];
const SEG_FILLS = [
'rgba(59,130,246,.14)',
'rgba(16,185,129,.14)',
'rgba(245,158,11,.14)',
'rgba(239,68,68,.14)',
'rgba(168,85,247,.14)',
];
const SEG_STROKES = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#a855f7'];
// ── Canvas setup ─────────────────────────────────────────────────────────────
function setupDetailCanvas() {
const wrap = document.getElementById('bnd-wrap');
const img = document.getElementById('bnd-img');
const wrap = document.getElementById('bnd-wrap');
const img = document.getElementById('bnd-img');
const canvas = document.getElementById('bnd-canvas');
if (!wrap || !img || !canvas || !S.selected) return;
const {type, id} = S.selected;
const { type, id } = S.selected;
const node = findNode(id);
if (!node || (type !== 'cabinet' && type !== 'shelf')) return;
const axis = type === 'cabinet' ? 'y' : 'x';
const boundaries = parseBounds(type === 'cabinet' ? node.shelf_boundaries : node.book_boundaries);
const axis = type === 'cabinet' ? 'y' : 'x';
const boundaries = parseBounds(type === 'cabinet' ? node.shelf_boundaries : node.book_boundaries);
const pluginResults = parseBndPluginResults(type === 'cabinet' ? node.ai_shelf_boundaries : node.ai_book_boundaries);
const pluginIds = Object.keys(pluginResults);
const segments = type === 'cabinet'
? node.shelves.map((s,i) => ({id:s.id, label:s.name||`Shelf ${i+1}`}))
: node.books.map((b,i) => ({id:b.id, label:b.title||`Book ${i+1}`}));
const pluginIds = Object.keys(pluginResults);
const segments =
type === 'cabinet'
? node.shelves.map((s, i) => ({ id: s.id, label: s.name || `Shelf ${i + 1}` }))
: node.books.map((b, i) => ({ id: b.id, label: b.title || `Book ${i + 1}` }));
const hasChildren = type === 'cabinet' ? node.shelves.length > 0 : node.books.length > 0;
const prevSel = (_bnd?.nodeId === id) ? _bnd.selectedPlugin
: (hasChildren ? null : pluginIds[0] ?? null);
const hasChildren = type === 'cabinet' ? node.shelves.length > 0 : node.books.length > 0;
const prevSel = _bnd?.nodeId === id ? _bnd.selectedPlugin : hasChildren ? null : (pluginIds[0] ?? null);
_bnd = {wrap, img, canvas, axis, boundaries:[...boundaries],
pluginResults, selectedPlugin: prevSel, segments, nodeId:id, nodeType:type};
_bnd = {
wrap,
img,
canvas,
axis,
boundaries: [...boundaries],
pluginResults,
selectedPlugin: prevSel,
segments,
nodeId: id,
nodeType: type,
};
function sizeAndDraw() {
canvas.width = img.offsetWidth;
canvas.width = img.offsetWidth;
canvas.height = img.offsetHeight;
drawBnd();
}
@@ -69,17 +93,18 @@ function setupDetailCanvas() {
canvas.addEventListener('pointerdown', bndPointerDown);
canvas.addEventListener('pointermove', bndPointerMove);
canvas.addEventListener('pointerup', bndPointerUp);
canvas.addEventListener('click', bndClick);
canvas.addEventListener('mousemove', bndHover);
canvas.addEventListener('mouseleave', () => clearSegHover());
canvas.addEventListener('pointerup', bndPointerUp);
canvas.addEventListener('click', bndClick);
canvas.addEventListener('mousemove', bndHover);
canvas.addEventListener('mouseleave', () => clearSegHover());
}
// ── Draw ─────────────────────────────────────────────────────────────────────
function drawBnd(dragIdx = -1, dragVal = null) {
if (!_bnd || S._cropMode) return;
const {canvas, axis, boundaries, segments} = _bnd;
const W = canvas.width, H = canvas.height;
const { canvas, axis, boundaries, segments } = _bnd;
const W = canvas.width,
H = canvas.height;
if (!W || !H) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
@@ -94,11 +119,12 @@ function drawBnd(dragIdx = -1, dragVal = null) {
// Draw segments
for (let i = 0; i < full.length - 1; i++) {
const a = full[i], b = full[i + 1];
const a = full[i],
b = full[i + 1];
const ci = i % SEG_FILLS.length;
ctx.fillStyle = SEG_FILLS[ci];
if (axis === 'y') ctx.fillRect(0, a*H, W, (b-a)*H);
else ctx.fillRect(a*W, 0, (b-a)*W, H);
if (axis === 'y') ctx.fillRect(0, a * H, W, (b - a) * H);
else ctx.fillRect(a * W, 0, (b - a) * W, H);
// Label
const seg = segments[i];
if (seg) {
@@ -106,10 +132,13 @@ function drawBnd(dragIdx = -1, dragVal = null) {
ctx.fillStyle = 'rgba(0,0,0,.5)';
const lbl = seg.label.slice(0, 24);
if (axis === 'y') {
ctx.fillText(lbl, 4, a*H + 14);
ctx.fillText(lbl, 4, a * H + 14);
} else {
ctx.save(); ctx.translate(a*W + 12, 14); ctx.rotate(Math.PI/2);
ctx.fillText(lbl, 0, 0); ctx.restore();
ctx.save();
ctx.translate(a * W + 12, 14);
ctx.rotate(Math.PI / 2);
ctx.fillText(lbl, 0, 0);
ctx.restore();
}
}
}
@@ -118,26 +147,36 @@ function drawBnd(dragIdx = -1, dragVal = null) {
ctx.setLineDash([5, 3]);
ctx.lineWidth = 2;
for (let i = 0; i < boundaries.length; i++) {
const val = (dragIdx === i && dragVal !== null) ? full[i+1] : boundaries[i];
const val = dragIdx === i && dragVal !== null ? full[i + 1] : boundaries[i];
ctx.strokeStyle = '#1e3a5f';
ctx.beginPath();
if (axis === 'y') { ctx.moveTo(0, val*H); ctx.lineTo(W, val*H); }
else { ctx.moveTo(val*W, 0); ctx.lineTo(val*W, H); }
if (axis === 'y') {
ctx.moveTo(0, val * H);
ctx.lineTo(W, val * H);
} else {
ctx.moveTo(val * W, 0);
ctx.lineTo(val * W, H);
}
ctx.stroke();
}
// Draw plugin boundary suggestions (dashed, non-interactive)
const {pluginResults, selectedPlugin} = _bnd;
const { pluginResults, selectedPlugin } = _bnd;
const pluginIds = Object.keys(pluginResults);
if (selectedPlugin && pluginIds.length) {
ctx.setLineDash([3, 6]);
ctx.lineWidth = 1.5;
const drawPluginBounds = (bounds, color) => {
ctx.strokeStyle = color;
for (const ab of (bounds || [])) {
for (const ab of bounds || []) {
ctx.beginPath();
if (axis === 'y') { ctx.moveTo(0, ab*H); ctx.lineTo(W, ab*H); }
else { ctx.moveTo(ab*W, 0); ctx.lineTo(ab*W, H); }
if (axis === 'y') {
ctx.moveTo(0, ab * H);
ctx.lineTo(W, ab * H);
} else {
ctx.moveTo(ab * W, 0);
ctx.lineTo(ab * W, H);
}
ctx.stroke();
}
};
@@ -151,46 +190,61 @@ function drawBnd(dragIdx = -1, dragVal = null) {
}
// ── Drag machinery ───────────────────────────────────────────────────────────
let _dragIdx = -1, _dragging = false;
let _dragIdx = -1,
_dragging = false;
function fracFromEvt(e) {
const r = _bnd.canvas.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
const y = (e.clientY - r.top) / r.height;
const y = (e.clientY - r.top) / r.height;
return _bnd.axis === 'y' ? y : x;
}
function nearestBnd(frac) {
const {boundaries, canvas, axis} = _bnd;
const { boundaries, canvas, axis } = _bnd;
const r = canvas.getBoundingClientRect();
const dim = axis === 'y' ? r.height : r.width;
const thresh = (window._grabPx ?? 14) / dim;
let best = -1, bestD = thresh;
boundaries.forEach((b,i) => { const d=Math.abs(b-frac); if(d<bestD){bestD=d;best=i;} });
let best = -1,
bestD = thresh;
boundaries.forEach((b, i) => {
const d = Math.abs(b - frac);
if (d < bestD) {
bestD = d;
best = i;
}
});
return best;
}
function snapToAi(frac) {
if (!_bnd?.selectedPlugin) return frac;
const {pluginResults, selectedPlugin} = _bnd;
const snapBounds = selectedPlugin === 'all'
? Object.values(pluginResults).flat()
: (pluginResults[selectedPlugin] || []);
const { pluginResults, selectedPlugin } = _bnd;
const snapBounds =
selectedPlugin === 'all' ? Object.values(pluginResults).flat() : pluginResults[selectedPlugin] || [];
if (!snapBounds.length) return frac;
const r = _bnd.canvas.getBoundingClientRect();
const dim = _bnd.axis === 'y' ? r.height : r.width;
const thresh = (window._grabPx ?? 14) / dim;
let best = frac, bestD = thresh;
snapBounds.forEach(ab => { const d = Math.abs(ab - frac); if (d < bestD) { bestD = d; best = ab; } });
let best = frac,
bestD = thresh;
snapBounds.forEach((ab) => {
const d = Math.abs(ab - frac);
if (d < bestD) {
bestD = d;
best = ab;
}
});
return best;
}
function bndPointerDown(e) {
if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
const idx = nearestBnd(frac);
const idx = nearestBnd(frac);
if (idx >= 0) {
_dragIdx = idx; _dragging = true;
_dragIdx = idx;
_dragging = true;
_bnd.canvas.setPointerCapture(e.pointerId);
e.stopPropagation();
}
@@ -200,8 +254,7 @@ function bndPointerMove(e) {
if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
const near = nearestBnd(frac);
_bnd.canvas.style.cursor = (near >= 0 || _dragging)
? (_bnd.axis==='y' ? 'ns-resize' : 'ew-resize') : 'default';
_bnd.canvas.style.cursor = near >= 0 || _dragging ? (_bnd.axis === 'y' ? 'ns-resize' : 'ew-resize') : 'default';
if (_dragging && _dragIdx >= 0) drawBnd(_dragIdx, frac);
}
@@ -209,22 +262,24 @@ async function bndPointerUp(e) {
if (!_dragging || !_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
_dragging = false;
const {boundaries, nodeId, nodeType} = _bnd;
const { boundaries, nodeId, nodeType } = _bnd;
const full = [0, ...boundaries, 1];
const clamped = Math.max(full[_dragIdx]+0.005, Math.min(full[_dragIdx+2]-0.005, frac));
const clamped = Math.max(full[_dragIdx] + 0.005, Math.min(full[_dragIdx + 2] - 0.005, frac));
boundaries[_dragIdx] = Math.round(snapToAi(clamped) * 10000) / 10000;
_bnd.boundaries = [...boundaries];
_dragIdx = -1;
drawBnd();
const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
const url = nodeType === 'cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
try {
await req('PATCH', url, {boundaries});
await req('PATCH', url, { boundaries });
const node = findNode(nodeId);
if (node) {
if (nodeType==='cabinet') node.shelf_boundaries = JSON.stringify(boundaries);
if (nodeType === 'cabinet') node.shelf_boundaries = JSON.stringify(boundaries);
else node.book_boundaries = JSON.stringify(boundaries);
}
} catch(err) { toast('Save failed: ' + err.message); }
} catch (err) {
toast('Save failed: ' + err.message);
}
}
async function bndClick(e) {
@@ -232,40 +287,59 @@ async function bndClick(e) {
if (!e.ctrlKey || !e.altKey) return;
e.preventDefault();
const frac = snapToAi(fracFromEvt(e));
const {boundaries, nodeId, nodeType} = _bnd;
const newBounds = [...boundaries, frac].sort((a,b)=>a-b);
const { boundaries, nodeId, nodeType } = _bnd;
const newBounds = [...boundaries, frac].sort((a, b) => a - b);
_bnd.boundaries = newBounds;
const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
const url = nodeType === 'cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`;
try {
await req('PATCH', url, {boundaries: newBounds});
await req('PATCH', url, { boundaries: newBounds });
if (nodeType === 'cabinet') {
const s = await req('POST', `/api/cabinets/${nodeId}/shelves`, null);
S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===nodeId){
c.shelf_boundaries=JSON.stringify(newBounds); c.shelves.push({...s,books:[]});
}}));
S.tree.forEach((r) =>
r.cabinets.forEach((c) => {
if (c.id === nodeId) {
c.shelf_boundaries = JSON.stringify(newBounds);
c.shelves.push({ ...s, books: [] });
}
}),
);
} else {
const b = await req('POST', `/api/shelves/${nodeId}/books`);
S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===nodeId){
s.book_boundaries=JSON.stringify(newBounds); s.books.push(b);
}})));
S.tree.forEach((r) =>
r.cabinets.forEach((c) =>
c.shelves.forEach((s) => {
if (s.id === nodeId) {
s.book_boundaries = JSON.stringify(newBounds);
s.books.push(b);
}
}),
),
);
}
render();
} catch(err) { toast('Error: ' + err.message); }
} catch (err) {
toast('Error: ' + err.message);
}
}
function bndHover(e) {
if (!_bnd || S._cropMode) return;
const frac = fracFromEvt(e);
const {boundaries, segments} = _bnd;
const { boundaries, segments } = _bnd;
const full = [0, ...boundaries, 1];
let segIdx = -1;
for (let i = 0; i < full.length-1; i++) { if(frac>=full[i]&&frac<full[i+1]){segIdx=i;break;} }
for (let i = 0; i < full.length - 1; i++) {
if (frac >= full[i] && frac < full[i + 1]) {
segIdx = i;
break;
}
}
clearSegHover();
if (segIdx>=0 && segments[segIdx]) {
if (segIdx >= 0 && segments[segIdx]) {
document.querySelector(`.node[data-id="${segments[segIdx].id}"] .nrow`)?.classList.add('seg-hover');
}
}
function clearSegHover() {
document.querySelectorAll('.seg-hover').forEach(el=>el.classList.remove('seg-hover'));
document.querySelectorAll('.seg-hover').forEach((el) => el.classList.remove('seg-hover'));
}