@cairnvibe/sdk 0.1.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cairn-widget.js +228 -0
  3. package/dist/context-collector.d.ts +1 -0
  4. package/dist/context-collector.js +23 -0
  5. package/dist/dashboard-sqlite.d.ts +8 -0
  6. package/dist/dashboard-sqlite.js +50 -0
  7. package/dist/dashboard.d.ts +39 -0
  8. package/dist/dashboard.js +60 -0
  9. package/dist/element-ladder.d.ts +7 -0
  10. package/dist/element-ladder.js +60 -0
  11. package/dist/index.d.ts +31 -0
  12. package/dist/index.js +1069 -0
  13. package/dist/key-rotator.d.ts +7 -0
  14. package/dist/key-rotator.js +31 -0
  15. package/dist/package.json +1 -0
  16. package/dist/realtime-cli.d.ts +2 -0
  17. package/dist/realtime-cli.js +59 -0
  18. package/dist/realtime-server.d.ts +10 -0
  19. package/dist/realtime-server.js +291 -0
  20. package/dist/server.d.ts +95 -0
  21. package/dist/server.js +298 -0
  22. package/dist/speak-server.d.ts +16 -0
  23. package/dist/speak-server.js +41 -0
  24. package/dist/transcribe-server.d.ts +14 -0
  25. package/dist/transcribe-server.js +47 -0
  26. package/dist/tts-stream.d.ts +33 -0
  27. package/dist/tts-stream.js +124 -0
  28. package/dist/verb-executor.d.ts +17 -0
  29. package/dist/verb-executor.js +67 -0
  30. package/package.json +56 -0
  31. package/src/context-collector.ts +21 -0
  32. package/src/dashboard-sqlite.ts +52 -0
  33. package/src/dashboard.ts +82 -0
  34. package/src/element-ladder.ts +67 -0
  35. package/src/index.tsx +1250 -0
  36. package/src/key-rotator.ts +29 -0
  37. package/src/realtime-cli.ts +62 -0
  38. package/src/realtime-server.ts +342 -0
  39. package/src/server.ts +386 -0
  40. package/src/speak-server.ts +56 -0
  41. package/src/transcribe-server.ts +68 -0
  42. package/src/tts-stream.ts +140 -0
  43. package/src/verb-executor.ts +84 -0
  44. package/src/web-component.ts +1252 -0
