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:
@@ -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'));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user