308 lines
11 KiB
JavaScript
308 lines
11 KiB
JavaScript
/*
|
|
* 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 <pre> dump of the raw text
|
|
* - a section that isn't a doc -> a <pre> dump of that section, others still render
|
|
* - a window that throws -> a <pre> dump 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 the <pre> dump 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);
|