@speechos/client 0.2.7 → 0.2.9

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 (38) hide show
  1. package/dist/index.cjs +651 -110
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.iife.js +684 -114
  6. package/dist/index.iife.js.map +1 -1
  7. package/dist/index.iife.min.js +122 -100
  8. package/dist/index.iife.min.js.map +1 -1
  9. package/dist/index.js +652 -112
  10. package/dist/index.js.map +1 -1
  11. package/dist/settings-sync.d.ts +68 -0
  12. package/dist/settings-sync.d.ts.map +1 -0
  13. package/dist/settings-sync.test.d.ts +5 -0
  14. package/dist/settings-sync.test.d.ts.map +1 -0
  15. package/dist/speechos.d.ts.map +1 -1
  16. package/dist/stores/language-settings.d.ts +12 -1
  17. package/dist/stores/language-settings.d.ts.map +1 -1
  18. package/dist/stores/language-settings.test.d.ts +5 -0
  19. package/dist/stores/language-settings.test.d.ts.map +1 -0
  20. package/dist/stores/snippets-store.d.ts +12 -1
  21. package/dist/stores/snippets-store.d.ts.map +1 -1
  22. package/dist/stores/transcript-store.d.ts +13 -2
  23. package/dist/stores/transcript-store.d.ts.map +1 -1
  24. package/dist/stores/vocabulary-store.d.ts +12 -1
  25. package/dist/stores/vocabulary-store.d.ts.map +1 -1
  26. package/dist/ui/index.d.ts.map +1 -1
  27. package/dist/ui/styles/modal-styles.d.ts.map +1 -1
  28. package/dist/ui/styles/theme.d.ts.map +1 -1
  29. package/dist/ui/tabs/history-tab.d.ts +2 -0
  30. package/dist/ui/tabs/history-tab.d.ts.map +1 -1
  31. package/dist/ui/tabs/settings-tab.d.ts +1 -0
  32. package/dist/ui/tabs/settings-tab.d.ts.map +1 -1
  33. package/dist/ui/tabs/snippets-tab.d.ts +2 -0
  34. package/dist/ui/tabs/snippets-tab.d.ts.map +1 -1
  35. package/dist/ui/tabs/vocabulary-tab.d.ts +2 -0
  36. package/dist/ui/tabs/vocabulary-tab.d.ts.map +1 -1
  37. package/dist/ui/widget.d.ts.map +1 -1
  38. package/package.json +1 -1
@@ -26911,7 +26911,9 @@
26911
26911
  userId: "",
26912
26912
  host: DEFAULT_HOST,
26913
26913
  debug: false,
26914
- webSocketFactory: void 0
26914
+ webSocketFactory: void 0,
26915
+ fetchHandler: void 0,
26916
+ settingsToken: void 0
26915
26917
  };
26916
26918
  /**
26917
26919
  * Validates and merges user config with defaults
@@ -26925,7 +26927,9 @@
26925
26927
  userId: userConfig.userId ?? defaultConfig.userId,
26926
26928
  host: userConfig.host ?? defaultConfig.host,
26927
26929
  debug: userConfig.debug ?? defaultConfig.debug,
26928
- webSocketFactory: userConfig.webSocketFactory ?? defaultConfig.webSocketFactory
26930
+ webSocketFactory: userConfig.webSocketFactory ?? defaultConfig.webSocketFactory,
26931
+ fetchHandler: userConfig.fetchHandler ?? defaultConfig.fetchHandler,
26932
+ settingsToken: userConfig.settingsToken ?? defaultConfig.settingsToken
26929
26933
  };
26930
26934
  }
26931
26935
  /**
@@ -26962,6 +26966,29 @@
26962
26966
  };
26963
26967
  }
26964
26968
  /**
26969
+ * Get the settings token from the current configuration
26970
+ * @returns The settings token or undefined if not configured
26971
+ */
26972
+ function getSettingsToken() {
26973
+ return currentConfig.settingsToken;
26974
+ }
26975
+ /**
26976
+ * Clear the settings token (e.g., when it expires)
26977
+ */
26978
+ function clearSettingsToken() {
26979
+ currentConfig = {
26980
+ ...currentConfig,
26981
+ settingsToken: void 0
26982
+ };
26983
+ }
26984
+ /**
26985
+ * Get the fetch handler from the current configuration
26986
+ * @returns The fetch handler or undefined if not configured
26987
+ */
26988
+ function getFetchHandler() {
26989
+ return currentConfig.fetchHandler;
26990
+ }
26991
+ /**
26965
26992
  * LocalStorage key for anonymous ID persistence
26966
26993
  */
26967
26994
  const ANONYMOUS_ID_KEY = "speechos_anonymous_id";
@@ -27214,14 +27241,16 @@
27214
27241
  });
27215
27242
  }
27216
27243
  /**
27217
- * Complete the recording flow and return to idle
27244
+ * Complete the recording flow and return to idle.
27245
+ * Keeps widget visible but collapsed (just mic button, no action bubbles).
27218
27246
  */
27219
27247
  completeRecording() {
27220
27248
  this.setState({
27221
27249
  recordingState: "idle",
27222
27250
  activeAction: null,
27223
27251
  isConnected: false,
27224
- isMicEnabled: false
27252
+ isMicEnabled: false,
27253
+ isExpanded: false
27225
27254
  });
27226
27255
  }
27227
27256
  /**
@@ -29245,6 +29274,11 @@
29245
29274
  * Persists input language preferences to localStorage
29246
29275
  */
29247
29276
  const STORAGE_KEY$4 = "speechos_language_settings";
29277
+ /**
29278
+ * In-memory cache for language settings. When server sync is enabled, this is the
29279
+ * source of truth. localStorage is only used when server sync is disabled.
29280
+ */
29281
+ let memoryCache$3 = null;
29248
29282
  /**
29249
29283
  * Supported input languages for speech recognition
29250
29284
  * Each language has a name, primary code, and available variants
@@ -29296,9 +29330,13 @@
29296
29330
  smartFormat: true,
29297
29331
  };
29298
29332
  /**
29299
- * Get current language settings from localStorage
29333
+ * Get current language settings. Prefers in-memory cache (from server sync),
29334
+ * then falls back to localStorage.
29300
29335
  */
