vibesurf 0.1.19__py3-none-any.whl → 0.1.21__py3-none-any.whl

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.

Potentially problematic release.


This version of vibesurf might be problematic. Click here for more details.

Files changed (42) hide show
  1. vibe_surf/_version.py +2 -2
  2. vibe_surf/agents/report_writer_agent.py +1 -1
  3. vibe_surf/backend/api/task.py +1 -1
  4. vibe_surf/backend/api/voices.py +481 -0
  5. vibe_surf/backend/database/migrations/v003_fix_task_status_case.sql +11 -0
  6. vibe_surf/backend/database/migrations/v004_add_voice_profiles.sql +35 -0
  7. vibe_surf/backend/database/models.py +38 -1
  8. vibe_surf/backend/database/queries.py +189 -1
  9. vibe_surf/backend/main.py +2 -0
  10. vibe_surf/backend/shared_state.py +1 -1
  11. vibe_surf/backend/voice_model_config.py +25 -0
  12. vibe_surf/browser/agen_browser_profile.py +2 -0
  13. vibe_surf/browser/agent_browser_session.py +3 -3
  14. vibe_surf/chrome_extension/background.js +224 -9
  15. vibe_surf/chrome_extension/content.js +147 -0
  16. vibe_surf/chrome_extension/manifest.json +11 -2
  17. vibe_surf/chrome_extension/permission-iframe.html +38 -0
  18. vibe_surf/chrome_extension/permission-request.html +104 -0
  19. vibe_surf/chrome_extension/scripts/api-client.js +61 -0
  20. vibe_surf/chrome_extension/scripts/main.js +8 -2
  21. vibe_surf/chrome_extension/scripts/permission-iframe-request.js +188 -0
  22. vibe_surf/chrome_extension/scripts/permission-request.js +118 -0
  23. vibe_surf/chrome_extension/scripts/settings-manager.js +690 -3
  24. vibe_surf/chrome_extension/scripts/ui-manager.js +730 -119
  25. vibe_surf/chrome_extension/scripts/user-settings-storage.js +422 -0
  26. vibe_surf/chrome_extension/scripts/voice-recorder.js +514 -0
  27. vibe_surf/chrome_extension/sidepanel.html +106 -29
  28. vibe_surf/chrome_extension/styles/components.css +35 -0
  29. vibe_surf/chrome_extension/styles/input.css +164 -1
  30. vibe_surf/chrome_extension/styles/layout.css +1 -1
  31. vibe_surf/chrome_extension/styles/settings-environment.css +138 -0
  32. vibe_surf/chrome_extension/styles/settings-forms.css +7 -7
  33. vibe_surf/chrome_extension/styles/variables.css +51 -0
  34. vibe_surf/tools/voice_asr.py +125 -0
  35. {vibesurf-0.1.19.dist-info → vibesurf-0.1.21.dist-info}/METADATA +9 -12
  36. {vibesurf-0.1.19.dist-info → vibesurf-0.1.21.dist-info}/RECORD +40 -31
  37. vibe_surf/chrome_extension/icons/convert-svg.js +0 -33
  38. vibe_surf/chrome_extension/icons/logo-preview.html +0 -187
  39. {vibesurf-0.1.19.dist-info → vibesurf-0.1.21.dist-info}/WHEEL +0 -0
  40. {vibesurf-0.1.19.dist-info → vibesurf-0.1.21.dist-info}/entry_points.txt +0 -0
  41. {vibesurf-0.1.19.dist-info → vibesurf-0.1.21.dist-info}/licenses/LICENSE +0 -0
  42. {vibesurf-0.1.19.dist-info → vibesurf-0.1.21.dist-info}/top_level.txt +0 -0
@@ -7,14 +7,19 @@ class VibeSurfSettingsManager {
7
7
  this.state = {
8
8
  llmProfiles: [],
9
9
  mcpProfiles: [],
10
+ voiceProfiles: [],
10
11
  settings: {},
11
12
  currentProfileForm: null
12
13
  };
13
14
  this.elements = {};
14
15
  this.eventListeners = new Map();
15
16
 
17
+ // Initialize user settings storage
18
+ this.userSettingsStorage = new VibeSurfUserSettingsStorage();
19
+
16
20
  this.bindElements();
17
21
  this.bindEvents();
22
+ this.initializeUserSettings();
18
23
  }
19
24
 
