ai-agent-session-center 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +618 -0
  2. package/bin/cli.js +20 -0
  3. package/hooks/dashboard-hook-codex.sh +67 -0
  4. package/hooks/dashboard-hook-gemini.sh +102 -0
  5. package/hooks/dashboard-hook.ps1 +147 -0
  6. package/hooks/dashboard-hook.sh +142 -0
  7. package/hooks/dashboard-hooks-backup.json +103 -0
  8. package/hooks/install-hooks.js +543 -0
  9. package/hooks/reset.js +357 -0
  10. package/hooks/setup-wizard.js +156 -0
  11. package/package.json +52 -0
  12. package/public/css/dashboard.css +10200 -0
  13. package/public/index.html +915 -0
  14. package/public/js/analyticsPanel.js +467 -0
  15. package/public/js/app.js +1148 -0
  16. package/public/js/browserDb.js +806 -0
  17. package/public/js/chartUtils.js +383 -0
  18. package/public/js/historyPanel.js +298 -0
  19. package/public/js/movementManager.js +155 -0
  20. package/public/js/navController.js +32 -0
  21. package/public/js/robotManager.js +526 -0
  22. package/public/js/sceneManager.js +7 -0
  23. package/public/js/sessionPanel.js +2477 -0
  24. package/public/js/settingsManager.js +924 -0
  25. package/public/js/soundManager.js +249 -0
  26. package/public/js/statsPanel.js +118 -0
  27. package/public/js/terminalManager.js +391 -0
  28. package/public/js/timelinePanel.js +278 -0
  29. package/public/js/wsClient.js +88 -0
  30. package/server/apiRouter.js +321 -0
  31. package/server/config.js +120 -0
  32. package/server/hookProcessor.js +55 -0
  33. package/server/hookRouter.js +18 -0
  34. package/server/hookStats.js +107 -0
  35. package/server/index.js +314 -0
  36. package/server/logger.js +67 -0
  37. package/server/mqReader.js +218 -0
  38. package/server/serverConfig.js +27 -0
  39. package/server/sessionStore.js +1049 -0
  40. package/server/sshManager.js +339 -0
  41. package/server/wsManager.js +83 -0