29301
29336
  function getLanguageSettings() {
29337
+ if (memoryCache$3 !== null) {
29338
+ return { ...memoryCache$3 };
29339
+ }
29302
29340
  try {
29303
29341
  const stored = localStorage.getItem(STORAGE_KEY$4);
29304
29342
  if (!stored)
@@ -29310,14 +29348,27 @@
29310
29348
  }
29311
29349
  }
29312
29350
  /**
29313
- * Save language settings to localStorage
29351
+ * Set language settings directly (used by settings sync from server data).
29352
+ */
29353
+ function setLanguageSettings(settings) {
29354
+ memoryCache$3 = { ...defaultSettings$1, ...settings };
29355
+ }
29356
+ /**
29357
+ * Reset memory cache (for testing only)
29358
+ */
29359
+ function resetMemoryCache$2() {
29360
+ memoryCache$3 = null;
29361
+ }
29362
+ /**
29363
+ * Save language settings (updates memory cache and tries localStorage)
29314
29364
  */
29315
29365
  function saveLanguageSettings(settings) {
29366
+ memoryCache$3 = settings;
29316
29367
  try {
29317
29368
  localStorage.setItem(STORAGE_KEY$4, JSON.stringify(settings));
29318
29369
  }
29319
29370
  catch {
29320
- // localStorage full or unavailable - silently fail
29371
+ // localStorage full or unavailable - memory cache still updated
29321
29372
  }
29322
29373
  }
29323
29374
  /**
@@ -29421,6 +29472,7 @@
29421
29472
  * Reset language settings to defaults
29422
29473
  */
29423
29474
  function resetLanguageSettings() {
29475
+ memoryCache$3 = null;
29424
29476
  try {
29425
29477
  localStorage.removeItem(STORAGE_KEY$4);
29426
29478
  }
@@ -29430,6 +29482,7 @@
29430
29482
  }
29431
29483
  const languageSettings = {
29432
29484
  getLanguageSettings,
29485
+ setLanguageSettings,
29433
29486
  getInputLanguageCode,
29434
29487
  setInputLanguageCode,
29435
29488
  getOutputLanguageCode,
@@ -29440,6 +29493,7 @@
29440
29493
  getSmartFormatEnabled,
29441
29494
  setSmartFormatEnabled,
29442
29495
  resetLanguageSettings,
29496
+ resetMemoryCache: resetMemoryCache$2,
29443
29497
  SUPPORTED_LANGUAGES,
29444
29498
  // Legacy aliases
29445
29499
  getLanguageCode,
@@ -29455,6 +29509,11 @@
29455
29509
  const MAX_SNIPPETS = 25;
29456
29510
  const MAX_TRIGGER_LENGTH = 30;
29457
29511
  const MAX_EXPANSION_LENGTH = 300;
29512
+ /**
29513
+ * In-memory cache for snippets. When server sync is enabled, this is the
29514
+ * source of truth. localStorage is only used when server sync is disabled.
29515
+ */
29516
+ let memoryCache$2 = null;
29458
29517
  /**
29459
29518
  * Generate a unique ID for snippet entries
29460
29519
  */
@@ -29462,15 +29521,18 @@
29462
29521
  return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
29463
29522
  }
29464
29523
  /**
29465
- * Get all snippets from localStorage
29524
+ * Get all snippets. Prefers in-memory cache (from server sync),
29525
+ * then falls back to localStorage.
29466
29526
  */
29467
29527
  function getSnippets() {
29528
+ if (memoryCache$2 !== null) {
29529
+ return [...memoryCache$2].sort((a, b) => b.createdAt - a.createdAt);
29530
+ }
29468
29531
  try {
29469
29532
  const stored = localStorage.getItem(STORAGE_KEY$3);
29470
29533
  if (!stored)
29471
29534
  return [];
29472
29535
  const entries = JSON.parse(stored);
29473
- // Return newest first
29474
29536
  return entries.sort((a, b) => b.createdAt - a.createdAt);
29475
29537
  }
29476
29538
  catch {
@@ -29478,14 +29540,27 @@
29478
29540
  }
29479
29541
  }
29480
29542
  /**
29481
- * Save snippets to localStorage
29543
+ * Set snippets directly (used by settings sync from server data).
29544
+ */
29545
+ function setSnippets(snippets) {
29546
+ memoryCache$2 = snippets.slice(0, MAX_SNIPPETS);
29547
+ }
29548
+ /**
29549
+ * Reset memory cache (for testing only)
29550
+ */
29551
+ function resetMemoryCache$1() {
29552
+ memoryCache$2 = null;
29553
+ }
29554
+ /**
29555
+ * Save snippets (updates memory cache and tries localStorage)
29482
29556
  */
29483
29557
  function saveSnippets(snippets) {
29558
+ memoryCache$2 = snippets;
29484
29559
  try {
29485
29560
  localStorage.setItem(STORAGE_KEY$3, JSON.stringify(snippets));
29486
29561
  }
29487
29562
  catch {
29488
- // localStorage full or unavailable - silently fail
29563
+ // localStorage full or unavailable - memory cache still updated
29489
29564
  }
29490
29565
  }
29491
29566
  /**
@@ -29603,13 +29678,14 @@
29603
29678
  * Clear all snippets
29604
29679
  */
29605
29680
  function clearSnippets() {
29681
+ memoryCache$2 = [];
29606
29682
  try {
29607
29683
  localStorage.removeItem(STORAGE_KEY$3);
29608
- events.emit("settings:changed", { setting: "snippets" });
29609
29684
  }
29610
29685
  catch {
29611
29686
  // Silently fail
29612
29687
  }
29688
+ events.emit("settings:changed", { setting: "snippets" });
29613
29689
  }
