openvoiceui 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 (185) hide show
  1. package/.env.example +104 -0
  2. package/Dockerfile +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +638 -0
  5. package/SETUP.md +360 -0
  6. package/app.py +232 -0
  7. package/auto-approve-devices.js +111 -0
  8. package/cli/index.js +372 -0
  9. package/config/__init__.py +4 -0
  10. package/config/default.yaml +43 -0
  11. package/config/flags.yaml +67 -0
  12. package/config/loader.py +203 -0
  13. package/config/providers.yaml +71 -0
  14. package/config/speech_normalization.yaml +182 -0
  15. package/config/theme.json +4 -0
  16. package/data/greetings.json +25 -0
  17. package/default-pages/ai-image-creator.html +915 -0
  18. package/default-pages/bulk-image-uploader.html +492 -0
  19. package/default-pages/desktop.html +2865 -0
  20. package/default-pages/file-explorer.html +854 -0
  21. package/default-pages/interactive-map.html +655 -0
  22. package/default-pages/style-guide.html +1005 -0
  23. package/default-pages/website-setup.html +1623 -0
  24. package/deploy/openclaw/Dockerfile +46 -0
  25. package/deploy/openvoiceui.service +30 -0
  26. package/deploy/setup-nginx.sh +50 -0
  27. package/deploy/setup-sudo.sh +306 -0
  28. package/deploy/skill-runner/Dockerfile +19 -0
  29. package/deploy/skill-runner/requirements.txt +14 -0
  30. package/deploy/skill-runner/server.py +269 -0
  31. package/deploy/supertonic/Dockerfile +22 -0
  32. package/deploy/supertonic/server.py +79 -0
  33. package/docker-compose.pinokio.yml +11 -0
  34. package/docker-compose.yml +59 -0
  35. package/greetings.json +25 -0
  36. package/index.html +65 -0
  37. package/inject-device-identity.js +142 -0
  38. package/package.json +82 -0
  39. package/profiles/default.json +114 -0
  40. package/profiles/manager.py +354 -0
  41. package/profiles/schema.json +337 -0
  42. package/prompts/voice-system-prompt.md +149 -0
  43. package/providers/__init__.py +39 -0
  44. package/providers/base.py +63 -0
  45. package/providers/llm/__init__.py +12 -0
  46. package/providers/llm/base.py +71 -0
  47. package/providers/llm/clawdbot_provider.py +112 -0
  48. package/providers/llm/zai_provider.py +115 -0
  49. package/providers/registry.py +320 -0
  50. package/providers/stt/__init__.py +12 -0
  51. package/providers/stt/base.py +58 -0
  52. package/providers/stt/webspeech_provider.py +49 -0
  53. package/providers/stt/whisper_provider.py +100 -0
  54. package/providers/tts/__init__.py +20 -0
  55. package/providers/tts/base.py +91 -0
  56. package/providers/tts/groq_provider.py +74 -0
  57. package/providers/tts/supertonic_provider.py +72 -0
  58. package/requirements.txt +38 -0
  59. package/routes/__init__.py +10 -0
  60. package/routes/admin.py +515 -0
  61. package/routes/canvas.py +1315 -0
  62. package/routes/chat.py +51 -0
  63. package/routes/conversation.py +2158 -0
  64. package/routes/elevenlabs_hybrid.py +306 -0
  65. package/routes/greetings.py +98 -0
  66. package/routes/icons.py +279 -0
  67. package/routes/image_gen.py +364 -0
  68. package/routes/instructions.py +190 -0
  69. package/routes/music.py +838 -0
  70. package/routes/onboarding.py +43 -0
  71. package/routes/pi.py +62 -0
  72. package/routes/profiles.py +215 -0
  73. package/routes/report_issue.py +68 -0
  74. package/routes/static_files.py +533 -0
  75. package/routes/suno.py +664 -0
  76. package/routes/theme.py +81 -0
  77. package/routes/transcripts.py +199 -0
  78. package/routes/vision.py +348 -0
  79. package/routes/workspace.py +288 -0
  80. package/server.py +1510 -0
  81. package/services/__init__.py +1 -0
  82. package/services/auth.py +143 -0
  83. package/services/canvas_versioning.py +239 -0
  84. package/services/db_pool.py +107 -0
  85. package/services/gateway.py +16 -0
  86. package/services/gateway_manager.py +333 -0
  87. package/services/gateways/__init__.py +12 -0
  88. package/services/gateways/base.py +110 -0
  89. package/services/gateways/compat.py +264 -0
  90. package/services/gateways/openclaw.py +1134 -0
  91. package/services/health.py +100 -0
  92. package/services/memory_client.py +455 -0
  93. package/services/paths.py +26 -0
  94. package/services/speech_normalizer.py +285 -0
  95. package/services/tts.py +270 -0
  96. package/setup-config.js +262 -0
  97. package/sounds/air_horn.mp3 +0 -0
  98. package/sounds/bruh.mp3 +0 -0
  99. package/sounds/crowd_cheer.mp3 +0 -0
  100. package/sounds/gunshot.mp3 +0 -0
  101. package/sounds/impact.mp3 +0 -0
  102. package/sounds/lets_go.mp3 +0 -0
  103. package/sounds/record_stop.mp3 +0 -0
  104. package/sounds/rewind.mp3 +0 -0
  105. package/sounds/sad_trombone.mp3 +0 -0
  106. package/sounds/scratch_long.mp3 +0 -0
  107. package/sounds/yeah.mp3 +0 -0
  108. package/src/adapters/ClawdBotAdapter.js +264 -0
  109. package/src/adapters/_template.js +133 -0
  110. package/src/adapters/elevenlabs-classic.js +841 -0
  111. package/src/adapters/elevenlabs-hybrid.js +812 -0
  112. package/src/adapters/hume-evi.js +676 -0
  113. package/src/admin.html +1339 -0
  114. package/src/app.js +8802 -0
  115. package/src/core/Config.js +173 -0
  116. package/src/core/EmotionEngine.js +307 -0
  117. package/src/core/EventBridge.js +180 -0
  118. package/src/core/EventBus.js +117 -0
  119. package/src/core/VoiceSession.js +607 -0
  120. package/src/face/BaseFace.js +259 -0
  121. package/src/face/EyeFace.js +208 -0
  122. package/src/face/HaloSmokeFace.js +509 -0
  123. package/src/face/manifest.json +27 -0
  124. package/src/face/previews/eyes.svg +16 -0
  125. package/src/face/previews/orb.svg +29 -0
  126. package/src/features/MusicPlayer.js +620 -0
  127. package/src/features/Soundboard.js +128 -0
  128. package/src/providers/DeepgramSTT.js +472 -0
  129. package/src/providers/DeepgramStreamingSTT.js +766 -0
  130. package/src/providers/GroqSTT.js +559 -0
  131. package/src/providers/TTSPlayer.js +323 -0
  132. package/src/providers/WebSpeechSTT.js +479 -0
  133. package/src/providers/tts/BaseTTSProvider.js +81 -0
  134. package/src/providers/tts/HumeProvider.js +77 -0
  135. package/src/providers/tts/SupertonicProvider.js +174 -0
  136. package/src/providers/tts/index.js +140 -0
  137. package/src/shell/adapter-registry.js +154 -0
  138. package/src/shell/caller-bridge.js +35 -0
  139. package/src/shell/camera-bridge.js +28 -0
  140. package/src/shell/canvas-bridge.js +32 -0
  141. package/src/shell/commercial-bridge.js +44 -0
  142. package/src/shell/face-bridge.js +44 -0
  143. package/src/shell/music-bridge.js +60 -0
  144. package/src/shell/orchestrator.js +233 -0
  145. package/src/shell/profile-discovery.js +303 -0
  146. package/src/shell/sounds-bridge.js +28 -0
  147. package/src/shell/transcript-bridge.js +61 -0
  148. package/src/shell/waveform-bridge.js +33 -0
  149. package/src/styles/base.css +2862 -0
  150. package/src/styles/face.css +417 -0
  151. package/src/styles/pi-overrides.css +89 -0
  152. package/src/styles/theme-dark.css +67 -0
  153. package/src/test-tts.html +175 -0
  154. package/src/ui/AppShell.js +544 -0
  155. package/src/ui/ProfileSwitcher.js +228 -0
  156. package/src/ui/SessionControl.js +240 -0
  157. package/src/ui/face/FacePicker.js +195 -0
  158. package/src/ui/face/FaceRenderer.js +309 -0
  159. package/src/ui/settings/PlaylistEditor.js +366 -0
  160. package/src/ui/settings/SettingsPanel.css +684 -0
  161. package/src/ui/settings/SettingsPanel.js +419 -0
  162. package/src/ui/settings/TTSVoicePreview.js +210 -0
  163. package/src/ui/themes/ThemeManager.js +213 -0
  164. package/src/ui/visualizers/BaseVisualizer.js +29 -0
  165. package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
  166. package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
  167. package/static/emulators/jsdos/js-dos.css +1 -0
  168. package/static/emulators/jsdos/js-dos.js +22 -0
  169. package/static/favicon.svg +55 -0
  170. package/static/icons/apple-touch-icon.png +0 -0
  171. package/static/icons/favicon-32.png +0 -0
  172. package/static/icons/icon-192.png +0 -0
  173. package/static/icons/icon-512.png +0 -0
  174. package/static/install.html +449 -0
  175. package/static/manifest.json +26 -0
  176. package/static/sw.js +21 -0
  177. package/tts_providers/__init__.py +136 -0
  178. package/tts_providers/base_provider.py +319 -0
  179. package/tts_providers/groq_provider.py +155 -0
  180. package/tts_providers/hume_provider.py +226 -0
  181. package/tts_providers/providers_config.json +119 -0
  182. package/tts_providers/qwen3_provider.py +371 -0
  183. package/tts_providers/resemble_provider.py +315 -0
  184. package/tts_providers/supertonic_provider.py +557 -0
  185. package/tts_providers/supertonic_tts.py +399 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * ProfileSwitcher — Agent profile selector UI (P4-T6)
