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