29614
29690
  /**
29615
29691
  * Get snippet count info
@@ -29625,12 +29701,14 @@
29625
29701
  }
29626
29702
  const snippetsStore = {
29627
29703
  getSnippets,
29704
+ setSnippets,
29628
29705
  addSnippet,
29629
29706
  updateSnippet,
29630
29707
  deleteSnippet,
29631
29708
  clearSnippets,
29632
29709
  getSnippetCount,
29633
29710
  isAtSnippetLimit,
29711
+ resetMemoryCache: resetMemoryCache$1,
29634
29712
  MAX_SNIPPETS,
29635
29713
  MAX_TRIGGER_LENGTH,
29636
29714
  MAX_EXPANSION_LENGTH,
@@ -29643,6 +29721,11 @@
29643
29721
  const STORAGE_KEY$2 = "speechos_vocabulary";
29644
29722
  const MAX_TERMS = 50;
29645
29723
  const MAX_TERM_LENGTH = 50;
29724
+ /**
29725
+ * In-memory cache for vocabulary. When server sync is enabled, this is the
29726
+ * source of truth. localStorage is only used when server sync is disabled.
29727
+ */
29728
+ let memoryCache$1 = null;
29646
29729
  /**
29647
29730
  * Generate a unique ID for vocabulary entries
29648
29731
  */
@@ -29650,15 +29733,18 @@
29650
29733
  return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
29651
29734
  }
29652
29735
  /**
29653
- * Get all vocabulary terms from localStorage
29736
+ * Get all vocabulary terms. Prefers in-memory cache (from server sync),
29737
+ * then falls back to localStorage.
29654
29738
  */
29655
29739
  function getVocabulary() {
29740
+ if (memoryCache$1 !== null) {
29741
+ return [...memoryCache$1].sort((a, b) => b.createdAt - a.createdAt);
29742
+ }
29656
29743
  try {
29657
29744
  const stored = localStorage.getItem(STORAGE_KEY$2);
29658
29745
  if (!stored)
29659
29746
  return [];
29660
29747
  const entries = JSON.parse(stored);
29661
- // Return newest first
29662
29748
  return entries.sort((a, b) => b.createdAt - a.createdAt);
29663
29749
  }
29664
29750
  catch {
@@ -29666,14 +29752,27 @@
29666
29752
  }
29667
29753
  }
29668
29754
  /**
29669
- * Save vocabulary to localStorage
29755
+ * Set vocabulary directly (used by settings sync from server data).
29756
+ */
29757
+ function setVocabulary(terms) {
29758
+ memoryCache$1 = terms.slice(0, MAX_TERMS);
29759
+ }
29760
+ /**
29761
+ * Reset memory cache (for testing only)
29762
+ */
29763
+ function resetMemoryCache() {
29764
+ memoryCache$1 = null;
29765
+ }
29766
+ /**
29767
+ * Save vocabulary (updates memory cache and tries localStorage)
29670
29768
  */
29671
29769
  function saveVocabulary(terms) {
29770
+ memoryCache$1 = terms;
29672
29771
  try {
29673
29772
  localStorage.setItem(STORAGE_KEY$2, JSON.stringify(terms));
29674
29773
  }
29675
29774
  catch {
29676
- // localStorage full or unavailable - silently fail
29775
+ // localStorage full or unavailable - memory cache still updated
29677
29776
  }
29678
29777
  }
29679
29778
  /**
@@ -29741,13 +29840,14 @@
29741
29840
  * Clear all vocabulary
29742
29841
  */
29743
29842
  function clearVocabulary() {
29843
+ memoryCache$1 = [];
29744
29844
  try {
29745
29845
  localStorage.removeItem(STORAGE_KEY$2);
29746
- events.emit("settings:changed", { setting: "vocabulary" });
29747
29846
  }
29748
29847
  catch {
29749
29848
  // Silently fail
29750
29849
  }
29850
+ events.emit("settings:changed", { setting: "vocabulary" });
29751
29851
  }
