CMMS fork: pcb AI bridge — robustness for the first-message race

The chat's get-buffer request can arrive before studio.js init() has
built the DB / selected a board (or while an existing project ZIP is
still streaming in), which read back as "the editor is still loading".

- buildProjectView() waits up to 4.5s for db + a board, then still
  returns a valid (possibly empty) project view with a "no board open"
  note rather than null - only a missing db is "still loading".
- init() with no loadUrl (a "+ New board RE") now ensures a starter
  board exists (studio.js only auto-creates one when the whole DB is
  empty, not when the selected device just has no boards).
- reply prefers evt.source, falls back to a DOM query for the chat
  iframe (cad-editor hit an unusable cross-realm Window).
- [cmms-pcb-bridge] console tracing on every step.
This commit is contained in:
Stuart 2026-09-03 15:09:33 +10:00
commit 04d29dc0ac

View file

@ -113,19 +113,41 @@
// ---- AI Assistant editor-buffer bridge ------------------------------- // ---- AI Assistant editor-buffer bridge -------------------------------
// The chat embed (an iframe) postMessages here; this top-level page is the // The chat embed (an iframe) postMessages here; this top-level page is the
// "editor" side of the cmms-ai-editor-* protocol (like cad-editor's Rust // "editor" side of the cmms-ai-editor-* protocol (like cad-editor, whose
// listener). read: serialise the open board; apply: merge a JSON patch of // WASM app is also the top-level page). read: serialise the open board;
// proposed components + nets. Nothing is saved. // 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();
}
async function buildProjectView() { async function buildProjectView() {
if (!currentDeviceId || !currentBomId) return null; // 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 dev = deviceList.find(d => d.id === currentDeviceId);
const board = bomList.find(b => b.id === currentBomId); const board = bomList.find(b => b.id === currentBomId);
const allNets = await db.getNets(); const allNets = currentBomId ? await db.getNets() : [];
const nets = allNets.filter(n => n.projectId === currentBomId); const nets = allNets.filter(n => n.projectId === currentBomId);
return JSON.stringify({ const view = {
device: dev ? dev.name : null, device: dev ? dev.name : null,
board: board ? board.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: bomImages.length, image_count: bomImages.length,
images: bomImages.map(i => ({ id: i.id, name: i.name || '' })), images: bomImages.map(i => ({ id: i.id, name: i.name || '' })),
components: bomData.map(c => ({ components: bomData.map(c => ({
@ -133,7 +155,9 @@
x: Math.round(c.x), y: Math.round(c.y), x: Math.round(c.x), y: Math.round(c.y),
})), })),
nets: nets.map(n => ({ name: n.name, pins: (n.nodes || []).map(nd => nd.label) })), 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);
} }
async function applyProjectPatch(patchJson) { async function applyProjectPatch(patchJson) {
@ -184,22 +208,57 @@
status(`AI proposal applied (${nc} component${nc === 1 ? '' : 's'}, ${nn} net${nn === 1 ? '' : 's'}) — check each against the photo, then Save`); 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) => { window.addEventListener('message', async (evt) => {
if (evt.origin !== window.location.origin || !evt.data || typeof evt.data !== 'object') return; if (evt.origin !== window.location.origin || !evt.data || typeof evt.data !== 'object') return;
const d = evt.data; const d = evt.data;
if (d.type === 'cmms-ai-editor-get-buffer') { if (d.type === 'cmms-ai-editor-get-buffer') {
trace('get-buffer received');
let buffer = null; let buffer = null;
try { buffer = await buildProjectView(); } catch (e) { /* reply null */ } try { buffer = await buildProjectView(); } catch (e) { trace('buildProjectView threw:', e && e.message); }
try { evt.source.postMessage({ type: 'cmms-ai-editor-buffer', buffer: buffer }, window.location.origin); } catch (e) {} replyToChat(evt.source, { type: 'cmms-ai-editor-buffer', buffer: buffer });
} else if (d.type === 'cmms-ai-editor-apply' && d.editor === 'pcb' && typeof d.new_source === 'string') { } else if (d.type === 'cmms-ai-editor-apply' && d.editor === 'pcb' && typeof d.new_source === 'string') {
trace('apply received');
try { try {
await applyProjectPatch(d.new_source); await applyProjectPatch(d.new_source);
} catch (e) { } catch (e) {
trace('applyProjectPatch threw:', e && e.message);
status('Could not apply the AI proposal: ' + (e.message || e), true); 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() { async function init() {
if (CFG.dark) document.documentElement.setAttribute('data-theme', 'dark'); if (CFG.dark) document.documentElement.setAttribute('data-theme', 'dark');
injectControls(); injectControls();
@ -215,6 +274,8 @@
} catch (e) { } catch (e) {
status('Could not load the saved board: ' + (e.message || e), true); status('Could not load the saved board: ' + (e.message || e), true);
} }
} else {
try { await ensureBoard(); } catch (e) { trace('ensureBoard threw:', e && e.message); }
} }
} }