3
+ *
4
+ * Displays available agent profiles as clickable cards. Activating a profile:
5
+ * 1. POSTs to /api/profiles/activate
6
+ * 2. Applies UI settings (theme preset, face mode) locally
7
+ * 3. Emits 'profile:switched' on EventBus so VoiceSession can reload
8
+ *
9
+ * Usage (standalone):
10
+ * import { ProfileSwitcher } from './ui/ProfileSwitcher.js';
11
+ * const switcher = new ProfileSwitcher();
12
+ * switcher.mount(document.getElementById('profile-switcher-root'));
13
+ *
14
+ * Usage (via SettingsPanel):
15
+ * SettingsPanel.open('profiles');
16
+ *
17
+ * EventBus events emitted:
18
+ * 'profile:switched' { profile } — after successful activation
19
+ * 'profile:error' { message } — on activation failure
20
+ *
21
+ * ADR-002: Profiles stored as JSON files, served via /api/profiles.
22
+ * ADR-009: Simple manager pattern — no framework.
23
+ */
24
+
25
+ import { eventBus } from '../core/EventBus.js';
26
+
27
+ export class ProfileSwitcher {
28
+ constructor({ serverUrl = '' } = {}) {
29
+ this.serverUrl = serverUrl;
30
+ this._root = null;
31
+ this._profiles = [];
32
+ this._activeId = null;
33
+ this._busy = false;
34
+ }
35
+
36
+ // ── Public API ────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Mount the ProfileSwitcher UI into `container` (replaces contents).
40
+ * @param {HTMLElement} container
41
+ */
42
+ async mount(container) {
43
+ this._root = container;
44
+ this._root.innerHTML = '<div class="ps-loading">Loading profiles\u2026</div>';
45
+ await this._loadProfiles();
46
+ }
47
+
48
+ destroy() {
49
+ if (this._root) {
50
+ this._root.innerHTML = '';
51
+ this._root = null;
52
+ }
53
+ }
54
+
55
+ // ── Data loading ──────────────────────────────────────────────────────────
56
+
57
+ async _loadProfiles() {
58
+ try {
59
+ const resp = await fetch(`${this.serverUrl}/api/profiles`);
60
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
61
+ const data = await resp.json();
62
+ this._profiles = data.profiles || [];
63
+ this._activeId = data.active || null;
64
+ this._render();
65
+ } catch (err) {
66
+ console.error('[ProfileSwitcher] Failed to load profiles:', err);
67
+ if (this._root) {
68
+ this._root.innerHTML = `<div class="ps-error">Failed to load profiles: ${this._esc(err.message)}</div>`;
69
+ }
70
+ }
71
+ }
72
+
73
+ // ── Rendering ─────────────────────────────────────────────────────────────
74
+
75
+ _render() {
76
+ if (!this._root) return;
77
+
78
+ if (!this._profiles.length) {
79
+ this._root.innerHTML = '<div class="ps-empty">No profiles found.</div>';
80
+ return;
81
+ }
82
+
83
+ const cards = this._profiles.map(p => this._renderCard(p)).join('');
84
+ this._root.innerHTML = `
85
+ <div class="profile-switcher">
86
+ <div class="ps-grid">${cards}</div>
87
+ <div class="ps-status" id="ps-status"></div>
88
+ </div>
89
+ `;
90
+ this._attachListeners();
91
+ }
92
+
93
+ _renderCard(profile) {
94
+ const isActive = profile.id === this._activeId;
95
+ const icon = this._esc(profile.icon || '🤖');
96
+ const name = this._esc(profile.name || profile.id);
97
+ const desc = this._esc(profile.description || '');
98
+ const tts = this._esc(profile.voice?.tts_provider || '—');
99
+ const voice = this._esc(profile.voice?.voice_id || '—');
100
+ const llm = this._esc(profile.llm?.provider || '—');
101
+
102
+ return `
103
+ <button
104
+ class="ps-card${isActive ? ' ps-card--active' : ''}"
105
+ data-profile-id="${this._esc(profile.id)}"
106
+ title="${isActive ? 'Currently active' : `Switch to ${name}`}"
107
+ ${isActive ? 'aria-current="true"' : ''}
108
+ >
109
+ <div class="ps-card-icon">${icon}</div>
110
+ <div class="ps-card-body">
111
+ <div class="ps-card-name">${name}${isActive ? ' <span class="ps-active-badge">active</span>' : ''}</div>
112
+ <div class="ps-card-desc">${desc}</div>
113
+ <div class="ps-card-meta">
114
+ <span class="ps-meta-item" title="LLM provider">${llm}</span>
115
+ <span class="ps-meta-sep">·</span>
116
+ <span class="ps-meta-item" title="TTS provider">${tts} / ${voice}</span>
117
+ </div>
118
+ </div>
119
+ ${isActive ? '<div class="ps-card-check">&#10003;</div>' : ''}
120
+ </button>
121
+ `;
122
+ }
123
+
124
+ // ── Events ────────────────────────────────────────────────────────────────
125
+
126
+ _attachListeners() {
127
+ if (!this._root) return;
128
+ this._root.addEventListener('click', (e) => {
129
+ const card = e.target.closest('.ps-card');
130
+ if (!card || this._busy) return;
131
+ const profileId = card.dataset.profileId;
132
+ if (profileId && profileId !== this._activeId) {
133
+ this._activate(profileId);
134
+ }
135
+ });
136
+ }
137
+
138
+ async _activate(profileId) {
139
+ if (this._busy) return;
140
+ this._busy = true;
141
+ this._setStatus('Switching profile\u2026', 'pending');
142
+
143
+ try {
144
+ const resp = await fetch(`${this.serverUrl}/api/profiles/activate`, {
145
+ method: 'POST',
146
+ headers: { 'Content-Type': 'application/json' },
147
+ body: JSON.stringify({ profile_id: profileId }),
148
+ });
149
+
150
+ if (!resp.ok) {
151
+ const err = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }));
152
+ throw new Error(err.error || `HTTP ${resp.status}`);
153
+ }
154
+
155
+ const data = await resp.json();
156
+ const profile = data.profile || {};
157
+
158
+ this._activeId = profileId;
159
+
160
+ // Apply UI settings from the profile
161
+ this._applyProfileUI(profile);
162
+
163
+ // Re-render cards to update active state
164
+ const cards = this._profiles.map(p => this._renderCard(p)).join('');
165
+ const grid = this._root?.querySelector('.ps-grid');
166
+ if (grid) grid.innerHTML = cards;
167
+ this._attachListeners();
168
+
169
+ this._setStatus(`Switched to ${profile.name || profileId}`, 'success');
170
+ setTimeout(() => this._setStatus('', ''), 3000);
171
+
172
+ eventBus.emit('profile:switched', { profile });
173
+ console.log(`[ProfileSwitcher] Activated: ${profileId}`);
174
+
175
+ } catch (err) {
176
+ console.error('[ProfileSwitcher] Activate failed:', err);
177
+ this._setStatus(`Failed: ${err.message}`, 'error');
178
+ eventBus.emit('profile:error', { message: err.message });
179
+ setTimeout(() => this._setStatus('', ''), 4000);
180
+ } finally {
181
+ this._busy = false;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Apply UI-related profile settings immediately (theme preset, face mode).
187
+ * TTS/LLM provider changes take effect on the next conversation turn
188
+ * (server reads the active profile before each Gateway call).
189
+ * @param {object} profile
190
+ */
191
+ _applyProfileUI(profile) {
192
+ const ui = profile.ui || {};
193
+
194
+ // Apply theme preset if ThemeManager is available
195
+ const themePreset = ui.theme_preset;
196
+ if (themePreset && window.ThemeManager?.applyPreset) {
197
+ window.ThemeManager.applyPreset(themePreset);
198
+ }
199
+
200
+ // Apply face mood if FaceRenderer is available
201
+ const faceMood = ui.face_mood;
202
+ if (faceMood && window.FaceRenderer?.setMood) {
203
+ window.FaceRenderer.setMood(faceMood);
204
+ }
205
+
206
+ // Toggle face visibility
207
+ if (typeof ui.face_enabled === 'boolean' && window.FaceRenderer?.setEnabled) {
208
+ window.FaceRenderer.setEnabled(ui.face_enabled);
209
+ }
210
+ }
211
+
212
+ _setStatus(text, state) {
213
+ const el = this._root?.querySelector('#ps-status');
214
+ if (!el) return;
215
+ el.textContent = text;
216
+ el.className = 'ps-status' + (state ? ` ps-status--${state}` : '');
217
+ }
218
+
219
+ _esc(str) {
220
+ return String(str)
221
+ .replace(/&/g, '&amp;')
222
+ .replace(/</g, '&lt;')
223
+ .replace(/>/g, '&gt;')
224
+ .replace(/"/g, '&quot;');
225
+ }
226
+ }
227
+
228
+ export default ProfileSwitcher;
@@ -0,0 +1,240 @@
1
+ /**
2
+ * SessionControl — Reset Conversation button and session management UI
3
+ *
4
+ * Provides a UI for resetting the conversation session:
5
+ * - Soft reset: increment session key only (history stays in server memory)
6
+ * - Hard reset: increment session key + clear all in-memory conversation history
7
+ *
8
+ * Usage:
9
+ * import { SessionControl } from './ui/SessionControl.js';
10
+ * const ctrl = new SessionControl({ serverUrl: '' });
11
+ * ctrl.mount(document.getElementById('session-control-root'));
12
+ *
13
+ * EventBus events emitted:
14
+ * 'session:reset' { old, new, mode } — after successful reset
15
+ * 'session:error' { message } — on reset failure
16
+ *
17
+ * ADR-009: simple manager pattern (no framework)
18
+ */
19
+
20
+ import { eventBus } from '../core/EventBus.js';
21
+
22
+ export class SessionControl {
23
+ /**
24
+ * @param {object} opts
25
+ * @param {string} [opts.serverUrl] — base URL of Flask server (default: '')
26
+ */
27
+ constructor({ serverUrl = '' } = {}) {
28
+ this.serverUrl = serverUrl;
29
+ this._root = null;
30
+ this._busy = false;
31
+ }
32
+
33
+ // ── Mount / Unmount ───────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Mount the SessionControl UI into a container element.
37
+ * @param {HTMLElement} container
38
+ */
39
+ mount(container) {
40
+ this._root = container;
41
+ this._render();
42
+ }
43
+
44
+ /**
45
+ * Destroy the component and clean up DOM.
46
+ */
47
+ destroy() {
48
+ if (this._root) {
49
+ this._root.innerHTML = '';
50
+ this._root = null;
51
+ }
52
+ }
53
+
54
+ // ── Render ────────────────────────────────────────────────────────────────
55
+
56
+ _render() {
57
+ if (!this._root) return;
58
+ this._root.innerHTML = `
59
+ <div class="session-control">
60
+ <div class="sc-header">
61
+ <span class="sc-title">Session</span>
62
+ </div>
63
+ <div class="sc-actions">
64
+ <button class="sc-btn sc-soft-btn" id="sc-soft-reset" title="Soft reset: start a new context window, keep server state">
65
+ 🔄 Reset (Soft)
66
+ </button>
67
+ <button class="sc-btn sc-hard-btn" id="sc-hard-reset" title="Hard reset: start fresh — clear all conversation history">
68
+ ⚡ Reset (Hard)
69
+ </button>
70
+ </div>
71
+ <div class="sc-status" id="sc-status"></div>
72
+ <div class="sc-confirm" id="sc-confirm" style="display:none;">
73
+ <div class="sc-confirm-msg" id="sc-confirm-msg"></div>
74
+ <div class="sc-confirm-btns">
75
+ <button class="sc-btn sc-confirm-yes" id="sc-confirm-yes">Confirm</button>
76
+ <button class="sc-btn sc-confirm-cancel" id="sc-confirm-cancel">Cancel</button>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ `;
81
+ this._attachListeners();
82
+ }
83
+
84
+ _attachListeners() {
85
+ const softBtn = this._root.querySelector('#sc-soft-reset');
86
+ const hardBtn = this._root.querySelector('#sc-hard-reset');
87
+ const cancelBtn = this._root.querySelector('#sc-confirm-cancel');
88
+ const confirmBtn = this._root.querySelector('#sc-confirm-yes');
89
+
90
+ if (softBtn) softBtn.addEventListener('click', () => this._confirmReset('soft'));
91
+ if (hardBtn) hardBtn.addEventListener('click', () => this._confirmReset('hard'));
92
+ if (cancelBtn) cancelBtn.addEventListener('click', () => this._hideConfirm());
93
+ if (confirmBtn) confirmBtn.addEventListener('click', () => this._executeReset());
94
+
95
+ // Close confirm on outside click
96
+ document.addEventListener('keydown', (e) => {
97
+ if (e.key === 'Escape') this._hideConfirm();
98
+ }, { once: false });
99
+ }
100
+
101
+ // ── Confirm dialog ────────────────────────────────────────────────────────
102
+
103
+ _confirmReset(mode) {
104
+ this._pendingMode = mode;
105
+ const msgEl = this._root?.querySelector('#sc-confirm-msg');
106
+ const confirmEl = this._root?.querySelector('#sc-confirm');
107
+ if (!confirmEl) return;
108
+
109
+ const msgs = {
110
+ soft: 'Start a fresh conversation context? (Server history stays — this is quick.)',
111
+ hard: 'Hard reset will clear ALL conversation history on the server. Are you sure?'
112
+ };
113
+
114
+ if (msgEl) msgEl.textContent = msgs[mode] || msgs.soft;
115
+ confirmEl.style.display = 'block';
116
+ }
117
+
118
+ _hideConfirm() {
119
+ const confirmEl = this._root?.querySelector('#sc-confirm');
120
+ if (confirmEl) confirmEl.style.display = 'none';
121
+ this._pendingMode = null;
122
+ }
123
+
124
+ // ── Reset execution ───────────────────────────────────────────────────────
125
+
126
+ async _executeReset() {
127
+ const mode = this._pendingMode || 'soft';
128
+ this._hideConfirm();
129
+
130
+ if (this._busy) return;
131
+ this._busy = true;
132
+ this._setStatus('Resetting…', 'pending');
133
+
134
+ try {
135
+ const res = await fetch(`${this.serverUrl}/api/session/reset`, {
136
+ method: 'POST',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({ mode })
139
+ });
140
+
141
+ if (!res.ok) {
142
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
143
+ throw new Error(err.error || `HTTP ${res.status}`);
144
+ }
145
+
146
+ const data = await res.json();
147
+ const modeLabel = mode === 'hard' ? 'Hard' : 'Soft';
148
+ this._setStatus(`${modeLabel} reset done.`, 'success');
149
+
150
+ eventBus.emit('session:reset', {
151
+ old: data.old,
152
+ new: data.new,
153
+ mode: data.mode
154
+ });
155
+
156
+ console.log(`[SessionControl] Reset ${mode}: ${data.old} → ${data.new}`);
157
+
158
+ // Clear status after 3 s
159
+ setTimeout(() => this._setStatus('', ''), 3000);
160
+
161
+ } catch (err) {
162
+ console.error('[SessionControl] Reset failed:', err);
163
+ this._setStatus(`Reset failed: ${err.message}`, 'error');
164
+ eventBus.emit('session:error', { message: `Session reset failed: ${err.message}` });
165
+ setTimeout(() => this._setStatus('', ''), 4000);
166
+ } finally {
167
+ this._busy = false;
168
+ }
169
+ }
170
+
171
+ _setStatus(text, state) {
172
+ const el = this._root?.querySelector('#sc-status');
173
+ if (!el) return;
174
+ el.textContent = text;
175
+ el.className = 'sc-status' + (state ? ` sc-status--${state}` : '');
176
+ }
177
+
178
+ // ── Static convenience ────────────────────────────────────────────────────
179
+
180
+ /**
181
+ * Perform a programmatic soft reset (no UI confirmation).
182
+ * @param {string} [serverUrl]
183
+ * @returns {Promise<object>} reset result
184
+ */
185
+ static async softReset(serverUrl = '') {
186
+ const ctrl = new SessionControl({ serverUrl });
187
+ ctrl._pendingMode = 'soft';
188
+ return ctrl._executeReset();
189
+ }
190
+
191
+ /**
192
+ * Perform a programmatic hard reset (no UI confirmation).
193
+ * @param {string} [serverUrl]
194
+ * @returns {Promise<object>} reset result
195
+ */
196
+ static async hardReset(serverUrl = '') {
197
+ const ctrl = new SessionControl({ serverUrl });
198
+ ctrl._pendingMode = 'hard';
199
+ return ctrl._executeReset();
200
+ }
201
+ }
202
+
203
+ // ── Global singleton for legacy/inline usage ──────────────────────────────────
204
+
205
+ /**
206
+ * Global SessionControl helper — call from ActionConsole or AppShell inline handlers.
207
+ * Usage:
208
+ * window.SessionControl.reset('soft')
209
+ * window.SessionControl.reset('hard')
210
+ */
211
+ window.SessionControl = {
212
+ _instance: null,
213
+
214
+ /**
215
+ * Mount a SessionControl into a container.
216
+ * @param {HTMLElement} container
217
+ * @param {string} [serverUrl]
218
+ * @returns {SessionControl}
219
+ */
220
+ mount(container, serverUrl = '') {
221
+ if (this._instance) this._instance.destroy();
222
+ this._instance = new SessionControl({ serverUrl });
223
+ this._instance.mount(container);
224
+ return this._instance;
225
+ },
226
+
227
+ /**
228
+ * Trigger a reset (soft or hard) programmatically, no UI confirmation.
229
+ * @param {'soft'|'hard'} [mode]
230
+ * @param {string} [serverUrl]
231
+ */
232
+ async reset(mode = 'soft', serverUrl = '') {
233
+ if (mode === 'hard') {
234
+ return SessionControl.hardReset(serverUrl);
235
+ }
236
+ return SessionControl.softReset(serverUrl);
237
+ }
238
+ };
239
+
240
+ export default SessionControl;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * FacePicker — gallery UI for selecting the active face/avatar.
3
+ *
4
+ * Reads face definitions from /src/face/manifest.json, renders a card grid,
5
+ * and delegates activation to faceManager (ES module) or FaceRenderer (legacy).
6
+ *
7
+ * Usage (standalone):
8
+ * const picker = new FacePicker();
9
+ * await picker.mount(document.getElementById('face-picker-root'));
10
+ *
11
+ * Usage (embedded in SettingsPanel):
12
+ * const html = await FacePicker.renderHTML();
13
+ * container.innerHTML = html;
14
+ * FacePicker.attachListeners(container);
15
+ */
16
+
17
+ const MANIFEST_URL = '/src/face/manifest.json';
18
+ const STORAGE_KEY = 'ai-face-active';
19
+
20
+ // ── helpers ────────────────────────────────────────────────────────────────
21
+
22
+ function getActiveFaceId() {
23
+ // ES module faceManager takes priority
24
+ try {
25
+ if (window._faceManager?.activeFaceId) return window._faceManager.activeFaceId;
26
+ } catch (_) {}
27
+ // Server profile is source of truth
28
+ try {
29
+ if (window._serverProfile?.ui?.face_mode) return window._serverProfile.ui.face_mode;
30
+ } catch (_) {}
31
+ return 'eyes';
32
+ }
33
+
34
+ function persistFaceId(id) {
35
+ // Save to server profile so all devices see the same face
36
+ const profileId = window.providerManager?._activeProfileId || 'default';
37
+ fetch('/api/profiles/' + profileId, {
38
+ method: 'PUT',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ ui: { face_mode: id } })
41
+ }).catch(e => console.warn('Failed to save face to profile:', e));
42
+ // Update cached profile
43
+ if (window._serverProfile) {
44
+ if (!window._serverProfile.ui) window._serverProfile.ui = {};
45
+ window._serverProfile.ui.face_mode = id;
46
+ }
47
+ }
48
+
49
+ async function loadManifest() {
50
+ try {
51
+ const res = await fetch(MANIFEST_URL);
52
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
53
+ return await res.json();
54
+ } catch (err) {
55
+ console.warn('[FacePicker] Could not load manifest, using defaults:', err.message);
56
+ return {
57
+ default: 'eyes',
58
+ faces: [
59
+ { id: 'eyes', name: 'AI Eyes', description: 'Classic animated eyes', preview: null, moods: [], features: [] },
60
+ { id: 'orb', name: 'Sound Orb', description: 'Audio-reactive orb', preview: null, moods: [], features: [] }
61
+ ]
62
+ };
63
+ }
64
+ }
65
+
66
+ async function activateFace(id) {
67
+ persistFaceId(id);
68
+
69
+ // Try ES module faceManager first
70
+ if (window._faceManager && typeof window._faceManager.load === 'function') {
71
+ await window._faceManager.load(id);
72
+ return;
73
+ }
74
+
75
+ // Fall back to legacy FaceRenderer
76
+ if (window.FaceRenderer && typeof window.FaceRenderer.setMode === 'function') {
77
+ window.FaceRenderer.setMode(id);
78
+ return;
79
+ }
80
+
81
+ console.warn('[FacePicker] No face system available to activate face:', id);
82
+ }
83
+
84
+ // ── rendering ──────────────────────────────────────────────────────────────
85
+
86
+ function renderFeatureTags(features) {
87
+ if (!features || features.length === 0) return '';
88
+ return `<div class="face-card-features">
89
+ ${features.map(f => `<span class="face-feature-tag">${f}</span>`).join('')}
90
+ </div>`;
91
+ }
92
+
93
+ function renderMoodDots(moods) {
94
+ if (!moods || moods.length === 0) return '';
95
+ const dots = moods.map(m => `<span class="face-mood-dot" title="${m}" data-mood="${m}"></span>`).join('');
96
+ return `<div class="face-card-moods" title="Supported moods">${dots}</div>`;
97
+ }
98
+
99
+ function renderCard(face, isActive) {
100
+ const previewSrc = face.preview || '';
101
+ const previewHtml = previewSrc
102
+ ? `<img class="face-card-preview" src="${previewSrc}" alt="${face.name} preview" loading="lazy">`
103
+ : `<div class="face-card-preview face-card-preview--placeholder">
104
+ <span>${face.name.charAt(0)}</span>
105
+ </div>`;
106
+
107
+ return `
108
+ <div class="face-card ${isActive ? 'face-card--active' : ''}" data-face-id="${face.id}" role="button" tabindex="0"
109
+ aria-label="Select ${face.name}${isActive ? ' (active)' : ''}">
110
+ <div class="face-card-media">
111
+ ${previewHtml}
112
+ ${isActive ? '<div class="face-card-active-badge">Active</div>' : ''}
113
+ </div>
114
+ <div class="face-card-info">
115
+ <div class="face-card-name">${face.name}</div>
116
+ <div class="face-card-desc">${face.description}</div>
117
+ ${renderMoodDots(face.moods)}
118
+ ${renderFeatureTags(face.features)}
119
+ </div>
120
+ </div>
121
+ `;
122
+ }
123
+
124
+ function renderGallery(manifest, activeFaceId) {
125
+ const cards = manifest.faces.map(f => renderCard(f, f.id === activeFaceId)).join('');
126
+ return `
127
+ <div class="face-picker-gallery" role="radiogroup" aria-label="Face picker">
128
+ ${cards}
129
+ </div>
130
+ `;
131
+ }
132
+
133
+ // ── event attachment ───────────────────────────────────────────────────────
134
+
135
+ function attachListeners(root) {
136
+ root.querySelectorAll('.face-card').forEach(card => {
137
+ const activate = async () => {
138
+ const id = card.dataset.faceId;
139
+ if (!id) return;
140
+
141
+ // Update UI immediately
142
+ root.querySelectorAll('.face-card').forEach(c => {
143
+ c.classList.remove('face-card--active');
144
+ c.setAttribute('aria-label', c.querySelector('.face-card-name')?.textContent || '');
145
+ const badge = c.querySelector('.face-card-active-badge');
146
+ if (badge) badge.remove();
147
+ });
148
+ card.classList.add('face-card--active');
149
+ card.setAttribute('aria-label', `${card.querySelector('.face-card-name')?.textContent || id} (active)`);
150
+ const media = card.querySelector('.face-card-media');
151
+ if (media && !media.querySelector('.face-card-active-badge')) {
152
+ const badge = document.createElement('div');
153
+ badge.className = 'face-card-active-badge';
154
+ badge.textContent = 'Active';
155
+ media.appendChild(badge);
156
+ }
157
+
158
+ await activateFace(id);
159
+ };
160
+
161
+ card.addEventListener('click', activate);
162
+ card.addEventListener('keydown', e => {
163
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); }
164
+ });
165
+ });
166
+ }
167
+
168
+ // ── public API ─────────────────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Render gallery HTML string (async, loads manifest).
172
+ * @returns {Promise<string>}
173
+ */
174
+ async function renderHTML() {
175
+ const manifest = await loadManifest();
176
+ const activeFaceId = getActiveFaceId();
177
+ return renderGallery(manifest, activeFaceId);
178
+ }
179
+
180
+ /**
181
+ * Mount the face picker into a container element.
182
+ * @param {HTMLElement} container
183
+ * @returns {Promise<void>}
184
+ */
185
+ async function mount(container) {
186
+ container.innerHTML = '<div class="face-picker-loading">Loading faces...</div>';
187
+ container.innerHTML = await renderHTML();
188
+ attachListeners(container);
189
+ }
190
+
191
+ // Expose as window global for SettingsPanel
192
+ const FacePicker = { renderHTML, mount, attachListeners };
193
+ if (typeof window !== 'undefined') {
194
+ window.FacePicker = FacePicker;
195
+ }