29752
29852
  /**
29753
29853
  * Get vocabulary count info
@@ -29763,11 +29863,13 @@
29763
29863
  }
29764
29864
  const vocabularyStore = {
29765
29865
  getVocabulary,
29866
+ setVocabulary,
29766
29867
  addTerm,
29767
29868
  deleteTerm,
29768
29869
  clearVocabulary,
29769
29870
  getVocabularyCount,
29770
29871
  isAtVocabularyLimit,
29872
+ resetMemoryCache,
29771
29873
  MAX_TERMS,
29772
29874
  MAX_TERM_LENGTH,
29773
29875
  };
@@ -29865,6 +29967,462 @@
29865
29967
  resetAudioSettings,
29866
29968
  };
29867
29969
 
29970
+ /**
29971
+ * Transcript history store
29972
+ * Persists transcripts to localStorage for viewing in the settings modal
29973
+ */
29974
+ const STORAGE_KEY = "speechos_transcripts";
29975
+ const MAX_ENTRIES = 50;
29976
+ /**
29977
+ * In-memory cache for transcripts. When server sync is enabled, this is the
29978
+ * source of truth. localStorage is only used when server sync is disabled.
29979
+ */
29980
+ let memoryCache = null;
29981
+ /**
29982
+ * Generate a unique ID for transcript entries
29983
+ */
29984
+ function generateId() {
29985
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
29986
+ }
29987
+ /**
29988
+ * Get all transcripts. Prefers in-memory cache (from server sync),
29989
+ * then falls back to localStorage.
29990
+ */
29991
+ function getTranscripts() {
29992
+ // If we have in-memory data (from server sync), use it
29993
+ if (memoryCache !== null) {
29994
+ return [...memoryCache].sort((a, b) => b.timestamp - a.timestamp);
29995
+ }
29996
+ // Fall back to localStorage (when server sync is disabled)
29997
+ try {
29998
+ const stored = localStorage.getItem(STORAGE_KEY);
29999
+ if (!stored)
30000
+ return [];
30001
+ const entries = JSON.parse(stored);
30002
+ return entries.sort((a, b) => b.timestamp - a.timestamp);
30003
+ }
30004
+ catch {
30005
+ return [];
30006
+ }
30007
+ }
30008
+ /**
30009
+ * Set transcripts directly (used by settings sync from server data).
30010
+ * Server data is the source of truth - just update memory cache.
30011
+ */
30012
+ function setTranscripts(entries) {
30013
+ memoryCache = entries.slice(0, MAX_ENTRIES);
30014
+ }
30015
+ /**
30016
+ * Save a new transcript entry
30017
+ */
30018
+ function saveTranscript(text, action, originalTextOrOptions) {
30019
+ const entry = {
30020
+ id: generateId(),
30021
+ text,
30022
+ timestamp: Date.now(),
30023
+ action,
30024
+ };
30025
+ // Handle edit action with originalText string
30026
+ if (action === "edit" && typeof originalTextOrOptions === "string") {
30027
+ entry.originalText = originalTextOrOptions;
30028
+ }
30029
+ // Handle command action with options object
30030
+ if (action === "command" && typeof originalTextOrOptions === "object") {
30031
+ const options = originalTextOrOptions;
30032
+ if (options.inputText !== undefined)
30033
+ entry.inputText = options.inputText;
30034
+ if (options.commandResult !== undefined)
30035
+ entry.commandResult = options.commandResult;
30036
+ if (options.commandConfig !== undefined)
30037
+ entry.commandConfig = options.commandConfig;
30038
+ }
30039
+ const entries = getTranscripts();
30040
+ entries.unshift(entry);
30041
+ // Prune to max entries
30042
+ const pruned = entries.slice(0, MAX_ENTRIES);
30043
+ // Update memory cache (always)
30044
+ memoryCache = pruned;
30045
+ // Try to persist to localStorage (for when server sync is disabled)
30046
+ try {
30047
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
30048
+ }
30049
+ catch {
30050
+ // Quota exceeded - memory cache is still updated
30051
+ }
30052
+ // Emit settings change event to trigger sync
30053
+ events.emit("settings:changed", { setting: "history" });
30054
+ return entry;
30055
+ }
30056
+ /**
30057
+ * Clear all transcript history
30058
+ */
30059
+ function clearTranscripts() {
30060
+ memoryCache = [];
30061
+ try {
30062
+ localStorage.removeItem(STORAGE_KEY);
30063
+ }
30064
+ catch {
30065
+ // Silently fail
30066
+ }
30067
+ events.emit("settings:changed", { setting: "history" });
30068
+ }
30069
+ /**
30070
+ * Delete a single transcript by ID
30071
+ */
30072
+ function deleteTranscript(id) {
30073
+ const entries = getTranscripts().filter((e) => e.id !== id);
30074
+ memoryCache = entries;
30075
+ try {
30076
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
30077
+ }
30078
+ catch {
30079
+ // Silently fail
30080
+ }
30081
+ events.emit("settings:changed", { setting: "history" });
30082
+ }
30083
+ const transcriptStore = {
30084
+ getTranscripts,
30085
+ setTranscripts,
30086
+ saveTranscript,
30087
+ clearTranscripts,
30088
+ deleteTranscript,
30089
+ };
30090
+
30091
+ /**
30092
+ * Settings sync manager
30093
+ * Syncs user settings (language, vocabulary, snippets, history) with the server
30094
+ */
30095
+ // Sync debounce delay in milliseconds
30096
+ const SYNC_DEBOUNCE_MS = 2000;
30097
+ // Maximum retry attempts
30098
+ const MAX_RETRIES = 3;
30099
+ // Base retry delay in milliseconds (exponential backoff)
30100
+ const BASE_RETRY_DELAY_MS = 2000;
30101
+ /**
30102
+ * Settings sync manager singleton
30103
+ */
30104
+ class SettingsSync {
30105
+ constructor() {
30106
+ this.syncTimer = null;
30107
+ this.isSyncing = false;
30108
+ this.retryCount = 0;
30109
+ this.isInitialized = false;
30110
+ this.unsubscribe = null;
30111
+ /** When true, sync is disabled due to CSP or network restrictions */
30112
+ this.syncDisabled = false;
30113
+ }
30114
+ /**
30115
+ * Make a fetch request using custom fetchHandler if configured, otherwise native fetch.
30116
+ * This allows the Chrome extension to route fetch traffic through the service worker
30117
+ * to bypass page CSP restrictions.
30118
+ */
30119
+ async doFetch(url, options) {
30120
+ const config = getConfig();
30121
+ const customHandler = getFetchHandler();
30122
+ if (customHandler) {
30123
+ if (config.debug) {
30124
+ console.log("[SpeechOS] Using custom fetch handler (extension proxy)", options.method, url);
30125
+ }
30126
+ return customHandler(url, options);
30127
+ }
30128
+ if (config.debug) {
30129
+ console.log("[SpeechOS] Using native fetch", options.method, url);
30130
+ }
30131
+ // Use native fetch and wrap response to match FetchResponse interface
30132
+ const response = await fetch(url, options);
30133
+ return response;
30134
+ }
30135
+ /**
30136
+ * Initialize the settings sync manager
30137
+ * If a settingsToken is configured, loads settings from server
30138
+ */
30139
+ async init() {
30140
+ const token = getSettingsToken();
30141
+ if (!token) {
30142
+ // No token configured, sync is disabled
30143
+ return;
30144
+ }
30145
+ if (this.isInitialized) {
30146
+ return;
30147
+ }
30148
+ this.isInitialized = true;
30149
+ // Subscribe to settings changes
30150
+ this.unsubscribe = events.on("settings:changed", () => {
30151
+ this.scheduleSyncToServer();
30152
+ });
30153
+ // Load settings from server
30154
+ await this.loadFromServer();
30155
+ }
30156
+ /**
30157
+ * Stop the sync manager and clean up
30158
+ */
30159
+ destroy() {
30160
+ if (this.syncTimer) {
30161
+ clearTimeout(this.syncTimer);
30162
+ this.syncTimer = null;
30163
+ }
30164
+ if (this.unsubscribe) {
30165
+ this.unsubscribe();
30166
+ this.unsubscribe = null;
30167
+ }
30168
+ this.isInitialized = false;
30169
+ this.retryCount = 0;
30170
+ this.syncDisabled = false;
30171
+ }
30172
+ /**
30173
+ * Load settings from the server and merge with local
30174
+ */
30175
+ async loadFromServer() {
30176
+ const token = getSettingsToken();
30177
+ if (!token) {
30178
+ return;
30179
+ }
30180
+ const config = getConfig();
30181
+ try {
30182
+ const response = await this.doFetch(`${config.host}/api/user-settings/`, {
30183
+ method: "GET",
30184
+ headers: {
30185
+ Authorization: `Bearer ${token}`,
30186
+ "Content-Type": "application/json",
30187
+ },
30188
+ });
30189
+ if (config.debug) {
30190
+ console.log("[SpeechOS] Settings fetch response:", response.status, response.ok ? "OK" : response.statusText);
30191
+ }
30192
+ if (response.status === 404) {
30193
+ // No settings on server yet (new user) - sync local settings to server
30194
+ if (config.debug) {
30195
+ console.log("[SpeechOS] No server settings found, syncing local to server");
30196
+ }
30197
+ await this.syncToServer();
30198
+ return;
30199
+ }
30200
+ if (response.status === 401 || response.status === 403) {
30201
+ // Token expired or invalid
30202
+ this.handleTokenExpired();
30203
+ return;
30204
+ }
30205
+ if (!response.ok) {
30206
+ throw new Error(`Server returned ${response.status}`);
30207
+ }
30208
+ const serverSettings = (await response.json());
30209
+ if (config.debug) {
30210
+ console.log("[SpeechOS] Settings received from server:", {
30211
+ language: serverSettings.language,
30212
+ vocabularyCount: serverSettings.vocabulary?.length ?? 0,
30213
+ snippetsCount: serverSettings.snippets?.length ?? 0,
30214
+ historyCount: serverSettings.history?.length ?? 0,
30215
+ lastSyncedAt: serverSettings.lastSyncedAt,
30216
+ });
30217
+ if (serverSettings.history?.length > 0) {
30218
+ console.log("[SpeechOS] History entries:", serverSettings.history);
30219
+ }
30220
+ }
30221
+ this.mergeSettings(serverSettings);
30222
+ events.emit("settings:loaded", undefined);
30223
+ if (config.debug) {
30224
+ console.log("[SpeechOS] Settings merged and loaded");
30225
+ }
30226
+ }
30227
+ catch (error) {
30228
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
30229
+ // Check if this is a CSP/network restriction - disable sync permanently for this session
30230
+ if (this.isNetworkRestrictionError(error)) {
30231
+ this.syncDisabled = true;
30232
+ if (config.debug) {
30233
+ console.log("[SpeechOS] Settings sync disabled (CSP/network restriction), using localStorage only");
30234
+ }
30235
+ }
30236
+ else if (config.debug) {
30237
+ console.warn("[SpeechOS] Failed to load settings from server:", errorMessage);
30238
+ }
30239
+ events.emit("settings:syncFailed", { error: errorMessage });
30240
+ // Continue with local settings on error
30241
+ }
30242
+ }
30243
+ /**
30244
+ * Merge server settings with local (server wins).
30245
+ * Uses store setters to update memory cache - localStorage is a fallback.
30246
+ */
30247
+ mergeSettings(serverSettings) {
30248
+ // Language settings - server wins
30249
+ if (serverSettings.language) {
30250
+ setLanguageSettings(serverSettings.language);
30251
+ }
30252
+ // Vocabulary - server wins
30253
+ if (serverSettings.vocabulary) {
30254
+ setVocabulary(serverSettings.vocabulary);
30255
+ }
30256
+ // Snippets - server wins
30257
+ if (serverSettings.snippets) {
30258
+ setSnippets(serverSettings.snippets);
30259
+ }
30260
+ // History - server wins
30261
+ if (serverSettings.history) {
30262
+ setTranscripts(serverSettings.history);
30263
+ }
30264
+ }
30265
+ /**
30266
+ * Schedule a debounced sync to server
30267
+ */
30268
+ scheduleSyncToServer() {
30269
+ const token = getSettingsToken();
30270
+ if (!token || this.syncDisabled) {
30271
+ return;
30272
+ }
30273
+ // Cancel any pending sync
30274
+ if (this.syncTimer) {
30275
+ clearTimeout(this.syncTimer);
30276
+ }
30277
+ // Schedule new sync
30278
+ this.syncTimer = setTimeout(() => {
30279
+ this.syncToServer();
30280
+ }, SYNC_DEBOUNCE_MS);
30281
+ }
30282
+ /**
30283
+ * Sync current settings to server
30284
+ */
30285
+ async syncToServer() {
30286
+ const token = getSettingsToken();
30287
+ if (!token || this.isSyncing || this.syncDisabled) {
30288
+ return;
30289
+ }
30290
+ this.isSyncing = true;
30291
+ const config = getConfig();
30292
+ try {
30293
+ const languageSettings = getLanguageSettings();
30294
+ const vocabulary = getVocabulary();
30295
+ const snippets = getSnippets();
30296
+ const transcripts = getTranscripts();
30297
+ const payload = {
30298
+ language: {
30299
+ inputLanguageCode: languageSettings.inputLanguageCode,
30300
+ outputLanguageCode: languageSettings.outputLanguageCode,
30301
+ smartFormat: languageSettings.smartFormat,
30302
+ },
30303
+ vocabulary: vocabulary.map((v) => ({
30304
+ id: v.id,
30305
+ term: v.term,
30306
+ createdAt: v.createdAt,
30307
+ })),
30308
+ snippets: snippets.map((s) => ({
30309
+ id: s.id,
30310
+ trigger: s.trigger,
30311
+ expansion: s.expansion,
30312
+ createdAt: s.createdAt,
30313
+ })),
30314
+ // Sync history (excluding commandConfig to reduce payload size)
30315
+ history: transcripts.map((t) => ({
30316
+ id: t.id,
30317
+ text: t.text,
30318
+ timestamp: t.timestamp,
30319
+ action: t.action,
30320
+ ...(t.originalText && { originalText: t.originalText }),
30321
+ ...(t.inputText && { inputText: t.inputText }),
30322
+ ...(t.commandResult !== undefined && { commandResult: t.commandResult }),
30323
+ })),
30324
+ };
30325
+ const response = await this.doFetch(`${config.host}/api/user-settings/`, {
30326
+ method: "PUT",
30327
+ headers: {
30328
+ Authorization: `Bearer ${token}`,
30329
+ "Content-Type": "application/json",
30330
+ },
30331
+ body: JSON.stringify(payload),
30332
+ });
30333
+ if (response.status === 401 || response.status === 403) {
30334
+ this.handleTokenExpired();
30335
+ return;
30336
+ }
30337
+ if (!response.ok) {
30338
+ throw new Error(`Server returned ${response.status}`);
30339
+ }
30340
+ // Reset retry count on success
30341
+ this.retryCount = 0;
30342
+ events.emit("settings:synced", undefined);
30343
+ if (config.debug) {
30344
+ console.log("[SpeechOS] Settings synced to server");
30345
+ }
30346
+ }
30347
+ catch (error) {
30348
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
30349
+ // Check if this is a CSP/network restriction - disable sync permanently for this session
30350
+ if (this.isNetworkRestrictionError(error)) {
30351
+ this.syncDisabled = true;
30352
+ if (config.debug) {
30353
+ console.log("[SpeechOS] Settings sync disabled (CSP/network restriction), using localStorage only");
30354
+ }
30355
+ events.emit("settings:syncFailed", { error: errorMessage });
30356
+ // Don't retry - CSP errors are permanent
30357
+ }
30358
+ else {
30359
+ if (config.debug) {
30360
+ console.warn("[SpeechOS] Failed to sync settings to server:", errorMessage);
30361
+ }
30362
+ events.emit("settings:syncFailed", { error: errorMessage });
30363
+ // Retry with exponential backoff (only for transient errors)
30364
+ this.scheduleRetry();
30365
+ }
30366
+ }
30367
+ finally {
30368
+ this.isSyncing = false;
30369
+ }
30370
+ }
30371
+ /**
30372
+ * Schedule a retry with exponential backoff
30373
+ */
30374
+ scheduleRetry() {
30375
+ if (this.retryCount >= MAX_RETRIES) {
30376
+ const config = getConfig();
30377
+ if (config.debug) {
30378
+ console.warn("[SpeechOS] Max retries reached, giving up sync");
30379
+ }
30380
+ this.retryCount = 0;
30381
+ return;
30382
+ }
30383
+ this.retryCount++;
30384
+ const delay = BASE_RETRY_DELAY_MS * Math.pow(2, this.retryCount - 1);
30385
+ this.syncTimer = setTimeout(() => {
30386
+ this.syncToServer();
30387
+ }, delay);
30388
+ }
30389
+ /**
30390
+ * Check if an error is a CSP or network restriction error
30391
+ * These errors are permanent and shouldn't trigger retries
30392
+ */
30393
+ isNetworkRestrictionError(error) {
30394
+ if (error instanceof TypeError) {
30395
+ const message = error.message.toLowerCase();
30396
+ // Common CSP/network error messages
30397
+ return (message.includes("failed to fetch") ||
30398
+ message.includes("network request failed") ||
30399
+ message.includes("content security policy") ||
30400
+ message.includes("csp") ||
30401
+ message.includes("blocked"));
30402
+ }
30403
+ return false;
30404
+ }
30405
+ /**
30406
+ * Handle token expiration
30407
+ */
30408
+ handleTokenExpired() {
30409
+ clearSettingsToken();
30410
+ events.emit("settings:tokenExpired", undefined);
30411
+ const config = getConfig();
30412
+ if (config.debug) {
30413
+ console.warn("[SpeechOS] Settings token expired");
30414
+ }
30415
+ }
30416
+ /**
30417
+ * Check if sync is enabled (token is configured)
30418
+ */
30419
+ isEnabled() {
30420
+ return !!getSettingsToken();
30421
+ }
30422
+ }
30423
+ // Singleton instance
30424
+ const settingsSync = new SettingsSync();
30425
+
29868
30426
  /******************************************************************************
29869
30427
  Copyright (c) Microsoft Corporation.
29870
30428
 
@@ -29948,6 +30506,9 @@
29948
30506
  */
