create-spatius-app 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/dist/cli.js +9046 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/templates.js +2856 -0
  6. package/dist/templates.js.map +1 -0
  7. package/package.json +79 -0
  8. package/schemas/result-v1.schema.json +129 -0
  9. package/schemas/result-v2.schema.json +146 -0
  10. package/templates/cloudflare-livekit/.editorconfig +15 -0
  11. package/templates/cloudflare-livekit/.prettierignore +6 -0
  12. package/templates/cloudflare-livekit/.prettierrc.json +4 -0
  13. package/templates/cloudflare-livekit/AGENTS.md +41 -0
  14. package/templates/cloudflare-livekit/README.md +37 -0
  15. package/templates/cloudflare-livekit/agent/Dockerfile.pip +31 -0
  16. package/templates/cloudflare-livekit/agent/Dockerfile.uv +29 -0
  17. package/templates/cloudflare-livekit/agent/README.md +84 -0
  18. package/templates/cloudflare-livekit/agent/dockerignore +11 -0
  19. package/templates/cloudflare-livekit/agent/env.example +8 -0
  20. package/templates/cloudflare-livekit/agent/pyproject.toml +21 -0
  21. package/templates/cloudflare-livekit/agent/python-version +1 -0
  22. package/templates/cloudflare-livekit/agent/src/__init__.py +1 -0
  23. package/templates/cloudflare-livekit/agent/src/agent.py +97 -0
  24. package/templates/cloudflare-livekit/agent/src/dispatch_metadata.py +64 -0
  25. package/templates/cloudflare-livekit/agent/tests/test_agent_startup.py +211 -0
  26. package/templates/cloudflare-livekit/agent/tests/test_dispatch_metadata.py +97 -0
  27. package/templates/cloudflare-livekit/agent/uv.lock +2272 -0
  28. package/templates/cloudflare-livekit/components.json +23 -0
  29. package/templates/cloudflare-livekit/contracts/agent-dispatch-metadata.schema.json +40 -0
  30. package/templates/cloudflare-livekit/dev.vars.example +12 -0
  31. package/templates/cloudflare-livekit/eslint.config.mjs +41 -0
  32. package/templates/cloudflare-livekit/gitignore +30 -0
  33. package/templates/cloudflare-livekit/index.html +17 -0
  34. package/templates/cloudflare-livekit/npmrc +4 -0
  35. package/templates/cloudflare-livekit/package-lock.json +6514 -0
  36. package/templates/cloudflare-livekit/package.json +66 -0
  37. package/templates/cloudflare-livekit/playwright.config.ts +29 -0
  38. package/templates/cloudflare-livekit/pnpm-lock.yaml +4012 -0
  39. package/templates/cloudflare-livekit/pnpm-workspace.yaml +8 -0
  40. package/templates/cloudflare-livekit/scripts/agent-runtime.mjs +129 -0
  41. package/templates/cloudflare-livekit/scripts/command.mjs +52 -0
  42. package/templates/cloudflare-livekit/scripts/dev-agent.mjs +77 -0
  43. package/templates/cloudflare-livekit/scripts/dev-web.mjs +18 -0
  44. package/templates/cloudflare-livekit/scripts/managed-process.mjs +144 -0
  45. package/templates/cloudflare-livekit/scripts/process-tree.mjs +111 -0
  46. package/templates/cloudflare-livekit/scripts/run-agent-python.mjs +25 -0
  47. package/templates/cloudflare-livekit/tests/dev-agent.test.mjs +327 -0
  48. package/templates/cloudflare-livekit/tests/dev-fixtures.mjs +235 -0
  49. package/templates/cloudflare-livekit/tests/dev-supervision.test.mjs +203 -0
  50. package/templates/cloudflare-livekit/tests/e2e/conversation.spec.ts +376 -0
  51. package/templates/cloudflare-livekit/tests/e2e/fake-avatar.ts +24 -0
  52. package/templates/cloudflare-livekit/tests/e2e/fake-components.tsx +173 -0
  53. package/templates/cloudflare-livekit/tests/e2e/fake-livekit.ts +123 -0
  54. package/templates/cloudflare-livekit/tests/e2e/harness.html +12 -0
  55. package/templates/cloudflare-livekit/tests/e2e/harness.tsx +1 -0
  56. package/templates/cloudflare-livekit/tests/e2e/vite.config.ts +48 -0
  57. package/templates/cloudflare-livekit/tests/web/api.test.ts +64 -0
  58. package/templates/cloudflare-livekit/tests/web/app.test.tsx +125 -0
  59. package/templates/cloudflare-livekit/tests/web/audio-visualizer.test.tsx +53 -0
  60. package/templates/cloudflare-livekit/tests/web/avatar-session.test.ts +119 -0
  61. package/templates/cloudflare-livekit/tests/web/components.test.tsx +152 -0
  62. package/templates/cloudflare-livekit/tests/web/conversation.test.tsx +76 -0
  63. package/templates/cloudflare-livekit/tests/web/session-attempt.test.ts +178 -0
  64. package/templates/cloudflare-livekit/tsconfig.json +9 -0
  65. package/templates/cloudflare-livekit/tsconfig.node.json +23 -0
  66. package/templates/cloudflare-livekit/tsconfig.web.json +26 -0
  67. package/templates/cloudflare-livekit/tsconfig.worker.json +22 -0
  68. package/templates/cloudflare-livekit/vite.config.ts +49 -0
  69. package/templates/cloudflare-livekit/vitest.config.ts +11 -0
  70. package/templates/cloudflare-livekit/web/App.tsx +176 -0
  71. package/templates/cloudflare-livekit/web/api.ts +69 -0
  72. package/templates/cloudflare-livekit/web/assets/FONT-PROVENANCE.md +8 -0
  73. package/templates/cloudflare-livekit/web/assets/OFL.txt +92 -0
  74. package/templates/cloudflare-livekit/web/assets/inter-latin.woff2 +0 -0
  75. package/templates/cloudflare-livekit/web/avatar-session.ts +67 -0
  76. package/templates/cloudflare-livekit/web/components/agents-ui/LICENSE +201 -0
  77. package/templates/cloudflare-livekit/web/components/agents-ui/PROVENANCE.md +32 -0
  78. package/templates/cloudflare-livekit/web/components/agents-ui/agent-audio-visualizer-bar.tsx +43 -0
  79. package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-input.tsx +78 -0
  80. package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-transcript.tsx +145 -0
  81. package/templates/cloudflare-livekit/web/components/agents-ui/agent-disconnect-button.tsx +27 -0
  82. package/templates/cloudflare-livekit/web/components/agents-ui/agent-session-provider.tsx +29 -0
  83. package/templates/cloudflare-livekit/web/components/agents-ui/agent-track-toggle.tsx +51 -0
  84. package/templates/cloudflare-livekit/web/components/agents-ui/start-audio-button.tsx +13 -0
  85. package/templates/cloudflare-livekit/web/components/conversation.tsx +352 -0
  86. package/templates/cloudflare-livekit/web/components/icons.tsx +39 -0
  87. package/templates/cloudflare-livekit/web/components/transcript-panel.tsx +12 -0
  88. package/templates/cloudflare-livekit/web/main.tsx +17 -0
  89. package/templates/cloudflare-livekit/web/session-attempt.ts +110 -0
  90. package/templates/cloudflare-livekit/web/styles.css +571 -0
  91. package/templates/cloudflare-livekit/worker/dispatch-metadata.ts +22 -0
  92. package/templates/cloudflare-livekit/worker/index.test.ts +152 -0
  93. package/templates/cloudflare-livekit/worker/index.ts +61 -0
  94. package/templates/cloudflare-livekit/worker/session.ts +103 -0
  95. package/templates/cloudflare-livekit/worker-configuration.d.ts +17184 -0
  96. package/templates/cloudflare-livekit/wrangler.jsonc +27 -0