20
25
  bindElements() {
@@ -24,6 +29,11 @@ class VibeSurfSettingsManager {
24
29
  settingsTabs: document.querySelectorAll('.settings-tab'),
25
30
  settingsTabContents: document.querySelectorAll('.settings-tab-content'),
26
31
 
32
+ // General Settings
33
+ themeSelect: document.getElementById('theme-select'),
34
+ defaultAsrSelect: document.getElementById('default-asr-select'),
35
+ defaultTtsSelect: document.getElementById('default-tts-select'),
36
+
27
37
  // LLM Profiles
28
38
  llmProfilesContainer: document.getElementById('llm-profiles-container'),
29
39
  addLlmProfileBtn: document.getElementById('add-llm-profile-btn'),
@@ -32,6 +42,10 @@ class VibeSurfSettingsManager {
32
42
  mcpProfilesContainer: document.getElementById('mcp-profiles-container'),
33
43
  addMcpProfileBtn: document.getElementById('add-mcp-profile-btn'),
34
44
 
45
+ // Voice Profiles
46
+ voiceProfilesContainer: document.getElementById('voice-profiles-container'),
47
+ addVoiceProfileBtn: document.getElementById('add-voice-profile-btn'),
48
+
35
49
  // Profile Form Modal
36
50
  profileFormModal: document.getElementById('profile-form-modal'),
37
51
  profileFormTitle: document.getElementById('profile-form-title'),
@@ -64,6 +78,7 @@ class VibeSurfSettingsManager {
64
78
  // Profile management
65
79
  this.elements.addLlmProfileBtn?.addEventListener('click', () => this.handleAddProfile('llm'));
66
80
  this.elements.addMcpProfileBtn?.addEventListener('click', () => this.handleAddProfile('mcp'));
81
+ this.elements.addVoiceProfileBtn?.addEventListener('click', () => this.handleAddProfile('voice'));
67
82
 
68
83
  // Profile form modal
69
84
  this.elements.profileFormCancel?.addEventListener('click', this.closeProfileForm.bind(this));
@@ -78,6 +93,11 @@ class VibeSurfSettingsManager {
78
93
  this.elements.profileFormSubmit.addEventListener('click', this.handleProfileFormSubmitClick.bind(this));
79
94
  }
80
95
 
96
+ // General settings
97
+ this.elements.themeSelect?.addEventListener('change', this.handleThemeChange.bind(this));
98
+ this.elements.defaultAsrSelect?.addEventListener('change', this.handleDefaultAsrChange.bind(this));
99
+ this.elements.defaultTtsSelect?.addEventListener('change', this.handleDefaultTtsChange.bind(this));
100
+
81
101
  // Environment variables
82
102
  this.elements.saveEnvVarsBtn?.addEventListener('click', this.handleSaveEnvironmentVariables.bind(this));
83
103
 
@@ -88,6 +108,65 @@ class VibeSurfSettingsManager {
88
108
  document.addEventListener('keydown', this.handleKeydown.bind(this));
89
109
  }
90
110
 
111
+ // Initialize user settings storage
112
+ async initializeUserSettings() {
113
+ try {
114
+ await this.userSettingsStorage.initialize();
115
+
116
+ // Listen to storage events
117
+ this.userSettingsStorage.on('settingChanged', this.handleStorageSettingChanged.bind(this));
118
+ this.userSettingsStorage.on('settingsChanged', this.handleStorageSettingsChanged.bind(this));
119
+
120
+ } catch (error) {
121
+ console.error('[SettingsManager] Failed to initialize user settings storage:', error);
122
+ }
123
+ }
124
+
125
+ // Handle individual setting changes from storage
126
+ handleStorageSettingChanged(data) {
127
+
128
+ // Apply setting changes to UI if needed
129
+ switch (data.key) {
130
+ case 'theme':
131
+ this.applyTheme(data.value);
132
+ if (this.elements.themeSelect) {
133
+ this.elements.themeSelect.value = data.value;
134
+ }
135
+ break;
136
+ case 'defaultAsr':
137
+ if (this.elements.defaultAsrSelect) {
138
+ this.elements.defaultAsrSelect.value = data.value;
139
+ }
140
+ break;
141
+ case 'defaultTts':
142
+ if (this.elements.defaultTtsSelect) {
143
+ this.elements.defaultTtsSelect.value = data.value;
144
+ }
145
+ break;
146
+ }
147
+ }
148
+
149
+ // Handle bulk settings changes from storage
150
+ handleStorageSettingsChanged(allSettings) {
151
+ console.log('[SettingsManager] Storage settings changed (bulk):', allSettings);
152
+
153
+ // Apply bulk setting changes to UI if needed
154
+ if (allSettings.theme) {
155
+ this.applyTheme(allSettings.theme);
156
+ if (this.elements.themeSelect) {
157
+ this.elements.themeSelect.value = allSettings.theme;
158
+ }
159
+ }
160
+
161
+ if (allSettings.defaultAsr && this.elements.defaultAsrSelect) {
162
+ this.elements.defaultAsrSelect.value = allSettings.defaultAsr;
163
+ }
164
+
165
+ if (allSettings.defaultTts && this.elements.defaultTtsSelect) {
166
+ this.elements.defaultTtsSelect.value = allSettings.defaultTts;
167
+ }
168
+ }
169
+
91
170
  handleKeydown(event) {
92
171
  // Close settings modal on Escape key
93
172
  if (event.key === 'Escape') {
@@ -140,6 +219,11 @@ class VibeSurfSettingsManager {
140
219
  if (targetContent) {
141
220
  targetContent.classList.add('active');
142
221
  }
222
+
223
+ // If switching to general tab, ensure environment variables are loaded
224
+ if (targetTabId === 'general') {
225
+ this.loadEnvironmentVariables();
226
+ }
143
227
  }
144
228
 
145
229
  // Data Loading
@@ -151,13 +235,24 @@ class VibeSurfSettingsManager {
151
235
  // Load MCP profiles
152
236
  await this.loadMCPProfiles();
153
237
 
238
+ // Load Voice profiles
239
+ await this.loadVoiceProfiles();
240
+
154
241
  // Load environment variables
155
242
  await this.loadEnvironmentVariables();
156
243
 
244
+ // Load general settings
245
+ await this.loadGeneralSettings();
246
+
247
+ // Load voice profiles for general settings dropdowns
248
+ await this.loadVoiceProfilesForGeneral();
249
+
157
250
  // Emit event to update LLM profile select dropdown
251
+ // This should happen AFTER all data is loaded but BEFORE user selections are restored
158
252
  this.emit('profilesUpdated', {
159
253
  llmProfiles: this.state.llmProfiles,
160
- mcpProfiles: this.state.mcpProfiles
254
+ mcpProfiles: this.state.mcpProfiles,
255
+ voiceProfiles: this.state.voiceProfiles
161
256
  });
162
257
 
163
258
  } catch (error) {
@@ -214,6 +309,30 @@ class VibeSurfSettingsManager {
214
309
  }
215
310
  }
216
311
 
312
+ async loadVoiceProfiles() {
313
+ try {
314
+ const response = await this.apiClient.getVoiceProfiles(false); // Load all profiles, not just active
315
+ console.log('[SettingsManager] Voice profiles loaded:', response);
316
+
317
+ // Handle different response structures
318
+ let profiles = [];
319
+ if (Array.isArray(response)) {
320
+ profiles = response;
321
+ } else if (response.profiles && Array.isArray(response.profiles)) {
322
+ profiles = response.profiles;
323
+ } else if (response.data && Array.isArray(response.data)) {
324
+ profiles = response.data;
325
+ }
326
+
327
+ this.state.voiceProfiles = profiles;
328
+ this.renderVoiceProfiles(profiles);
329
+ } catch (error) {
330
+ console.error('[SettingsManager] Failed to load Voice profiles:', error);
331
+ this.state.voiceProfiles = [];
332
+ this.renderVoiceProfiles([]);
333
+ }
334
+ }
335
+
217
336
  async loadEnvironmentVariables() {
218
337
  try {
219
338
  const response = await this.apiClient.getEnvironmentVariables();
@@ -226,6 +345,215 @@ class VibeSurfSettingsManager {
226
345
  }
227
346
  }
228
347
 
348
+ async loadGeneralSettings() {
349
+ try {
350
+ // Load and apply theme setting from user settings storage
351
+ const savedTheme = await this.userSettingsStorage.getTheme();
352
+ if (this.elements.themeSelect) {
353
+ this.elements.themeSelect.value = savedTheme;
354
+ }
355
+ this.applyTheme(savedTheme);
356
+
357
+ // Voice profile defaults will be handled by autoSelectLatestVoiceProfiles
358
+ // after voice profiles are loaded in loadVoiceProfilesForGeneral
359
+
360
+ console.log('[SettingsManager] General settings loaded successfully');
361
+ } catch (error) {
362
+ console.error('[SettingsManager] Failed to load general settings:', error);
363
+ }
364
+ }
365
+
366
+ async loadVoiceProfilesForGeneral() {
367
+ try {
368
+ // Load voice profiles for ASR and TTS dropdowns in general settings
369
+ const response = await this.apiClient.getVoiceProfiles(false);
370
+
371
+ // Handle different response structures
372
+ let profiles = [];
373
+ if (Array.isArray(response)) {
374
+ profiles = response;
375
+ } else if (response.profiles && Array.isArray(response.profiles)) {
376
+ profiles = response.profiles;
377
+ } else if (response.data && Array.isArray(response.data)) {
378
+ profiles = response.data;
379
+ }
380
+
381
+ // Filter profiles by type
382
+ const asrProfiles = profiles.filter(p => p.voice_model_type === 'asr' && p.is_active);
383
+ const ttsProfiles = profiles.filter(p => p.voice_model_type === 'tts' && p.is_active);
384
+
385
+ // Populate ASR dropdown
386
+ if (this.elements.defaultAsrSelect) {
387
+ this.elements.defaultAsrSelect.innerHTML = '<option value="">No ASR profile selected</option>';
388
+ asrProfiles.forEach(profile => {
389
+ const option = document.createElement('option');
390
+ option.value = profile.voice_profile_name;
391
+ option.textContent = profile.voice_profile_name;
392
+ this.elements.defaultAsrSelect.appendChild(option);
393
+ });
394
+ }
395
+
396
+ // Populate TTS dropdown
397
+ if (this.elements.defaultTtsSelect) {
398
+ this.elements.defaultTtsSelect.innerHTML = '<option value="">No TTS profile selected</option>';
399
+ ttsProfiles.forEach(profile => {
400
+ const option = document.createElement('option');
401
+ option.value = profile.voice_profile_name;
402
+ option.textContent = profile.voice_profile_name;
403
+ this.elements.defaultTtsSelect.appendChild(option);
404
+ });
405
+ }
406
+
407
+ // Auto-select latest updated profiles if no defaults are set
408
+ await this.autoSelectLatestVoiceProfiles(asrProfiles, ttsProfiles);
409
+
410
+ } catch (error) {
411
+ console.error('[SettingsManager] Failed to load voice profiles for general settings:', error);
412
+ // Populate with empty options on error
413
+ if (this.elements.defaultAsrSelect) {
414
+ this.elements.defaultAsrSelect.innerHTML = '<option value="">Failed to load ASR profiles</option>';
415
+ }
416
+ if (this.elements.defaultTtsSelect) {
417
+ this.elements.defaultTtsSelect.innerHTML = '<option value="">Failed to load TTS profiles</option>';
418
+ }
419
+ }
420
+ }
421
+
422
+ async autoSelectLatestVoiceProfiles(asrProfiles, ttsProfiles) {
423
+ try {
424
+ // Get current saved defaults
425
+ const savedAsrProfile = await this.userSettingsStorage.getDefaultAsr();
426
+ const savedTtsProfile = await this.userSettingsStorage.getDefaultTts();
427
+
428
+ // Check ASR profile
429
+ if (!savedAsrProfile || !asrProfiles.find(p => p.voice_profile_name === savedAsrProfile)) {
430
+ // No ASR profile selected or saved profile doesn't exist, select latest updated
431
+ if (asrProfiles.length > 0) {
432
+ // Sort by updated_at desc to get the latest updated profile
433
+ const latestAsrProfile = asrProfiles.sort((a, b) => {
434
+ const dateA = new Date(a.updated_at || a.created_at);
435
+ const dateB = new Date(b.updated_at || b.created_at);
436
+ return dateB - dateA; // DESC order
437
+ })[0];
438
+
439
+ console.log('[SettingsManager] Auto-selecting latest ASR profile:', latestAsrProfile.voice_profile_name);
440
+
441
+ // Set as default in storage
442
+ await this.userSettingsStorage.setDefaultAsr(latestAsrProfile.voice_profile_name);
443
+
444
+ // Update UI
445
+ if (this.elements.defaultAsrSelect) {
446
+ this.elements.defaultAsrSelect.value = latestAsrProfile.voice_profile_name;
447
+ }
448
+
449
+ this.emit('notification', {
450
+ message: `Auto-selected latest ASR profile: ${latestAsrProfile.voice_profile_name}`,
451
+ type: 'info'
452
+ });
453
+ }
454
+ } else {
455
+ // Saved ASR profile exists and is valid - restore it to UI
456
+ console.log('[SettingsManager] Restoring saved ASR profile to UI:', savedAsrProfile);
457
+ if (this.elements.defaultAsrSelect) {
458
+ this.elements.defaultAsrSelect.value = savedAsrProfile;
459
+ }
460
+ }
461
+
462
+ // Check TTS profile
463
+ if (!savedTtsProfile || !ttsProfiles.find(p => p.voice_profile_name === savedTtsProfile)) {
464
+ // No TTS profile selected or saved profile doesn't exist, select latest updated
465
+ if (ttsProfiles.length > 0) {
466
+ // Sort by updated_at desc to get the latest updated profile
467
+ const latestTtsProfile = ttsProfiles.sort((a, b) => {
468
+ const dateA = new Date(a.updated_at || a.created_at);
469
+ const dateB = new Date(b.updated_at || b.created_at);
470
+ return dateB - dateA; // DESC order
471
+ })[0];
472
+
473
+ console.log('[SettingsManager] Auto-selecting latest TTS profile:', latestTtsProfile.voice_profile_name);
474
+
475
+ // Set as default in storage
476
+ await this.userSettingsStorage.setDefaultTts(latestTtsProfile.voice_profile_name);
477
+
478
+ // Update UI
479
+ if (this.elements.defaultTtsSelect) {
480
+ this.elements.defaultTtsSelect.value = latestTtsProfile.voice_profile_name;
481
+ }
482
+
483
+ this.emit('notification', {
484
+ message: `Auto-selected latest TTS profile: ${latestTtsProfile.voice_profile_name}`,
485
+ type: 'info'
486
+ });
487
+ }
488
+ } else {
489
+ // Saved TTS profile exists and is valid - restore it to UI
490
+ console.log('[SettingsManager] Restoring saved TTS profile to UI:', savedTtsProfile);
491
+ if (this.elements.defaultTtsSelect) {
492
+ this.elements.defaultTtsSelect.value = savedTtsProfile;
493
+ }
494
+ }
495
+
496
+ } catch (error) {
497
+ console.error('[SettingsManager] Failed to auto-select latest voice profiles:', error);
498
+ }
499
+ }
500
+
501
+ async handleDefaultAsrChange(event) {
502
+ const selectedProfile = event.target.value;
503
+
504
+ try {
505
+ // Store ASR profile preference in user settings storage
506
+ await this.userSettingsStorage.setDefaultAsr(selectedProfile);
507
+
508
+ if (selectedProfile) {
509
+ this.emit('notification', {
510
+ message: `Default ASR profile set to ${selectedProfile}`,
511
+ type: 'success'
512
+ });
513
+ } else {
514
+ this.emit('notification', {
515
+ message: 'Default ASR profile cleared',
516
+ type: 'info'
517
+ });
518
+ }
519
+
520
+ } catch (error) {
521
+ console.error('[SettingsManager] Failed to change default ASR profile:', error);
522
+ this.emit('notification', {
523
+ message: 'Failed to change default ASR profile',
524
+ type: 'error'
525
+ });
526
+ }
527
+ }
528
+
529
+ async handleDefaultTtsChange(event) {
530
+ const selectedProfile = event.target.value;
531
+
532
+ try {
533
+ // Store TTS profile preference in user settings storage
534
+ await this.userSettingsStorage.setDefaultTts(selectedProfile);
535
+
536
+ if (selectedProfile) {
537
+ this.emit('notification', {
538
+ message: `Default TTS profile set to ${selectedProfile}`,
539
+ type: 'success'
540
+ });
541
+ } else {
542
+ this.emit('notification', {
543
+ message: 'Default TTS profile cleared',
544
+ type: 'info'
545
+ });
546
+ }
547
+
548
+ } catch (error) {
549
+ console.error('[SettingsManager] Failed to change default TTS profile:', error);
550
+ this.emit('notification', {
551
+ message: 'Failed to change default TTS profile',
552
+ type: 'error'
553
+ });
554
+ }
555
+ }
556
+
229
557
  // Profile Management
230
558
  async handleAddProfile(type) {
231
559
  try {
@@ -238,7 +566,7 @@ class VibeSurfSettingsManager {
238
566
 
239
567
  async showProfileForm(type, profile = null) {
240
568
  const isEdit = profile !== null;
241
- const title = isEdit ? `Edit ${type.toUpperCase()} Profile` : `Add ${type.toUpperCase()} Profile`;
569
+ const title = isEdit ? `Edit ${type.toUpperCase()}` : `Add ${type.toUpperCase()}`;
242
570
 
243
571
  if (this.elements.profileFormTitle) {
244
572
  this.elements.profileFormTitle.textContent = title;
@@ -250,6 +578,8 @@ class VibeSurfSettingsManager {
250
578
  formHTML = await this.generateLLMProfileForm(profile);
251
579
  } else if (type === 'mcp') {
252
580
  formHTML = this.generateMCPProfileForm(profile);
581
+ } else if (type === 'voice') {
582
+ formHTML = await this.generateVoiceProfileForm(profile);
253
583
  }
254
584
 
255
585
  if (this.elements.profileForm) {
@@ -257,7 +587,7 @@ class VibeSurfSettingsManager {
257
587
  this.elements.profileForm.dataset.type = type;
258
588
  this.elements.profileForm.dataset.mode = isEdit ? 'edit' : 'create';
259
589
  if (isEdit && profile) {
260
- this.elements.profileForm.dataset.profileId = profile.profile_name || profile.mcp_id;
590
+ this.elements.profileForm.dataset.profileId = profile.profile_name || profile.mcp_id || profile.voice_profile_name;
261
591
  }
262
592
  }
263
593
 
@@ -367,6 +697,104 @@ class VibeSurfSettingsManager {
367
697
  `;
368
698
  }
369
699
 
700
+ async generateVoiceProfileForm(profile = null) {
701
+ // Fetch available voice models
702
+ let models = [];
703
+ try {
704
+ const response = await this.apiClient.getVoiceModels();
705
+ models = response.models || response || [];
706
+ } catch (error) {
707
+ console.error('[SettingsManager] Failed to fetch voice models:', error);
708
+ }
709
+
710
+ // Group models by type
711
+ const asrModels = models.filter(m => m.model_type === 'asr');
712
+ const ttsModels = models.filter(m => m.model_type === 'tts');
713
+
714
+ const selectedModelType = profile?.voice_model_type || 'asr';
715
+ const availableModels = selectedModelType === 'asr' ? asrModels : ttsModels;
716
+
717
+ const modelsOptions = availableModels.map(m =>
718
+ `<option value="${m.model_name}" ${profile?.voice_model_name === m.model_name ? 'selected' : ''}>${m.model_name}</option>`
719
+ ).join('');
720
+
721
+ // Convert existing meta params to JSON for editing
722
+ let defaultMetaJson = '{}';
723
+ if (profile?.voice_meta_params) {
724
+ try {
725
+ defaultMetaJson = JSON.stringify(profile.voice_meta_params, null, 2);
726
+ } catch (error) {
727
+ console.warn('[SettingsManager] Failed to stringify existing voice_meta_params:', error);
728
+ }
729
+ }
730
+
731
+ return `
732
+ <div class="form-group">
733
+ <label class="form-label required">Profile Name</label>
734
+ <input type="text" name="voice_profile_name" class="form-input" value="${profile?.voice_profile_name || ''}"
735
+ placeholder="Enter a unique name for this profile" required ${profile ? 'readonly' : ''}>
736
+ <div class="form-help">A unique identifier for this voice configuration</div>
737
+ </div>
738
+
739
+ <div class="form-group">
740
+ <label class="form-label required">Model Type</label>
741
+ <select name="voice_model_type" class="form-select" required>
742
+ <option value="asr" ${selectedModelType === 'asr' ? 'selected' : ''}>ASR (Speech Recognition)</option>
743
+ <option value="tts" ${selectedModelType === 'tts' ? 'selected' : ''}>TTS (Text to Speech)</option>
744
+ </select>
745
+ <div class="form-help">Choose the type of voice model</div>
746
+ </div>
747
+
748
+ <div class="form-group">
749
+ <label class="form-label required">Voice Model</label>
750
+ <select name="voice_model_name" class="form-select voice-model-select" required>
751
+ <option value="">Select a model</option>
752
+ ${modelsOptions}
753
+ </select>
754
+ <div class="form-help">Choose your voice model</div>
755
+ </div>
756
+
757
+ <div class="form-group api-key-field">
758
+ <label class="form-label required">API Key</label>
759
+ <input type="password" name="api_key" class="form-input api-key-input"
760
+ placeholder="${profile ? 'Leave empty to keep existing key' : 'Enter your API key'}"
761
+ ${profile ? '' : 'required'}>
762
+ <button type="button" class="api-key-toggle" title="Toggle visibility">
763
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
764
+ <path d="M1 12S5 4 12 4S23 12 23 12S19 20 12 20S1 12 1 12Z" stroke="currentColor" stroke-width="2"/>
765
+ <circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/>
766
+ </svg>
767
+ </button>
768
+ <div class="form-help">Your voice provider's API key for authentication</div>
769
+ </div>
770
+
771
+ <div class="form-group">
772
+ <label class="form-label">Model Parameters (JSON)</label>
773
+ <textarea name="voice_meta_params_json" class="form-textarea json-input" rows="4"
774
+ placeholder="Enter JSON configuration for model parameters (optional)">${defaultMetaJson}</textarea>
775
+ <div class="json-validation-feedback"></div>
776
+ <div class="form-help">
777
+ Optional JSON configuration for model-specific parameters. Example:
778
+ <br><code>{"language": "zh", "sample_rate": 16000}</code>
779
+ </div>
780
+ </div>
781
+
782
+ <div class="form-group">
783
+ <label class="form-label">Description</label>
784
+ <textarea name="description" class="form-textarea" placeholder="Optional description for this profile">${profile?.description || ''}</textarea>
785
+ <div class="form-help">Optional description to help identify this profile</div>
786
+ </div>
787
+
788
+ <div class="form-group">
789
+ <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
790
+ <input type="checkbox" name="is_active" ${profile?.is_active !== false ? 'checked' : ''}>
791
+ <span class="form-label" style="margin: 0;">Active</span>
792
+ </label>
793
+ <div class="form-help">Whether this voice profile is active and available for use</div>
794
+ </div>
795
+ `;
796
+ }
797
+
370
798
  generateMCPProfileForm(profile = null) {
371
799
  // Convert existing profile to JSON for editing
372
800
  let defaultJson = '{\n "command": "npx",\n "args": [\n "-y",\n "@modelcontextprotocol/server-filesystem",\n "/path/to/directory"\n ]\n}';
@@ -430,6 +858,12 @@ class VibeSurfSettingsManager {
430
858
  providerSelect.addEventListener('change', this.handleProviderChange.bind(this));
431
859
  }
432
860
 
861
+ // Voice model type change handler for Voice profiles
862
+ const voiceModelTypeSelect = this.elements.profileForm?.querySelector('select[name="voice_model_type"]');
863
+ if (voiceModelTypeSelect) {
864
+ voiceModelTypeSelect.addEventListener('change', this.handleVoiceModelTypeChange.bind(this));
865
+ }
866
+
433
867
  // API key toggle handler
434
868
  const apiKeyToggle = this.elements.profileForm?.querySelector('.api-key-toggle');
435
869
  const apiKeyInput = this.elements.profileForm?.querySelector('.api-key-input');
@@ -457,6 +891,16 @@ class VibeSurfSettingsManager {
457
891
  // Trigger initial validation
458
892
  this.handleJsonInputValidation({ target: jsonInput });
459
893
  }
894
+
895
+ // JSON validation handler for Voice meta params
896
+ const voiceJsonInput = this.elements.profileForm?.querySelector('textarea[name="voice_meta_params_json"]');
897
+ if (voiceJsonInput) {
898
+ voiceJsonInput.addEventListener('input', this.handleVoiceJsonInputValidation.bind(this));
899
+ voiceJsonInput.addEventListener('blur', this.handleVoiceJsonInputValidation.bind(this));
900
+
901
+ // Trigger initial validation
902
+ this.handleVoiceJsonInputValidation({ target: voiceJsonInput });
903
+ }
460
904
  }
461
905
 
462
906
  handleJsonInputValidation(event) {
@@ -511,6 +955,48 @@ class VibeSurfSettingsManager {
511
955
  }
512
956
  }
513
957
 
958
+ handleVoiceJsonInputValidation(event) {
959
+ const textarea = event.target;
960
+ const feedbackElement = textarea.parentElement.querySelector('.json-validation-feedback');
961
+
962
+ if (!feedbackElement) return;
963
+
964
+ const jsonText = textarea.value.trim();
965
+
966
+ if (!jsonText) {
967
+ feedbackElement.innerHTML = '';
968
+ textarea.classList.remove('json-valid', 'json-invalid');
969
+ return;
970
+ }
971
+
972
+ try {
973
+ const parsed = JSON.parse(jsonText);
974
+
975
+ // Validate that it's an object (not array, string, etc.)
976
+ if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
977
+ throw new Error('Voice meta parameters must be a JSON object');
978
+ }
979
+
980
+ // Success - no specific validation required for voice meta params (flexible structure)
981
+ feedbackElement.innerHTML = '<span class="json-success">✓ Valid JSON configuration</span>';
982
+ textarea.classList.remove('json-invalid');
983
+ textarea.classList.add('json-valid');
984
+
985
+ // Store valid state for form submission
986
+ textarea.dataset.isValid = 'true';
987
+
988
+ } catch (error) {
989
+ const errorMessage = error.message;
990
+ feedbackElement.innerHTML = `<span class="json-error">✗ Invalid JSON: ${errorMessage}</span>`;
991
+ textarea.classList.remove('json-valid');
992
+ textarea.classList.add('json-invalid');
993
+
994
+ // Store invalid state for form submission
995
+ textarea.dataset.isValid = 'false';
996
+ textarea.dataset.errorMessage = errorMessage;
997
+ }
998
+ }
999
+
514
1000
  handleProfileFormSubmitClick(event) {
515
1001
  console.log('[SettingsManager] Profile form submit button clicked');
516
1002
  event.preventDefault();
@@ -523,6 +1009,41 @@ class VibeSurfSettingsManager {
523
1009
  }
524
1010
  }
525
1011
 
1012
+ async handleVoiceModelTypeChange(event) {
1013
+ const selectedType = event.target.value;
1014
+ const modelSelect = this.elements.profileForm?.querySelector('select[name="voice_model_name"]');
1015
+
1016
+ if (!selectedType || !modelSelect) {
1017
+ return;
1018
+ }
1019
+
1020
+ // Clear current options
1021
+ modelSelect.innerHTML = '<option value="">Loading...</option>';
1022
+
1023
+ try {
1024
+ const response = await this.apiClient.getVoiceModels(selectedType);
1025
+ const models = response.models || response || [];
1026
+
1027
+ // Models are already filtered by the API, no need to filter again
1028
+
1029
+ // Update select options
1030
+ modelSelect.innerHTML = '<option value="">Select a model</option>' +
1031
+ models.map(model =>
1032
+ `<option value="${model.model_name}">${model.model_name}</option>`
1033
+ ).join('');
1034
+
1035
+ } catch (error) {
1036
+ console.error('[SettingsManager] Failed to fetch voice models for type:', error);
1037
+ modelSelect.innerHTML = '<option value="">Failed to load models</option>';
1038
+
1039
+ // Show user-friendly error notification
1040
+ this.emit('notification', {
1041
+ message: `Failed to load models for ${selectedType}. Please try again.`,
1042
+ type: 'warning'
1043
+ });
1044
+ }
1045
+ }
1046
+
526
1047
  async handleProviderChange(event) {
527
1048
  const selectedProvider = event.target.value;
528
1049
  const modelInput = this.elements.profileForm?.querySelector('input[name="model"]');
@@ -623,6 +1144,35 @@ class VibeSurfSettingsManager {
623
1144
  }
624
1145
  }
625
1146
 
1147
+ // Handle Voice profile meta params structure - parse JSON input
1148
+ if (type === 'voice') {
1149
+ const jsonInput = data.voice_meta_params_json;
1150
+
1151
+ if (jsonInput && jsonInput.trim()) {
1152
+ try {
1153
+ const parsedParams = JSON.parse(jsonInput);
1154
+
1155
+ // Validate that it's an object (not array, string, etc.)
1156
+ if (typeof parsedParams !== 'object' || Array.isArray(parsedParams) || parsedParams === null) {
1157
+ throw new Error('Voice meta parameters must be a JSON object');
1158
+ }
1159
+
1160
+ // Set the parsed parameters
1161
+ data.voice_meta_params = parsedParams;
1162
+
1163
+ } catch (error) {
1164
+ console.error('[SettingsManager] Failed to parse Voice meta params JSON:', error);
1165
+ this.emit('error', { message: error.message });
1166
+ form.dataset.submitting = 'false';
1167
+ this.setProfileFormSubmitting(false);
1168
+ return;
1169
+ }
1170
+ }
1171
+
1172
+ // Remove the JSON field as it's not needed in the API request
1173
+ delete data.voice_meta_params_json;
1174
+ }
1175
+
626
1176
  // Handle MCP server params structure - parse JSON input
627
1177
  if (type === 'mcp') {
628
1178
  const jsonInput = data.mcp_server_params_json;
@@ -679,12 +1229,16 @@ class VibeSurfSettingsManager {
679
1229
  if (mode === 'create') {
680
1230
  if (type === 'llm') {
681
1231
  response = await this.apiClient.createLLMProfile(data);
1232
+ } else if (type === 'voice') {
1233
+ response = await this.apiClient.createVoiceProfile(data);
682
1234
  } else {
683
1235
  response = await this.apiClient.createMCPProfile(data);
684
1236
  }
685
1237
  } else {
686
1238
  if (type === 'llm') {
687
1239
  response = await this.apiClient.updateLLMProfile(profileId, data);
1240
+ } else if (type === 'voice') {
1241
+ response = await this.apiClient.updateVoiceProfile(profileId, data);
688
1242
  } else {
689
1243
  response = await this.apiClient.updateMCPProfile(profileId, data);
690
1244
  }
@@ -779,6 +1333,60 @@ class VibeSurfSettingsManager {
779
1333
  }
780
1334
  }
781
1335
 
1336
+ async handleThemeChange(event) {
1337
+ const selectedTheme = event.target.value;
1338
+
1339
+ try {
1340
+ // Store theme preference in user settings storage
1341
+ await this.userSettingsStorage.setTheme(selectedTheme);
1342
+
1343
+ // Apply theme to document
1344
+ this.applyTheme(selectedTheme);
1345
+
1346
+ this.emit('notification', {
1347
+ message: `Theme changed to ${selectedTheme}`,
1348
+ type: 'success'
1349
+ });
1350
+
1351
+ } catch (error) {
1352
+ console.error('[SettingsManager] Failed to change theme:', error);
1353
+ this.emit('notification', {
1354
+ message: 'Failed to change theme',
1355
+ type: 'error'
1356
+ });
1357
+ }
1358
+ }
1359
+
1360
+
1361
+ applyTheme(theme) {
1362
+ const root = document.documentElement;
1363
+
1364
+ if (theme === 'dark') {
1365
+ root.setAttribute('data-theme', 'dark');
1366
+ root.classList.add('dark-theme');
1367
+ root.classList.remove('light-theme');
1368
+ } else if (theme === 'light') {
1369
+ root.setAttribute('data-theme', 'light');
1370
+ root.classList.add('light-theme');
1371
+ root.classList.remove('dark-theme');
1372
+ } else { // auto
1373
+ root.classList.remove('dark-theme', 'light-theme');
1374
+ // Let system preference take over
1375
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
1376
+ root.setAttribute('data-theme', 'dark');
1377
+ root.classList.add('dark-theme');
1378
+ } else {
1379
+ root.setAttribute('data-theme', 'light');
1380
+ root.classList.add('light-theme');
1381
+ }
1382
+ }
1383
+
1384
+ // Force a repaint to ensure theme changes are applied immediately
1385
+ document.body.style.display = 'none';
1386
+ document.body.offsetHeight; // trigger reflow
1387
+ document.body.style.display = '';
1388
+ }
1389
+
782
1390
  async handleBackendUrlChange(event) {
783
1391
  const newUrl = event.target.value.trim();
784
1392
 
@@ -1006,6 +1614,79 @@ class VibeSurfSettingsManager {
1006
1614
  });
1007
1615
  }
1008
1616
 
1617
+ renderVoiceProfiles(profiles) {
1618
+ const container = document.getElementById('voice-profiles-list');
1619
+ if (!container) return;
1620
+
1621
+ if (profiles.length === 0) {
1622
+ container.innerHTML = `
1623
+ <div class="empty-state">
1624
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1625
+ <path d="M19 14C19 18.5 15.5 22 11 22C6.5 22 3 18.5 3 14V12C3 7.5 6.5 4 11 4S19 7.5 19 12V14ZM11 8C8.8 8 7 9.8 7 12V14C7 16.2 8.8 18 11 18S15 16.2 15 14V12C15 9.8 13.2 8 11 8Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1626
+ <circle cx="11" cy="11" r="2" stroke="currentColor" stroke-width="2"/>
1627
+ </svg>
1628
+ <h3>No Voice Profiles</h3>
1629
+ <p>Create your first voice profile to enable speech features</p>
1630
+ </div>
1631
+ `;
1632
+ return;
1633
+ }
1634
+
1635
+ const profilesHTML = profiles.map(profile => `
1636
+ <div class="profile-card ${profile.is_active ? 'active' : 'inactive'}" data-profile-id="${profile.voice_profile_name}">
1637
+ <div class="profile-status ${profile.is_active ? 'active' : 'inactive'}">
1638
+ ${profile.is_active ? 'Active' : 'Inactive'}
1639
+ </div>
1640
+ <div class="profile-header">
1641
+ <div class="profile-title">
1642
+ <h3>${this.escapeHtml(profile.voice_profile_name)}</h3>
1643
+ <span class="profile-provider">${this.escapeHtml(profile.voice_model_name)} (${profile.voice_model_type.toUpperCase()})</span>
1644
+ </div>
1645
+ <div class="profile-actions">
1646
+ <button class="profile-action-btn edit" title="Edit Profile" data-profile='${JSON.stringify(profile)}'>
1647
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1648
+ <path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1649
+ <path d="M18.5 2.5C18.8978 2.10217 19.4374 1.87868 20 1.87868C20.5626 1.87868 21.1022 2.10217 21.5 2.5C21.8978 2.89783 22.1213 3.43739 22.1213 4C22.1213 4.56261 21.8978 5.10217 21.5 5.5L12 15L8 16L9 12L18.5 2.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1650
+ </svg>
1651
+ </button>
1652
+ <button class="profile-action-btn delete" title="Delete Profile" data-profile-id="${profile.voice_profile_name}">
1653
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1654
+ <path d="M3 6H5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1655
+ <path d="M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1656
+ </svg>
1657
+ </button>
1658
+ </div>
1659
+ </div>
1660
+ <div class="profile-content">
1661
+ ${profile.description ? `<p class="profile-description">${this.escapeHtml(profile.description)}</p>` : ''}
1662
+ <div class="profile-details">
1663
+ <div class="profile-detail"><strong>Model:</strong> ${this.escapeHtml(profile.voice_model_name)}</div>
1664
+ <div class="profile-detail"><strong>Type:</strong> ${profile.voice_model_type.toUpperCase()}</div>
1665
+ ${profile.voice_meta_params ? `<div class="profile-detail"><strong>Parameters:</strong> ${Object.keys(profile.voice_meta_params).length} custom settings</div>` : ''}
1666
+ </div>
1667
+ </div>
1668
+ </div>
1669
+ `).join('');
1670
+
1671
+ container.innerHTML = profilesHTML;
1672
+
1673
+ // Add event listeners for profile actions
1674
+ container.querySelectorAll('.edit').forEach(btn => {
1675
+ btn.addEventListener('click', (e) => {
1676
+ e.stopPropagation();
1677
+ const profile = JSON.parse(btn.dataset.profile);
1678
+ this.showProfileForm('voice', profile);
1679
+ });
1680
+ });
1681
+
1682
+ container.querySelectorAll('.delete').forEach(btn => {
1683
+ btn.addEventListener('click', async (e) => {
1684
+ e.stopPropagation();
1685
+ await this.handleDeleteProfile('voice', btn.dataset.profileId);
1686
+ });
1687
+ });
1688
+ }
1689
+
1009
1690
  renderEnvironmentVariables(envVars) {
1010
1691
  const container = this.elements.envVariablesList;
1011
1692
  if (!container) return;
@@ -1142,6 +1823,8 @@ class VibeSurfSettingsManager {
1142
1823
 
1143
1824
  if (type === 'llm') {
1144
1825
  await this.apiClient.deleteLLMProfile(profileId);
1826
+ } else if (type === 'voice') {
1827
+ await this.apiClient.deleteVoiceProfile(profileId);
1145
1828
  } else {
1146
1829
  await this.apiClient.deleteMCPProfile(profileId);
1147
1830
  }
@@ -1185,6 +1868,10 @@ class VibeSurfSettingsManager {
1185
1868
  return this.state.mcpProfiles || [];
1186
1869
  }
1187
1870
 
1871
+ getVoiceProfiles() {
1872
+ return this.state.voiceProfiles || [];
1873
+ }
1874
+
1188
1875
  showModal() {
1189
1876
  if (this.elements.settingsModal) {
1190
1877
  this.elements.settingsModal.classList.remove('hidden');