29949
30507
  const themeStyles = i$4 `
29950
30508
  :host {
30509
+ /* Font stack - system fonts for consistent rendering across sites */
30510
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
30511
+
29951
30512
  /* Color tokens */
29952
30513
  --speechos-primary: #10B981;
29953
30514
  --speechos-primary-hover: #059669;
@@ -30071,100 +30632,6 @@
30071
30632
  }
30072
30633
  `;
30073
30634
 
30074
- /**
30075
- * Transcript history store
30076
- * Persists transcripts to localStorage for viewing in the settings modal
30077
- */
30078
- const STORAGE_KEY = "speechos_transcripts";
30079
- const MAX_ENTRIES = 50;
30080
- /**
30081
- * Generate a unique ID for transcript entries
30082
- */
30083
- function generateId() {
30084
- return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
30085
- }
30086
- /**
30087
- * Get all transcripts from localStorage
30088
- */
30089
- function getTranscripts() {
30090
- try {
30091
- const stored = localStorage.getItem(STORAGE_KEY);
30092
- if (!stored)
30093
- return [];
30094
- const entries = JSON.parse(stored);
30095
- // Return newest first
30096
- return entries.sort((a, b) => b.timestamp - a.timestamp);
30097
- }
30098
- catch {
30099
- return [];
30100
- }
30101
- }
30102
- /**
30103
- * Save a new transcript entry
30104
- */
30105
- function saveTranscript(text, action, originalTextOrOptions) {
30106
- const entry = {
30107
- id: generateId(),
30108
- text,
30109
- timestamp: Date.now(),
30110
- action,
30111
- };
30112
- // Handle edit action with originalText string
30113
- if (action === "edit" && typeof originalTextOrOptions === "string") {
30114
- entry.originalText = originalTextOrOptions;
30115
- }
30116
- // Handle command action with options object
30117
- if (action === "command" && typeof originalTextOrOptions === "object") {
30118
- const options = originalTextOrOptions;
30119
- if (options.inputText !== undefined)
30120
- entry.inputText = options.inputText;
30121
- if (options.commandResult !== undefined)
30122
- entry.commandResult = options.commandResult;
30123
- if (options.commandConfig !== undefined)
30124
- entry.commandConfig = options.commandConfig;
30125
- }
30126
- const entries = getTranscripts();
30127
- entries.unshift(entry);
30128
- // Prune to max entries
30129
- const pruned = entries.slice(0, MAX_ENTRIES);
30130
- try {
30131
- localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
30132
- }
30133
- catch {
30134
- // localStorage full or unavailable - silently fail
30135
- }
30136
- return entry;
30137
- }
30138
- /**
30139
- * Clear all transcript history
30140
- */
30141
- function clearTranscripts() {
30142
- try {
30143
- localStorage.removeItem(STORAGE_KEY);
30144
- }
30145
- catch {
30146
- // Silently fail
30147
- }
30148
- }
30149
- /**
30150
- * Delete a single transcript by ID
30151
- */
30152
- function deleteTranscript(id) {
30153
- const entries = getTranscripts().filter((e) => e.id !== id);
30154
- try {
30155
- localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
30156
- }
30157
- catch {
30158
- // Silently fail
30159
- }
30160
- }
30161
- const transcriptStore = {
30162
- getTranscripts: getTranscripts,
30163
- saveTranscript: saveTranscript,
30164
- clearTranscripts: clearTranscripts,
30165
- deleteTranscript: deleteTranscript,
30166
- };
30167
-
30168
30635
  function isNativeField(field) {
30169
30636
  return field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement;
30170
30637
  }