@@ -0,0 +1,352 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ConnectionState, Track } from 'livekit-client';
3
+ import {
4
+ useAgent,
5
+ useLocalParticipant,
6
+ useMediaDeviceSelect,
7
+ useSession,
8
+ useSessionMessages,
9
+ } from '@livekit/components-react';
10
+ import type { PreparedSession } from '../session-attempt.js';
11
+ import { AgentSessionProvider } from './agents-ui/agent-session-provider.js';
12
+ import { AgentTrackToggle } from './agents-ui/agent-track-toggle.js';
13
+ import { AgentDisconnectButton } from './agents-ui/agent-disconnect-button.js';
14
+ import { AgentChatInput } from './agents-ui/agent-chat-input.js';
15
+ import {
16
+ AgentChatTranscript,
17
+ snapshotMessages,
18
+ type ConversationParticipants,
19
+ type TranscriptMessage,
20
+ } from './agents-ui/agent-chat-transcript.js';
21
+ import { StartAudioButton } from './agents-ui/start-audio-button.js';
22
+ import { AgentAudioVisualizerBar } from './agents-ui/agent-audio-visualizer-bar.js';
23
+ import { TranscriptPanel } from './transcript-panel.js';
24
+
25
+ export type ConversationEnd = {
26
+ messages: TranscriptMessage[];
27
+ agentName: string;
28
+ error?: string;
29
+ };
30
+ type Props = {
31
+ prepared: PreparedSession;
32
+ onEnd: (result: ConversationEnd) => void;
33
+ };
34
+
35
+ export function Conversation({ prepared, onEnd }: Props) {
36
+ const { attempt, tokenSource } = prepared;
37
+ const options = useMemo(
38
+ () => ({ room: attempt.room, agentConnectTimeoutMilliseconds: 30_000 }),
39
+ [attempt],
40
+ );
41
+ const session = useSession(tokenSource, options);
42
+ return (
43
+ <AgentSessionProvider session={session}>
44
+ <ConversationContent
45
+ prepared={prepared}
46
+ onEnd={onEnd}
47
+ session={session}
48
+ />
49
+ </AgentSessionProvider>
50
+ );
51
+ }
52
+
53
+ function ConversationContent({
54
+ prepared,
55
+ onEnd,
56
+ session,
57
+ }: Props & { session: ReturnType<typeof useSession> }) {
58
+ const { attempt } = prepared;
59
+ const agent = useAgent(session);
60
+ // One subscription owns synchronized speech and typed messages for every view.
61
+ const stream = useSessionMessages(session);
62
+ const participants = useRef<ConversationParticipants>({});
63
+ const previousMessages = useRef<TranscriptMessage[]>([]);
64
+ const agentIdentity = agent.internal.agentParticipant?.identity;
65
+ const avatarIdentity = agent.internal.workerParticipant?.identity;
66
+ // LiveKit identifies the Python agent separately from the participant that
67
+ // publishes avatar media on its behalf. Retain IDs through disconnection.
68
+ if (agentIdentity) participants.current.agentIdentity = agentIdentity;
69
+ if (avatarIdentity) participants.current.avatarIdentity = avatarIdentity;
70
+ const messages = useMemo(
71
+ () =>
72
+ snapshotMessages(
73
+ stream.messages,
74
+ participants.current,
75
+ previousMessages.current,
76
+ ),
77
+ [stream.messages, agentIdentity, avatarIdentity],
78
+ );
79
+ previousMessages.current = messages;
80
+ const agentName = agent.name?.trim() || 'Your agent';
81
+ const latest = useRef({ messages, agentName });
82
+ latest.current = { messages, agentName };
83
+ const [roomReady, setRoomReady] = useState(false);
84
+ const ready =
85
+ roomReady && ['listening', 'thinking', 'speaking'].includes(agent.state);
86
+ const readyRef = useRef(false);
87
+ if (ready) readyRef.current = true;
88
+ const microphoneRequested = useRef(false);
89
+ const [microphonePending, setMicrophonePending] = useState(false);
90
+ const [devicePending, setDevicePending] = useState(false);
91
+ const [mediaError, setMediaError] = useState('');
92
+ const [elapsed, setElapsed] = useState(0);
93
+ const mediaBusy = useRef(false);
94
+ const { isMicrophoneEnabled, microphoneTrack } = useLocalParticipant({
95
+ room: attempt.room,
96
+ });
97
+ // LiveKit rebuilds its device observer when this callback changes. An inline
98
+ // callback would reset the device state on every render and cause a loop.
99
+ const onDeviceError = useCallback(() => {
100
+ if (!attempt.abort.signal.aborted)
101
+ setMediaError(
102
+ 'Could not use that microphone. Choose another device or keep typing.',
103
+ );
104
+ }, [attempt]);
105
+ const { devices, activeDeviceId, setActiveMediaDevice } =
106
+ useMediaDeviceSelect({
107
+ room: attempt.room,
108
+ kind: 'audioinput',
109
+ requestPermissions: false,
110
+ onError: onDeviceError,
111
+ });
112
+ const toggleMicrophone = async (enabled: boolean) => {
113
+ if (mediaBusy.current || attempt.abort.signal.aborted) return;
114
+ mediaBusy.current = true;
115
+ setMicrophonePending(true);
116
+ setMediaError('');
117
+ try {
118
+ await attempt.setMicrophone(enabled);
119
+ } catch {
120
+ if (!attempt.abort.signal.aborted)
121
+ setMediaError(
122
+ 'Microphone unavailable. Allow access and try the microphone again, or continue by typing.',
123
+ );
124
+ } finally {
125
+ mediaBusy.current = false;
126
+ if (!attempt.abort.signal.aborted) setMicrophonePending(false);
127
+ }
128
+ };
129
+ const finish = (error?: string) => onEnd({ ...latest.current, error });
130
+ const startSession = useRef(session.start);
131
+ const finishRef = useRef(finish);
132
+ finishRef.current = finish;
133
+
134
+ useEffect(() => {
135
+ let cancelled = false;
136
+ const timeout = window.setTimeout(() => {
137
+ if (!readyRef.current && !cancelled && !attempt.abort.signal.aborted)
138
+ finishRef.current(
139
+ 'Your agent did not join in time. Check that the LiveKit agent is running and try again.',
140
+ );
141
+ }, 32_000);
142
+ // StrictMode rehearses effects. Only its surviving effect starts this attempt.
143
+ queueMicrotask(() => {
144
+ if (cancelled || attempt.abort.signal.aborted) return;
145
+ void startSession
146
+ .current({
147
+ signal: attempt.abort.signal,
148
+ tracks: {
149
+ microphone: { enabled: false },
150
+ camera: { enabled: false },
151
+ screenShare: { enabled: false },
152
+ },
153
+ })
154
+ .then(() => {
155
+ if (cancelled || attempt.abort.signal.aborted) return;
156
+ setRoomReady(true);
157
+ })
158
+ .catch(() => {
159
+ if (!cancelled && !attempt.abort.signal.aborted)
160
+ finishRef.current(
161
+ 'Could not connect to your agent. Please try again.',
162
+ );
163
+ });
164
+ });
165
+ return () => {
166
+ cancelled = true;
167
+ window.clearTimeout(timeout);
168
+ };
169
+ }, [attempt]);
170
+
171
+ useEffect(() => {
172
+ if (!ready || microphoneRequested.current || attempt.abort.signal.aborted)
173
+ return;
174
+ microphoneRequested.current = true;
175
+ void toggleMicrophone(true);
176
+ }, [ready, attempt]);
177
+
178
+ useEffect(() => {
179
+ // Snapshot after React commits both the final message and disconnection state.
180
+ if (
181
+ roomReady &&
182
+ session.connectionState === ConnectionState.Disconnected &&
183
+ !attempt.abort.signal.aborted
184
+ ) {
185
+ finishRef.current(
186
+ 'The connection ended. You can start a new conversation.',
187
+ );
188
+ }
189
+ }, [roomReady, session.connectionState, attempt]);
190
+
191
+ useEffect(() => {
192
+ if (!ready) return;
193
+ const started = Date.now();
194
+ const interval = window.setInterval(
195
+ () => setElapsed(Math.floor((Date.now() - started) / 1000)),
196
+ 1000,
197
+ );
198
+ return () => window.clearInterval(interval);
199
+ }, [ready]);
200
+
201
+ useEffect(() => {
202
+ if (
203
+ roomReady &&
204
+ agent.state === 'failed' &&
205
+ attempt.room.state === ConnectionState.Connected
206
+ )
207
+ finishRef.current(
208
+ 'Your agent is unavailable. Check that it is running and try again.',
209
+ );
210
+ }, [agent.state, roomReady, attempt]);
211
+
212
+ const reconnecting =
213
+ session.connectionState === ConnectionState.Reconnecting ||
214
+ session.connectionState === ConnectionState.SignalReconnecting;
215
+ const interactive = ready && session.isConnected && !reconnecting;
216
+ const status = reconnecting
217
+ ? 'Reconnecting'
218
+ : !ready
219
+ ? 'Connecting'
220
+ : agent.state === 'speaking'
221
+ ? 'Speaking'
222
+ : agent.state === 'thinking'
223
+ ? 'Thinking'
224
+ : isMicrophoneEnabled
225
+ ? 'Listening'
226
+ : 'Ready to chat';
227
+ const selectableDevices = devices.filter((device) => device.deviceId);
228
+ const send = async (message: string) => {
229
+ attempt.abort.signal.throwIfAborted();
230
+ if (!interactive) throw new Error('Not connected');
231
+ return stream.send(message);
232
+ };
233
+
234
+ return (
235
+ <div className="conversation-ui">
236
+ <header className="session-header">
237
+ <div className="identity-pill">
238
+ <span className="presence-dot" data-live={ready} />
239
+ <div>
240
+ <h1>{agentName}</h1>
241
+ <p role="status">{status} · AI agent</p>
242
+ </div>
243
+ <span
244
+ className="session-timer"
245
+ aria-label="Elapsed conversation time"
246
+ >
247
+ {Math.floor(elapsed / 60)}:{String(elapsed % 60).padStart(2, '0')}
248
+ </span>
249
+ </div>
250
+ </header>
251
+ {!ready && !reconnecting && (
252
+ <div className="connection-overlay">
253
+ <span className="loading-spinner" aria-hidden="true" />
254
+ <h2>
255
+ {roomReady ? 'Waiting for your agent' : 'Connecting to the room'}
256
+ </h2>
257
+ <p role="status">
258
+ {roomReady
259
+ ? 'Your agent is starting up. This can take a moment.'
260
+ : 'Your avatar is ready. Establishing a secure connection.'}
261
+ </p>
262
+ <button
263
+ className="text-button"
264
+ type="button"
265
+ onClick={() => finish()}
266
+ >
267
+ Cancel
268
+ </button>
269
+ </div>
270
+ )}
271
+ <div className="conversation-tools">
272
+ <StartAudioButton room={attempt.room} />
273
+ <AgentAudioVisualizerBar
274
+ audioTrack={{
275
+ participant: attempt.room.localParticipant,
276
+ source: Track.Source.Microphone,
277
+ publication: microphoneTrack,
278
+ }}
279
+ enabled={interactive && isMicrophoneEnabled}
280
+ />
281
+ <div className="call-dock">
282
+ <AgentTrackToggle
283
+ pressed={isMicrophoneEnabled}
284
+ pending={microphonePending}
285
+ disabled={!interactive}
286
+ onPressedChange={(enabled) => void toggleMicrophone(enabled)}
287
+ />
288
+ <div className="device-select">
289
+ <label className="sr-only" htmlFor="microphone-device">
290
+ Microphone device
291
+ </label>
292
+ <select
293
+ id="microphone-device"
294
+ aria-label="Microphone device"
295
+ value={activeDeviceId || ''}
296
+ disabled={!interactive || microphonePending || devicePending}
297
+ onChange={(event) => {
298
+ const id = event.target.value;
299
+ setDevicePending(true);
300
+ setMediaError('');
301
+ void attempt
302
+ .switchMicrophoneDevice(() => setActiveMediaDevice(id))
303
+ .catch(() => {
304
+ if (!attempt.abort.signal.aborted)
305
+ setMediaError(
306
+ 'Could not switch microphones. Try another device.',
307
+ );
308
+ })
309
+ .finally(() => {
310
+ if (!attempt.abort.signal.aborted) setDevicePending(false);
311
+ });
312
+ }}
313
+ >
314
+ <option value="">Default microphone</option>
315
+ {selectableDevices.map((device, index) => (
316
+ <option key={device.deviceId} value={device.deviceId}>
317
+ {device.label || `Microphone ${index + 1}`}
318
+ </option>
319
+ ))}
320
+ </select>
321
+ </div>
322
+ <AgentDisconnectButton
323
+ onClick={(event) => {
324
+ event.preventDefault();
325
+ finish();
326
+ }}
327
+ >
328
+ End call
329
+ </AgentDisconnectButton>
330
+ </div>
331
+ {mediaError && (
332
+ <p className="control-error" role="alert">
333
+ {mediaError}
334
+ </p>
335
+ )}
336
+ </div>
337
+ <TranscriptPanel>
338
+ <AgentChatTranscript
339
+ messages={messages}
340
+ agentState={agent.state}
341
+ agentName={agentName}
342
+ />
343
+ <div className="panel-composer">
344
+ <AgentChatInput
345
+ disabled={!interactive || stream.isSending}
346
+ onSend={send}
347
+ />
348
+ </div>
349
+ </TranscriptPanel>
350
+ </div>
351
+ );
352
+ }
@@ -0,0 +1,39 @@
1
+ export type IconName =
2
+ | 'mic'
3
+ | 'mic-off'
4
+ | 'end'
5
+ | 'chat'
6
+ | 'close'
7
+ | 'send'
8
+ | 'type'
9
+ | 'audio'
10
+ | 'arrow';
11
+ const paths: Record<IconName, string> = {
12
+ mic: 'M12 15a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v7a3 3 0 0 0 3 3ZM5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8',
13
+ 'mic-off':
14
+ 'm3 3 18 18M9 9v3a3 3 0 0 0 5.1 2.1M15 9V5a3 3 0 0 0-5.7-1.3M5 10v2a7 7 0 0 0 12 5M19 10v2M12 19v3M8 22h8',
15
+ end: 'M4 15v3H1v-5c6-6 16-6 22 0v5h-3v-3l-4-2v-3M8 10v3l-4 2',
16
+ chat: 'M4 4h16v13H8l-4 4V4ZM8 9h8M8 13h5',
17
+ close: 'm6 6 12 12M6 18 18 6',
18
+ send: 'm5 12 7-7 7 7M12 5v15',
19
+ type: 'M2 5h20v14H2V5ZM6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 13h.01M10 13h.01M14 13h4',
20
+ audio: 'm3 9 5 0 5-4v14l-5-4H3V9ZM17 8a6 6 0 0 1 0 8M20 5a10 10 0 0 1 0 14',
21
+ arrow: 'M5 12h14m-6-6 6 6-6 6',
22
+ };
23
+ export function Icon({ name }: { name: IconName }) {
24
+ return (
25
+ <svg
26
+ width="20"
27
+ height="20"
28
+ viewBox="0 0 24 24"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ strokeWidth="1.6"
32
+ strokeLinecap="round"
33
+ strokeLinejoin="round"
34
+ aria-hidden="true"
35
+ >
36
+ <path d={paths[name]} />
37
+ </svg>
38
+ );
39
+ }
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export function TranscriptPanel({ children }: { children: ReactNode }) {
4
+ return (
5
+ <aside className="transcript-panel" aria-labelledby="transcript-title">
6
+ <header className="transcript-heading">
7
+ <h2 id="transcript-title">Transcript</h2>
8
+ </header>
9
+ {children}
10
+ </aside>
11
+ );
12
+ }
@@ -0,0 +1,17 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+
4
+ import App from './App.js';
5
+ import './styles.css';
6
+
7
+ const root = document.querySelector('#root');
8
+
9
+ if (!root) {
10
+ throw new Error('Could not find the application root.');
11
+ }
12
+
13
+ createRoot(root).render(
14
+ <StrictMode>
15
+ <App />
16
+ </StrictMode>,
17
+ );
@@ -0,0 +1,110 @@
1
+ import { Room, TokenSource, Track } from 'livekit-client';
2
+ import { requestVoiceSession, type VoiceSession } from './api.js';
3
+ import type { AvatarSessionController } from './avatar-session.js';
4
+
5
+ export type PreparedSession = {
6
+ attempt: SessionAttempt;
7
+ tokenSource: ReturnType<typeof TokenSource.literal>;
8
+ };
9
+ export interface AttemptDependencies {
10
+ createRoom: () => Room;
11
+ request: (signal: AbortSignal) => Promise<VoiceSession>;
12
+ attach: (
13
+ container: HTMLElement,
14
+ credentials: VoiceSession,
15
+ room: Room,
16
+ signal: AbortSignal,
17
+ report: (status: string) => void,
18
+ ) => Promise<AvatarSessionController>;
19
+ }
20
+ const dependencies: AttemptDependencies = {
21
+ createRoom: () => new Room({ singlePeerConnection: false }),
22
+ request: requestVoiceSession,
23
+ attach: async (...args) => {
24
+ const { attachAvatarSession } = await import('./avatar-session.js');
25
+ return attachAvatarSession(...args);
26
+ },
27
+ };
28
+
29
+ export class SessionAttempt {
30
+ stage: 'bootstrap' | 'avatar' = 'bootstrap';
31
+ readonly room: Room;
32
+ readonly abort = new AbortController();
33
+ private avatar?: AvatarSessionController;
34
+ private disposal?: Promise<void>;
35
+ constructor(private readonly deps: AttemptDependencies = dependencies) {
36
+ this.room = deps.createRoom();
37
+ }
38
+ async prepare(
39
+ container: HTMLElement,
40
+ report: (status: string) => void,
41
+ ): Promise<PreparedSession> {
42
+ const { signal } = this.abort;
43
+ const credentials = await this.deps.request(signal);
44
+ signal.throwIfAborted();
45
+ // Warm LiveKit's region selection and connection while the avatar loads.
46
+ // This does not join the room or acquire media; attach must still finish
47
+ // before useSession starts the connection. A failed warmup is non-fatal.
48
+ void this.room
49
+ .prepareConnection(credentials.server_url, credentials.participant_token)
50
+ .catch(() => undefined);
51
+ this.stage = 'avatar';
52
+ const avatar = await this.deps.attach(
53
+ container,
54
+ credentials,
55
+ this.room,
56
+ signal,
57
+ report,
58
+ );
59
+ if (signal.aborted) {
60
+ await avatar.dispose();
61
+ signal.throwIfAborted();
62
+ }
63
+ this.avatar = avatar;
64
+ return {
65
+ attempt: this,
66
+ tokenSource: TokenSource.literal({
67
+ serverUrl: credentials.server_url,
68
+ participantToken: credentials.participant_token,
69
+ }),
70
+ };
71
+ }
72
+ async setMicrophone(enabled: boolean): Promise<void> {
73
+ this.abort.signal.throwIfAborted();
74
+ const publication =
75
+ await this.room.localParticipant.setMicrophoneEnabled(enabled);
76
+ if (this.abort.signal.aborted) {
77
+ // A pending permission prompt may finish after End. Stop its late track too.
78
+ publication?.track?.stop();
79
+ await this.room.localParticipant.setMicrophoneEnabled(false);
80
+ this.abort.signal.throwIfAborted();
81
+ }
82
+ }
83
+ async switchMicrophoneDevice(
84
+ switchDevice: () => Promise<void>,
85
+ ): Promise<void> {
86
+ this.abort.signal.throwIfAborted();
87
+ try {
88
+ await switchDevice();
89
+ } finally {
90
+ if (this.abort.signal.aborted) {
91
+ // Switching can replace the track; inspect the current publication, not a render snapshot.
92
+ this.room.localParticipant
93
+ .getTrackPublication(Track.Source.Microphone)
94
+ ?.track?.stop();
95
+ await this.room.disconnect(true);
96
+ }
97
+ }
98
+ this.abort.signal.throwIfAborted();
99
+ }
100
+ dispose(): Promise<void> {
101
+ this.abort.abort();
102
+ this.disposal ??= (async () => {
103
+ await Promise.allSettled([
104
+ this.room.disconnect(true),
105
+ this.avatar?.dispose() ?? Promise.resolve(),
106
+ ]);
107
+ })();
108
+ return this.disposal;
109
+ }
110
+ }