pcb-retrace/docs/cmms-bridge.js
Stuart 514551c9f2 CMMS fork: AI bridge reads the project from IndexedDB, not the globals
Console showed buildProjectView reporting "4 photos" but NO
buildProjectImages line at all - it was hitting the silent
`!bomImages.length` early return. The studio.js bomData/bomImages
globals get transiently emptied while a project loads / auto-stitches,
and the get-buffer handler's `await` between buildProjectView and
buildProjectImages is enough of a yield to catch that window.

- buildProjectView + buildProjectImages + applyProjectPatch now call
  db.getComponents/getImages/getNets(currentBomId) directly instead of
  reading bomData/bomImages/bomList.
- buildProjectImages traces on entry ("start (board: ...)") and after
  the DB read ("N image record(s)") so an empty result is never silent.
2026-09-03 15:35:16 +10:00

374 lines
15 KiB
JavaScript

/*
* Copyright (c) 2025-2026 Taras Greben (upstream) — CMMS fork addition.
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
*
* cmms-bridge.js — glue between pcb-retrace and the CMMS attachment system.
*
* Completely inert unless window.PCBRETRACE_CONFIG is present, which only
* public/vendor/pcb-retrace/edit.php sets. Standalone pcb.etaras.com / local
* use is unaffected.
*
* PCBRETRACE_CONFIG = {
* loadUrl: GET url for the project .pcbretrace.zip (omit for a new board)
* saveUrl: POST target ("/attachment.php")
* saveAction: "pcb_retrace_save" | "pcb_retrace_save_new"
* attachmentId: existing attachment id (omit for a new board)
* jobId: owner scope token "entityType:entityId", echoed back on save
* csrfToken: CMMS CSRF token
* originalName: filename to save as
* returnTo: url to navigate to after "Save & close"
* dark: boolean (studio.html is light-only today — recorded, not styled)
* }
*
* Relies on globals from studio.js (same page, classic scripts share scope):
* db, currentDeviceId, buildDeviceZIP(), processUrlImport().
* window.CMMS.init() is awaited at the end of studio.js's init().
*/
(function () {
'use strict';
const CFG = window.PCBRETRACE_CONFIG;
if (!CFG) return;
let saveInFlight = false;
let statusEl = null;
function status(msg, isError) {
if (!statusEl) return;
statusEl.textContent = msg || '';
statusEl.style.color = isError ? '#b91c1c' : '#475569';
if (msg && !isError) {
clearTimeout(status._t);
status._t = setTimeout(() => { if (statusEl.textContent === msg) statusEl.textContent = ''; }, 4000);
}
}
function injectControls() {
const topBar = document.querySelector('.top-bar');
if (!topBar) return;
const mk = (label, title, primary) => {
const b = document.createElement('button');
b.textContent = label;
b.title = title;
b.className = primary ? 'primary' : 'secondary';
b.style.cssText = 'white-space:nowrap;' + (primary ? 'background:#2563eb;color:#fff;border-color:#2563eb;' : '');
return b;
};
const saveBtn = mk('💾 Save', 'Save this board back to the CMMS', true);
const saveExitBtn = mk('Save & close', 'Save and return to the CMMS', false);
statusEl = document.createElement('span');
statusEl.style.cssText = 'font-size:0.8rem; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:180px;';
saveBtn.addEventListener('click', () => save(false));
saveExitBtn.addEventListener('click', () => save(true));
const box = document.createElement('div');
box.style.cssText = 'margin-left:auto; display:flex; gap:6px; align-items:center; flex:0 0 auto;';
box.append(statusEl, saveBtn, saveExitBtn);
topBar.appendChild(box);
// Hide the standalone import/export/share buttons — the CMMS owns
// persistence here. (The whole-device drag-drop still works.)
['Import from URL', 'Import Device', 'Export Device'].forEach(t => {
const el = document.querySelector('#device-actions button[title="' + t + '"]');
if (el) el.style.display = 'none';
});
}
async function save(thenExit) {
if (saveInFlight) return;
if (!currentDeviceId) { status('Nothing to save yet', true); return; }
saveInFlight = true;
status('Saving…');
try {
const name = CFG.originalName || 'board.pcbretrace.zip';
const { blob } = await buildDeviceZIP(null, true);
const fd = new FormData();
fd.append('action', CFG.saveAction);
fd.append('csrf_token', CFG.csrfToken);
fd.append('job_id', CFG.jobId);
fd.append('id', CFG.attachmentId ? String(CFG.attachmentId) : '');
fd.append('original_name', name);
fd.append('file', blob, name);
const r = await fetch(CFG.saveUrl, { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await r.json().catch(() => { throw new Error('HTTP ' + r.status); });
if (!data || !data.ok) throw new Error((data && data.error) || ('HTTP ' + r.status));
// First save of a new board — switch to updating it in place.
if (!CFG.attachmentId && data.id) {
CFG.attachmentId = String(data.id);
CFG.saveAction = 'pcb_retrace_save';
}
status('Saved ✓');
if (thenExit && CFG.returnTo) window.location.href = CFG.returnTo;
} catch (e) {
status('Save failed: ' + (e.message || e), true);
} finally {
saveInFlight = false;
}
}
// ---- AI Assistant editor-buffer bridge -------------------------------
// The chat embed (an iframe) postMessages here; this top-level page is the
// "editor" side of the cmms-ai-editor-* protocol (like cad-editor, whose
// WASM app is also the top-level page). read: serialise the open board;
// apply: merge a JSON patch of proposed components + nets. Nothing is saved.
const trace = (...a) => { try { console.log('[cmms-pcb-bridge]', ...a); } catch (e) {} };
// The chat's get-buffer request can land before studio.js's init() has
// finished creating the DB and selecting a board (or while an existing
// project ZIP is still downloading). Wait briefly rather than replying
// "not ready" - the widget times out at 2.5s, so stay under that.
const ready = () => !!(db && currentBomId);
async function waitReady(ms) {
const t0 = Date.now();
while (!ready() && Date.now() - t0 < ms) {
await new Promise(r => setTimeout(r, 100));
}
return ready();
}
// Read the project straight from IndexedDB rather than the studio.js globals
// (bomData/bomImages) - those get transiently reset while a project loads or
// auto-stitches, which was leaving the AI bridge with an empty image list
// even though buildProjectView's own snapshot showed photos.
async function readBoard() {
const [components, imageRecs, allNets] = await Promise.all([
db.getComponents(currentBomId),
db.getImages(currentBomId),
db.getNets(),
]);
return { components, imageRecs, nets: allNets.filter(n => n.projectId === currentBomId) };
}
async function buildProjectView() {
// The widget waits 6s for a pcb reply (ai-assistant-widget.js); stay
// under that so a "still initialising" answer still gets through.
if (!(await waitReady(4500))) {
trace('buildProjectView: db/board not ready after 4.5s (db:', !!db, 'board:', !!currentBomId, ')');
// db exists but no board -> a real (empty) project the model can
// still work from; only a missing db means "still loading".
if (!db) return null;
}
const dev = deviceList.find(d => d.id === currentDeviceId);
const board = bomList.find(b => b.id === currentBomId);
const { components, imageRecs, nets } = currentBomId
? await readBoard()
: { components: [], imageRecs: [], nets: [] };
const view = {
device: dev ? dev.name : null,
board: board ? board.name : null,
note: currentBomId ? undefined : 'No board is open yet - the user needs to create or select one before components/nets can be added.',
image_count: imageRecs.length,
images: imageRecs.map(i => ({ id: i.id, name: i.name || '' })),
components: components.map(c => ({
label: c.label, value: c.value || '', desc: c.desc || '',
x: Math.round(c.x), y: Math.round(c.y),
})),
nets: nets.map(n => ({ name: n.name, pins: (n.nodes || []).map(nd => nd.label) })),
};
trace('buildProjectView:', view.components.length, 'components,', view.nets.length, 'nets,', view.image_count, 'photos');
return JSON.stringify(view);
}
// The board photos live in pcb-retrace's IndexedDB (imported by the user or
// unpacked from the .pcbretrace.zip), NOT as CMMS attachments, so the
// assistant can't reach them with read_attachment_image. Hand them along
// with the buffer so a `read_board_image` tool can surface them.
const MAX_IMG_B64 = 4 * 1024 * 1024; // ~3MB raw; matches message.php's cap
function blobToB64(blob) {
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result).split(',', 2)[1] || '');
r.onerror = () => rej(new Error('FileReader failed'));
r.readAsDataURL(blob);
});
}
// Downscale a photo to ~maxEdge on the long side as JPEG. Returns a Blob.
async function downscale(blob, maxEdge) {
const bmp = await createImageBitmap(blob);
try {
const s = Math.min(1, maxEdge / Math.max(bmp.width, bmp.height));
const w = Math.max(1, Math.round(bmp.width * s));
const h = Math.max(1, Math.round(bmp.height * s));
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(bmp, 0, 0, w, h);
const out = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.8));
if (!out) throw new Error('canvas.toBlob returned null');
return out;
} finally {
try { bmp.close(); } catch (e) {}
}
}
async function buildProjectImages() {
trace('buildProjectImages: start (board:', currentBomId || '(none)', ')');
if (!currentBomId) return [];
const imgs = await db.getImages(currentBomId);
trace('buildProjectImages:', imgs.length, 'image record(s) in the DB');
const out = [];
for (const img of imgs.slice(0, 6)) {
let blob = img.blob;
if (!(blob instanceof Blob)) {
try { blob = new Blob([blob], { type: (img.type || 'image/jpeg') }); }
catch (e) { trace('image', img.id, 'has no usable blob'); continue; }
}
let b64 = null;
try {
b64 = await blobToB64(await downscale(blob, 1400));
trace('image', img.id, 'downscaled ok');
} catch (e) {
trace('image', img.id, 'downscale failed:', e && e.message, '- trying raw');
// Fall back to the raw blob (some builds / EXIF-heavy JPEGs break
// createImageBitmap or toBlob). Only if it's not huge.
try {
if (blob.size <= 3 * 1024 * 1024) b64 = await blobToB64(blob);
else trace('image', img.id, 'raw blob too large (', blob.size, ')');
} catch (e2) {
trace('image', img.id, 'raw encode also failed:', e2 && e2.message);
}
}
if (!b64) continue;
if (b64.length > MAX_IMG_B64) { trace('image', img.id, 'over size cap after encode'); continue; }
out.push({
id: img.id,
name: img.name || (img.id + '.jpg'),
mime: (blob.type && blob.type.indexOf('image/') === 0) ? blob.type : 'image/jpeg',
data_base64: b64,
});
}
trace('buildProjectImages:', out.length, 'of', imgs.length, 'photos encoded, total b64', out.reduce((n, i) => n + i.data_base64.length, 0));
return out;
}
async function applyProjectPatch(patchJson) {
const patch = JSON.parse(patchJson);
if (!currentBomId) { status('Select or create a board first', true); return; }
const comps = await db.getComponents(currentBomId);
const imgRecs = await db.getImages(currentBomId);
const byLabel = l => comps.find(c => (c.label || '').toUpperCase() === l.toUpperCase());
const fallbackImg = currentImgId || (imgRecs[0] && imgRecs[0].id) || null;
let spread = 40;
for (const c of (patch.components || [])) {
const existing = byLabel(c.label);
const rec = existing || { id: uuid(), boardId: currentBomId, label: c.label, x: 0, y: 0 };
if (c.value !== undefined) rec.value = c.value;
if (c.desc !== undefined) rec.desc = c.desc;
if (c.x !== undefined) rec.x = Math.round(c.x);
if (c.y !== undefined) rec.y = Math.round(c.y);
// New part with no coordinate → stagger it so it's draggable, not stacked.
if (!existing && c.x === undefined) { rec.x = spread; rec.y = spread; spread += 40; }
rec.source = 'ai_suggested';
await db.addComponent(rec);
}
const allNets = await db.getNets();
const projectNets = allNets.filter(n => n.projectId === currentBomId);
for (const n of (patch.nets || [])) {
let net = projectNets.find(x => x.name === n.name);
if (!net) net = { id: uuid(), name: n.name, projectId: currentBomId, nodes: [] };
const have = new Set((net.nodes || []).map(nd => nd.label));
for (const pin of (n.pins || [])) {
if (have.has(pin)) continue;
const comp = byLabel(pin.split('.')[0]);
net.nodes.push({
id: uuid(),
imgId: fallbackImg,
x: comp ? Math.round(comp.x) : 0,
y: comp ? Math.round(comp.y) : 0,
label: pin,
});
}
await db.addNet(net);
}
await loadProjectData();
if (window.netManager) await window.netManager.render();
if (window.inspector && window.inspector.updateNetNodeCache) window.inspector.updateNetNodeCache();
const nc = (patch.components || []).length, nn = (patch.nets || []).length;
status(`AI proposal applied (${nc} component${nc === 1 ? '' : 's'}, ${nn} net${nn === 1 ? '' : 's'}) — check each against the photo, then Save`);
}
// Reply to the chat iframe. Prefer evt.source; fall back to a DOM query
// (cad-editor hit a case where event.source was an unusable cross-realm
// Window - belt and braces here too).
function replyToChat(evtSource, msg) {
let posted = false;
try { if (evtSource && evtSource.postMessage) { evtSource.postMessage(msg, window.location.origin); posted = true; } } catch (e) {}
if (!posted) {
const f = document.querySelector('#ai-assistant-editor-embed iframe');
try { if (f && f.contentWindow) { f.contentWindow.postMessage(msg, window.location.origin); posted = true; } } catch (e) {}
}
trace('reply', msg.type, posted ? 'sent' : 'DROPPED');
}
window.addEventListener('message', async (evt) => {
if (evt.origin !== window.location.origin || !evt.data || typeof evt.data !== 'object') return;
const d = evt.data;
if (d.type === 'cmms-ai-editor-get-buffer') {
trace('get-buffer received');
let buffer = null;
let images = [];
try { buffer = await buildProjectView(); } catch (e) { trace('buildProjectView threw:', e && e.message); }
try { images = await buildProjectImages(); } catch (e) { trace('buildProjectImages threw:', e && e.message); }
replyToChat(evt.source, { type: 'cmms-ai-editor-buffer', buffer: buffer, images: images });
} else if (d.type === 'cmms-ai-editor-apply' && d.editor === 'pcb' && typeof d.new_source === 'string') {
trace('apply received');
try {
await applyProjectPatch(d.new_source);
} catch (e) {
trace('applyProjectPatch threw:', e && e.message);
status('Could not apply the AI proposal: ' + (e.message || e), true);
}
}
});
// A "+ New board RE" launch with no board on the selected device (studio.js
// only auto-creates one when the whole DB is empty) - make one so the user
// lands on a working board, not an empty "No Boards" shell.
async function ensureBoard() {
if (currentBomId) return;
let devId = currentDeviceId;
if (!devId || !deviceList.find(d => d.id === devId)) {
devId = uuid();
await db.addDevice({ id: devId, name: 'Board' });
currentDeviceId = devId;
try { localStorage.setItem('pcb_device_id', devId); } catch (e) {}
}
await db.addProject({ id: uuid(), deviceId: devId, name: 'Board 1', sortMode: 'none' });
deviceList = await db.getDevices();
updateDeviceDropdown();
await loadDeviceBoms();
trace('ensureBoard: created a starter board');
}
async function init() {
if (CFG.dark) document.documentElement.setAttribute('data-theme', 'dark');
injectControls();
if (CFG.loadUrl) {
status('Loading board…');
try {
// processUrlImport → importFile → processZIP → restoreDevice,
// which refreshes the device/board UI itself. silent: skip the
// "restore?" confirm for a trusted CMMS attachment.
await processUrlImport(CFG.loadUrl, { silent: true });
status('');
} catch (e) {
status('Could not load the saved board: ' + (e.message || e), true);
}
} else {
try { await ensureBoard(); } catch (e) { trace('ensureBoard threw:', e && e.message); }
}
}
window.CMMS = { init, save };
})();