@@ -32162,6 +32629,8 @@
32162
32629
  inset: 0;
32163
32630
  pointer-events: none;
32164
32631
  z-index: calc(var(--speechos-z-base) + 100);
32632
+ /* Ensure consistent font rendering across all sites */
32633
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
32165
32634
  }
32166
32635
 
32167
32636
  .modal-overlay {
@@ -32650,6 +33119,7 @@
32650
33119
  constructor() {
32651
33120
  super(...arguments);
32652
33121
  this.transcripts = [];
33122
+ this.unsubscribeSettingsLoaded = null;
32653
33123
  }
32654
33124
  static { this.styles = [
32655
33125
  themeStyles,
@@ -32786,11 +33256,36 @@
32786
33256
  background: rgba(239, 68, 68, 0.18);
32787
33257
  border-color: rgba(239, 68, 68, 0.25);
32788
33258
  }
33259
+
33260
+ .command-matched {
33261
+ font-size: 12px;
33262
+ color: rgba(255, 255, 255, 0.5);
33263
+ margin-top: 6px;
33264
+ }
33265
+
33266
+ .command-matched code {
33267
+ background: rgba(245, 158, 11, 0.15);
33268
+ color: #fbbf24;
33269
+ padding: 2px 6px;
33270
+ border-radius: 4px;
33271
+ font-family: monospace;
33272
+ }
32789
33273
  `,
32790
33274
  ]; }
32791
33275
  connectedCallback() {
32792
33276
  super.connectedCallback();
32793
33277
  this.loadTranscripts();
33278
+ // Refresh when settings are loaded from the server (history may have changed)
33279
+ this.unsubscribeSettingsLoaded = events.on("settings:loaded", () => {
33280
+ this.loadTranscripts();
33281
+ });
33282
+ }
33283
+ disconnectedCallback() {
33284
+ super.disconnectedCallback();
33285
+ if (this.unsubscribeSettingsLoaded) {
33286
+ this.unsubscribeSettingsLoaded();
33287
+ this.unsubscribeSettingsLoaded = null;
33288
+ }
32794
33289
  }
32795
33290
  /** Reload transcripts from store */
32796
33291
  refresh() {
@@ -32861,7 +33356,13 @@
32861
33356
  renderCommandDetails(entry) {
32862
33357
  // Show the transcript text (what the user said)
32863
33358
  const displayText = entry.inputText || entry.text;
32864
- return b `<div class="transcript-text">${displayText}</div>`;
33359
+ const commandName = entry.commandResult?.name;
33360
+ return b `
33361
+ <div class="transcript-text">${displayText}</div>
33362
+ ${commandName
33363
+ ? b `<div class="command-matched">Matched: <code>${commandName}</code></div>`
33364
+ : null}
33365
+ `;
32865
33366
  }
32866
33367
  getCopyText(entry) {
32867
33368
  if (entry.action === "command") {
@@ -33411,6 +33912,7 @@
33411
33912
  this.permissionGranted = false;
33412
33913
  this.smartFormatEnabled = true;
33413
33914
  this.savedIndicatorTimeout = null;
33915
+ this.unsubscribeSettingsLoaded = null;
33414
33916
  }
33415
33917
  static { this.styles = [
33416
33918
  themeStyles,
@@ -33585,12 +34087,20 @@
33585
34087
  connectedCallback() {
33586
34088
  super.connectedCallback();
33587
34089
  this.loadSettings();
34090
+ // Refresh when settings are loaded from the server
34091
+ this.unsubscribeSettingsLoaded = events.on("settings:loaded", () => {
34092
+ this.loadSettings();
34093
+ });
33588
34094
  }
33589
34095
  disconnectedCallback() {
33590
34096
  super.disconnectedCallback();
33591
34097
  if (this.savedIndicatorTimeout) {
33592
34098
  clearTimeout(this.savedIndicatorTimeout);
33593
34099
  }
34100
+ if (this.unsubscribeSettingsLoaded) {
34101
+ this.unsubscribeSettingsLoaded();
34102
+ this.unsubscribeSettingsLoaded = null;
34103
+ }
33594
34104
  this.isTestingMic = false;
33595
34105
  }
33596
34106
  /** Called when tab becomes active */
@@ -33889,6 +34399,7 @@
33889
34399
  this.trigger = "";
33890
34400
  this.expansion = "";
33891
34401
  this.error = "";
34402
+ this.unsubscribeSettingsLoaded = null;
33892
34403
  }
33893
34404
  static { this.styles = [
33894
34405
  themeStyles,
@@ -33994,6 +34505,17 @@
33994
34505
  connectedCallback() {
33995
34506
  super.connectedCallback();
33996
34507
  this.loadSnippets();
34508
+ // Refresh when settings are loaded from the server
34509
+ this.unsubscribeSettingsLoaded = events.on("settings:loaded", () => {
34510
+ this.loadSnippets();
34511
+ });
34512
+ }
34513
+ disconnectedCallback() {
34514
+ super.disconnectedCallback();
34515
+ if (this.unsubscribeSettingsLoaded) {
34516
+ this.unsubscribeSettingsLoaded();
34517
+ this.unsubscribeSettingsLoaded = null;
34518
+ }
33997
34519
  }
33998
34520
  /** Reload snippets from store */
33999
34521
  refresh() {
@@ -34236,6 +34758,7 @@
34236
34758
  this.showForm = false;
34237
34759
  this.term = "";
34238
34760
  this.error = "";
34761
+ this.unsubscribeSettingsLoaded = null;
34239
34762
  }
34240
34763
  static { this.styles = [
34241
34764
  themeStyles,
@@ -34288,6 +34811,17 @@
34288
34811
  connectedCallback() {
34289
34812
  super.connectedCallback();
34290
34813
  this.loadVocabulary();
34814
+ // Refresh when settings are loaded from the server
34815
+ this.unsubscribeSettingsLoaded = events.on("settings:loaded", () => {
34816
+ this.loadVocabulary();
34817
+ });
34818
+ }
34819
+ disconnectedCallback() {
34820
+ super.disconnectedCallback();
34821
+ if (this.unsubscribeSettingsLoaded) {
34822
+ this.unsubscribeSettingsLoaded();
34823
+ this.unsubscribeSettingsLoaded = null;
34824
+ }
34291
34825
  }
34292
34826
  /** Reload vocabulary from store */
34293
34827
  refresh() {
@@ -35895,6 +36429,8 @@
35895
36429
  const originalContent = this.getElementContent(target) || "";
35896
36430
  if (tagName === "input" || tagName === "textarea") {
35897
36431
  const inputEl = target;
36432
+ // Ensure DOM focus is on the input before inserting
36433
+ inputEl.focus();
35898
36434
  // Restore cursor position before inserting
35899
36435
  const start = this.dictationCursorStart ?? inputEl.value.length;
35900
36436
  const end = this.dictationCursorEnd ?? inputEl.value.length;
@@ -36231,8 +36767,6 @@
36231
36767
  // Note: command:complete event is already emitted by the backend
36232
36768
  // when the command_result message is received, so we don't emit here
36233
36769
  state.completeRecording();
36234
- // Keep widget visible but collapsed (just mic button, no action bubbles)
36235
- state.setState({ isExpanded: false });
36236
36770
  // Show command feedback
36237
36771
  this.showActionFeedback(result ? "command-success" : "command-none");
36238
36772
  backend.disconnect().catch(() => { });
@@ -36390,6 +36924,8 @@
36390
36924
  if (tagName === "input" || tagName === "textarea") {
36391
36925
  const inputEl = target;
36392
36926
  originalContent = inputEl.value;
36927
+ // Ensure DOM focus is on the input before editing
36928
+ inputEl.focus();
36393
36929
  // Restore the original selection/cursor position
36394
36930
  const selectionStart = this.editSelectionStart ?? 0;
36395
36931
  const selectionEnd = this.editSelectionEnd ?? inputEl.value.length;
@@ -36557,6 +37093,30 @@
36557
37093
  t$1("speechos-widget")
36558
37094
  ], SpeechOSWidget);
36559
37095
 
37096
+ /**
37097
+ * UI module exports
37098
+ * Lit-based Shadow DOM components
37099
+ */
37100
+ // Patch customElements.define to silently ignore duplicate registrations for speechos-* elements.
37101
+ // This prevents errors when the extension loads on a page that already has SpeechOS.
37102
+ // The patch is scoped to only affect speechos-* tags to avoid unintended effects on host pages.
37103
+ const originalDefine = customElements.define.bind(customElements);
37104
+ customElements.define = (name, constructor, options) => {
37105
+ // Only intercept speechos-* elements
37106
+ if (name.startsWith("speechos-")) {
37107
+ if (customElements.get(name) === undefined) {
37108
+ originalDefine(name, constructor, options);
37109
+ }
37110
+ // Skip silently if already registered
37111
+ }
37112
+ else {
37113
+ // Pass through for non-speechos elements
37114
+ originalDefine(name, constructor, options);
37115
+ }
37116
+ };
37117
+ // Restore original customElements.define after our components are registered
37118
+ customElements.define = originalDefine;
37119
+
36560
37120
  /**
36561
37121
  * Main SpeechOS Client SDK class
36562
37122
  * Composes core logic with UI components
@@ -36638,6 +37198,13 @@
36638
37198
  state.show();
36639
37199
  }
36640
37200
  this.isInitialized = true;
37201
+ // Initialize settings sync if token is configured
37202
+ // This loads settings from server and subscribes to changes
37203
+ settingsSync.init().catch((error) => {
37204
+ if (finalConfig.debug) {
37205
+ console.warn("[SpeechOS] Settings sync initialization failed:", error);
37206
+ }
37207
+ });
36641
37208
  // Log initialization in debug mode
36642
37209
  if (finalConfig.debug) {
36643
37210
  console.log("[SpeechOS] Initialized with config:", finalConfig);
@@ -36668,6 +37235,8 @@
36668
37235
  resetClientConfig();
36669
37236
  // Reset text input handler to default
36670
37237
  resetTextInputHandler();
37238
+ // Stop settings sync
37239
+ settingsSync.destroy();
36671
37240
  // Clear instance
36672
37241
  this.instance = null;
36673
37242
  this.isInitialized = false;
@@ -36891,6 +37460,7 @@
36891
37460
  exports.setOutputLanguageCode = setOutputLanguageCode;
36892
37461
  exports.setSmartFormatEnabled = setSmartFormatEnabled;
36893
37462
  exports.setTextInputHandler = setTextInputHandler;
37463
+ exports.settingsSync = settingsSync;
36894
37464
  exports.snippetsStore = snippetsStore;
36895
37465
  exports.state = state;
36896
37466
  exports.transcriptStore = transcriptStore;