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

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.
@@ -154,6 +155,61 @@ let theme = createCascadeTheme({ mode: 'dark', brightness: 8, contrast: 64 });
154
155
  applyCascadeTheme(document.documentElement, theme.state);
155
156
  ```
156
157
 
158
+ Use `cascade-theme-widget` for shell-level quick controls and
159
+ `cascade-theme-editor` for the full layout panel. Keep both components on the
160
+ same `storage-key` and `target-selector`; the widget emits
161
+ `cascade-theme-open-full`, and the host opens a registered `panel-layout`
162
+ panel:
163
+
164
+ ```html
165
+ <section id="app-shell">
166
+ <header>
167
+ <cascade-theme-widget
168
+ storage-key="my-app:cascade-theme"
169
+ target-selector="#app-shell"
170
+ ></cascade-theme-widget>
171
+ </header>
172
+ <panel-layout id="workspace-layout"></panel-layout>
173
+ </section>
174
+ ```
175
+
176
+ ```js
177
+ import { defineModule } from 'symbiote-ui/ui';
178
+
179
+ defineModule('cascade-theme-widget');
180
+ defineModule('cascade-theme-editor');
181
+ defineModule('panel-layout');
182
+
183
+ class AppThemePanel extends HTMLElement {
184
+ connectedCallback() {
185
+ if (this.ready) return;
186
+ this.ready = true;
187
+ this.innerHTML = `
188
+ <cascade-theme-editor
189
+ storage-key="my-app:cascade-theme"
190
+ target-selector="#app-shell"
191
+ ></cascade-theme-editor>
192
+ `;
193
+ }
194
+ }
195
+
196
+ customElements.define('app-theme-panel', AppThemePanel);
197
+
198
+ let layout = document.querySelector('#workspace-layout');
199
+ layout.registerPanelType('theme-editor', {
200
+ title: 'Theme',
201
+ icon: 'palette',
202
+ component: 'app-theme-panel',
203
+ });
204
+
205
+ document.addEventListener('cascade-theme-open-full', () => {
206
+ layout.openPanel('theme-editor', {
207
+ uiInvoked: true,
208
+ source: 'cascade-theme-widget',
209
+ });
210
+ });
211
+ ```
212
+
157
213
  For chat construction, use `chat-workspace` when the host needs the complete
158
214
  reusable shell: sidebar, transcript, composer, footer controls, voice-control
159
215
  intents, and animated `cell-bg` lifecycle. `chat-sidebar-shell` and
@@ -179,7 +235,12 @@ chunks and dispatches a `chat-composer-audio-captured` event with the resulting
179
235
  raw audio blob so the host can process or transcribe it. Use
180
236
  `symbiote-ui/chat/voice-input-defaults.js` for shared wake/send/cancel/delete/off
181
237
  phrases, command parsing, and matching when hosts need Agent Portal-compatible
182
- voice command behavior. `setFooterHtml()`
238
+ voice command behavior. `VoiceController` in
239
+ `symbiote-ui/chat/voice-controller.js` coordinates browser wake listening,
240
+ pause/resume while recording or speaking, wake-command matching, and response
241
+ speech lifecycle while leaving settings, transcription providers, persistence,
242
+ and routing to the host. It is also exported through `symbiote-ui/ui` for
243
+ browser hosts. `setFooterHtml()`
183
244
  remains available only for trusted host-rendered footer markup.
184
245
  `extractChatTitleFromAgentText()` provides a product-neutral parser for
185
246
  standalone `<chat-title>...</chat-title>` responses; any prompt instruction that
@@ -327,8 +388,10 @@ agents that compose dynamic workspaces:
327
388
  ```js
328
389
  import {
329
390
  LayoutTree,
391
+ resumeLayoutSubtree,
330
392
  resolveLayoutMinSize,
331
393
  resolveResponsiveLayoutState,
394
+ suspendLayoutSubtree,
332
395
  } from 'symbiote-ui/layout';
333
396
 
