Update frontend/dist/assets/boot.js

#1
Files changed (1) hide show
  1. frontend/dist/assets/boot.js +174 -281
frontend/dist/assets/boot.js CHANGED
@@ -1,83 +1,70 @@
1
- /* ============================================================
2
- ELYSIUM — boot.js
3
- ------------------------------------------------------------
4
- Wires UI /api/turn. Critical guarantees:
5
-
6
- The JSON envelope returned by the model is NEVER rendered
7
- as raw text anywhere on screen. It is parsed and ROUTED to:
8
- hypergraph_delta → canvas nodes / edges
9
- council_deliberation council overlay + TTS audio
10
- ui_directives → bioluminescence pulses, focus, alert
11
- direct_answer → optional small toast
12
- metrics → bottom stats bar + legend densities
13
-
14
- File uploads are previewed above the textbox with X removal
15
- and only sent when the user presses Send.
16
-
17
- • Every DOM lookup is defensive — a single missing element
18
- never throws an unhandled "Cannot read properties of null".
19
- This was the root cause of the previous "classList of null"
20
- toast that appeared on top.
21
- ============================================================ */
22
  (() => {
23
- 'use strict';
24
-
25
- // ── Safe element accessor (logs once if missing) ──
26
- const _missing = new Set();
27
- const $ = (id) => {
28
- const el = document.getElementById(id);
29
- if (!el && !_missing.has(id)) {
30
- _missing.add(id);
31
- console.warn('[elysium] missing element:', id);
32
- }
33
- return el;
34
- };
35
 
36
- // Safe wrappers — no-op if element missing
37
- const addCls = (el, c) => { if (el && el.classList) el.classList.add(c); };
38
- const rmCls = (el, c) => { if (el && el.classList) el.classList.remove(c); };
 
 
 
39
 
40
- // ── Toast helper (always returns; never throws) ──
41
  window.toast = function (msg, kind = '') {
42
- const host = $('toasts');
43
- if (!host) { console.log('[toast]', msg); return; }
 
44
  const t = document.createElement('div');
45
- t.className = 'toast ' + (kind || '');
46
- t.textContent = String(msg).slice(0, 280);
 
 
47
  host.appendChild(t);
48
- setTimeout(() => { try { t.remove(); } catch {} }, 5200);
49
  };
50
 
51
- function escapeHtml(s) {
52
- return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
53
- '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
54
- }[c]));
 
 
 
 
 
 
 
 
 
55
  }
56
 
57
- // ── DOM references (all may be null — handled defensively) ──
58
- const input = $('q-input');
59
- const send = $('q-send');
60
- const fileEl = $('q-file');
61
- const upBtn = $('q-upload');
62
- const strip = $('attach-strip');
63
- const seedHint = $('seed-hint');
64
-
65
- /* =========================================================
66
- PART A — FILE ATTACHMENTS (preview above textbox, max 2)
67
- ========================================================= */
68
  const MAX_FILES = 2;
69
- const MAX_BYTES = 12 * 1024 * 1024;
70
- let attachments = []; // [{file, name, kind:'image'|'pdf', previewUrl}]
71
 
