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.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/cli.js +9046 -0
- package/dist/cli.js.map +1 -0
- package/dist/templates.js +2856 -0
- package/dist/templates.js.map +1 -0
- package/package.json +79 -0
- package/schemas/result-v1.schema.json +129 -0
- package/schemas/result-v2.schema.json +146 -0
- package/templates/cloudflare-livekit/.editorconfig +15 -0
- package/templates/cloudflare-livekit/.prettierignore +6 -0
- package/templates/cloudflare-livekit/.prettierrc.json +4 -0
- package/templates/cloudflare-livekit/AGENTS.md +41 -0
- package/templates/cloudflare-livekit/README.md +37 -0
- package/templates/cloudflare-livekit/agent/Dockerfile.pip +31 -0
- package/templates/cloudflare-livekit/agent/Dockerfile.uv +29 -0
- package/templates/cloudflare-livekit/agent/README.md +84 -0
- package/templates/cloudflare-livekit/agent/dockerignore +11 -0
- package/templates/cloudflare-livekit/agent/env.example +8 -0
- package/templates/cloudflare-livekit/agent/pyproject.toml +21 -0
- package/templates/cloudflare-livekit/agent/python-version +1 -0
- package/templates/cloudflare-livekit/agent/src/__init__.py +1 -0
- package/templates/cloudflare-livekit/agent/src/agent.py +97 -0
- package/templates/cloudflare-livekit/agent/src/dispatch_metadata.py +64 -0
- package/templates/cloudflare-livekit/agent/tests/test_agent_startup.py +211 -0
- package/templates/cloudflare-livekit/agent/tests/test_dispatch_metadata.py +97 -0
- package/templates/cloudflare-livekit/agent/uv.lock +2272 -0
- package/templates/cloudflare-livekit/components.json +23 -0
- package/templates/cloudflare-livekit/contracts/agent-dispatch-metadata.schema.json +40 -0
- package/templates/cloudflare-livekit/dev.vars.example +12 -0
- package/templates/cloudflare-livekit/eslint.config.mjs +41 -0
- package/templates/cloudflare-livekit/gitignore +30 -0
- package/templates/cloudflare-livekit/index.html +17 -0
- package/templates/cloudflare-livekit/npmrc +4 -0
- package/templates/cloudflare-livekit/package-lock.json +6514 -0
- package/templates/cloudflare-livekit/package.json +66 -0
- package/templates/cloudflare-livekit/playwright.config.ts +29 -0
- package/templates/cloudflare-livekit/pnpm-lock.yaml +4012 -0
- package/templates/cloudflare-livekit/pnpm-workspace.yaml +8 -0
- package/templates/cloudflare-livekit/scripts/agent-runtime.mjs +129 -0
- package/templates/cloudflare-livekit/scripts/command.mjs +52 -0
- package/templates/cloudflare-livekit/scripts/dev-agent.mjs +77 -0
- package/templates/cloudflare-livekit/scripts/dev-web.mjs +18 -0
- package/templates/cloudflare-livekit/scripts/managed-process.mjs +144 -0
- package/templates/cloudflare-livekit/scripts/process-tree.mjs +111 -0
- package/templates/cloudflare-livekit/scripts/run-agent-python.mjs +25 -0
- package/templates/cloudflare-livekit/tests/dev-agent.test.mjs +327 -0
- package/templates/cloudflare-livekit/tests/dev-fixtures.mjs +235 -0
- package/templates/cloudflare-livekit/tests/dev-supervision.test.mjs +203 -0
- package/templates/cloudflare-livekit/tests/e2e/conversation.spec.ts +376 -0
- package/templates/cloudflare-livekit/tests/e2e/fake-avatar.ts +24 -0
- package/templates/cloudflare-livekit/tests/e2e/fake-components.tsx +173 -0
- package/templates/cloudflare-livekit/tests/e2e/fake-livekit.ts +123 -0
- package/templates/cloudflare-livekit/tests/e2e/harness.html +12 -0
- package/templates/cloudflare-livekit/tests/e2e/harness.tsx +1 -0
- package/templates/cloudflare-livekit/tests/e2e/vite.config.ts +48 -0
- package/templates/cloudflare-livekit/tests/web/api.test.ts +64 -0
- package/templates/cloudflare-livekit/tests/web/app.test.tsx +125 -0
- package/templates/cloudflare-livekit/tests/web/audio-visualizer.test.tsx +53 -0
- package/templates/cloudflare-livekit/tests/web/avatar-session.test.ts +119 -0
- package/templates/cloudflare-livekit/tests/web/components.test.tsx +152 -0
- package/templates/cloudflare-livekit/tests/web/conversation.test.tsx +76 -0
- package/templates/cloudflare-livekit/tests/web/session-attempt.test.ts +178 -0
- package/templates/cloudflare-livekit/tsconfig.json +9 -0
- package/templates/cloudflare-livekit/tsconfig.node.json +23 -0
- package/templates/cloudflare-livekit/tsconfig.web.json +26 -0
- package/templates/cloudflare-livekit/tsconfig.worker.json +22 -0
- package/templates/cloudflare-livekit/vite.config.ts +49 -0
- package/templates/cloudflare-livekit/vitest.config.ts +11 -0
- package/templates/cloudflare-livekit/web/App.tsx +176 -0
- package/templates/cloudflare-livekit/web/api.ts +69 -0
- package/templates/cloudflare-livekit/web/assets/FONT-PROVENANCE.md +8 -0
- package/templates/cloudflare-livekit/web/assets/OFL.txt +92 -0
- package/templates/cloudflare-livekit/web/assets/inter-latin.woff2 +0 -0
- package/templates/cloudflare-livekit/web/avatar-session.ts +67 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/LICENSE +201 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/PROVENANCE.md +32 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-audio-visualizer-bar.tsx +43 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-input.tsx +78 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-transcript.tsx +145 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-disconnect-button.tsx +27 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-session-provider.tsx +29 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-track-toggle.tsx +51 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/start-audio-button.tsx +13 -0
- package/templates/cloudflare-livekit/web/components/conversation.tsx +352 -0
- package/templates/cloudflare-livekit/web/components/icons.tsx +39 -0
- package/templates/cloudflare-livekit/web/components/transcript-panel.tsx +12 -0
- package/templates/cloudflare-livekit/web/main.tsx +17 -0
- package/templates/cloudflare-livekit/web/session-attempt.ts +110 -0
- package/templates/cloudflare-livekit/web/styles.css +571 -0
- package/templates/cloudflare-livekit/worker/dispatch-metadata.ts +22 -0
- package/templates/cloudflare-livekit/worker/index.test.ts +152 -0
- package/templates/cloudflare-livekit/worker/index.ts +61 -0
- package/templates/cloudflare-livekit/worker/session.ts +103 -0
- package/templates/cloudflare-livekit/worker-configuration.d.ts +17184 -0
- 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
|
+
}
|