/* * DigestRender — turns digest JSON into a canvas of DigestWindows. * * Everything in here is written around one requirement from the plan's testing * checklist: "If a run's LLM output produces malformed canvas-SDK calls, does the * digest fall back to plain text instead of a broken/blank page?" A local model * emitting near-miss JSON four times a day is a routine event, not an exception, * so every layer degrades instead of throwing: * * - unparseable input -> a
dump of the raw text * - a section that isn't a doc -> adump of that section, others still render * - a window that throws -> adump of that window, siblings still render * - a globe with no markers -> the globe still draws, just empty * * A blank page is the one outcome that must never happen. * * PER-PERSON SECTIONS * ------------------- * A run carries `people` — each household member's own set of digest sections, as * identity's admin panel recorded it and run.py copied it in (see * digest-engine/preferences.py). Given `options.person` — a name, nickname or id * that Home Assistant already resolved — only that person's sections are drawn. * * This is a DISPLAY FILTER, NOT AN ACCESS CONTROL. digest-web serves this same JSON * read-only to anything on the LAN, so a section left out here is off a screen, not * out of anyone's reach. Do not describe it to a user as privacy. */ (function (global) { 'use strict'; function rawDump(container, value, note) { var pre = document.createElement('pre'); pre.className = 'digest-canvas-raw'; var text = typeof value === 'string' ? value : safeStringify(value); pre.textContent = (note ? note + '\n\n' : '') + text; container.appendChild(pre); return pre; } function safeStringify(value) { try { return JSON.stringify(value, null, 2); } catch (err) { return String(value); } } function parse(input) { if (typeof input !== 'string') { return input; } try { return JSON.parse(input); } catch (err) { return { __unparseable: input }; } } // Accepts what run.py writes ({sections: {compact: [...], full: [...]}}), a bare // array of section documents, or a single section document. function toSectionList(parsed, detailLevel) { if (Array.isArray(parsed)) { return parsed; } if (!parsed || typeof parsed !== 'object') { return []; } if (parsed.sections && typeof parsed.sections === 'object') { var sections = parsed.sections; if (Array.isArray(sections)) { return sections; } var picked = sections[detailLevel] || sections.full || sections.compact; return Array.isArray(picked) ? picked : []; } if (parsed.windows) { return [parsed]; } return []; } // The sections this viewer gets, or null for "nobody was resolved / nothing is // known about them" — which the caller, not this function, decides what to do with. function sectionsForPerson(parsed, person) { if (!person) { return null; } var wanted = String(person).trim().toLowerCase(); if (!wanted) { return null; } var people = (parsed && parsed.people) || []; for (var i = 0; i < people.length; i++) { var entry = people[i] || {}; var matches = String(entry.name || '').toLowerCase() === wanted || String(entry.nickname || '').toLowerCase() === wanted || String(entry.id) === wanted; if (matches) { return entry.digest_sections || []; } } // Named somebody this digest has never heard of — treated exactly like naming // nobody, never as "show them everything because they must be new". return null; } function filterSections(sections, parsed, options) { var person = String(options.person || '').trim(); var people = (parsed && parsed.people) || []; // Somebody WAS resolved, but this run has no preferences for anyone — identity was // unreachable when it generated (see preferences.py). That run already fell back to // generating everything, and hiding a resolved person's own personal section on top // of it would make one container being down cost them more than it has to. Same // direction of failure at both ends: more digest, not less. if (person && !people.length) { return sections; } var allowed = sectionsForPerson(parsed, person); if (allowed) { return sections.filter(function (doc) { return doc && allowed.indexOf(doc.section) !== -1; }); } // No resolved person. The personal section is somebody's mail and messages, and // the plan's rule for the shared kiosk is that it is never shown on a guess — see // thinclient_agent/digest_canvas.py, which passes `person` only when Home // Assistant has already resolved exactly who asked. if (options.requirePersonForPersonal) { return sections.filter(function (doc) { return !doc || doc.section !== 'personal'; }); } return sections; } // A marker's own briefing: the summary of what is happening there, and its // sources folded away underneath. Rendered below the globe rather than as a // tooltip on the marker — the globe rotates, markers pass behind the limb, and a // summary you can only read while its marker happens to be facing you is not a // summary. The label ties the two together. function renderMarkerBriefs(container, markers) { var briefed = (markers || []).filter(function (marker) { return marker && (marker.summary || (marker.sources && marker.sources.length)); }); if (!briefed.length) { return; } var wrap = document.createElement('div'); wrap.className = 'digest-globe-briefs'; briefed.forEach(function (marker) { var brief = document.createElement('div'); brief.className = 'digest-globe-brief'; var heading = document.createElement('h4'); heading.className = 'digest-brief-title'; // Carries the marker's own colour so the eye can pair a red hammer-and-sickle // on the globe with the paragraph explaining it. if (marker.color) { heading.style.color = marker.color; } heading.textContent = marker.label || 'Marker'; brief.appendChild(heading); if (marker.summary) { var text = document.createElement('p'); text.className = 'digest-brief-summary'; text.textContent = marker.summary; brief.appendChild(text); } var sources = global.DigestWindow.sources(marker.sources); if (sources) { brief.appendChild(sources); } wrap.appendChild(brief); }); container.appendChild(wrap); } function renderGlobeWindow(container, win) { var mount = document.createElement('div'); var el = global.DigestWindow.open({ title: win.title || 'Globe', content: mount, container: container, variant: 'globe', sources: win.sources }); var globe = new global.DigestGlobe(mount); (win.globe_markers || []).forEach(function (marker) { try { globe.addMarker(marker.lat, marker.lon, { icon: marker.icon, color: marker.color, glow: marker.glow, label: marker.label }); } catch (err) { // One bad marker must not cost the whole globe. if (global.console) { global.console.warn('digest: skipped a globe marker', err); } } }); if (typeof win.content === 'string' && win.content.trim()) { var caption = document.createElement('p'); caption.className = 'digest-globe-caption'; caption.textContent = win.content; mount.parentNode.appendChild(caption); } renderMarkerBriefs(mount.parentNode, win.globe_markers); return el; } function renderWindow(container, win) { if (!win || typeof win !== 'object') { return rawDump(container, win, '// malformed window'); } if (win.kind === 'globe') { return renderGlobeWindow(container, win); } return global.DigestWindow.open({ title: win.title || '', content: win.content, container: container, // Any window may carry citations, not just the globe — a "Highlights" list // and a standalone analysis window both need somewhere to put their receipts. sources: win.sources, x: win.x, y: win.y, w: win.w, h: win.h }); } function renderSection(container, doc) { if (!doc || typeof doc !== 'object' || !Array.isArray(doc.windows)) { return rawDump(container, doc, '// section did not match the digest schema'); } doc.windows.forEach(function (win) { try { var el = renderWindow(container, win); if (el && doc.section) { el.dataset.section = doc.section; } } catch (err) { if (global.console) { global.console.warn('digest: window fell back to plain text', err); } rawDump(container, win, '// window failed to render: ' + err); } }); } var DigestRender = { render: function (container, input, options) { options = options || {}; container.innerHTML = ''; container.classList.add('digest-canvas'); var parsed = parse(input); if (parsed && parsed.__unparseable !== undefined) { rawDump(container, parsed.__unparseable, '// digest JSON did not parse'); return; } var sections = toSectionList(parsed, options.detailLevel || 'full'); if (!sections.length) { rawDump(container, parsed, '// no digest sections found in this document'); return; } var visible = filterSections(sections, parsed, options); // Everything this run generated was filtered out — every section this viewer // wanted is one nobody generated, or they asked for none at all. That is a // legitimate outcome of the settings, not a failure, so it gets a plain window // rather than thedump a malformed digest gets. Still never a blank page. if (!visible.length) { global.DigestWindow.open({ title: 'Nothing in this digest', content: 'No digest section is switched on for this screen. Sections are chosen ' + 'per person in the identity admin panel.', container: container }); return; } visible.forEach(function (doc) { try { renderSection(container, doc); } catch (err) { if (global.console) { global.console.warn('digest: section fell back to plain text', err); } rawDump(container, doc, '// section failed to render: ' + err); } }); }, // Convenience used by both templates: fetch, render, and put the failure on // screen rather than only in the console if the fetch itself fails. load: function (container, url, options) { return global.fetch(url, { cache: 'no-store' }) .then(function (response) { if (!response.ok) { throw new Error(response.status + ' ' + response.statusText); } return response.text(); }) .then(function (text) { DigestRender.render(container, text, options); }) .catch(function (err) { container.innerHTML = ''; container.classList.add('digest-canvas'); rawDump(container, String(err), '// could not load ' + url); }); } }; global.DigestRender = DigestRender; })(window);