72
  function renderStrip() {
73
  if (!strip) return;
74
  if (attachments.length === 0) {
75
- addCls(strip, 'hidden');
76
  strip.innerHTML = '';
77
  return;
78
  }
79
- rmCls(strip, 'hidden');
80
- const hint = attachments.length < MAX_FILES
81
  ? `<span class="attach-hint">${MAX_FILES - attachments.length} more allowed</span>`
82
  : `<span class="attach-hint">max ${MAX_FILES} reached</span>`;
83
  strip.innerHTML = attachments.map((a, i) => {
@@ -89,28 +76,22 @@
89
  <div class="preview-tile" data-i="${i}">
90
  ${preview}
91
  <span class="nm" title="${escapeHtml(a.name)}">${escapeHtml(a.name)} · ${sizeKb}KB</span>
92
- <button class="x" data-i="${i}" title="Remove attachment" aria-label="Remove">×</button>
93
  </div>`;
94
- }).join('') + hint;
95
-
96
- // Wire remove buttons
97
  strip.querySelectorAll('.x').forEach(b => {
98
  b.onclick = (ev) => {
99
- ev.preventDefault();
100
  ev.stopPropagation();
101
  const i = +b.dataset.i;
102
  const removed = attachments.splice(i, 1)[0];
103
- if (removed && removed.previewUrl) {
104
- try { URL.revokeObjectURL(removed.previewUrl); } catch {}
105
- }
106
  renderStrip();
107
  };
108
  });
109
  }
110
 
111
  function addFiles(files) {
112
- if (!files) return;
113
- const list = Array.from(files);
114
  for (const f of list) {
115
  if (attachments.length >= MAX_FILES) {
116
  window.toast(`Max ${MAX_FILES} attachments allowed`, 'warn');
@@ -120,10 +101,10 @@
120
  const isImg = mime.startsWith('image/');
121
  const isPdf = mime === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
122
  if (!isImg && !isPdf) {
123
- window.toast(`Unsupported: ${f.name} (only images & PDFs)`, 'warn');
124
  continue;
125
  }
126
- if (f.size > MAX_BYTES) {
127
  window.toast(`${f.name} is too large (>12MB)`, 'warn');
128
  continue;
129
  }
@@ -135,292 +116,211 @@
135
  });
136
  }
137
  renderStrip();
138
- if (fileEl) fileEl.value = ''; // allow re-selecting same file after removal
139
  }
140
 
141
- if (fileEl) {
142
- fileEl.addEventListener('change', e => addFiles(e.target.files));
143
- }
144
 
145
- // Optional polish: drag-and-drop files anywhere on the window
146
  ['dragenter', 'dragover'].forEach(ev =>
147
  window.addEventListener(ev, e => { e.preventDefault(); }));
148
  window.addEventListener('drop', e => {
149
- if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
150
  e.preventDefault();
151
  addFiles(e.dataTransfer.files);
152
  });
153
 
154
- /* =========================================================
155
- PART B — BUSY LOCK (disables input while model thinks)
156
- ========================================================= */
157
  function setBusy(b) {
158
  document.body.dataset.busy = b ? '1' : '0';
159
  if (input) input.disabled = b;
160
  if (send) send.disabled = b;
161
  if (b) {
162
- if (send) send.classList.add('loading');
163
- if (upBtn) upBtn.classList.add('disabled');
164
  if (fileEl) fileEl.disabled = true;
165
  if (input) {
166
- input.dataset.prev = input.placeholder || '';
167
  input.placeholder = 'Council deliberating…';
168
  }
169
  } else {
170
- if (send) send.classList.remove('loading');
171
- if (upBtn) upBtn.classList.remove('disabled');
172
  if (fileEl) fileEl.disabled = false;
173
- if (input) {
174
- input.placeholder = input.dataset.prev || 'Speak to your civilization seed…';
175
- }
176
  }
177
  }
178
 
179
- /* =========================================================
180
- PART C — RESTORE existing civilization on page load
181
- ========================================================= */
182
  async function restore() {
183
  try {
184
- const h = await ElysiumAPI.hypergraph();
185
  (h.nodes || []).forEach(n => {
186
- if (n.node_id !== 'CORE' && typeof window.elysiumAddNode === 'function') {
187
- window.elysiumAddNode(n);
188
- }
189
- });
190
- (h.edges || []).forEach(e => {
191
- if (typeof window.elysiumAddEdge === 'function') window.elysiumAddEdge(e);
192
  });
193
- if ((h.nodes || []).length > 1) addCls(seedHint, 'hidden');
 
194
  updateLegend();
195
  updateMetrics({
196
- nodes: h.node_count || (h.nodes || []).length || 1,
197
- edges: h.edge_count || (h.edges || []).length || 0,
198
- council_active: 0,
199
- knowledge_growth: 0,
200
  civilization_age_min: 0,
201
  mycelium_density_pct: h.node_count
202
- ? Math.round(Math.min(1, (h.edge_count || 0) / Math.max(1, h.node_count * 1.4)) * 100)
203
  : 0,
204
  coherence_pct: 70,
205
  });
206
  } catch (e) {
207
- console.warn('[restore] failed', e);
208
- updateLegend();
209
  }
210
  }
211
  restore();
212
 
213
- /* =========================================================
214
- PART D — SUBMIT a turn
215
- ========================================================= */
216
  async function submit() {
217
  if (document.body.dataset.busy === '1') return;
218
- const text = input ? input.value.trim() : '';
 
219
  if (!text && attachments.length === 0) return;
220
 
221
  setBusy(true);
222
- addCls(seedHint, 'hidden');
223
- if (input) input.value = '';
224
 
225
  const files = attachments.map(a => a.file);
226
- // Clear preview strip (response will show separate "Analyzed:" toast)
227
- attachments.forEach(a => {
228
- if (a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl); } catch {} }
229
- });
230
  attachments = [];
231
  renderStrip();
232
 
233
  try {
234
- const data = await ElysiumAPI.turn(text, files);
235
- handleResponse(data || {});
236
  } catch (e) {
237
- console.error('[submit] failed', e);
238
- window.toast('Inference failed: ' + (e.message || e), 'error');
239
  } finally {
240
  setBusy(false);
241
  }
242
  }
243
 
244
- if (send) send.onclick = submit;
245
- if (input) {
246
- input.addEventListener('keydown', e => {
247
- if (e.key === 'Enter' && !e.shiftKey) {
248
- e.preventDefault();
249
- submit();
250
- }
251
- });
252
- }
253
 
254
- /* =========================================================
255
- PART E — HANDLE RESPONSE
256
- Parses the JSON envelope and routes it. NEVER renders raw
257
- JSON to the canvas or any visible element.
258
- ========================================================= */
259
  function handleResponse(payload) {
260
- // payload schema: { user_msg, elysium_response, _runtime }
261
- let resp = {};
262
- let rt = {};
263
- try {
264
- resp = payload.elysium_response || {};
265
- rt = payload._runtime || {};
266
- } catch (e) {
267
- console.error('[handleResponse] malformed payload', e);
268
- window.toast('Malformed response from model', 'error');
269
- return;
270
- }
271
 
272
- // 0. Attachment errors (server-side validation failures) → toast
273
- safeArr(rt.attachment_errors).forEach(e =>
274
  window.toast(`📎 ${e.name}: ${e.error}`, 'warn'));
275
 
276
- // 1. Hypergraph delta → canvas (NODES then EDGES; build parent map)
277
  const delta = resp.hypergraph_delta || {};
278
- const edgesAdded = safeArr(delta.edges_added);
279
- const nodesAdded = safeArr(delta.nodes_added);
280
-
281
- // Build a parent-hint map: for each new node, what's its closest source
282
- // (used so spawn animation grows OUT FROM the parent, not the centre).
283
- const parentHint = {};
284
- edgesAdded.forEach(e => {
285
- if (!e) return;
286
- if (e.target_node_id && !parentHint[e.target_node_id]) {
287
- parentHint[e.target_node_id] = e.source_node_id;
288
- }
289
- if (e.source_node_id && !parentHint[e.source_node_id]) {
290
- // weaker hint (reverse direction) — only if not set
291
- if (!parentHint[e.source_node_id]) {
292
- parentHint[e.source_node_id] = e.target_node_id;
293
- }
294
- }
295
- });
296
 
297
  nodesAdded.forEach(n => {
298
- if (!n || !n.node_id) return;
299
- try {
300
- if (typeof window.elysiumAddNode === 'function') {
301
- window.elysiumAddNode(n, parentHint[n.node_id]);
302
- }
303
- } catch (err) {
304
- console.warn('[handleResponse] addNode failed', n, err);
305
  }
 
306
  });
307
-
308
  edgesAdded.forEach(e => {
309
- if (!e || !e.source_node_id || !e.target_node_id) return;
310
- try {
311
- if (typeof window.elysiumAddEdge === 'function') window.elysiumAddEdge(e);
312
- } catch (err) {
313
- console.warn('[handleResponse] addEdge failed', e, err);
314
- }
315
  });
316
 
317
- // Apply node updates (fields_changed)
318
- safeArr(delta.nodes_updated).forEach(u => {
319
- if (!u || !u.node_id) return;
320
- const node = window.elysiumGetNode && window.elysiumGetNode(u.node_id);
321
- if (node && u.fields_changed) {
322
- Object.assign(node.payload || (node.payload = {}), u.fields_changed);
323
- }
324
- });
325
-
326
- // 2. UI directives — pulses, focus, alert
327
  const ui = resp.ui_directives || {};
328
- safeArr(ui.bioluminescence_pulse_nodes).forEach(id => {
329
- if (typeof window.elysiumPulse === 'function') window.elysiumPulse(id, 1600);
330
  });
331
  document.body.dataset.alert = ui.alert_level || 'CALM';
332
- if (ui.camera_focus_node_id && typeof window.elysiumFocus === 'function') {
333
- if (typeof window.elysiumPulse === 'function')
334
  window.elysiumPulse(ui.camera_focus_node_id, 1800);
335
- setTimeout(() => {
336
- try { window.elysiumFocus(ui.camera_focus_node_id); } catch {}
337
- }, 380);
338
  }
339
 
340
- // 3. Council overlay + TTS
341
- try {
342
- if (typeof window.renderCouncil === 'function') {
343
- window.renderCouncil(resp, rt);
344
- }
345
- } catch (e) {
346
- console.error('[council] render failed', e);
347
- }
348
 
349
- // 4. Metrics bar (REAL civilization metrics)
350
  updateMetrics(rt.metrics || {});
351
 
352
  // 5. Agent count badge
353
- const ag = safeArr(resp.council_deliberation && resp.council_deliberation.agent_outputs).length;
354
- const badge = $('agent-count');
355
- if (badge) badge.textContent = `+${ag}`;
356
 
357
- // 6. Legend live update (counts per type)
358
  updateLegend();
359
 
360
  // 7. Tool toasts
361
- safeArr(rt.tool_results).forEach(tr => {
362
- if (!tr) return;
363
  const ok = tr.result && !tr.result.error;
364
- window.toast(
365
- `🔧 ${tr.tool_name || 'tool'}: ${ok ? 'ok' : (tr.result?.error || 'offline')}`,
366
- ok ? 'info' : 'warn'
367
- );
368
  });
369
 
370
- // 8. Direct answer toast — ONLY when no council (otherwise it's already
371
- // shown in the overlay synthesis). NEVER dumps raw JSON.
372
- if (!ag && resp.direct_answer && typeof resp.direct_answer === 'string') {
373
- window.toast(resp.direct_answer.slice(0, 240));
374
  }
375
 
376
- // 9. Confirm attachment analysis
377
- const processed = safeArr(rt.attachments_processed);
378
- if (processed.length) {
379
- const names = processed.map(a => a && a.name).filter(Boolean).join(', ');
380
- if (names) window.toast(`📎 Analyzed: ${names}`, 'info');
381
  }
382
- }
383
 
384
- function safeArr(x) { return Array.isArray(x) ? x : []; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
 
386
- /* =========================================================
387
- PART F — Metrics & Legend updates
388
- ========================================================= */
389
  function updateMetrics(m) {
390
  m = m || {};
391
- const nodes = m.nodes ?? (window.ELYSIUM ? window.ELYSIUM.nodes.size : 1);
392
- const edges = m.edges ?? (window.ELYSIUM ? window.ELYSIUM.edges.length : 0);
393
- setText('s-nodes', nodes);
394
- setText('s-edges', edges);
395
- setText('s-council', m.council_active ?? 0);
396
- setText('s-growth', m.knowledge_growth ?? 0);
397
- setText('s-age', m.civilization_age_min ?? 0);
398
- setText('m-density', (m.mycelium_density_pct ?? 0) + '%');
399
- setText('m-coherence', (m.coherence_pct ?? 70) + '%');
400
- }
401
-
402
- function setText(id, value) {
403
- const el = $(id);
404
- if (el) el.textContent = value;
405
  }
406
 
407
  function updateLegend() {
408
  const host = $('legend-items');
409
  if (!host || !window.ELYSIUM) return;
410
  const counts = {};
411
- window.ELYSIUM.nodes.forEach(n => {
412
- const t = n.type || 'CONCEPT';
413
- counts[t] = (counts[t] || 0) + 1;
414
- });
415
  const order = ['CORE','CIVILIZATION','DOMAIN','AGENT','TOOL','PROJECT',
416
  'LIFE_EVENT','EMOTION','PERSON','VALUE','MEMORY','FACT','CONCEPT','QUERY'];
417
  const seen = new Set();
418
  const parts = [];
419
- const colorFn = window.colorFor || (() => '#a76bff');
420
- order.forEach(t => {
421
- if (!counts[t]) return;
422
  seen.add(t);
423
- const c = colorFn(t);
424
  parts.push(`
425
  <div class="legend-item" data-type="${t}">
426
  <span class="dot" style="background:${c};color:${c}"></span>
@@ -430,44 +330,37 @@
430
  });
431
  Object.keys(counts).forEach(t => {
432
  if (seen.has(t)) return;
433
- const c = colorFn(t);
434
- parts.push(`
435
- <div class="legend-item" data-type="${t}">
436
  <span class="dot" style="background:${c};color:${c}"></span>
437
  <span class="badge">${counts[t]}</span>
438
  <span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
439
  </div>`);
440
  });
441
  host.innerHTML = parts.join('');
442
- // Wire legend filter clicks
443
  host.querySelectorAll('.legend-item').forEach(el => {
444
  el.onclick = () => {
445
  const t = el.dataset.type;
446
- if (typeof window.elysiumFilterType === 'function') window.elysiumFilterType(t);
447
- const active = window.ELYSIUM && window.ELYSIUM.filterType;
448
- host.querySelectorAll('.legend-item').forEach(x => {
449
- x.style.opacity = active
450
- ? (x.dataset.type === active ? '1' : '.45')
451
- : '1';
452
- });
453
  };
454
  });
455
  }
