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.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- 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">✓</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, '&')
|
|
222
|
+
.replace(/</g, '<')
|
|
223
|
+
.replace(/>/g, '>')
|
|
224
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|