334
397
  let root = LayoutTree.createSplit(
@@ -343,6 +406,9 @@ let state = resolveResponsiveLayoutState(
343
406
  { collapse: 'auto', overflow: 'scroll-inline', responsiveMode: 'scroll-inline' },
344
407
  { inlineSize: 520, blockSize: 420, layoutMinSize: minSize }
345
408
  );
409
+
410
+ suspendLayoutSubtree(workspaceEl, { reason: 'workspace-inactive' });
411
+ resumeLayoutSubtree(workspaceEl, { reason: 'workspace-active' });
346
412
  ```
347
413
 
348
414
  `panel-layout` uses the same contract at runtime. Root `layoutBehavior`
@@ -361,6 +427,12 @@ smoke tests, and agents can distinguish requested policy from active fallback.
361
427
  `layout-sidebar` owns only its sidebar configuration and width persistence; its
362
428
  reset control clears that state and emits `layout-sidebar-reset` for host-owned
363
429
  layout resets instead of clearing host storage or reloading the page.
430
+ `suspendLayoutSubtree()` and `resumeLayoutSubtree()` call public
431
+ `suspendLayout()`/`resumeLayout()` methods on reusable components and host
432
+ adapters in a hidden layout subtree. `chat-workspace`, `chat-composer`, and
433
+ `cell-bg` implement this contract so hidden workspace groups stop animated
434
+ backgrounds, wake listeners, voice capture, and UI timers without destroying
435
+ host-owned chat, route, or layout state.
364
436
 
365
437
  ## Spatial Algorithms
366
438
 
@@ -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
+ }
@@ -0,0 +1,149 @@
1
+ import {
2
+ defaultSendCommandPhrases,
3
+ defaultVoiceActionCommandPhrases,
4
+ defaultWakeCommandPhrases,
5
+ normalizeWakeCommandPhrase,
6
+ parseVoiceCommandList,
7
+ } from './voice-input-defaults.js';
8
+
9
+ export const DEFAULT_VOICE_SETTINGS = Object.freeze({
10
+ commandMode: false,
11
+ responseEnabled: false,
12
+ languageMode: 'en',
13
+ });
14
+
15
+ const VOICE_LOCALES = Object.freeze(['en', 'ru', 'es']);
16
+ const VOICE_ACTIONS = Object.freeze(['cancel', 'delete', 'off']);
17
+
18
+ function asObject(value) {
19
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
20
+ }
21
+
22
+ export function normalizeVoiceLanguageMode(mode, fallbackLocale = 'en') {
23
+ let value = String(mode || '').trim().toLowerCase();
24
+ if (['ru', 'es', 'en'].includes(value)) return value;
25
+ return fallbackLocale;
26
+ }
27
+
28
+ function getSafeStorage(customStorage) {
29
+ if (customStorage) return customStorage;
30
+ if (typeof window !== 'undefined' && typeof window.localStorage !== 'undefined') {
31
+ return window.localStorage;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function loadVoiceSettings(storage, fallbackLocale = 'en') {
37
+ let safeStorage = getSafeStorage(storage);
38
+ let commandMode = DEFAULT_VOICE_SETTINGS.commandMode;
39
+ let responseEnabled = DEFAULT_VOICE_SETTINGS.responseEnabled;
40
+ let languageMode = normalizeVoiceLanguageMode('', fallbackLocale);
41
+
42
+ if (safeStorage) {
43
+ try {
44
+ let savedCommandMode = safeStorage.getItem('symbiote_voice_command_mode');
45
+ if (savedCommandMode !== null) {
46
+ commandMode = savedCommandMode === 'true';
47
+ }
48
+ let savedResponseEnabled = safeStorage.getItem('symbiote_voice_response_enabled');
49
+ if (savedResponseEnabled !== null) {
50
+ responseEnabled = savedResponseEnabled === 'true';
51
+ }
52
+ let savedLanguageMode = safeStorage.getItem('symbiote_voice_language_mode');
53
+ if (savedLanguageMode !== null) {
54
+ languageMode = normalizeVoiceLanguageMode(savedLanguageMode, fallbackLocale);
55
+ }
56
+ } catch (_) {}
57
+ }
58
+
59
+ return {
60
+ commandMode,
61
+ responseEnabled,
62
+ languageMode,
63
+ };
64
+ }
65
+
66
+ export function saveVoiceSettings(settings, storage) {
67
+ let safeStorage = getSafeStorage(storage);
68
+ if (!safeStorage) return false;
69
+
70
+ try {
71
+ if (settings.commandMode !== undefined) {
72
+ safeStorage.setItem('symbiote_voice_command_mode', String(settings.commandMode));
73
+ }
74
+ if (settings.responseEnabled !== undefined) {
75
+ safeStorage.setItem('symbiote_voice_response_enabled', String(settings.responseEnabled));
76
+ }
77
+ if (settings.languageMode !== undefined) {
78
+ safeStorage.setItem('symbiote_voice_language_mode', String(settings.languageMode));
79
+ }
80
+ return true;
81
+ } catch (_) {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ export function mergeServerVoiceSettings(local, serverVoiceInput, fallbackLocale = 'en') {
87
+ let result = { ...local };
88
+ if (!serverVoiceInput || typeof serverVoiceInput !== 'object') {
89
+ return result;
90
+ }
91
+
92
+ if (serverVoiceInput.sendByCommandEnabled !== undefined) {
93
+ result.commandMode = Boolean(serverVoiceInput.sendByCommandEnabled);
94
+ }
95
+ if (serverVoiceInput.voiceResponseEnabled !== undefined) {
96
+ result.responseEnabled = Boolean(serverVoiceInput.voiceResponseEnabled);
97
+ }
98
+ if (serverVoiceInput.languageMode !== undefined) {
99
+ result.languageMode = normalizeVoiceLanguageMode(serverVoiceInput.languageMode, fallbackLocale);
100
+ }
101
+
102
+ return result;
103
+ }
104
+
105
+ export function normalizeVoiceCommandSettings(raw = {}) {
106
+ let data = asObject(raw);
107
+ let sendDefaults = defaultSendCommandPhrases();
108
+ let wakeDefaults = defaultWakeCommandPhrases();
109
+ let actionDefaults = defaultVoiceActionCommandPhrases();
110
+ let savedSend = asObject(data.sendCommands);
111
+ let savedWake = asObject(data.wakeCommands);
112
+ let savedActions = asObject(data.actionCommands);
113
+ let legacySendCommand = String(data.sendCommand || '').trim();
114
+
115
+ let sendCommands = Object.fromEntries(
116
+ VOICE_LOCALES.map((locale) => {
117
+ let value = String(savedSend[locale] || (locale === 'en' ? legacySendCommand : '') || sendDefaults[locale]).trim();
118
+ return [locale, value || sendDefaults[locale]];
119
+ })
120
+ );
121
+
122
+ let wakeCommands = Object.fromEntries(
123
+ VOICE_LOCALES.map((locale) => [
124
+ locale,
125
+ normalizeWakeCommandPhrase(savedWake[locale] || wakeDefaults[locale], locale),
126
+ ])
127
+ );
128
+
129
+ let actionCommands = Object.fromEntries(
130
+ VOICE_ACTIONS.map((action) => {
131
+ let values = asObject(savedActions[action]);
132
+ return [
133
+ action,
134
+ Object.fromEntries(
135
+ VOICE_LOCALES.map((locale) => [
136
+ locale,
137
+ parseVoiceCommandList(values[locale], actionDefaults[action][locale]),
138
+ ])
139
+ ),
140
+ ];
141
+ })
142
+ );
143
+
144
+ return {
145
+ sendCommands,
146
+ wakeCommands,
147
+ actionCommands,
148
+ };
149
+ }
package/cli.js CHANGED
@@ -1,11 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { realpathSync } from 'node:fs';
3
4
  import { fileURLToPath } from 'node:url';
4
5
 
5
6
  import { cmdDiscover } from './discover.js';
6
7
 
7
8
  let command = process.argv[2] || 'discover';
8
- let isMain = fileURLToPath(import.meta.url) === process.argv[1];
9
+ let modulePath = fileURLToPath(import.meta.url);
10
+ let argvPath = process.argv[1] || '';
11
+ let isMain = modulePath === argvPath;
12
+
13
+ if (!isMain && argvPath) {
14
+ try {
15
+ isMain = modulePath === realpathSync(argvPath);
16
+ } catch (error) {
17
+ void error;
18
+ }
19
+ }
9
20
 
10
21
  if (isMain && command === 'discover') {
11
22
  let result = await cmdDiscover({});
package/core/Node.js CHANGED
@@ -16,7 +16,7 @@ export class Node {
16
16
  * @param {string} [options.id] - Custom ID (default: auto-generated)
17
17
  * @param {string} [options.type] - Node type identifier (e.g. 'ai/llm')
18
18
  * @param {string} [options.category] - Category for styling (server/instance/control)
19
- * @param {string} [options.shape] - Shape name (rect/pill/circle/diamond/comment)
19
+ * @param {string} [options.shape] - Shape name (rect/pill/circle/disc/diamond/comment or registered SVG preset)
20
20
  * @param {string} [options.icon] - Material icon name for visual rendering
21
21
  */
22
22
  constructor(label, options = {}) {