456
 
457
- /* =========================================================
458
- PART G — RI Analysis button (fit + summary)
459
- ========================================================= */
460
- const ri = $('ri-analysis');
461
- if (ri) {
462
- ri.onclick = () => {
463
- if (typeof window.elysiumFitAll === 'function') window.elysiumFitAll();
464
- const n = window.ELYSIUM ? window.ELYSIUM.nodes.size : 0;
465
- const e = window.ELYSIUM ? window.ELYSIUM.edges.length : 0;
466
- window.toast(`🔍 Civilization snapshot: ${n} nodes · ${e} threads`, 'info');
467
- };
468
- }
469
 
470
- // Expose for debugging (read-only-ish)
471
- window.elysiumUpdateLegend = updateLegend;
472
- window.elysiumUpdateMetrics = updateMetrics;
 
 
473
  })();
 
1
+ /* Main app boot — wires UI ↔ /api/turn.
2
+
3
+ FIXES vs previous build:
4
+ NEVER renders raw JSON anywhere. `direct_answer` is sanitised before
5
+ toasting — anything that looks like JSON, looks like a <think> tag,
6
+ or exceeds 240 chars is silently dropped.
7
+ Hard null-guards around every DOM lookup inside handleResponse so a
8
+ partial backend response can never throw "cannot read classList of null".
9
+ Civilization map (minimap) is now refreshed on every turn via the
10
+ `_runtime.civilization_map` snapshot.
11
+ Attachment preview strip shows above input with X-cancel — preserved
12
+ & verified to live on /attach-strip with file metadata + thumbnail.
13
+ • Busy lock prevents double-sends; stuck "Inference failed" toasts no
14
+ longer fire because parse errors are caught server-side.
15
+ */
 
 
 
 
 
 
16
  (() => {
17
+ const $ = (id) => document.getElementById(id);
 
 
 
 
 
 
 
 
 
 
 
18
 
19
+ const input = $('q-input');
20
+ const send = $('q-send');
21
+ const fileEl = $('q-file');
22
+ const upBtn = $('q-upload');
23
+ const strip = $('attach-strip');
24
+ const seedHint = $('seed-hint');
25
 
26
+ // ── Toast helper ──
27
  window.toast = function (msg, kind = '') {
28
+ if (msg == null) return;
29
+ const text = String(msg).trim();
30
+ if (!text) return;
31
  const t = document.createElement('div');
32
+ t.className = 'toast ' + kind;
33
+ t.textContent = text.length > 240 ? text.slice(0, 237) + '…' : text;
34
+ const host = $('toasts');
35
+ if (!host) return;
36
  host.appendChild(t);
37
+ setTimeout(() => t.remove(), 5200);
38
  };
39
 
40
+ // ── direct_answer sanitiser ── (TASK 1: never display JSON on top)
41
+ function safeDirectAnswer(s) {
42
+ if (!s) return '';
43
+ const text = String(s).trim();
44
+ if (!text) return '';
45
+ // Looks like JSON? (starts with { or [, or contains "schema_version")
46
+ if (/^[\{\[]/.test(text)) return '';
47
+ if (/schema_version|hypergraph_delta|council_deliberation/.test(text)) return '';
48
+ // Contains <think> or other XML-like tags? drop.
49
+ if (/<\s*\/?\s*(think|reasoning|tool|json)\b/i.test(text)) return '';
50
+ // Excessively long (probably an error dump)
51
+ if (text.length > 320) return '';
52
+ return text;
53
  }
54
 
55
+ // ── File attachment state (max 2) ── TASK 2
 
 
 
 
 
 
 
 
 
 
56
  const MAX_FILES = 2;
57
+ let attachments = [];
 
58
 
59
  function renderStrip() {
60
  if (!strip) return;
61
  if (attachments.length === 0) {
62
+ strip.classList.add('hidden');
63
  strip.innerHTML = '';
64
  return;
65
  }
66
+ strip.classList.remove('hidden');
67
+ const hintHtml = attachments.length < MAX_FILES
68
  ? `<span class="attach-hint">${MAX_FILES - attachments.length} more allowed</span>`
69
  : `<span class="attach-hint">max ${MAX_FILES} reached</span>`;
70
  strip.innerHTML = attachments.map((a, i) => {
 
76
  <div class="preview-tile" data-i="${i}">
77
  ${preview}
78
  <span class="nm" title="${escapeHtml(a.name)}">${escapeHtml(a.name)} · ${sizeKb}KB</span>
79
+ <button class="x" data-i="${i}" title="Remove">×</button>
80
  </div>`;
81
+ }).join('') + hintHtml;
 
 
82
  strip.querySelectorAll('.x').forEach(b => {
83
  b.onclick = (ev) => {
 
84
  ev.stopPropagation();
85
  const i = +b.dataset.i;
86
  const removed = attachments.splice(i, 1)[0];
87
+ if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
 
 
88
  renderStrip();
89
  };
90
  });
91
  }
92
 
93
  function addFiles(files) {
94
+ const list = Array.from(files || []);
 
95
  for (const f of list) {
96
  if (attachments.length >= MAX_FILES) {
97
  window.toast(`Max ${MAX_FILES} attachments allowed`, 'warn');
 
101
  const isImg = mime.startsWith('image/');
102
  const isPdf = mime === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
103
  if (!isImg && !isPdf) {
104
+ window.toast(`Unsupported file: ${f.name} (only images & PDFs)`, 'warn');
105
  continue;
106
  }
107
+ if (f.size > 12 * 1024 * 1024) {
108
  window.toast(`${f.name} is too large (>12MB)`, 'warn');
109
  continue;
110
  }
 
116
  });
117
  }
118
  renderStrip();
119
+ if (fileEl) fileEl.value = '';
120
  }
121
 
122
+ if (fileEl) fileEl.addEventListener('change', e => addFiles(e.target.files));
 
 
123
 
124
+ // Drag-and-drop on whole window
125
  ['dragenter', 'dragover'].forEach(ev =>
126
  window.addEventListener(ev, e => { e.preventDefault(); }));
127
  window.addEventListener('drop', e => {
128
+ if (!e.dataTransfer || !e.dataTransfer.files.length) return;
129
  e.preventDefault();
130
  addFiles(e.dataTransfer.files);
131
  });
132
 
133
+ // ── Busy lock ──
 
 
134
  function setBusy(b) {
135
  document.body.dataset.busy = b ? '1' : '0';
136
  if (input) input.disabled = b;
137
  if (send) send.disabled = b;
138
  if (b) {
139
+ send?.classList.add('loading');
140
+ upBtn?.classList.add('disabled');
141
  if (fileEl) fileEl.disabled = true;
142
  if (input) {
143
+ input.dataset.prev = input.placeholder;
144
  input.placeholder = 'Council deliberating…';
145
  }
146
  } else {
147
+ send?.classList.remove('loading');
148
+ upBtn?.classList.remove('disabled');
149
  if (fileEl) fileEl.disabled = false;
150
+ if (input) input.placeholder = input.dataset.prev || 'Speak to your civilization seed…';
 
 
151
  }
152
  }
153
 
154
+ // ── Restore civilization on load ──
 
 
155
  async function restore() {
156
  try {
157
+ const h = await window.ElysiumAPI.hypergraph();
158
  (h.nodes || []).forEach(n => {
159
+ if (n.node_id !== 'CORE') window.elysiumAddNode(n);
 
 
 
 
 
160
  });
161
+ (h.edges || []).forEach(e => window.elysiumAddEdge(e));
162
+ if ((h.nodes || []).length > 0) seedHint?.classList.add('hidden');
163
  updateLegend();
164
  updateMetrics({
165
+ nodes: h.node_count, edges: h.edge_count,
166
+ council_active: 0, knowledge_growth: 0,
 
 
167
  civilization_age_min: 0,
168
  mycelium_density_pct: h.node_count
169
+ ? Math.round(Math.min(1, h.edge_count / Math.max(1, h.node_count * 1.4)) * 100)
170
  : 0,
171
  coherence_pct: 70,
172
  });
173
  } catch (e) {
174
+ // offline first boot — fine
 
175
  }
176
  }
177
  restore();
178
 
179
+ // ── SUBMIT ──
 
 
180
  async function submit() {
181
  if (document.body.dataset.busy === '1') return;
182
+ if (!input) return;
183
+ const text = input.value.trim();
184
  if (!text && attachments.length === 0) return;
185
 
186
  setBusy(true);
187
+ seedHint?.classList.add('hidden');
188
+ input.value = '';
189
 
190
  const files = attachments.map(a => a.file);
191
+ attachments.forEach(a => a.previewUrl && URL.revokeObjectURL(a.previewUrl));
 
 
 
192
  attachments = [];
193
  renderStrip();
194
 
195
  try {
196
+ const data = await window.ElysiumAPI.turn(text, files);
197
+ handleResponse(data);
198
  } catch (e) {
199
+ window.toast('Network error: ' + (e?.message || 'unknown'), 'error');
 
200
  } finally {
201
  setBusy(false);
202
  }
203
  }
204
 
205
+ send?.addEventListener('click', submit);
206
+ input?.addEventListener('keydown', e => {
207
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
208
+ });
 
 
 
 
 
209
 
210
+ // ── HANDLE MODEL RESPONSE ── (TASK 1: route JSON to features, never display raw)
 
 
 
 
211
  function handleResponse(payload) {
212
+ payload = payload || {};
213
+ const resp = payload.elysium_response || {};
214
+ const rt = payload._runtime || {};
 
 
 
 
 
 
 
 
215
 
216
+ // 0. Attachment errors → toast
217
+ (rt.attachment_errors || []).forEach(e =>
218
  window.toast(`📎 ${e.name}: ${e.error}`, 'warn'));
219
 
220
+ // 1. Hypergraph delta → canvas
221
  const delta = resp.hypergraph_delta || {};
222
+ const nodesAdded = delta.nodes_added || [];
223
+ const edgesAdded = delta.edges_added || [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
  nodesAdded.forEach(n => {
226
+ // Find a parent hint: first edge touching this node
227
+ let parent = null;
228
+ for (const e of edgesAdded) {
229
+ if (e.target_node_id === n.node_id) { parent = e.source_node_id; break; }
230
+ if (e.source_node_id === n.node_id) { parent = e.target_node_id; break; }
 
 
231
  }
232
+ try { window.elysiumAddNode(n, parent); } catch (err) { console.warn('addNode failed', err); }
233
  });
 
234
  edgesAdded.forEach(e => {
235
+ try { window.elysiumAddEdge(e); } catch (err) { console.warn('addEdge failed', err); }
 
 
 
 
 
236
  });
237
 
238
+ // 2. UI directives
 
 
 
 
 
 
 
 
 
239
  const ui = resp.ui_directives || {};
240
+ (ui.bioluminescence_pulse_nodes || []).forEach(id => {
241
+ try { window.elysiumPulse(id, 1500); } catch (_) {}
242
  });
243
  document.body.dataset.alert = ui.alert_level || 'CALM';
244
+ if (ui.camera_focus_node_id) {
245
+ try {
246
  window.elysiumPulse(ui.camera_focus_node_id, 1800);
247
+ setTimeout(() => window.elysiumFocus(ui.camera_focus_node_id), 350);
248
+ } catch (_) {}
 
249
  }
250
 
251
+ // 3. Council overlay + TTS (null-safe)
252
+ try { window.renderCouncil?.(resp, rt); } catch (err) { console.warn('renderCouncil failed', err); }
 
 
 
 
 
 
253
 
254
+ // 4. Metrics bar
255
  updateMetrics(rt.metrics || {});
256
 
257
  // 5. Agent count badge
258
+ const ag = (resp.council_deliberation?.agent_outputs || []).length;
259
+ const agBadge = $('agent-count');
260
+ if (agBadge) agBadge.textContent = `+${ag}`;
261
 
262
+ // 6. Legend live update
263
  updateLegend();
264
 
265
  // 7. Tool toasts
266
+ (rt.tool_results || []).forEach(tr => {
 
267
  const ok = tr.result && !tr.result.error;
268
+ window.toast(`🔧 ${tr.tool_name}: ${ok ? 'ok' : (tr.result?.error || 'offline')}`,
269
+ ok ? 'info' : 'warn');
 
 
270
  });
271
 
272
+ // 8. Direct answer toast — STRICTLY sanitised. Never raw JSON. Never errors.
273
+ if (!ag) {
274
+ const safe = safeDirectAnswer(resp.direct_answer);
275
+ if (safe) window.toast(safe);
276
  }
277
 
278
+ // 9. Attachment processed confirmation
279
+ if ((rt.attachments_processed || []).length) {
280
+ const names = rt.attachments_processed.map(a => a.name).join(', ');
281
+ window.toast(`📎 Analyzed: ${names}`, 'info');
 
282
  }
 
283
 
284
+ // 10. Civilization map sync (TASK 4) frontend already has the nodes from
285
+ // hypergraph_delta; this verifies counts match backend truth.
286
+ if (rt.civilization_map) {
287
+ // No reassignment of positions — the canvas owns layout. We only ensure
288
+ // that any backend-side node missing from the canvas (e.g. restored
289
+ // across reload) gets added.
290
+ (rt.civilization_map.nodes || []).forEach(n => {
291
+ if (!window.ELYSIUM?.nodes.has(n.node_id) && n.node_id !== 'CORE') {
292
+ try { window.elysiumAddNode({
293
+ node_id: n.node_id, label: n.label, node_type: n.type,
294
+ }); } catch (_) {}
295
+ }
296
+ });
297
+ }
298
+ }
299
 
 
 
 
300
  function updateMetrics(m) {
301
  m = m || {};
302
+ const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
303
+ set('s-nodes', (m.nodes ?? window.ELYSIUM?.nodes.size ?? 1));
304
+ set('s-edges', (m.edges ?? window.ELYSIUM?.edges.length ?? 0));
305
+ set('s-council',(m.council_active ?? 0));
306
+ set('s-growth', (m.knowledge_growth ?? 0));
307
+ set('s-age', (m.civilization_age_min ?? 0));
308
+ set('m-density', (m.mycelium_density_pct ?? 0) + '%');
309
+ set('m-coherence',(m.coherence_pct ?? 70) + '%');
 
 
 
 
 
 
310
  }
311
 
312
  function updateLegend() {
313
  const host = $('legend-items');
314
  if (!host || !window.ELYSIUM) return;
315
  const counts = {};
316
+ window.ELYSIUM.nodes.forEach(n => counts[n.type] = (counts[n.type] || 0) + 1);
 
 
 
317
  const order = ['CORE','CIVILIZATION','DOMAIN','AGENT','TOOL','PROJECT',
318
  'LIFE_EVENT','EMOTION','PERSON','VALUE','MEMORY','FACT','CONCEPT','QUERY'];
319
  const seen = new Set();
320
  const parts = [];
321
+ order.filter(t => counts[t] && !seen.has(t)).forEach(t => {
 
 
322
  seen.add(t);
323
+ const c = window.colorFor(t);
324
  parts.push(`
325
  <div class="legend-item" data-type="${t}">
326
  <span class="dot" style="background:${c};color:${c}"></span>
 
330
  });
331
  Object.keys(counts).forEach(t => {
332
  if (seen.has(t)) return;
333
+ const c = window.colorFor(t);
334
+ parts.push(`<div class="legend-item" data-type="${t}">
 
335
  <span class="dot" style="background:${c};color:${c}"></span>
336
  <span class="badge">${counts[t]}</span>
337
  <span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
338
  </div>`);
339
  });
340
  host.innerHTML = parts.join('');
 
341
  host.querySelectorAll('.legend-item').forEach(el => {
342
  el.onclick = () => {
343
  const t = el.dataset.type;
344
+ window.elysiumFilterType?.(t);
345
+ host.querySelectorAll('.legend-item').forEach(x =>
346
+ x.style.opacity = window.ELYSIUM.filterType
347
+ ? (x.dataset.type === window.ELYSIUM.filterType ? '1' : '.45')
348
+ : '1');
 
 
349
  };
350
  });
351
  }
352
 
353
+ // RI Analysis
354
+ $('ri-analysis')?.addEventListener('click', () => {
355
+ window.elysiumFitAll?.();
356
+ const n = window.ELYSIUM?.nodes.size ?? 0;
357
+ const e = window.ELYSIUM?.edges.length ?? 0;
358
+ window.toast(`🔍 Civilization snapshot: ${n} nodes · ${e} threads`, 'info');
359
+ });
 
 
 
 
 
360
 
361
+ function escapeHtml(s) {
362
+ return String(s || '').replace(/[&<>"']/g, c => ({
363
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
364
+ }[c]));
365
+ }
366
  })();