symbiote-ui 0.3.0-alpha.13 → 0.3.0-alpha.14

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/README.md CHANGED
@@ -25,7 +25,7 @@ npm install jsda-kit linkedom
25
25
 
26
26
  - `symbiote-ui` - Node-safe core primitives.
27
27
  - `symbiote-ui/core` - graph editor data primitives.
28
- - `symbiote-ui/layout` - SSR-safe layout helpers.
28
+ - `symbiote-ui/layout` - SSR-safe layout, behavior, and lifecycle helpers.
29
29
  - `symbiote-ui/graph` - provider graph normalization and projection helpers.
30
30
  - `symbiote-ui/manifest` - component, schema, rule, theme, and provider catalogs.
31
31
  - `symbiote-ui/runtime` - Node-safe agent UI construction helpers.
@@ -35,6 +35,7 @@ npm install jsda-kit linkedom
35
35
  - `symbiote-ui/locale` - Node-safe locale catalogs and translation helpers.
36
36
  - `symbiote-ui/discover` - provider discovery JSON API used by the CLI.
37
37
  - `symbiote-ui/chat/voice-input-defaults.js` - Node-safe wake/send/action voice command defaults and matching helpers.
38
+ - `symbiote-ui/chat/voice-controller.js` - browser wake listening and response speech orchestration for chat hosts.
38
39
  - `symbiote-ui/custom-elements.json` - Custom Elements manifest.
39
40
  - `symbiote-ui/schemas/*`, `symbiote-ui/tokens/*`, `symbiote-ui/rules/*` - machine-readable provider contracts.
40
41
  - `symbiote-ui/display/*` - reusable display utilities exposed by package export map.
@@ -179,7 +180,12 @@ chunks and dispatches a `chat-composer-audio-captured` event with the resulting
179
180
  raw audio blob so the host can process or transcribe it. Use
180
181
  `symbiote-ui/chat/voice-input-defaults.js` for shared wake/send/cancel/delete/off
181
182
  phrases, command parsing, and matching when hosts need Agent Portal-compatible
182
- voice command behavior. `setFooterHtml()`
183
+ voice command behavior. `VoiceController` in
184
+ `symbiote-ui/chat/voice-controller.js` coordinates browser wake listening,
185
+ pause/resume while recording or speaking, wake-command matching, and response
186
+ speech lifecycle while leaving settings, transcription providers, persistence,
187
+ and routing to the host. It is also exported through `symbiote-ui/ui` for
188
+ browser hosts. `setFooterHtml()`
183
189
  remains available only for trusted host-rendered footer markup.
184
190
  `extractChatTitleFromAgentText()` provides a product-neutral parser for
185
191
  standalone `<chat-title>...</chat-title>` responses; any prompt instruction that
@@ -327,8 +333,10 @@ agents that compose dynamic workspaces:
327
333
  ```js
328
334
  import {
329
335
  LayoutTree,
336
+ resumeLayoutSubtree,
330
337
  resolveLayoutMinSize,
331
338
  resolveResponsiveLayoutState,
339
+ suspendLayoutSubtree,
332
340
  } from 'symbiote-ui/layout';
333
341
 
334
342
  let root = LayoutTree.createSplit(
@@ -343,6 +351,9 @@ let state = resolveResponsiveLayoutState(
343
351
  { collapse: 'auto', overflow: 'scroll-inline', responsiveMode: 'scroll-inline' },
344
352
  { inlineSize: 520, blockSize: 420, layoutMinSize: minSize }
345
353
  );
354
+
355
+ suspendLayoutSubtree(workspaceEl, { reason: 'workspace-inactive' });
356
+ resumeLayoutSubtree(workspaceEl, { reason: 'workspace-active' });
346
357
  ```
347
358
 
348
359
  `panel-layout` uses the same contract at runtime. Root `layoutBehavior`
@@ -361,6 +372,12 @@ smoke tests, and agents can distinguish requested policy from active fallback.
361
372
  `layout-sidebar` owns only its sidebar configuration and width persistence; its
362
373
  reset control clears that state and emits `layout-sidebar-reset` for host-owned
363
374
  layout resets instead of clearing host storage or reloading the page.
375
+ `suspendLayoutSubtree()` and `resumeLayoutSubtree()` call public
376
+ `suspendLayout()`/`resumeLayout()` methods on reusable components and host
377
+ adapters in a hidden layout subtree. `chat-workspace`, `chat-composer`, and
378
+ `cell-bg` implement this contract so hidden workspace groups stop animated
379
+ backgrounds, wake listeners, voice capture, and UI timers without destroying
380
+ host-owned chat, route, or layout state.
364
381
 
365
382
  ## Spatial Algorithms
366
383
 
@@ -340,6 +340,29 @@ export class ChatComposer extends Symbiote {
340
340
  super.disconnectedCallback?.();
341
341
  }
342
342
 