@@ -0,0 +1,924 @@
1
+ // settingsManager.js — Settings persistence and event system
2
+ import * as db from './browserDb.js';
3
+
4
+ const defaults = {
5
+ theme: 'command-center',
6
+ fontSize: '13',
7
+ soundEnabled: 'true',
8
+ soundVolume: '0.5',
9
+ soundActions: '',
10
+ scanlineEnabled: 'true',
11
+ cardSize: 'small',
12
+ activityFeedVisible: 'true',
13
+ characterModel: 'robot',
14
+ animationIntensity: '100',
15
+ animationSpeed: '100',
16
+ movementActions: '',
17
+ hookDensity: 'off',
18
+ labelSettings: JSON.stringify({
19
+ ONEOFF: { sound: 'alarm', movement: 'shake', frame: 'fire' },
20
+ HEAVY: { sound: 'urgentAlarm', movement: 'flash', frame: 'electric' },
21
+ IMPORTANT: { sound: 'fanfare', movement: 'bounce', frame: 'liquid' },
22
+ })
23
+ };
24
+
25
+ let settings = { ...defaults };
26
+ const listeners = new Map(); // key -> Set of callbacks
27
+
28
+ export async function loadSettings() {
29
+ try {
30
+ const data = await db.getAllSettings();
31
+ settings = { ...defaults, ...data };
32
+ } catch (e) {
33
+ console.error('[settings] Failed to load:', e.message);
34
+ }
35
+ return settings;
36
+ }
37
+
38
+ export function get(key) {
39
+ return settings[key] ?? defaults[key];
40
+ }
41
+
42
+ export function getAll() {
43
+ return { ...settings };
44
+ }
45
+
46
+ export async function set(key, value) {
47
+ settings[key] = value;
48
+ try {
49
+ await db.setSetting(key, String(value));
50
+ flashAutosave();
51
+ } catch (e) {
52
+ console.error('[settings] Failed to save:', e.message);
53
+ }
54
+ // Notify listeners
55
+ const cbs = listeners.get(key);
56
+ if (cbs) cbs.forEach(cb => cb(value));
57
+ }
58
+
59
+ export function onChange(key, callback) {
60
+ if (!listeners.has(key)) listeners.set(key, new Set());
61
+ listeners.get(key).add(callback);
62
+ }
63
+
64
+ // Apply theme to body
65
+ export function applyTheme(themeName) {
66
+ if (themeName === 'command-center') {
67
+ document.body.removeAttribute('data-theme');
68
+ } else {
69
+ document.body.setAttribute('data-theme', themeName);
70
+ }
71
+ }
72
+
73
+ // Apply font size
74
+ export function applyFontSize(size) {
75
+ document.documentElement.style.fontSize = size + 'px';
76
+ }
77
+
78
+ // Apply scanline setting
79
+ export function applyScanline(enabled) {
80
+ document.body.classList.toggle('no-scanlines', enabled !== 'true');
81
+ }
82
+
83
+ // Apply animation intensity (0-200, default 100)
84
+ export function applyAnimationIntensity(value) {
85
+ const v = parseFloat(value) / 100;
86
+ document.documentElement.style.setProperty('--anim-intensity', v);
87
+ }
88
+
89
+ // Apply animation speed (30-200, default 100 → lower = faster)
90
+ export function applyAnimationSpeed(value) {
91
+ const v = parseFloat(value) / 100;
92
+ document.documentElement.style.setProperty('--anim-speed', v);
93
+ }
94
+
95
+ // Apply activity feed visibility
96
+ export function applyActivityFeed(visible) {
97
+ const feed = document.getElementById('activity-feed');
98
+ if (feed) feed.style.display = visible === 'true' ? '' : 'none';
99
+ }
100
+
101
+ // ---- Hook Density ----
102
+
103
+ const DENSITY_EVENTS = {
104
+ high: ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop', 'SessionEnd'],
105
+ medium: ['SessionStart', 'UserPromptSubmit', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop', 'SessionEnd'],
106
+ low: ['SessionStart', 'UserPromptSubmit', 'Stop', 'SessionEnd']
107
+ };
108
+
109
+ // Fetch live status from ~/.claude/settings.json and sync UI
110
+ export async function syncHookDensityUI() {
111
+ const statusEl = document.getElementById('hook-density-status');
112
+ const btns = document.querySelectorAll('.density-btn');
113
+ const installBtn = document.getElementById('hook-install-btn');
114
+ const uninstallBtn = document.getElementById('hook-uninstall-btn');
115
+ if (!statusEl) return;
116
+
117
+ try {
118
+ const resp = await fetch('/api/hooks/status');
119
+ const data = await resp.json();
120
+
121
+ // Update button active state
122
+ btns.forEach(b => b.classList.toggle('active', b.dataset.density === data.density));
123
+
124
+ // Update status text
125
+ if (data.installed) {
126
+ statusEl.innerHTML = `<span class="hook-status-dot installed"></span> Installed: <strong>${data.density}</strong> (${data.events.length} events)`;
127
+ statusEl.title = data.events.join(', ');
128
+ } else {
129
+ statusEl.innerHTML = `<span class="hook-status-dot"></span> Not installed`;
130
+ statusEl.title = '';
131
+ }
132
+
133
+ if (installBtn) installBtn.textContent = data.installed ? 'Re-install' : 'Install';
134
+ if (uninstallBtn) uninstallBtn.classList.toggle('hidden', !data.installed);
135
+ } catch {
136
+ statusEl.innerHTML = '<span class="hook-status-dot"></span> Unable to check';
137
+ }
138
+ }
139
+
140
+ export async function installHookDensity(density) {
141
+ const statusEl = document.getElementById('hook-density-status');
142
+ const installBtn = document.getElementById('hook-install-btn');
143
+ if (installBtn) { installBtn.disabled = true; installBtn.textContent = 'Installing...'; }
144
+
145
+ try {
146
+ const resp = await fetch('/api/hooks/install', {
147
+ method: 'POST',
148
+ headers: { 'Content-Type': 'application/json' },
149
+ body: JSON.stringify({ density })
150
+ });
151
+ const data = await resp.json();
152
+ if (!resp.ok) throw new Error(data.error || 'Install failed');
153
+ await set('hookDensity', density);
154
+ await syncHookDensityUI();
155
+ } catch (err) {
156
+ if (statusEl) statusEl.innerHTML = `<span class="hook-status-dot"></span> Error: ${err.message}`;
157
+ } finally {
158
+ if (installBtn) installBtn.disabled = false;
159
+ }
160
+ }
161
+
162
+ export async function uninstallHooks() {
163
+ const statusEl = document.getElementById('hook-density-status');
164
+ const uninstallBtn = document.getElementById('hook-uninstall-btn');
165
+ if (uninstallBtn) { uninstallBtn.disabled = true; uninstallBtn.textContent = 'Removing...'; }
166
+
167
+ try {
168
+ const resp = await fetch('/api/hooks/uninstall', { method: 'POST' });
169
+ const data = await resp.json();
170
+ if (!resp.ok) throw new Error(data.error || 'Uninstall failed');
171
+ await set('hookDensity', 'off');
172
+ await syncHookDensityUI();
173
+ } catch (err) {
174
+ if (statusEl) statusEl.innerHTML = `<span class="hook-status-dot"></span> Error: ${err.message}`;
175
+ } finally {
176
+ if (uninstallBtn) { uninstallBtn.disabled = false; uninstallBtn.textContent = 'Uninstall'; }
177
+ }
178
+ }
179
+
180
+ // Flash autosave indicator
181
+ function flashAutosave() {
182
+ const el = document.getElementById('settings-autosave');
183
+ if (!el) return;
184
+ el.classList.remove('visible');
185
+ void el.offsetWidth; // force reflow
186
+ el.classList.add('visible');
187
+ }
188
+
189
+ // Export all settings as JSON file download
190
+ export function exportSettings() {
191
+ const data = JSON.stringify(getAll(), null, 2);
192
+ const blob = new Blob([data], { type: 'application/json' });
193
+ const url = URL.createObjectURL(blob);
194
+ const a = document.createElement('a');
195
+ a.href = url;
196
+ a.download = 'claude-dashboard-settings.json';
197
+ a.click();
198
+ URL.revokeObjectURL(url);
199
+ }
200
+
201
+ // Import settings from JSON file
202
+ export async function importSettings(file) {
203
+ const text = await file.text();
204
+ const imported = JSON.parse(text);
205
+ for (const [key, value] of Object.entries(imported)) {
206
+ await set(key, value);
207
+ }
208
+ // Re-apply all visual settings
209
+ applyTheme(get('theme'));
210
+ applyFontSize(get('fontSize'));
211
+ applyScanline(get('scanlineEnabled'));
212
+ applyActivityFeed(get('activityFeedVisible'));
213
+ applyAnimationIntensity(get('animationIntensity'));
214
+ applyAnimationSpeed(get('animationSpeed'));
215
+ // Refresh the UI to reflect imported values
216
+ syncUIToSettings();
217
+ }
218
+
219
+ // Reset all settings to defaults
220
+ export async function resetDefaults() {
221
+ for (const [key, value] of Object.entries(defaults)) {
222
+ await set(key, value);
223
+ }
224
+ applyTheme(defaults.theme);
225
+ applyFontSize(defaults.fontSize);
226
+ applyScanline(defaults.scanlineEnabled);
227
+ applyActivityFeed(defaults.activityFeedVisible);
228
+ applyAnimationIntensity(defaults.animationIntensity);
229
+ applyAnimationSpeed(defaults.animationSpeed);
230
+ syncUIToSettings();
231
+ }
232
+
233
+ // Sync all UI controls to current settings state
234
+ function syncUIToSettings() {
235
+ const currentTheme = get('theme');
236
+ applyTheme(currentTheme);
237
+ document.querySelectorAll('.theme-swatch').forEach(s => {
238
+ s.classList.toggle('active', s.dataset.theme === currentTheme);
239
+ });
240
+
241
+ const fontSize = get('fontSize');
242
+ applyFontSize(fontSize);
243
+ const slider = document.getElementById('font-size-slider');
244
+ const display = document.getElementById('font-size-display');
245
+ if (slider) slider.value = fontSize;
246
+ if (display) display.textContent = fontSize + 'px';
247
+
248
+ const soundEn = document.getElementById('sound-enabled');
249
+ if (soundEn) soundEn.checked = get('soundEnabled') === 'true';
250
+ const soundVol = document.getElementById('sound-volume');
251
+ if (soundVol) soundVol.value = get('soundVolume');
252
+ const volDisplay = document.getElementById('volume-display');
253
+ if (volDisplay) volDisplay.textContent = Math.round(parseFloat(get('soundVolume')) * 100) + '%';
254
+
255
+ const scanlineEl = document.getElementById('scanline-enabled');
256
+ if (scanlineEl) scanlineEl.checked = get('scanlineEnabled') === 'true';
257
+ applyScanline(get('scanlineEnabled'));
258
+
259
+ const feedEl = document.getElementById('activity-feed-visible');
260
+ if (feedEl) feedEl.checked = get('activityFeedVisible') === 'true';
261
+ applyActivityFeed(get('activityFeedVisible'));
262
+
263
+ // Character model
264
+ const currentModel = get('characterModel');
265
+ document.querySelectorAll('.char-swatch').forEach(s => {
266
+ s.classList.toggle('active', s.dataset.model === currentModel);
267
+ });
268
+
269
+ // Animation intensity
270
+ const animIntensity = get('animationIntensity');
271
+ applyAnimationIntensity(animIntensity);
272
+ const intSlider = document.getElementById('anim-intensity-slider');
273
+ const intDisplay = document.getElementById('anim-intensity-display');
274
+ if (intSlider) intSlider.value = animIntensity;
275
+ if (intDisplay) intDisplay.textContent = animIntensity + '%';
276
+
277
+ // Animation speed
278
+ const animSpeed = get('animationSpeed');
279
+ applyAnimationSpeed(animSpeed);
280
+ const spdSlider = document.getElementById('anim-speed-slider');
281
+ const spdDisplay = document.getElementById('anim-speed-display');
282
+ if (spdSlider) spdSlider.value = animSpeed;
283
+ if (spdDisplay) spdDisplay.textContent = animSpeed + '%';
284
+
285
+ }
286
+
287
+ // Reusable API key field wiring (toggle, save, load)
288
+ function setupApiKeyField(inputId, settingKey) {
289
+ const input = document.getElementById(inputId);
290
+ const toggle = document.getElementById(inputId + '-toggle');
291
+ const saveBtn = document.getElementById(inputId + '-save');
292
+ const status = document.getElementById(inputId + '-status');
293
+ if (!input) return;
294
+
295
+ // Load stored value
296
+ const stored = get(settingKey);
297
+ if (stored) {
298
+ input.value = stored;
299
+ if (status) status.textContent = 'Key saved in browser';
300
+ }
301
+
302
+ // Toggle show/hide
303
+ if (toggle) {
304
+ toggle.addEventListener('click', () => {
305
+ const isPassword = input.type === 'password';
306
+ input.type = isPassword ? 'text' : 'password';
307
+ toggle.textContent = isPassword ? 'HIDE' : 'SHOW';
308
+ });
309
+ }
310
+
311
+ // Save
312
+ if (saveBtn) {
313
+ saveBtn.addEventListener('click', async () => {
314
+ const val = input.value.trim();
315
+ if (val) {
316
+ await set(settingKey, val);
317
+ if (status) { status.textContent = 'Saved'; status.style.color = 'var(--accent-green, #4caf50)'; }
318
+ } else {
319
+ await set(settingKey, '');
320
+ if (status) { status.textContent = 'Cleared'; status.style.color = 'var(--text-dim)'; }
321
+ }
322
+ setTimeout(() => { if (status) { status.textContent = val ? 'Key saved in browser' : ''; status.style.color = ''; } }, 2000);
323
+ });
324
+ }
325
+ }
326
+
327
+ // Initialize settings UI bindings
328
+ export function initSettingsUI() {
329
+ // Open/close settings
330
+ document.getElementById('open-settings').addEventListener('click', () => {
331
+ document.getElementById('settings-modal').classList.remove('hidden');
332
+ });
333
+ document.getElementById('close-settings').addEventListener('click', () => {
334
+ document.getElementById('settings-modal').classList.add('hidden');
335
+ });
336
+ document.getElementById('settings-modal').addEventListener('click', (e) => {
337
+ if (e.target.id === 'settings-modal') {
338
+ document.getElementById('settings-modal').classList.add('hidden');
339
+ }
340
+ });
341
+
342
+ // --- Settings tab switching ---
343
+ let lastSettingsTab = 'appearance';
344
+ document.querySelector('.settings-tabs').addEventListener('click', (e) => {
345
+ const tab = e.target.closest('.settings-tab');
346
+ if (!tab) return;
347
+ const tabName = tab.dataset.settingsTab;
348
+ document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
349
+ document.querySelectorAll('.settings-tab-content').forEach(c => c.classList.remove('active'));
350
+ tab.classList.add('active');
351
+ const content = document.getElementById('settings-tab-' + tabName);
352
+ if (content) content.classList.add('active');
353
+ lastSettingsTab = tabName;
354
+ });
355
+
356
+ // Theme selection
357
+ document.getElementById('theme-grid').addEventListener('click', (e) => {
358
+ const swatch = e.target.closest('.theme-swatch');
359
+ if (!swatch) return;
360
+ const theme = swatch.dataset.theme;
361
+ document.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active'));
362
+ swatch.classList.add('active');
363
+ applyTheme(theme);
364
+ set('theme', theme);
365
+ });
366
+
367
+ // Font size controls
368
+ const slider = document.getElementById('font-size-slider');
369
+ const display = document.getElementById('font-size-display');
370
+ slider.addEventListener('input', () => {
371
+ const val = slider.value;
372
+ display.textContent = val + 'px';
373
+ applyFontSize(val);
374
+ set('fontSize', val);
375
+ });
376
+ document.getElementById('font-decrease').addEventListener('click', () => {
377
+ const val = Math.max(10, parseInt(slider.value) - 1);
378
+ slider.value = val;
379
+ display.textContent = val + 'px';
380
+ applyFontSize(val);
381
+ set('fontSize', String(val));
382
+ });
383
+ document.getElementById('font-increase').addEventListener('click', () => {
384
+ const val = Math.min(20, parseInt(slider.value) + 1);
385
+ slider.value = val;
386
+ display.textContent = val + 'px';
387
+ applyFontSize(val);
388
+ set('fontSize', String(val));
389
+ });
390
+
391
+ // --- Scanline toggle ---
392
+ const scanlineCheckbox = document.getElementById('scanline-enabled');
393
+ if (scanlineCheckbox) {
394
+ scanlineCheckbox.addEventListener('change', (e) => {
395
+ const enabled = String(e.target.checked);
396
+ applyScanline(enabled);
397
+ set('scanlineEnabled', enabled);
398
+ });
399
+ }
400
+
401
+ // Sound controls
402
+ document.getElementById('sound-enabled').addEventListener('change', (e) => {
403
+ set('soundEnabled', String(e.target.checked));
404
+ });
405
+ document.getElementById('sound-volume').addEventListener('input', (e) => {
406
+ const vol = e.target.value;
407
+ document.getElementById('volume-display').textContent = Math.round(vol * 100) + '%';
408
+ set('soundVolume', vol);
409
+ });
410
+
411
+ // --- Import / Export ---
412
+ const exportBtn = document.getElementById('export-settings');
413
+ if (exportBtn) {
414
+ exportBtn.addEventListener('click', () => exportSettings());
415
+ }
416
+ const importBtn = document.getElementById('import-settings-btn');
417
+ const importFile = document.getElementById('import-settings-file');
418
+ if (importBtn && importFile) {
419
+ importBtn.addEventListener('click', () => importFile.click());
420
+ importFile.addEventListener('change', async (e) => {
421
+ const file = e.target.files[0];
422
+ if (!file) return;
423
+ try {
424
+ await importSettings(file);
425
+ } catch (err) {
426
+ console.error('[settings] Import failed:', err.message);
427
+ }
428
+ importFile.value = '';
429
+ });
430
+ }
431
+
432
+ // --- Reset to defaults ---
433
+ const resetBtn = document.getElementById('reset-defaults');
434
+ if (resetBtn) {
435
+ resetBtn.addEventListener('click', async () => {
436
+ await resetDefaults();
437
+ });
438
+ }
439
+
440
+ // --- Animation intensity slider ---
441
+ const intensitySlider = document.getElementById('anim-intensity-slider');
442
+ const intensityDisplay = document.getElementById('anim-intensity-display');
443
+ if (intensitySlider && intensityDisplay) {
444
+ intensitySlider.addEventListener('input', () => {
445
+ const val = intensitySlider.value;
446
+ intensityDisplay.textContent = val + '%';
447
+ applyAnimationIntensity(val);
448
+ set('animationIntensity', val);
449
+ });
450
+ }
451
+
452
+ // --- Animation speed slider ---
453
+ const speedSlider = document.getElementById('anim-speed-slider');
454
+ const speedDisplay = document.getElementById('anim-speed-display');
455
+ if (speedSlider && speedDisplay) {
456
+ speedSlider.addEventListener('input', () => {
457
+ const val = speedSlider.value;
458
+ speedDisplay.textContent = val + '%';
459
+ applyAnimationSpeed(val);
460
+ set('animationSpeed', val);
461
+ });
462
+ }
463
+
464
+ // --- Character model ---
465
+ const charGrid = document.getElementById('char-model-grid');
466
+ if (charGrid) {
467
+ charGrid.addEventListener('click', async (e) => {
468
+ const swatch = e.target.closest('.char-swatch');
469
+ if (!swatch) return;
470
+ const model = swatch.dataset.model;
471
+ document.querySelectorAll('.char-swatch').forEach(s => s.classList.remove('active'));
472
+ swatch.classList.add('active');
473
+ await set('characterModel', model);
474
+ });
475
+ }
476
+
477
+ // --- Activity feed toggle ---
478
+ const feedToggle = document.getElementById('activity-feed-visible');
479
+ if (feedToggle) {
480
+ feedToggle.addEventListener('change', (e) => {
481
+ const visible = String(e.target.checked);
482
+ applyActivityFeed(visible);
483
+ set('activityFeedVisible', visible);
484
+ });
485
+ }
486
+
487
+ // --- Hook density controls ---
488
+ const densityControl = document.getElementById('hook-density-control');
489
+ if (densityControl) {
490
+ densityControl.addEventListener('click', (e) => {
491
+ const btn = e.target.closest('.density-btn');
492
+ if (!btn) return;
493
+ document.querySelectorAll('.density-btn').forEach(b => b.classList.remove('active'));
494
+ btn.classList.add('active');
495
+ });
496
+ }
497
+ const installBtn = document.getElementById('hook-install-btn');
498
+ if (installBtn) {
499
+ installBtn.addEventListener('click', () => {
500
+ const activeBtn = document.querySelector('.density-btn.active');
501
+ const density = activeBtn?.dataset.density || 'medium';
502
+ installHookDensity(density);
503
+ });
504
+ }
505
+ const uninstallBtn = document.getElementById('hook-uninstall-btn');
506
+ if (uninstallBtn) {
507
+ uninstallBtn.addEventListener('click', () => uninstallHooks());
508
+ }
509
+ // Fetch live hook status on settings init
510
+ syncHookDensityUI();
511
+
512
+ // --- API Keys (Anthropic, OpenAI, Gemini) ---
513
+ setupApiKeyField('settings-api-key', 'anthropicApiKey');
514
+ setupApiKeyField('settings-openai-key', 'openaiApiKey');
515
+ setupApiKeyField('settings-gemini-key', 'geminiApiKey');
516
+
517
+ // --- Apply all current settings to UI ---
518
+ syncUIToSettings();
519
+
520
+ // Build per-action sound config grid (deferred import to avoid circular)
521
+ initSoundGrid();
522
+
523
+ // Build per-action movement effect grid
524
+ initMovementGrid();
525
+
526
+ // Build label completion alerts grid
527
+ initLabelGrid();
528
+
529
+ // Build summary prompt template management
530
+ initSummaryPromptSettings();
531
+ }
532
+
533
+ /**
534
+ * Shared per-action config grid component.
535
+ * Used by both sound and movement settings.
536
+ *
537
+ * @param {HTMLElement} grid - container element
538
+ * @param {Object} opts
539
+ * @param {Object|string[]} opts.library - available options: { key: label } or [name, ...]
540
+ * @param {Object} opts.currentMapping - action -> current value
541
+ * @param {Object} opts.labels - action -> display label
542
+ * @param {Object} opts.categories - category -> [action, ...]
543
+ * @param {Function} opts.onChange - (action, value) => void
544
+ * @param {Function} [opts.onPreview] - (value) => void (adds preview button if provided)
545
+ */
546
+ function buildActionGrid(grid, opts) {
547
+ const { library, currentMapping, labels, categories, onChange, onPreview } = opts;
548
+
549
+ // Normalize library to [{ value, label }]
550
+ let options;
551
+ if (Array.isArray(library)) {
552
+ options = library.map(s => ({ value: s, label: s }));
553
+ } else {
554
+ options = Object.entries(library).map(([val, lbl]) => ({ value: val, label: lbl }));
555
+ }
556
+
557
+ let html = '';
558
+ for (const [category, actions] of Object.entries(categories)) {
559
+ html += `<div class="sound-category-label">${category}</div>`;
560
+ for (const action of actions) {
561
+ const current = currentMapping[action] || 'none';
562
+ const optionsHtml = options.map(o =>
563
+ `<option value="${o.value}"${o.value === current ? ' selected' : ''}>${o.label}</option>`
564
+ ).join('');
565
+ html += `<div class="sound-action-row">` +
566
+ `<span class="sound-action-label">${labels[action] || action}</span>` +
567
+ `<select class="sound-action-select" data-action="${action}">${optionsHtml}</select>` +
568
+ (onPreview ? `<button class="sound-preview-btn" data-action="${action}" title="Preview">&#9654;</button>` : '') +
569
+ `</div>`;
570
+ }
571
+ }
572
+ grid.innerHTML = html;
573
+
574
+ grid.addEventListener('change', (e) => {
575
+ const sel = e.target.closest('.sound-action-select');
576
+ if (!sel) return;
577
+ onChange(sel.dataset.action, sel.value);
578
+ });
579
+
580
+ if (onPreview) {
581
+ grid.addEventListener('click', (e) => {
582
+ const btn = e.target.closest('.sound-preview-btn');
583
+ if (!btn) return;
584
+ const action = btn.dataset.action;
585
+ const sel = grid.querySelector(`.sound-action-select[data-action="${action}"]`);
586
+ if (sel) onPreview(sel.value);
587
+ });
588
+ }
589
+ }
590
+
591
+ async function initMovementGrid() {
592
+ const movementManager = await import('./movementManager.js');
593
+ const robotManager = await import('./robotManager.js');
594
+ const grid = document.getElementById('movement-action-grid');
595
+ if (!grid) return;
596
+
597
+ // Render preview character in the viewport
598
+ const viewport = document.getElementById('movement-preview-viewport');
599
+ if (viewport) {
600
+ const templates = robotManager._getTemplates();
601
+ const currentModel = get('characterModel') || 'robot';
602
+ const templateFn = templates[currentModel] || templates.robot;
603
+
604
+ // Clear placeholder label, render character
605
+ viewport.innerHTML = '';
606
+ const previewChar = document.createElement('div');
607
+ previewChar.className = `css-robot char-${currentModel}`;
608
+ previewChar.dataset.status = 'idle';
609
+ previewChar.style.setProperty('--robot-color', '#00e5ff');
610
+ previewChar.innerHTML = templateFn('#00e5ff');
611
+ viewport.appendChild(previewChar);
612
+
613
+ // Effect name label
614
+ const nameLabel = document.createElement('div');
615
+ nameLabel.className = 'movement-preview-effect-name';
616
+ viewport.appendChild(nameLabel);
617
+
618
+ // Store ref for preview callback
619
+ viewport._previewChar = previewChar;
620
+ viewport._nameLabel = nameLabel;
621
+ viewport._clearTimer = null;
622
+ }
623
+
624
+ buildActionGrid(grid, {
625
+ library: movementManager.getEffectLibrary(),
626
+ currentMapping: movementManager.getActionEffects(),
627
+ labels: movementManager.getActionLabels(),
628
+ categories: movementManager.getActionCategories(),
629
+ onChange: (action, value) => movementManager.setActionEffect(action, value),
630
+ onPreview: (effectName) => {
631
+ if (!viewport || !viewport._previewChar) return;
632
+ const char = viewport._previewChar;
633
+ const label = viewport._nameLabel;
634
+
635
+ // Clear previous
636
+ if (viewport._clearTimer) clearTimeout(viewport._clearTimer);
637
+ char.removeAttribute('data-movement');
638
+ void char.offsetWidth; // force reflow for re-trigger
639
+
640
+ if (effectName === 'none') {
641
+ label.textContent = '';
642
+ label.classList.remove('visible');
643
+ return;
644
+ }
645
+
646
+ // Apply effect
647
+ char.setAttribute('data-movement', effectName);
648
+ const lib = movementManager.getEffectLibrary();
649
+ label.textContent = lib[effectName] || effectName;
650
+ label.classList.add('visible');
651
+
652
+ // Auto-clear after 3.5s
653
+ viewport._clearTimer = setTimeout(() => {
654
+ char.removeAttribute('data-movement');
655
+ label.classList.remove('visible');
656
+ }, 3500);
657
+ },
658
+ });
659
+ }
660
+
661
+ async function initSoundGrid() {
662
+ const soundManager = await import('./soundManager.js');
663
+ const grid = document.getElementById('sound-action-grid');
664
+ if (!grid) return;
665
+
666
+ buildActionGrid(grid, {
667
+ library: soundManager.getSoundLibrary(),
668
+ currentMapping: soundManager.getActionSounds(),
669
+ labels: soundManager.getActionLabels(),
670
+ categories: soundManager.getActionCategories(),
671
+ onChange: (action, value) => soundManager.setActionSound(action, value),
672
+ onPreview: (value) => soundManager.previewSound(value),
673
+ });
674
+ }
675
+
676
+ // ---- Summary Prompt Template Settings ----
677
+
678
+ async function initSummaryPromptSettings() {
679
+ const list = document.getElementById('settings-prompt-list');
680
+ const nameInput = document.getElementById('settings-prompt-name');
681
+ const textInput = document.getElementById('settings-prompt-text');
682
+ const saveBtn = document.getElementById('settings-prompt-save');
683
+ const cancelEditBtn = document.getElementById('settings-prompt-cancel-edit');
684
+ if (!list || !nameInput || !textInput || !saveBtn) return;
685
+
686
+ let editingId = null;
687
+
688
+ async function loadAndRender() {
689
+ try {
690
+ const all = await db.getAll('summaryPrompts');
691
+ const prompts = all.map(p => ({ ...p, is_default: p.isDefault }));
692
+ renderList(prompts);
693
+ } catch(e) {
694
+ list.innerHTML = '<div style="color:var(--text-dim);padding:8px">Failed to load prompts</div>';
695
+ }
696
+ }
697
+
698
+ function renderList(prompts) {
699
+ list.innerHTML = prompts.map(p => `
700
+ <div class="settings-prompt-item${p.is_default ? ' default' : ''}" data-id="${p.id}">
701
+ <div class="settings-prompt-item-row">
702
+ <button class="settings-prompt-star${p.is_default ? ' active' : ''}" data-id="${p.id}" title="${p.is_default ? 'Default' : 'Set as default'}">&#9733;</button>
703
+ <span class="settings-prompt-item-name">${escapeHtml(p.name)}</span>
704
+ ${p.is_default ? '<span class="settings-prompt-badge">DEFAULT</span>' : ''}
705
+ <button class="settings-prompt-edit" data-id="${p.id}" title="Edit">&#9998;</button>
706
+ <button class="settings-prompt-del" data-id="${p.id}" title="Delete">&times;</button>
707
+ </div>
708
+ <div class="settings-prompt-item-preview">${escapeHtml(p.prompt).substring(0, 120)}${p.prompt.length > 120 ? '...' : ''}</div>
709
+ </div>
710
+ `).join('') || '<div style="color:var(--text-dim);padding:8px">No prompt templates</div>';
711
+
712
+ // Star (set default)
713
+ list.querySelectorAll('.settings-prompt-star').forEach(btn => {
714
+ btn.addEventListener('click', async () => {
715
+ const id = parseInt(btn.dataset.id, 10);
716
+ // Clear all defaults first, then set the selected one
717
+ const all = await db.getAll('summaryPrompts');
718
+ for (const p of all) {
719
+ if (p.isDefault && p.id !== id) {
720
+ await db.put('summaryPrompts', { ...p, isDefault: 0, updatedAt: Date.now() });
721
+ }
722
+ }
723
+ const target = all.find(p => p.id === id);
724
+ if (target) {
725
+ await db.put('summaryPrompts', { ...target, isDefault: 1, updatedAt: Date.now() });
726
+ }
727
+ loadAndRender();
728
+ });
729
+ });
730
+
731
+ // Edit
732
+ list.querySelectorAll('.settings-prompt-edit').forEach(btn => {
733
+ btn.addEventListener('click', async () => {
734
+ const id = parseInt(btn.dataset.id, 10);
735
+ const p = await db.get('summaryPrompts', id);
736
+ if (!p) return;
737
+ editingId = id;
738
+ nameInput.value = p.name;
739
+ textInput.value = p.prompt;
740
+ saveBtn.textContent = 'Update Template';
741
+ cancelEditBtn.classList.remove('hidden');
742
+ });
743
+ });
744
+
745
+ // Delete
746
+ list.querySelectorAll('.settings-prompt-del').forEach(btn => {
747
+ btn.addEventListener('click', async () => {
748
+ await db.del('summaryPrompts', parseInt(btn.dataset.id, 10));
749
+ loadAndRender();
750
+ });
751
+ });
752
+ }
753
+
754
+ // Save / Update
755
+ saveBtn.addEventListener('click', async () => {
756
+ const name = nameInput.value.trim();
757
+ const prompt = textInput.value.trim();
758
+ if (!name || !prompt) return;
759
+
760
+ const now = Date.now();
761
+ if (editingId) {
762
+ const existing = await db.get('summaryPrompts', editingId);
763
+ await db.put('summaryPrompts', { ...existing, name, prompt, updatedAt: now });
764
+ } else {
765
+ await db.put('summaryPrompts', { name, prompt, isDefault: 0, createdAt: now, updatedAt: now });
766
+ }
767
+ nameInput.value = '';
768
+ textInput.value = '';
769
+ editingId = null;
770
+ saveBtn.textContent = 'Add Template';
771
+ cancelEditBtn.classList.add('hidden');
772
+ loadAndRender();
773
+ });
774
+
775
+ // Cancel edit
776
+ cancelEditBtn.addEventListener('click', () => {
777
+ editingId = null;
778
+ nameInput.value = '';
779
+ textInput.value = '';
780
+ saveBtn.textContent = 'Add Template';
781
+ cancelEditBtn.classList.add('hidden');
782
+ });
783
+
784
+ loadAndRender();
785
+ }
786
+
787
+ // ---- Label Settings (per-label completion alerts) ----
788
+
789
+ export function getLabelSettings() {
790
+ const raw = get('labelSettings');
791
+ try {
792
+ return JSON.parse(raw);
793
+ } catch {
794
+ return JSON.parse(defaults.labelSettings);
795
+ }
796
+ }
797
+
798
+ export async function setLabelSetting(label, field, value) {
799
+ const current = getLabelSettings();
800
+ if (!current[label]) current[label] = { sound: 'none', movement: 'none' };
801
+ current[label] = { ...current[label], [field]: value };
802
+ await set('labelSettings', JSON.stringify(current));
803
+ }
804
+
805
+ // Frame effect library for label cards
806
+ const FRAME_EFFECTS = {
807
+ none: 'None',
808
+ fire: 'Burning Fire',
809
+ electric: 'Electric Current',
810
+ chains: 'Golden Chains',
811
+ liquid: 'Liquid Flow',
812
+ plasma: 'Plasma Ring',
813
+ };
814
+
815
+ export function getFrameEffects() {
816
+ return { ...FRAME_EFFECTS };
817
+ }
818
+
819
+ async function initLabelGrid() {
820
+ const container = document.getElementById('label-settings-grid');
821
+ if (!container) return;
822
+
823
+ const soundManager = await import('./soundManager.js');
824
+ const movementManager = await import('./movementManager.js');
825
+
826
+ const sounds = soundManager.getSoundLibrary(); // string[]
827
+ const effects = movementManager.getEffectLibrary(); // { key: label }
828
+ const labelConfig = getLabelSettings();
829
+
830
+ const LABELS = ['ONEOFF', 'HEAVY', 'IMPORTANT'];
831
+ const LABEL_COLORS = { ONEOFF: '#ff9100', HEAVY: '#ff3355', IMPORTANT: '#aa66ff' };
832
+ const LABEL_ICONS = { ONEOFF: '&#128293;', HEAVY: '&#9733;', IMPORTANT: '&#9888;' };
833
+
834
+ let html = '';
835
+ for (const label of LABELS) {
836
+ const cfg = labelConfig[label] || { sound: 'none', movement: 'none', frame: 'none' };
837
+ const color = LABEL_COLORS[label];
838
+
839
+ const soundOpts = sounds.map(s =>
840
+ `<option value="${s}"${s === cfg.sound ? ' selected' : ''}>${s}</option>`
841
+ ).join('');
842
+
843
+ const effectOpts = Object.entries(effects).map(([key, name]) =>
844
+ `<option value="${key}"${key === cfg.movement ? ' selected' : ''}>${name}</option>`
845
+ ).join('');
846
+
847
+ const frameOpts = Object.entries(FRAME_EFFECTS).map(([key, name]) =>
848
+ `<option value="${key}"${key === (cfg.frame || 'none') ? ' selected' : ''}>${name}</option>`
849
+ ).join('');
850
+
851
+ html += `
852
+ <div class="label-config-card" style="--label-color: ${color}" data-frame="${cfg.frame || 'none'}">
853
+ <div class="label-config-header">
854
+ <span class="label-config-icon">${LABEL_ICONS[label]}</span>
855
+ <span class="label-config-name">${label}</span>
856
+ </div>
857
+ <div class="label-config-row">
858
+ <span class="label-config-field">Card Frame</span>
859
+ <select class="label-config-select" data-label="${label}" data-field="frame">${frameOpts}</select>
860
+ </div>
861
+ <div class="label-config-row">
862
+ <span class="label-config-field">Sound</span>
863
+ <select class="label-config-select" data-label="${label}" data-field="sound">${soundOpts}</select>
864
+ <button class="sound-preview-btn label-preview-btn" data-label="${label}" data-field="sound" title="Preview">&#9654;</button>
865
+ </div>
866
+ <div class="label-config-row">
867
+ <span class="label-config-field">Movement</span>
868
+ <select class="label-config-select" data-label="${label}" data-field="movement">${effectOpts}</select>
869
+ <button class="sound-preview-btn label-preview-btn" data-label="${label}" data-field="movement" title="Preview">&#9654;</button>
870
+ </div>
871
+ </div>`;
872
+ }
873
+ container.innerHTML = html;
874
+
875
+ // Change handler
876
+ container.addEventListener('change', (e) => {
877
+ const sel = e.target.closest('.label-config-select');
878
+ if (!sel) return;
879
+ const field = sel.dataset.field;
880
+ const label = sel.dataset.label;
881
+ setLabelSetting(label, field, sel.value);
882
+
883
+ // Live-preview frame effect on the config card itself
884
+ if (field === 'frame') {
885
+ const configCard = sel.closest('.label-config-card');
886
+ if (configCard) configCard.dataset.frame = sel.value;
887
+ // Also update any live session cards with this label
888
+ document.querySelectorAll(`.session-card.${label.toLowerCase()}-session`).forEach(card => {
889
+ if (sel.value && sel.value !== 'none') {
890
+ card.dataset.frame = sel.value;
891
+ } else {
892
+ delete card.dataset.frame;
893
+ }
894
+ });
895
+ }
896
+ });
897
+
898
+ // Preview handler
899
+ container.addEventListener('click', (e) => {
900
+ const btn = e.target.closest('.label-preview-btn');
901
+ if (!btn) return;
902
+ const label = btn.dataset.label;
903
+ const field = btn.dataset.field;
904
+ const sel = container.querySelector(`.label-config-select[data-label="${label}"][data-field="${field}"]`);
905
+ if (!sel) return;
906
+ if (field === 'sound') {
907
+ soundManager.previewSound(sel.value);
908
+ } else {
909
+ // Preview movement on the first session card character
910
+ const card = document.querySelector('.session-card .css-robot');
911
+ if (card && sel.value !== 'none') {
912
+ card.removeAttribute('data-movement');
913
+ void card.offsetWidth;
914
+ card.setAttribute('data-movement', sel.value);
915
+ setTimeout(() => card.removeAttribute('data-movement'), 3500);
916
+ }
917
+ }
918
+ });
919
+ }
920
+
921
+ function escapeHtml(str) {
922
+ if (!str) return '';
923
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
924
+ }