package/dist/index.js ADDED
@@ -0,0 +1,1069 @@
1
+ "use strict";
2
+ "use client";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.Copilot = Copilot;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const navigation_1 = require("next/navigation");
8
+ const lucide_react_1 = require("lucide-react");
9
+ const context_collector_1 = require("./context-collector");
10
+ const element_ladder_1 = require("./element-ladder");
11
+ const verb_executor_1 = require("./verb-executor");
12
+ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, reportMissesEndpoint, transcribeEndpoint, speakEndpoint, realtimeUrl, persona = "Cairn", }) {
13
+ const pathname = (0, navigation_1.usePathname)() ?? "/";
14
+ const router = (0, navigation_1.useRouter)();
15
+ const [open, setOpen] = (0, react_1.useState)(false);
16
+ const [question, setQuestion] = (0, react_1.useState)("");
17
+ const [answer, setAnswer] = (0, react_1.useState)(null);
18
+ const [status, setStatus] = (0, react_1.useState)("idle");
19
+ const [caption, setCaption] = (0, react_1.useState)("");
20
+ // The user's own last question, shown as its own floating caption bubble
21
+ // alongside the agent's — set once per ask() call, not cleared on
22
+ // completion, so the exchange stays paired on screen the way a caption
23
+ // track shows the current line, not a scrolling transcript.
24
+ const [lastQuestion, setLastQuestion] = (0, react_1.useState)(null);
25
+ const [rtMicMuted, setRtMicMuted] = (0, react_1.useState)(false);
26
+ const [rtSpeakerMuted, setRtSpeakerMuted] = (0, react_1.useState)(false);
27
+ // Set while a "tour" verb's steps are being narrated/highlighted one at a
28
+ // time — drives the step-progress caption and blocks the input/mic so a
29
+ // typed or spoken question can't interrupt mid-walkthrough.
30
+ const [tourStep, setTourStep] = (0, react_1.useState)(null);
31
+ const tourGenerationRef = (0, react_1.useRef)(0); // bumped to cancel an in-progress tour (e.g. widget closed) without extra flags
32
+ // Mirrors whether a tour is running, for use inside the mic's
33
+ // onaudioprocess callback (a stale closure over React state there would
34
+ // miss a tour that started after the callback was created) — a tour
35
+ // reuses "rt-speaking" to hold the mic off too, but must NOT be
36
+ // barge-in-able the way a real conversational reply is (see the RMS
37
+ // check below): it's a deliberate walkthrough, not a turn to interrupt.
38
+ const touringRef = (0, react_1.useRef)(false);
39
+ // Resolver for "this tour step's audio has fully finished playing" when
40
+ // narrating over an already-open realtime session (see maybeResumeListening
41
+ // and speakOverRealtime) — set right before sending a step's text, cleared
42
+ // once it resolves.
43
+ const rtTourAudioDoneRef = (0, react_1.useRef)(null);
44
+ // Conversation memory for the typed/mic path. Not React state — nothing
45
+ // about it should trigger a re-render, it just needs to persist across
46
+ // ask() calls and be resent each time (see ask() below; the realtime path
47
+ // keeps its own history server-side instead, since that connection is
48
+ // already stateful).
49
+ const historyRef = (0, react_1.useRef)([]);
50
+ const mediaRecorderRef = (0, react_1.useRef)(null);
51
+ const audioChunksRef = (0, react_1.useRef)([]);
52
+ const transcribeInFlightRef = (0, react_1.useRef)(false);
53
+ const rtSocketRef = (0, react_1.useRef)(null);
54
+ const rtCleanupRef = (0, react_1.useRef)(null);
55
+ const rtStateRef = (0, react_1.useRef)("idle"); // mirrors `status` for use inside audio callbacks (avoids stale closures)
56
+ const rtMicMutedRef = (0, react_1.useRef)(false);
57
+ const rtSpeakerMutedRef = (0, react_1.useRef)(false);
58
+ const rtStartingRef = (0, react_1.useRef)(false); // closes the click-to-first-state-update gap so a rapid double-click can't open two sessions
59
+ const activeAudioRef = (0, react_1.useRef)(null);
60
+ // Watchdog for the "rt-thinking" state: started on every "final" transcript,
61
+ // cleared the moment the server responds with anything for that turn
62
+ // (verb/speaking_start/speaking_end/turn_complete/error). If it ever
63
+ // fires, the server went silent for this turn — force the mic back to
64
+ // listening instead of leaving the session stuck showing "Thinking…"
65
+ // forever with no way to speak again short of ending the call.
66
+ const rtThinkingWatchdogRef = (0, react_1.useRef)(null);
67
+ // Streamed TTS playback: each server audio_chunk is raw PCM16, scheduled
68
+ // as its own AudioBufferSourceNode straight into this graph, gapless,
69
+ // instead of buffering a whole clip into one <audio> element first — that
70
+ // buffering was the "agent takes 5-10s to speak" bug (nothing plays until
71
+ // Deepgram AND the network finish delivering the entire reply).
72
+ const rtPlaybackCtxRef = (0, react_1.useRef)(null);
73
+ const rtPlaybackGainRef = (0, react_1.useRef)(null);
74
+ const rtNextPlayTimeRef = (0, react_1.useRef)(0);
75
+ const rtScheduledSourcesRef = (0, react_1.useRef)([]);
76
+ // True once the server says no more audio_chunks are coming for the
77
+ // current turn (speaking_end/turn_complete) — listening only resumes once
78
+ // this AND every scheduled chunk has actually finished playing, not just
79
+ // finished arriving, so the mic can't start sending while the agent is
80
+ // still audibly speaking.
81
+ const rtAudioDoneArrivingRef = (0, react_1.useRef)(true);
82
+ // Starts false on both server and client's first render (avoids a
83
+ // hydration mismatch — `navigator` doesn't exist during SSR), then
84
+ // updated after mount, once we're only ever running in the browser.
85
+ const [micSupported, setMicSupported] = (0, react_1.useState)(false);
86
+ (0, react_1.useEffect)(() => {
87
+ setMicSupported(!!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== "undefined");
88
+ }, []);
89
+ const asking = status === "asking";
90
+ const recording = status === "recording";
91
+ const realtimeActive = status.startsWith("rt-");
92
+ const touring = tourStep !== null;
93
+ const busy = asking || status === "rt-thinking" || touring;
94
+ // `caption` is overloaded by design (see its setters above): during a
95
+ // tour it's a step-progress label ("Step 1 of 2"), not user speech, so it
96
+ // reads as a small chip over the agent's bubble instead. While actively
97
+ // recording or on a live realtime call it's the user's own live/last
98
+ // transcript, so it reads as the user's floating bubble; otherwise that
99
+ // slot falls back to the last typed question.
100
+ const tourChip = touring ? caption : "";
101
+ const userCaption = !touring && (recording || realtimeActive) ? caption : lastQuestion ?? "";
102
+ function setRtStatus(next) {
103
+ rtStateRef.current = next;
104
+ setStatus(next);
105
+ }
106
+ function reportMiss(context) {
107
+ (0, element_ladder_1.logMiss)(context);
108
+ if (reportMissesEndpoint) {
109
+ fetch(reportMissesEndpoint, {
110
+ method: "POST",
111
+ headers: { "content-type": "application/json" },
112
+ body: JSON.stringify(context),
113
+ }).catch(() => { });
114
+ }
115
+ }
116
+ function handleVerb(raw) {
117
+ (0, verb_executor_1.executeVerbResponse)(raw, pathname, {
118
+ onExplain: (text) => {
119
+ setAnswer(text);
120
+ if (!realtimeActive)
121
+ void speak(text); // realtime mode gets audio over the socket instead
122
+ },
123
+ onNavigate: (route) => router.push(route),
124
+ onMiss: reportMiss,
125
+ onDo,
126
+ onTour: (steps) => void runTour(steps),
127
+ registeredActions,
128
+ });
129
+ }
130
+ /**
131
+ * Walks a "tour" verb's steps one at a time: highlight this step's
132
+ * target (if any), speak/show its text, wait for that to finish, then
133
+ * move on — this is what makes a multi-part answer feel like someone
134
+ * actually showing you around instead of one paragraph naming several
135
+ * buttons at once with nothing highlighted.
136
+ *
137
+ * Always narrates via speakEndpoint (plain request/response TTS), even
138
+ * during a live realtime session — a tour is a distinct guided
139
+ * walkthrough, not a conversational turn, so it doesn't need the
140
+ * streaming relay's turn-taking machinery. If the widget is mid
141
+ * realtime call, the mic is held off (mirrors "rt-speaking") for the
142
+ * tour's duration so it can't pick up the tour's own narration.
143
+ */
144
+ async function runTour(steps) {
145
+ const myGeneration = ++tourGenerationRef.current;
146
+ const wasRealtimeListening = realtimeActive;
147
+ touringRef.current = true;
148
+ if (wasRealtimeListening)
149
+ setRtStatus("rt-speaking");
150
+ setAnswer(null);
151
+ // Tracked locally rather than reading the component's `pathname` —
152
+ // that's only current as of this render, and a step below can navigate
153
+ // mid-tour (router.push doesn't update it synchronously, and this
154
+ // async function's closure over the render-time value would otherwise
155
+ // go stale for every step after the first navigation).
156
+ let currentRoute = pathname;
157
+ try {
158
+ for (let i = 0; i < steps.length; i++) {
159
+ if (tourGenerationRef.current !== myGeneration)
160
+ return; // superseded — e.g. widget closed or a new question came in
161
+ const step = steps[i];
162
+ setTourStep({ index: i, total: steps.length });
163
+ setCaption(`Step ${i + 1} of ${steps.length}`);
164
+ setAnswer(step.text);
165
+ if (step.route && step.route !== currentRoute) {
166
+ router.push(step.route);
167
+ currentRoute = step.route;
168
+ // router.push() in the App Router doesn't return a promise to
169
+ // await — a short fixed pause is the pragmatic way to give the
170
+ // new route's DOM a moment to mount before the target lookup
171
+ // below runs against it. Steps already pace at 1-3s+ for
172
+ // narration, so this doesn't read as a hang.
173
+ await new Promise((resolve) => setTimeout(resolve, 500));
174
+ if (tourGenerationRef.current !== myGeneration)
175
+ return;
176
+ }
177
+ if (step.target) {
178
+ const el = (0, element_ladder_1.findElement)(step.target);
179
+ if (el)
180
+ (0, element_ladder_1.highlightElement)(el);
181
+ else
182
+ reportMiss({ attempted: step.target, route: currentRoute });
183
+ }
184
+ if (wasRealtimeListening && rtSocketRef.current?.readyState === WebSocket.OPEN) {
185
+ // Already have a live streaming connection open — reuse it
186
+ // (same Speak WS, same gapless PCM scheduling a normal reply
187
+ // uses) instead of falling back to a separate buffered REST call.
188
+ await speakOverRealtime(step.text);
189
+ }
190
+ else if (speakEndpoint) {
191
+ await speakAndWait(step.text);
192
+ }
193
+ else {
194
+ // No TTS configured — pace by an estimate of reading time instead
195
+ // of racing through every step instantly.
196
+ await new Promise((resolve) => setTimeout(resolve, Math.max(1200, step.text.length * 45)));
197
+ }
198
+ if (tourGenerationRef.current !== myGeneration)
199
+ return;
200
+ }
201
+ if (tourGenerationRef.current !== myGeneration)
202
+ return;
203
+ setTourStep(null);
204
+ setCaption("");
205
+ if (wasRealtimeListening && realtimeActive)
206
+ setRtStatus("rt-listening");
207
+ }
208
+ finally {
209
+ if (tourGenerationRef.current === myGeneration)
210
+ touringRef.current = false;
211
+ }
212
+ }
213
+ // ---------------------------------------------------------------------
214
+ // Typed / push-to-talk question flow
215
+ // ---------------------------------------------------------------------
216
+ async function ask(q) {
217
+ setStatus("asking");
218
+ setAnswer(null);
219
+ setLastQuestion(q);
220
+ setQuestion("");
221
+ try {
222
+ const res = await fetch(endpoint, {
223
+ method: "POST",
224
+ headers: { "content-type": "application/json" },
225
+ body: JSON.stringify({ route: pathname, question: q, visible: (0, context_collector_1.collectVisible)(), history: historyRef.current }),
226
+ });
227
+ const data = await res.json().catch(() => null);
228
+ handleVerb(data);
229
+ // Unlike the realtime relay (one persistent connection, memory lives
230
+ // server-side), each of these POSTs is stateless — the widget itself
231
+ // is what remembers, and resends it above so the model has context
232
+ // for "the first one" / "do that instead" on the next question.
233
+ historyRef.current = [
234
+ ...historyRef.current,
235
+ { role: "user", text: q },
236
+ { role: "assistant", text: summarizeVerbForHistory(data) },
237
+ ].slice(-MAX_HISTORY_TURNS);
238
+ }
239
+ catch {
240
+ setAnswer("Something went wrong reaching the help service — try again in a moment.");
241
+ }
242
+ finally {
243
+ setStatus("idle");
244
+ }
245
+ }
246
+ /**
247
+ * The one place that starts audio playback for a spoken response — stops
248
+ * whatever's currently playing first, so two responses (e.g. a rapid
249
+ * double-click on "start conversation", or two utterances resolved close
250
+ * together) can never be heard overlapping. Used by both the typed/mic
251
+ * path and the realtime path.
252
+ */
253
+ function playResponseAudio(blob) {
254
+ if (activeAudioRef.current) {
255
+ activeAudioRef.current.pause();
256
+ activeAudioRef.current.currentTime = 0;
257
+ }
258
+ const url = URL.createObjectURL(blob);
259
+ const audio = new Audio(url);
260
+ activeAudioRef.current = audio;
261
+ return new Promise((resolve) => {
262
+ const clear = () => {
263
+ URL.revokeObjectURL(url);
264
+ if (activeAudioRef.current === audio)
265
+ activeAudioRef.current = null;
266
+ resolve();
267
+ };
268
+ audio.onended = clear;
269
+ audio.play().catch(clear);
270
+ });
271
+ }
272
+ async function speak(text) {
273
+ if (!speakEndpoint || !text.trim())
274
+ return;
275
+ try {
276
+ const res = await fetch(speakEndpoint, {
277
+ method: "POST",
278
+ headers: { "content-type": "application/json" },
279
+ body: JSON.stringify({ text }),
280
+ });
281
+ if (!res.ok)
282
+ return;
283
+ void playResponseAudio(await res.blob());
284
+ }
285
+ catch {
286
+ // Best-effort — never let speech playback break the widget.
287
+ }
288
+ }
289
+ /** Like speak(), but resolves once playback actually finishes — used by
290
+ * runTour() so each step's highlight stays up for exactly as long as its
291
+ * narration takes, instead of racing ahead to the next step. */
292
+ async function speakAndWait(text) {
293
+ if (!speakEndpoint || !text.trim())
294
+ return;
295
+ try {
296
+ const res = await fetch(speakEndpoint, {
297
+ method: "POST",
298
+ headers: { "content-type": "application/json" },
299
+ body: JSON.stringify({ text }),
300
+ });
301
+ if (!res.ok)
302
+ return;
303
+ await playResponseAudio(await res.blob());
304
+ }
305
+ catch {
306
+ // Best-effort — never let a synthesis failure hang the tour forever.
307
+ }
308
+ }
309
+ /** Like speakAndWait(), but narrates over an already-open realtime
310
+ * WebSocket instead of a separate REST call — same streaming Speak
311
+ * connection and gapless PCM scheduling a normal conversational reply
312
+ * uses, so a tour that happens mid-call is exactly as fast to start
313
+ * speaking as the conversation itself. Resolved by maybeResumeListening()
314
+ * (defined in startRealtime, where the audio_chunk scheduling lives) once
315
+ * this step's audio has both fully arrived and fully finished playing. */
316
+ function speakOverRealtime(text) {
317
+ return new Promise((resolve) => {
318
+ const ws = rtSocketRef.current;
319
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
320
+ resolve();
321
+ return;
322
+ }
323
+ rtAudioDoneArrivingRef.current = false;
324
+ let settled = false;
325
+ const finish = () => {
326
+ if (settled)
327
+ return;
328
+ settled = true;
329
+ rtTourAudioDoneRef.current = null;
330
+ resolve();
331
+ };
332
+ rtTourAudioDoneRef.current = finish;
333
+ // Safety net: if the server's "this step's audio is fully done"
334
+ // confirmation is ever dropped (a flaky Deepgram Flushed event, a
335
+ // closed connection mid-turn), don't let the tour hang on this step
336
+ // forever with the mic never resuming — move on instead.
337
+ setTimeout(() => {
338
+ if (!settled)
339
+ console.warn("[cairn] tour step audio confirmation timed out — continuing");
340
+ finish();
341
+ }, 15000);
342
+ ws.send(JSON.stringify({ type: "speak", text }));
343
+ });
344
+ }
345
+ async function startRecording() {
346
+ if (!transcribeEndpoint || !micSupported || realtimeActive)
347
+ return;
348
+ try {
349
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
350
+ const recorder = new MediaRecorder(stream);
351
+ audioChunksRef.current = [];
352
+ setCaption("");
353
+ recorder.ondataavailable = (e) => {
354
+ if (e.data.size === 0)
355
+ return;
356
+ audioChunksRef.current.push(e.data);
357
+ void transcribeSoFar(recorder.mimeType || "audio/webm", true);
358
+ };
359
+ recorder.onstop = () => {
360
+ void transcribeSoFar(recorder.mimeType || "audio/webm", false);
361
+ };
362
+ mediaRecorderRef.current = recorder;
363
+ recorder.start(2000);
364
+ setStatus("recording");
365
+ }
366
+ catch {
367
+ setAnswer("Couldn't access the microphone — check your browser's permission for this site.");
368
+ }
369
+ }
370
+ function stopRecording() {
371
+ const stream = mediaRecorderRef.current?.stream;
372
+ mediaRecorderRef.current?.stop();
373
+ stream?.getTracks().forEach((track) => track.stop());
374
+ setStatus("idle");
375
+ }
376
+ async function transcribeSoFar(mimeType, isProgressive) {
377
+ if (!transcribeEndpoint)
378
+ return;
379
+ if (isProgressive && transcribeInFlightRef.current)
380
+ return;
381
+ transcribeInFlightRef.current = true;
382
+ try {
383
+ const blob = new Blob(audioChunksRef.current, { type: mimeType });
384
+ const res = await fetch(transcribeEndpoint, { method: "POST", headers: { "content-type": mimeType }, body: blob });
385
+ const data = await res.json().catch(() => null);
386
+ if (data?.text) {
387
+ setQuestion(data.text);
388
+ setCaption(data.text);
389
+ }
390
+ else if (!isProgressive) {
391
+ setAnswer("Couldn't make that out — try typing instead.");
392
+ }
393
+ }
394
+ catch {
395
+ if (!isProgressive)
396
+ setAnswer("Couldn't reach the transcription service.");
397
+ }
398
+ finally {
399
+ transcribeInFlightRef.current = false;
400
+ }
401
+ }
402
+ // ---------------------------------------------------------------------
403
+ // Real-time voice conversation
404
+ // ---------------------------------------------------------------------
405
+ async function startRealtime() {
406
+ // rtStartingRef closes the gap between click and the first state update
407
+ // landing — without it a rapid double-click (or two-finger tap) could
408
+ // race past the `realtimeActive` check twice and open two sessions,
409
+ // which is exactly what "hearing the agent twice, in parallel" was.
410
+ if (!realtimeUrl || !micSupported || realtimeActive || rtStartingRef.current)
411
+ return;
412
+ rtStartingRef.current = true;
413
+ setAnswer(null);
414
+ setCaption("");
415
+ setRtStatus("rt-connecting");
416
+ try {
417
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
418
+ const ws = new WebSocket(realtimeUrl);
419
+ ws.binaryType = "arraybuffer";
420
+ rtSocketRef.current = ws;
421
+ // Separate AudioContext from the mic capture graph below — one for
422
+ // capture, one for playback, matching how the two are independently
423
+ // lifecycled (playback keeps scheduling audio after a turn while the
424
+ // mic graph is simultaneously idle, and vice versa).
425
+ const playbackCtx = new AudioContext();
426
+ const playbackGain = playbackCtx.createGain();
427
+ playbackGain.gain.value = rtSpeakerMutedRef.current ? 0 : 1;
428
+ playbackGain.connect(playbackCtx.destination);
429
+ rtPlaybackCtxRef.current = playbackCtx;
430
+ rtPlaybackGainRef.current = playbackGain;
431
+ rtNextPlayTimeRef.current = 0;
432
+ rtScheduledSourcesRef.current = [];
433
+ rtAudioDoneArrivingRef.current = true;
434
+ const audioCtx = new AudioContext();
435
+ const source = audioCtx.createMediaStreamSource(stream);
436
+ // ScriptProcessorNode is deprecated in favor of AudioWorklet, but needs
437
+ // no separate worklet file to serve — fine for this scope, still
438
+ // supported everywhere. Routed through a silent gain (not straight to
439
+ // destination) so the mic input is never audibly looped back.
440
+ const processor = audioCtx.createScriptProcessor(4096, 1, 1);
441
+ const silence = audioCtx.createGain();
442
+ silence.gain.value = 0;
443
+ processor.onaudioprocess = (e) => {
444
+ if (ws.readyState !== WebSocket.OPEN)
445
+ return;
446
+ if (rtMicMutedRef.current)
447
+ return;
448
+ // Barge-in: while the agent is speaking a real conversational
449
+ // reply (not touring — a tour deliberately can't be talked over),
450
+ // keep listening to the mic locally even though it isn't being
451
+ // sent yet, and cut the agent off the instant the user starts
452
+ // talking over it instead of making them wait for it to finish.
453
+ if (rtStateRef.current === "rt-speaking" && !touringRef.current) {
454
+ const rms = computeRms(e.inputBuffer.getChannelData(0));
455
+ if (rms > BARGE_IN_RMS_THRESHOLD)
456
+ triggerBargeIn();
457
+ return;
458
+ }
459
+ if (rtStateRef.current !== "rt-listening")
460
+ return; // don't send our own mic while the agent is thinking/speaking
461
+ const pcm = floatTo16BitPCM(downsampleTo16k(e.inputBuffer.getChannelData(0), audioCtx.sampleRate));
462
+ ws.send(pcm);
463
+ };
464
+ source.connect(processor);
465
+ processor.connect(silence);
466
+ silence.connect(audioCtx.destination);
467
+ rtCleanupRef.current = () => {
468
+ processor.disconnect();
469
+ source.disconnect();
470
+ stream.getTracks().forEach((t) => t.stop());
471
+ void audioCtx.close();
472
+ stopScheduledRtAudio();
473
+ void playbackCtx.close();
474
+ rtPlaybackCtxRef.current = null;
475
+ rtPlaybackGainRef.current = null;
476
+ };
477
+ // Only flips back to "listening" (and lets the mic resume sending —
478
+ // see the listening-only send guard above) once BOTH the server has
479
+ // said no more audio is coming for this turn AND every chunk already
480
+ // scheduled has actually finished playing. Doing this from playback
481
+ // completion rather than from the server's speaking_end alone is what
482
+ // stops the mic picking up the tail end of the agent's own voice.
483
+ //
484
+ // Shared with runTour()'s speakOverRealtime(): while touring, this
485
+ // same "audio fully drained" condition resolves the current step's
486
+ // wait instead of touching rtStatus/caption — a tour owns those for
487
+ // its whole duration, not per step.
488
+ function maybeResumeListening() {
489
+ if (!rtAudioDoneArrivingRef.current)
490
+ return;
491
+ if (rtScheduledSourcesRef.current.length > 0)
492
+ return;
493
+ if (touringRef.current) {
494
+ rtTourAudioDoneRef.current?.();
495
+ rtTourAudioDoneRef.current = null;
496
+ return;
497
+ }
498
+ setRtStatus("rt-listening");
499
+ setCaption("");
500
+ }
501
+ function disarmThinkingWatchdog() {
502
+ if (rtThinkingWatchdogRef.current) {
503
+ clearTimeout(rtThinkingWatchdogRef.current);
504
+ rtThinkingWatchdogRef.current = null;
505
+ }
506
+ }
507
+ function armThinkingWatchdog() {
508
+ disarmThinkingWatchdog();
509
+ rtThinkingWatchdogRef.current = setTimeout(() => {
510
+ rtThinkingWatchdogRef.current = null;
511
+ console.warn("[cairn] realtime turn timed out waiting on the server — resuming listening");
512
+ rtAudioDoneArrivingRef.current = true;
513
+ setRtStatus("rt-listening");
514
+ setCaption("");
515
+ }, 20000);
516
+ }
517
+ function stopScheduledRtAudio() {
518
+ for (const node of rtScheduledSourcesRef.current) {
519
+ try {
520
+ node.stop();
521
+ }
522
+ catch {
523
+ // may have already finished naturally
524
+ }
525
+ }
526
+ rtScheduledSourcesRef.current = [];
527
+ rtNextPlayTimeRef.current = rtPlaybackCtxRef.current?.currentTime ?? 0;
528
+ }
529
+ // Stops the agent immediately (locally) and tells the server to
530
+ // discard whatever it's still synthesizing/sending for this turn —
531
+ // the server tags every turn with a generation number and drops any
532
+ // now-stale audio_chunk/speaking_end that was already in flight, so a
533
+ // few straggling chunks can't sneak back in and resume playback.
534
+ function triggerBargeIn() {
535
+ disarmThinkingWatchdog();
536
+ stopScheduledRtAudio();
537
+ rtAudioDoneArrivingRef.current = true;
538
+ if (ws.readyState === WebSocket.OPEN)
539
+ ws.send(JSON.stringify({ type: "barge_in" }));
540
+ setRtStatus("rt-listening");
541
+ setCaption("");
542
+ }
543
+ ws.onopen = () => {
544
+ ws.send(JSON.stringify({ type: "context", route: pathname, visible: (0, context_collector_1.collectVisible)() }));
545
+ setRtStatus("rt-listening");
546
+ rtStartingRef.current = false;
547
+ };
548
+ ws.onmessage = (event) => {
549
+ if (typeof event.data !== "string")
550
+ return; // audio now arrives as base64 inside audio_chunk, not raw binary frames
551
+ const msg = JSON.parse(event.data);
552
+ if (msg.type === "interim") {
553
+ setCaption(msg.text);
554
+ }
555
+ else if (msg.type === "final") {
556
+ setCaption(msg.text);
557
+ setRtStatus("rt-thinking");
558
+ armThinkingWatchdog();
559
+ }
560
+ else if (msg.type === "verb") {
561
+ disarmThinkingWatchdog();
562
+ handleVerb(msg.verb);
563
+ }
564
+ else if (msg.type === "speaking_start") {
565
+ disarmThinkingWatchdog();
566
+ rtAudioDoneArrivingRef.current = false;
567
+ setRtStatus("rt-speaking");
568
+ }
569
+ else if (msg.type === "audio_chunk") {
570
+ const ctx = rtPlaybackCtxRef.current;
571
+ const gain = rtPlaybackGainRef.current;
572
+ if (!ctx || !gain)
573
+ return;
574
+ void ctx.resume().catch(() => { });
575
+ // Decode base64 linear16 PCM -> Float32 samples in [-1, 1], then
576
+ // schedule gapless-appended after whatever's already queued
577
+ // (rtNextPlayTimeRef) — this is what lets playback start on the
578
+ // first chunk instead of waiting for the whole reply.
579
+ const bytes = Uint8Array.from(atob(msg.audio), (c) => c.charCodeAt(0));
580
+ const sampleCount = bytes.length / 2;
581
+ const float32 = new Float32Array(sampleCount);
582
+ const view = new DataView(bytes.buffer);
583
+ for (let i = 0; i < sampleCount; i++) {
584
+ float32[i] = view.getInt16(i * 2, true) / 32768;
585
+ }
586
+ const sampleRate = typeof msg.sampleRate === "number" ? msg.sampleRate : 24000;
587
+ const buffer = ctx.createBuffer(1, sampleCount, sampleRate);
588
+ buffer.copyToChannel(float32, 0);
589
+ const bufferSource = ctx.createBufferSource();
590
+ bufferSource.buffer = buffer;
591
+ bufferSource.connect(gain);
592
+ const startAt = Math.max(ctx.currentTime, rtNextPlayTimeRef.current);
593
+ bufferSource.start(startAt);
594
+ rtNextPlayTimeRef.current = startAt + buffer.duration;
595
+ rtScheduledSourcesRef.current.push(bufferSource);
596
+ bufferSource.onended = () => {
597
+ rtScheduledSourcesRef.current = rtScheduledSourcesRef.current.filter((n) => n !== bufferSource);
598
+ maybeResumeListening();
599
+ };
600
+ }
601
+ else if (msg.type === "speaking_end" || msg.type === "turn_complete") {
602
+ // turn_complete covers a verb with nothing spoken (a plain
603
+ // highlight/navigate/do often has no text) — no audio_chunk ever
604
+ // arrives for it, so rtScheduledSourcesRef is already empty and
605
+ // maybeResumeListening() resumes immediately below.
606
+ disarmThinkingWatchdog();
607
+ rtAudioDoneArrivingRef.current = true;
608
+ maybeResumeListening();
609
+ }
610
+ else if (msg.type === "error") {
611
+ // Must actually unstick the turn, not just show the message —
612
+ // otherwise the mic never resumes and the session is stuck
613
+ // exactly the way a silently-dropped response used to leave it.
614
+ disarmThinkingWatchdog();
615
+ setAnswer(msg.message ?? "Something went wrong.");
616
+ if (!touringRef.current) {
617
+ setRtStatus("rt-listening");
618
+ setCaption("");
619
+ }
620
+ }
621
+ };
622
+ ws.onerror = () => {
623
+ setAnswer("Couldn't connect to the realtime voice service.");
624
+ endRealtime();
625
+ };
626
+ ws.onclose = () => {
627
+ if (rtStateRef.current !== "idle")
628
+ endRealtime();
629
+ };
630
+ }
631
+ catch {
632
+ setAnswer("Couldn't access the microphone — check your browser's permission for this site.");
633
+ setRtStatus("idle");
634
+ rtStartingRef.current = false;
635
+ }
636
+ }
637
+ function endRealtime() {
638
+ if (rtThinkingWatchdogRef.current) {
639
+ clearTimeout(rtThinkingWatchdogRef.current);
640
+ rtThinkingWatchdogRef.current = null;
641
+ }
642
+ rtStartingRef.current = false;
643
+ activeAudioRef.current?.pause();
644
+ activeAudioRef.current = null;
645
+ rtSocketRef.current?.close();
646
+ rtSocketRef.current = null;
647
+ rtCleanupRef.current?.();
648
+ rtCleanupRef.current = null;
649
+ setRtMicMuted(false);
650
+ setRtSpeakerMuted(false);
651
+ setCaption("");
652
+ setRtStatus("idle");
653
+ tourGenerationRef.current++; // cancel an in-progress tour rather than leaving it stuck waiting to resume rt-listening
654
+ touringRef.current = false;
655
+ setTourStep(null);
656
+ // Unstick a tour step mid-narration over realtime — the socket above is
657
+ // already closed, so nothing will ever deliver the audio_chunk/speaking_end
658
+ // that would normally resolve this; without forcing it, runTour()'s
659
+ // await would hang forever instead of noticing the generation bump above.
660
+ rtTourAudioDoneRef.current?.();
661
+ rtTourAudioDoneRef.current = null;
662
+ }
663
+ function toggleRtMic() {
664
+ rtMicMutedRef.current = !rtMicMutedRef.current;
665
+ setRtMicMuted(rtMicMutedRef.current);
666
+ }
667
+ function toggleRtSpeaker() {
668
+ rtSpeakerMutedRef.current = !rtSpeakerMutedRef.current;
669
+ setRtSpeakerMuted(rtSpeakerMutedRef.current);
670
+ // Zeroing the shared gain node silences output immediately, including
671
+ // whatever's mid-playback right now, and applies to every future
672
+ // scheduled chunk automatically — no per-chunk check needed.
673
+ if (rtPlaybackGainRef.current) {
674
+ rtPlaybackGainRef.current.gain.value = rtSpeakerMutedRef.current ? 0 : 1;
675
+ }
676
+ }
677
+ const statusLabel = {
678
+ idle: "",
679
+ asking: "Thinking…",
680
+ recording: "Listening — transcribing live…",
681
+ "rt-connecting": "Connecting…",
682
+ "rt-listening": "Listening…",
683
+ "rt-thinking": "Thinking…",
684
+ "rt-speaking": "Speaking…",
685
+ };
686
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("style", { suppressHydrationWarning: true, dangerouslySetInnerHTML: { __html: COPILOT_STYLES } }), (0, jsx_runtime_1.jsx)("button", { className: status === "rt-speaking" ? "cairn-fab cairn-fab-speaking" : "cairn-fab", "aria-label": open ? `Close ${persona} help` : `Open ${persona} help`, onClick: () => setOpen((v) => !v), children: open ? (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 22 }) : (0, jsx_runtime_1.jsx)(CairnMark, {}) }), open && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-panel", role: "dialog", "aria-label": `${persona} help panel`, children: [(userCaption || answer || busy) && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-stack", children: [userCaption && ((0, jsx_runtime_1.jsx)("div", { className: "cairn-bubble cairn-bubble-user", children: userCaption }, `u-${userCaption}`)), (answer || busy) && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-bubble cairn-bubble-agent", children: [tourChip && (0, jsx_runtime_1.jsx)("span", { className: "cairn-chip", children: tourChip }), answer ? ((0, jsx_runtime_1.jsx)("span", { className: "cairn-bubble-text", children: renderCaptionWords(answer) })) : ((0, jsx_runtime_1.jsxs)("span", { className: "cairn-thinking", "aria-label": "Thinking", children: [(0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" })] }))] }, `a-${answer ?? status}`))] })), realtimeActive ? ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-rt-bar", children: [(0, jsx_runtime_1.jsx)("span", { className: `cairn-rt-dot cairn-rt-dot-${status}` }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-rt-label", children: statusLabel[status] }), (0, jsx_runtime_1.jsxs)("div", { className: "cairn-rt-controls", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: status === "rt-speaking" ? "cairn-icon-btn cairn-icon-btn-speaking" : "cairn-icon-btn", "aria-label": rtMicMuted ? "Unmute microphone" : "Mute microphone", onClick: toggleRtMic, children: rtMicMuted ? (0, jsx_runtime_1.jsx)(lucide_react_1.MicOff, { size: 16 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Mic, { size: 16 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: status === "rt-speaking" ? "cairn-icon-btn cairn-icon-btn-speaking" : "cairn-icon-btn", "aria-label": rtSpeakerMuted ? "Unmute speaker" : "Mute speaker", onClick: toggleRtSpeaker, children: rtSpeakerMuted ? (0, jsx_runtime_1.jsx)(lucide_react_1.VolumeX, { size: 16 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Volume2, { size: 16 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "cairn-icon-btn cairn-icon-btn-end", "aria-label": "End conversation", onClick: endRealtime, children: (0, jsx_runtime_1.jsx)(lucide_react_1.PhoneOff, { size: 16 }) })] })] })) : ((0, jsx_runtime_1.jsx)("form", { onSubmit: (e) => {
687
+ e.preventDefault();
688
+ const trimmed = question.trim();
689
+ if (trimmed)
690
+ void ask(trimmed);
691
+ }, children: (0, jsx_runtime_1.jsxs)("div", { className: "cairn-input-row", children: [(0, jsx_runtime_1.jsx)("input", { value: question, onChange: (e) => setQuestion(e.target.value), placeholder: "What do you need help with?", "aria-label": `Ask ${persona} a question`, disabled: recording || touring, autoFocus: true }), realtimeUrl && micSupported && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "cairn-icon-btn", "aria-label": "Start realtime conversation", onClick: () => void startRealtime(), disabled: busy || recording, children: (0, jsx_runtime_1.jsx)(lucide_react_1.PhoneCall, { size: 16 }) })), transcribeEndpoint && micSupported && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: recording ? "cairn-icon-btn cairn-icon-btn-recording" : "cairn-icon-btn", "aria-label": recording ? "Stop recording" : "Ask by voice", onClick: () => (recording ? stopRecording() : void startRecording()), disabled: touring, children: recording ? (0, jsx_runtime_1.jsx)(lucide_react_1.Square, { size: 16 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Mic, { size: 16 }) })), (0, jsx_runtime_1.jsx)("button", { type: "submit", className: "cairn-send", "aria-label": "Send", disabled: !question.trim() || busy || recording, children: asking ? (0, jsx_runtime_1.jsx)(lucide_react_1.Loader2, { size: 16, className: "cairn-spin" }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Send, { size: 16 }) })] }) }))] }))] }));
692
+ }
693
+ const MAX_HISTORY_TURNS = 8; // 4 exchanges — matches the same cap the realtime relay uses server-side
694
+ /** Best-effort text form of a raw (unvalidated) verb response for the
695
+ * conversation-history log — not shown to the user, just fed back to the
696
+ * model on later turns. Deliberately loose/defensive rather than a full
697
+ * schema parse: a malformed field here just makes for a slightly less
698
+ * useful memory entry, never a UI action, so it doesn't need the strict
699
+ * validation executeVerbResponse already does for the real thing. */
700
+ function summarizeVerbForHistory(raw) {
701
+ if (!raw || typeof raw !== "object")
702
+ return "(no response)";
703
+ const v = raw;
704
+ if (typeof v.text === "string" && v.text)
705
+ return v.text;
706
+ switch (v.verb) {
707
+ case "highlight":
708
+ case "open":
709
+ return `(highlighted ${String(v.target)})`;
710
+ case "navigate":
711
+ return `(navigated to ${String(v.route)})`;
712
+ case "do":
713
+ return `(ran ${String(v.action)}${v.target ? ` on ${String(v.target)}` : ""})`;
714
+ case "tour":
715
+ return Array.isArray(v.steps) ? v.steps.map((s) => s.text ?? "").join(" ") : "(tour)";
716
+ default:
717
+ return "(no response)";
718
+ }
719
+ }
720
+ /**
721
+ * Renders text as a sequence of spans that light up in order — a caption
722
+ * "sweep" that reads like the agent is speaking it, whether or not audio is
723
+ * actually playing right now. This is a pacing *estimate* (staggered by
724
+ * word position, capped so long answers don't take forever), not synced to
725
+ * real TTS word timestamps — Deepgram's streaming API doesn't hand those to
726
+ * the client today, so a true audio-locked sync isn't wired up anywhere in
727
+ * this codebase yet.
728
+ */
729
+ function renderCaptionWords(text) {
730
+ const words = text.split(" ");
731
+ return words.map((word, i) => ((0, jsx_runtime_1.jsxs)("span", { className: "cairn-word", style: { animationDelay: `${Math.min(i * 55, 2800)}ms` }, children: [word, i < words.length - 1 ? " " : ""] }, i)));
732
+ }
733
+ function CairnMark() {
734
+ return ((0, jsx_runtime_1.jsxs)("svg", { width: "20", height: "20", viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", children: [(0, jsx_runtime_1.jsx)("rect", { x: "7", y: "12.5", width: "6", height: "2.6", rx: "0.5", fill: "currentColor" }), (0, jsx_runtime_1.jsx)("rect", { x: "4.5", y: "8.5", width: "11", height: "2.6", rx: "0.5", fill: "currentColor", opacity: "0.75" }), (0, jsx_runtime_1.jsx)("rect", { x: "8.2", y: "4.5", width: "3.6", height: "2.6", rx: "0.5", fill: "currentColor", opacity: "0.5" })] }));
735
+ }
736
+ // ---------------------------------------------------------------------------
737
+ // Audio helpers (real-time PCM16 capture — standard Web Audio API patterns)
738
+ // ---------------------------------------------------------------------------
739
+ // Heuristic energy gate for barge-in: real speech into a laptop/phone mic
740
+ // typically sits well above this; normal room noise and the mic's own
741
+ // noise floor typically sit below it. Not calibrated against real hardware
742
+ // in this environment (no live mic here) — reasonable starting point, may
743
+ // need tuning against a real device if it proves too trigger-happy or too
744
+ // insensitive in practice.
745
+ const BARGE_IN_RMS_THRESHOLD = 0.02;
746
+ function computeRms(channelData) {
747
+ let sumSquares = 0;
748
+ for (let i = 0; i < channelData.length; i++)
749
+ sumSquares += channelData[i] * channelData[i];
750
+ return Math.sqrt(sumSquares / channelData.length);
751
+ }
752
+ function downsampleTo16k(input, inputSampleRate) {
753
+ const targetRate = 16000;
754
+ if (inputSampleRate === targetRate)
755
+ return input;
756
+ const ratio = inputSampleRate / targetRate;
757
+ const outLength = Math.round(input.length / ratio);
758
+ const result = new Float32Array(outLength);
759
+ let offsetResult = 0;
760
+ let offsetInput = 0;
761
+ while (offsetResult < outLength) {
762
+ const nextOffsetInput = Math.round((offsetResult + 1) * ratio);
763
+ let accum = 0;
764
+ let count = 0;
765
+ for (let i = offsetInput; i < nextOffsetInput && i < input.length; i++) {
766
+ accum += input[i];
767
+ count++;
768
+ }
769
+ result[offsetResult] = count > 0 ? accum / count : 0;
770
+ offsetResult++;
771
+ offsetInput = nextOffsetInput;
772
+ }
773
+ return result;
774
+ }
775
+ function floatTo16BitPCM(input) {
776
+ const buffer = new ArrayBuffer(input.length * 2);
777
+ const view = new DataView(buffer);
778
+ for (let i = 0; i < input.length; i++) {
779
+ const s = Math.max(-1, Math.min(1, input[i]));
780
+ view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
781
+ }
782
+ return buffer;
783
+ }
784
+ const COPILOT_STYLES = `
785
+ @keyframes cairn-pulse {
786
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
787
+ 70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
788
+ }
789
+ @keyframes cairn-pulse-green {
790
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
791
+ 70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
792
+ }
793
+ @keyframes cairn-pulse-indigo {
794
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); }
795
+ 70% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); }
796
+ }
797
+ @keyframes cairn-spin {
798
+ from { transform: rotate(0deg); }
799
+ to { transform: rotate(360deg); }
800
+ }
801
+ @keyframes cairn-rt-dot {
802
+ 0%, 100% { opacity: 0.5; transform: scale(0.85); }
803
+ 50% { opacity: 1; transform: scale(1.15); }
804
+ }
805
+ @keyframes cairn-bubble-in {
806
+ from { opacity: 0; transform: translateY(6px); }
807
+ to { opacity: 1; transform: translateY(0); }
808
+ }
809
+ @keyframes cairn-word-sweep {
810
+ 0% { opacity: 0.35; text-shadow: none; }
811
+ 35% { opacity: 1; color: #4f46e5; text-shadow: 0 0 10px rgba(99, 102, 241, 0.45); }
812
+ 100% { opacity: 1; color: inherit; text-shadow: none; }
813
+ }
814
+ @keyframes cairn-thinking-bounce {
815
+ 0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
816
+ 40% { opacity: 0.9; transform: translateY(-3px); }
817
+ }
818
+ .cairn-glow {
819
+ animation: cairn-pulse-indigo 1.1s ease-out 2;
820
+ outline: 2px solid #6366f1;
821
+ outline-offset: 3px;
822
+ border-radius: 8px;
823
+ }
824
+ .cairn-spin {
825
+ animation: cairn-spin 0.8s linear infinite;
826
+ }
827
+ @media (prefers-reduced-motion: reduce) {
828
+ .cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot {
829
+ animation: none !important;
830
+ transition: none !important;
831
+ }
832
+ }
833
+
834
+ .cairn-fab {
835
+ position: fixed;
836
+ right: 20px;
837
+ bottom: 20px;
838
+ z-index: 2147483000;
839
+ width: 52px;
840
+ height: 52px;
841
+ border-radius: 999px;
842
+ border: none;
843
+ display: flex;
844
+ align-items: center;
845
+ justify-content: center;
846
+ background: #14151b;
847
+ color: white;
848
+ cursor: pointer;
849
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
850
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
851
+ }
852
+ .cairn-fab:hover {
853
+ transform: translateY(-1px);
854
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
855
+ }
856
+ .cairn-fab-speaking {
857
+ box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.22), 0 6px 20px rgba(0, 0, 0, 0.25);
858
+ animation: cairn-pulse-green 1.2s ease-out infinite;
859
+ }
860
+
861
+ /* One unified card — title, conversation, and input all live inside the
862
+ same bounded, padded container instead of floating as independent
863
+ fixed-position pieces. That "everything floats separately" approach
864
+ kept producing new collisions (title vs input, send button vs the
865
+ close FAB) every time one piece's position changed; grouping them
866
+ under one panel with real internal spacing removes that whole class
867
+ of bug at the source. */
868
+ .cairn-panel {
869
+ position: fixed;
870
+ right: 20px;
871
+ bottom: 92px;
872
+ z-index: 2147483000;
873
+ width: min(340px, calc(100vw - 40px));
874
+ max-height: 480px;
875
+ overflow-y: auto;
876
+ overflow-x: hidden;
877
+ display: flex;
878
+ flex-direction: column;
879
+ gap: 14px;
880
+ padding: 18px;
881
+ background: rgba(255, 255, 255, 0.96);
882
+ -webkit-backdrop-filter: blur(24px) saturate(160%);
883
+ backdrop-filter: blur(24px) saturate(160%);
884
+ border-radius: 20px;
885
+ box-shadow: 0 20px 50px rgba(15, 15, 25, 0.16), 0 2px 8px rgba(15, 15, 25, 0.06);
886
+ font: 13.5px/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", sans-serif;
887
+ animation: cairn-panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
888
+ }
889
+ @keyframes cairn-panel-in {
890
+ from { opacity: 0; transform: translateY(8px) scale(0.98); }
891
+ to { opacity: 1; transform: translateY(0) scale(1); }
892
+ }
893
+ .cairn-panel::-webkit-scrollbar {
894
+ width: 0;
895
+ }
896
+
897
+ .cairn-stack {
898
+ display: flex;
899
+ flex-direction: column;
900
+ gap: 10px;
901
+ }
902
+ .cairn-bubble {
903
+ max-width: 92%;
904
+ font-size: 13.5px;
905
+ line-height: 1.5;
906
+ color: #0b0d12;
907
+ animation: cairn-bubble-in 0.2s ease-out;
908
+ }
909
+ .cairn-bubble-user {
910
+ align-self: flex-end;
911
+ text-align: right;
912
+ color: #33384a;
913
+ }
914
+ .cairn-bubble-agent {
915
+ align-self: flex-start;
916
+ display: flex;
917
+ flex-direction: column;
918
+ gap: 4px;
919
+ }
920
+ .cairn-bubble-text {
921
+ white-space: pre-wrap;
922
+ }
923
+ .cairn-word {
924
+ display: inline-block;
925
+ animation: cairn-word-sweep 0.4s ease forwards;
926
+ }
927
+ .cairn-chip {
928
+ align-self: flex-start;
929
+ font-size: 10.5px;
930
+ font-weight: 700;
931
+ letter-spacing: 0.07em;
932
+ text-transform: uppercase;
933
+ color: rgba(11, 13, 18, 0.48);
934
+ }
935
+ .cairn-thinking {
936
+ display: inline-flex;
937
+ gap: 4px;
938
+ padding: 2px 0;
939
+ }
940
+ .cairn-thinking-dot {
941
+ width: 5px;
942
+ height: 5px;
943
+ border-radius: 999px;
944
+ background: rgba(11, 13, 18, 0.4);
945
+ animation: cairn-thinking-bounce 1.1s ease-in-out infinite;
946
+ }
947
+ .cairn-thinking-dot:nth-child(2) { animation-delay: 0.15s; }
948
+ .cairn-thinking-dot:nth-child(3) { animation-delay: 0.3s; }
949
+
950
+ .cairn-input-row {
951
+ display: flex;
952
+ gap: 7px;
953
+ align-items: center;
954
+ }
955
+ .cairn-input-row input {
956
+ flex: 1;
957
+ min-width: 0;
958
+ box-sizing: border-box;
959
+ padding: 10px 14px;
960
+ border: none;
961
+ border-radius: 999px;
962
+ font: inherit;
963
+ background: rgba(11, 13, 18, 0.045);
964
+ color: #0b0d12;
965
+ transition: background 0.15s ease, box-shadow 0.15s ease;
966
+ }
967
+ .cairn-input-row input::placeholder {
968
+ color: rgba(11, 13, 18, 0.4);
969
+ }
970
+ .cairn-input-row input:disabled {
971
+ opacity: 0.55;
972
+ }
973
+ .cairn-input-row input:focus {
974
+ outline: none;
975
+ background: rgba(11, 13, 18, 0.06);
976
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.16);
977
+ }
978
+ .cairn-icon-btn {
979
+ flex-shrink: 0;
980
+ width: 36px;
981
+ height: 36px;
982
+ display: flex;
983
+ align-items: center;
984
+ justify-content: center;
985
+ border-radius: 999px;
986
+ border: none;
987
+ background: rgba(11, 13, 18, 0.045);
988
+ color: #33384a;
989
+ cursor: pointer;
990
+ transition: background 0.15s ease, transform 0.15s ease;
991
+ }
992
+ .cairn-icon-btn:hover {
993
+ background: rgba(11, 13, 18, 0.09);
994
+ transform: translateY(-1px);
995
+ }
996
+ .cairn-icon-btn-recording {
997
+ background: #ef4444;
998
+ border-color: #ef4444;
999
+ color: white;
1000
+ animation: cairn-pulse 1.4s ease-out infinite;
1001
+ }
1002
+ .cairn-icon-btn-speaking {
1003
+ background: #10b981;
1004
+ border-color: #10b981;
1005
+ color: white;
1006
+ animation: cairn-pulse-green 1.2s ease-out infinite;
1007
+ }
1008
+ .cairn-icon-btn-end {
1009
+ background: #ef4444;
1010
+ border-color: #ef4444;
1011
+ color: white;
1012
+ }
1013
+ .cairn-send {
1014
+ flex-shrink: 0;
1015
+ width: 36px;
1016
+ height: 36px;
1017
+ display: flex;
1018
+ align-items: center;
1019
+ justify-content: center;
1020
+ border-radius: 999px;
1021
+ border: none;
1022
+ background: #14151b;
1023
+ color: white;
1024
+ cursor: pointer;
1025
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
1026
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
1027
+ }
1028
+ .cairn-send:hover:not(:disabled) {
1029
+ transform: translateY(-1px);
1030
+ }
1031
+ .cairn-send:disabled {
1032
+ background: rgba(11, 13, 18, 0.12);
1033
+ color: rgba(11, 13, 18, 0.35);
1034
+ box-shadow: none;
1035
+ cursor: not-allowed;
1036
+ }
1037
+
1038
+ .cairn-rt-bar {
1039
+ display: flex;
1040
+ align-items: center;
1041
+ gap: 8px;
1042
+ padding: 8px 12px;
1043
+ border-radius: 999px;
1044
+ background: rgba(11, 13, 18, 0.045);
1045
+ }
1046
+ .cairn-rt-dot {
1047
+ width: 8px;
1048
+ height: 8px;
1049
+ border-radius: 999px;
1050
+ background: #6366f1;
1051
+ animation: cairn-rt-dot 1.2s ease-in-out infinite;
1052
+ flex-shrink: 0;
1053
+ }
1054
+ .cairn-rt-dot-rt-speaking {
1055
+ background: #10b981;
1056
+ }
1057
+ .cairn-rt-dot-rt-thinking {
1058
+ background: #f59e0b;
1059
+ }
1060
+ .cairn-rt-label {
1061
+ flex: 1;
1062
+ font-size: 12.5px;
1063
+ color: #33384a;
1064
+ }
1065
+ .cairn-rt-controls {
1066
+ display: flex;
1067
+ gap: 6px;
1068
+ }
1069
+ `;