343
+ suspendLayout() {
344
+ this._resumeLocalWakeAfterSuspend = this._localVoiceActiveMode === 'wake';
345
+ this._stopLocalWakeRecognition();
346
+ this._voiceRuntime?.cancel?.();
347
+ this._localVoiceState = this._resumeLocalWakeAfterSuspend ? 'listening' : 'idle';
348
+ this._localVoiceActiveMode = this._resumeLocalWakeAfterSuspend ? 'wake' : 'idle';
349
+ this._localVoiceWakeMatched = false;
350
+ this._localWakeTriggering = false;
351
+ this.clearVoicePreview();
352
+ this._syncLocalVoiceControls();
353
+ }
354
+
355
+ resumeLayout() {
356
+ if (!this._resumeLocalWakeAfterSuspend) return;
357
+ this._resumeLocalWakeAfterSuspend = false;
358
+ this._localVoiceActiveMode = 'wake';
359
+ this._localVoiceState = 'listening';
360
+ this._localVoiceWakeMatched = false;
361
+ this._localWakeTriggering = false;
362
+ this._syncLocalVoiceControls();
363
+ this._startLocalWakeRecognition();
364
+ }
365
+
343
366
  _voiceCommandLocale() {
344
367
  let locale = this._voiceControls?.language?.mode || 'en';
345
368
  return ['ru', 'es', 'en'].includes(locale) ? locale : 'en';
@@ -200,6 +200,19 @@ export class ChatWorkspace extends Symbiote {
200
200
  this.setBackgroundState({ state: 'stop', active: false });
201
201
  }
202
202
 
203
+ suspendLayout() {
204
+ this._layoutSuspendedBackgroundState = this.dataset.backgroundState || '';
205
+ this.getBackground()?.stop?.();
206
+ }
207
+
208
+ resumeLayout() {
209
+ let state = this._layoutSuspendedBackgroundState || this.dataset.backgroundState || '';
210
+ this._layoutSuspendedBackgroundState = '';
211
+ if (BACKGROUND_ACTIVE_STATES.has(state)) {
212
+ this.setBackgroundState({ state, active: true, reason: 'layout-resume' });
213
+ }
214
+ }
215
+
203
216
  _bindWorkspaceEvents() {
204
217
  let route = (sourceEvent, eventName, detailFactory = (event) => event.detail || {}) => {
205
218
  this.addEventListener(sourceEvent, (event) => {
@@ -15,7 +15,7 @@ export function sanitizeChatTitle(value = '', options = {}) {
15
15
  .replace(/[`*_#[\](){}<>]/g, '')
16
16
  .replace(/\s+/g, ' ')
17
17
  .trim()
18
- .replace(/^["']+|["']+$/g, '')
18
+ .replace(/^["'«“]+|["'»”]+$/g, '')
19
19
  .trim();
20
20
 
21
21
  if (!title) return '';
@@ -0,0 +1,196 @@
1
+ import { matchVoiceCommandInText } from './voice-input-defaults.js';
2
+
3
+ function createWakeError(error, message, cause = null) {
4
+ return { error, message, cause };
5
+ }
6
+
7
+ export class VoiceController {
8
+ constructor({
9
+ getLanguage = () => 'en-US',
10
+ getWakeCandidates = () => [],
11
+ onWakeTriggered = () => {},
12
+ onSpeechStart = () => {},
13
+ onSpeechEnd = () => {},
14
+ onWakeError = () => {},
15
+ } = {}) {
16
+ this.getLanguage = getLanguage;
17
+ this.getWakeCandidates = getWakeCandidates;
18
+ this.onWakeTriggered = onWakeTriggered;
19
+ this.onSpeechStart = onSpeechStart;
20
+ this.onSpeechEnd = onSpeechEnd;
21
+ this.onWakeError = onWakeError;
22
+
23
+ this.wakeEnabled = false;
24
+ this.wakePaused = false;
25
+ this.speaking = false;
26
+
27
+ this._wakeRecognition = null;
28
+ this._speechUtterance = null;
29
+ this._wakeTimeout = null;
30
+ }
31
+
32
+ static get hasSpeechRecognition() {
33
+ if (typeof window === 'undefined') return false;
34
+ return Boolean(window.SpeechRecognition || window.webkitSpeechRecognition);
35
+ }
36
+
37
+ static get hasSpeechSynthesis() {
38
+ if (typeof globalThis === 'undefined') return false;
39
+ return Boolean(globalThis.speechSynthesis && globalThis.SpeechSynthesisUtterance);
40
+ }
41
+
42
+ get isWakeActive() {
43
+ return this.wakeEnabled && !this.wakePaused;
44
+ }
45
+
46
+ startWake() {
47
+ this.wakeEnabled = true;
48
+ this.wakePaused = false;
49
+ this._startWakeRecognition();
50
+ }
51
+
52
+ stopWake({ disableMode = false } = {}) {
53
+ if (disableMode) {
54
+ this.wakeEnabled = false;
55
+ this.cancelSpeech();
56
+ }
57
+ this.wakePaused = false;
58
+ this._stopWakeRecognition();
59
+ }
60
+
61
+ pauseWake() {
62
+ if (!this.wakeEnabled) return;
63
+ this.wakePaused = true;
64
+ this._stopWakeRecognition();
65
+ }
66
+
67
+ resumeWake() {
68
+ if (!this.wakeEnabled) return;
69
+ this.wakePaused = false;
70
+ this._startWakeRecognition();
71
+ }
72
+
73
+ _startWakeRecognition() {
74
+ if (!this.wakeEnabled || this.wakePaused || this._wakeRecognition) return;
75
+ if (this._wakeTimeout) {
76
+ clearTimeout(this._wakeTimeout);
77
+ this._wakeTimeout = null;
78
+ }
79
+
80
+ const SpeechRecognition = typeof window !== 'undefined'
81
+ ? (window.SpeechRecognition || window.webkitSpeechRecognition)
82
+ : null;
83
+
84
+ if (!SpeechRecognition) {
85
+ this.wakeEnabled = false;
86
+ this.onWakeError(createWakeError(
87
+ 'not-supported',
88
+ 'Speech recognition is not supported by this browser.'
89
+ ));
90
+ return;
91
+ }
92
+
93
+ try {
94
+ const recognition = new SpeechRecognition();
95
+ recognition.lang = this.getLanguage();
96
+ recognition.interimResults = true;
97
+ recognition.continuous = true;
98
+ this._wakeRecognition = recognition;
99
+
100
+ recognition.onresult = (event) => {
101
+ let transcript = '';
102
+ for (let i = event.resultIndex; i < event.results.length; i++) {
103
+ transcript += event.results[i][0].transcript;
104
+ }
105
+ const candidates = this.getWakeCandidates();
106
+ if (matchVoiceCommandInText(transcript, candidates).matched) {
107
+ this.onWakeTriggered();
108
+ }
109
+ };
110
+
111
+ recognition.onerror = (event) => {
112
+ this._wakeRecognition = null;
113
+ this.onWakeError(event);
114
+ };
115
+
116
+ recognition.onend = () => {
117
+ this._wakeRecognition = null;
118
+ if (this.wakeEnabled && !this.wakePaused) {
119
+ this._wakeTimeout = setTimeout(() => this._startWakeRecognition(), 250);
120
+ }
121
+ };
122
+
123
+ recognition.start();
124
+ } catch (err) {
125
+ this.wakeEnabled = false;
126
+ this.wakePaused = false;
127
+ this._wakeRecognition = null;
128
+ this.onWakeError(createWakeError(
129
+ 'start-failed',
130
+ 'Speech recognition failed to start.',
131
+ err
132
+ ));
133
+ }
134
+ }
135
+
136
+ _stopWakeRecognition() {
137
+ if (this._wakeTimeout) {
138
+ clearTimeout(this._wakeTimeout);
139
+ this._wakeTimeout = null;
140
+ }
141
+ if (this._wakeRecognition) {
142
+ const rec = this._wakeRecognition;
143
+ this._wakeRecognition = null;
144
+ rec.onresult = null;
145
+ rec.onerror = null;
146
+ rec.onend = null;
147
+ try {
148
+ rec.abort();
149
+ } catch (_) {}
150
+ }
151
+ }
152
+
153
+ speak(text) {
154
+ if (!VoiceController.hasSpeechSynthesis) return;
155
+ this.cancelSpeech();
156
+
157
+ this.pauseWake();
158
+ this.speaking = true;
159
+ this.onSpeechStart();
160
+
161
+ const utterance = new SpeechSynthesisUtterance(text);
162
+ utterance.lang = this.getLanguage();
163
+ this._speechUtterance = utterance;
164
+
165
+ const cleanup = () => {
166
+ if (this._speechUtterance === utterance) {
167
+ this.speaking = false;
168
+ this._speechUtterance = null;
169
+ this.onSpeechEnd();
170
+ this.resumeWake();
171
+ }
172
+ };
173
+
174
+ utterance.onend = cleanup;
175
+ utterance.onerror = cleanup;
176
+
177
+ globalThis.speechSynthesis.cancel();
178
+ globalThis.speechSynthesis.speak(utterance);
179
+ }
180
+
181
+ cancelSpeech() {
182
+ if (!VoiceController.hasSpeechSynthesis) return;
183
+ if (this.speaking) {
184
+ globalThis.speechSynthesis.cancel();
185
+ this.speaking = false;
186
+ this._speechUtterance = null;
187
+ this.onSpeechEnd();
188
+ this.resumeWake();
189
+ }
190
+ }
191
+
192
+ destroy() {
193
+ this.stopWake({ disableMode: true });
194
+ this.cancelSpeech();
195
+ }
196
+ }