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

@@ -21,6 +21,8 @@
* Provides: collectQueueBooks(), renderPhotoQueue(), triggerPhoto()
*/
/* exported collectQueueBooks, renderPhotoQueue, triggerPhoto */
// ── Photo Queue ──────────────────────────────────────────────────────────────
function collectQueueBooks(node, type) {
const books = [];
@@ -29,9 +31,9 @@ function collectQueueBooks(node, type) {
if (n.identification_status !== 'user_approved') 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 === '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 books;
@@ -40,8 +42,12 @@ function collectQueueBooks(node, type) {
function renderPhotoQueue() {
const el = document.getElementById('photo-queue-overlay');
if (!el) return;
if (!_photoQueue) { el.style.display = 'none'; el.innerHTML = ''; return; }
const {books, index, processing} = _photoQueue;
if (!_photoQueue) {
el.style.display = 'none';
el.innerHTML = '';
return;
}
const { books, index, processing } = _photoQueue;
el.style.display = 'flex';
if (index >= books.length) {
el.innerHTML = `<div class="pq-hdr">
@@ -79,8 +85,8 @@ function renderPhotoQueue() {
const gphoto = document.getElementById('gphoto');
function triggerPhoto(type, id) {
S._photoTarget = {type, id};
if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture','environment');
S._photoTarget = { type, id };
if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture', 'environment');
else gphoto.removeAttribute('capture');
gphoto.value = '';
gphoto.click();
@@ -89,20 +95,22 @@ function triggerPhoto(type, id) {
gphoto.addEventListener('change', async () => {
const file = gphoto.files[0];
if (!file || !S._photoTarget) return;
const {type, id} = S._photoTarget;
const { type, id } = S._photoTarget;
S._photoTarget = null;
const fd = new FormData();
fd.append('image', file, file.name); // HD — no client-side compression
fd.append('image', file, file.name); // HD — no client-side compression
const urls = {
room: `/api/rooms/${id}/photo`,
room: `/api/rooms/${id}/photo`,
cabinet: `/api/cabinets/${id}/photo`,
shelf: `/api/shelves/${id}/photo`,
book: `/api/books/${id}/photo`,
shelf: `/api/shelves/${id}/photo`,
book: `/api/books/${id}/photo`,
};
try {
const res = await req('POST', urls[type], fd, true);
const key = type==='book' ? 'image_filename' : 'photo_filename';
walkTree(n=>{ if(n.id===id) n[key]=res[key]; });
const key = type === 'book' ? 'image_filename' : 'photo_filename';
walkTree((n) => {
if (n.id === id) n[key] = res[key];
});
// Photo queue mode: process and advance without full re-render
if (_photoQueue && type === 'book') {
_photoQueue.processing = true;
@@ -111,8 +119,12 @@ gphoto.addEventListener('change', async () => {
if (book && book.identification_status !== 'user_approved') {
try {
const br = await req('POST', `/api/books/${id}/process`);
walkTree(n => { if (n.id === id) Object.assign(n, br); });
} catch { /* continue queue on process error */ }
walkTree((n) => {
if (n.id === id) Object.assign(n, br);
});
} catch {
/* continue queue on process error */
}
}
_photoQueue.processing = false;
_photoQueue.index++;
@@ -127,12 +139,24 @@ gphoto.addEventListener('change', async () => {
if (book && book.identification_status !== 'user_approved') {
try {
const br = await req('POST', `/api/books/${id}/process`);
walkTree(n => { if(n.id===id) Object.assign(n, br); });
walkTree((n) => {
if (n.id === id) Object.assign(n, br);
});
toast(`Photo saved · Identified (${br.identification_status})`);
render();
} catch { toast('Photo saved'); }
} else { toast('Photo saved'); }
} else { toast('Photo saved'); }
} else { toast('Photo saved'); }
} catch(err) { toast('Upload failed: '+err.message); }
} catch {
toast('Photo saved');
}
} else {
toast('Photo saved');
}
} else {
toast('Photo saved');
}
} else {
toast('Photo saved');
}
} catch (err) {
toast('Upload failed: ' + err.message);
}
});