@cairnvibe/sdk 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cairn-widget.js +2 -2
- package/dist/element-ladder.d.ts +1 -1
- package/dist/element-ladder.js +10 -3
- package/dist/index.js +157 -25
- package/dist/realtime-server.d.ts +20 -2
- package/dist/realtime-server.js +107 -18
- package/dist/runtime-scan.d.ts +36 -0
- package/dist/runtime-scan.js +113 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.js +95 -40
- package/dist/verb-executor.d.ts +7 -0
- package/dist/verb-executor.js +66 -6
- package/package.json +1 -1
- package/src/element-ladder.ts +10 -3
- package/src/index.tsx +178 -31
- package/src/realtime-server.ts +124 -21
- package/src/runtime-scan.ts +141 -0
- package/src/server.ts +98 -41
- package/src/verb-executor.ts +72 -6
package/src/index.tsx
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { usePathname, useRouter } from "next/navigation";
|
|
5
5
|
import {
|
|
6
|
+
ChevronDown,
|
|
7
|
+
ChevronUp,
|
|
6
8
|
Loader2,
|
|
7
9
|
Mic,
|
|
8
10
|
MicOff,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
import type { HistoryTurn as HistoryEntry, TourStep } from "@cairnvibe/core";
|
|
18
20
|
import { collectVisible } from "./context-collector";
|
|
19
21
|
import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
|
|
22
|
+
import { createLiveElementRegistry } from "./runtime-scan";
|
|
20
23
|
import { executeVerbResponse } from "./verb-executor";
|
|
21
24
|
|
|
22
25
|
export interface CopilotProps {
|
|
@@ -63,8 +66,24 @@ export function Copilot({
|
|
|
63
66
|
persona = "Cairn",
|
|
64
67
|
}: CopilotProps) {
|
|
65
68
|
const pathname = usePathname() ?? "/";
|
|
69
|
+
// Mirrors `pathname` for use inside long-lived closures (a realtime
|
|
70
|
+
// session's handlers are all created once, when the connection opens —
|
|
71
|
+
// same staleness reason runTour tracks its own `currentRoute` locally
|
|
72
|
+
// rather than trusting its closure's `pathname` after a mid-tour
|
|
73
|
+
// navigation).
|
|
74
|
+
const pathnameRef = useRef(pathname);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
pathnameRef.current = pathname;
|
|
77
|
+
sendFreshContext(); // no-op if no realtime session is open
|
|
78
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
79
|
+
}, [pathname]);
|
|
66
80
|
const router = useRouter();
|
|
67
81
|
const [open, setOpen] = useState(false);
|
|
82
|
+
// Collapsed by default so the panel only ever shows the current exchange
|
|
83
|
+
// — the full archived transcript (built up over a long conversation)
|
|
84
|
+
// stays out of the way behind an explicit toggle instead of always being
|
|
85
|
+
// visible inline, which made the panel grow uncomfortably tall.
|
|
86
|
+
const [historyExpanded, setHistoryExpanded] = useState(false);
|
|
68
87
|
const [question, setQuestion] = useState("");
|
|
69
88
|
const [answer, setAnswer] = useState<string | null>(null);
|
|
70
89
|
const [status, setStatus] = useState<Status>("idle");
|
|
@@ -92,16 +111,19 @@ export function Copilot({
|
|
|
92
111
|
const [rtMicMuted, setRtMicMuted] = useState(false);
|
|
93
112
|
const [rtSpeakerMuted, setRtSpeakerMuted] = useState(false);
|
|
94
113
|
// Set while a "tour" verb's steps are being narrated/highlighted one at a
|
|
95
|
-
// time — drives the step-progress caption and blocks the input
|
|
96
|
-
// typed
|
|
114
|
+
// time — drives the step-progress caption and blocks the *typed* input so
|
|
115
|
+
// a typed question can't collide with the walkthrough (a voice
|
|
116
|
+
// interruption is handled separately — see touringRef/triggerBargeIn).
|
|
97
117
|
const [tourStep, setTourStep] = useState<{ index: number; total: number } | null>(null);
|
|
98
|
-
const tourGenerationRef = useRef(0); // bumped to cancel an in-progress tour (e.g. widget closed) without extra flags
|
|
118
|
+
const tourGenerationRef = useRef(0); // bumped to cancel an in-progress tour (e.g. widget closed, or a voice barge-in) without extra flags
|
|
99
119
|
// Mirrors whether a tour is running, for use inside the mic's
|
|
100
120
|
// onaudioprocess callback (a stale closure over React state there would
|
|
101
121
|
// miss a tour that started after the callback was created) — a tour
|
|
102
|
-
// reuses "rt-speaking" to hold the mic off
|
|
103
|
-
// barge-in-able
|
|
104
|
-
//
|
|
122
|
+
// reuses "rt-speaking" to hold the mic off between steps, but IS
|
|
123
|
+
// barge-in-able like a real conversational reply (see the RMS check
|
|
124
|
+
// below): interrupting mid-tour cancels the rest of the walkthrough,
|
|
125
|
+
// the way a real person giving a tour stops when you have a question
|
|
126
|
+
// instead of talking over you.
|
|
105
127
|
const touringRef = useRef(false);
|
|
106
128
|
// Resolver for "this tour step's audio has fully finished playing" when
|
|
107
129
|
// narrating over an already-open realtime session (see maybeResumeListening
|
|
@@ -115,6 +137,20 @@ export function Copilot({
|
|
|
115
137
|
// already stateful).
|
|
116
138
|
const historyRef = useRef<HistoryEntry[]>([]);
|
|
117
139
|
|
|
140
|
+
// A background scanner that keeps a live inventory of what's actually
|
|
141
|
+
// clickable on screen right now (runtime-scan.ts) — running continuously
|
|
142
|
+
// via a MutationObserver so there's never a pause to "go look at the
|
|
143
|
+
// page" right when a verb needs to click something. `liveMapRef` freezes
|
|
144
|
+
// one snapshot of it per turn (set alongside every context/question send,
|
|
145
|
+
// below) so a background rescan landing mid-flight can't shift what an id
|
|
146
|
+
// resolves to between when a request went out and its response came back.
|
|
147
|
+
const liveRegistryRef = useRef(createLiveElementRegistry());
|
|
148
|
+
const liveMapRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
liveRegistryRef.current.start();
|
|
151
|
+
return () => liveRegistryRef.current.stop();
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
118
154
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
119
155
|
const audioChunksRef = useRef<Blob[]>([]);
|
|
120
156
|
const transcribeInFlightRef = useRef(false);
|
|
@@ -210,6 +246,28 @@ export function Copilot({
|
|
|
210
246
|
setStatus(next);
|
|
211
247
|
}
|
|
212
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Refreshes the server's picture of route/visible/liveElements over an
|
|
251
|
+
* already-open realtime connection. Beyond the initial connect, called
|
|
252
|
+
* on every route change and whenever the mic is about to start listening
|
|
253
|
+
* again — a real, pre-existing gap this closes as a side effect: the
|
|
254
|
+
* server's context previously updated only once, at connection open, so
|
|
255
|
+
* navigating mid-call (via a "navigate" verb, or the user clicking
|
|
256
|
+
* around) left the server answering every later turn as if the user were
|
|
257
|
+
* still on the original page. Reads pathnameRef, not the closure's
|
|
258
|
+
* `pathname`, so it's correct even called from a handler created once at
|
|
259
|
+
* connection-open time.
|
|
260
|
+
*/
|
|
261
|
+
function sendFreshContext() {
|
|
262
|
+
const ws = rtSocketRef.current;
|
|
263
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
264
|
+
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
265
|
+
liveMapRef.current = liveScan.byId;
|
|
266
|
+
ws.send(
|
|
267
|
+
JSON.stringify({ type: "context", route: pathnameRef.current, visible: collectVisible(), liveElements: liveScan.elements }),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
213
271
|
function reportMiss(context: MissContext) {
|
|
214
272
|
logMiss(context);
|
|
215
273
|
if (reportMissesEndpoint) {
|
|
@@ -232,6 +290,7 @@ export function Copilot({
|
|
|
232
290
|
onDo,
|
|
233
291
|
onTour: (steps) => void runTour(steps),
|
|
234
292
|
registeredActions,
|
|
293
|
+
liveElements: liveMapRef.current,
|
|
235
294
|
});
|
|
236
295
|
}
|
|
237
296
|
|
|
@@ -242,12 +301,14 @@ export function Copilot({
|
|
|
242
301
|
* actually showing you around instead of one paragraph naming several
|
|
243
302
|
* buttons at once with nothing highlighted.
|
|
244
303
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
304
|
+
* During a live realtime session, narration reuses the same streaming
|
|
305
|
+
* Speak connection a normal conversational reply uses (see
|
|
306
|
+
* speakOverRealtime below) instead of a separate buffered REST call —
|
|
307
|
+
* otherwise falls back to speakEndpoint. Either way, the mic is held off
|
|
308
|
+
* between steps (mirrors "rt-speaking") so it doesn't pick up the tour's
|
|
309
|
+
* own narration — but it's still listening for a real interruption:
|
|
310
|
+
* talking during a step cancels the rest of the tour via triggerBargeIn,
|
|
311
|
+
* the same as interrupting a normal spoken reply.
|
|
251
312
|
*/
|
|
252
313
|
async function runTour(steps: TourStep[]) {
|
|
253
314
|
const myGeneration = ++tourGenerationRef.current;
|
|
@@ -292,9 +353,23 @@ export function Copilot({
|
|
|
292
353
|
}
|
|
293
354
|
|
|
294
355
|
if (step.target) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
356
|
+
// A fresh scan, not the tour's starting liveMapRef snapshot — a
|
|
357
|
+
// step after a mid-tour navigation targets elements on a page
|
|
358
|
+
// that didn't exist when the tour began.
|
|
359
|
+
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
360
|
+
const el = findElement(step.target, liveScan.byId);
|
|
361
|
+
if (el) {
|
|
362
|
+
highlightElement(el);
|
|
363
|
+
if (step.click) {
|
|
364
|
+
el.click();
|
|
365
|
+
// Give whatever the click reveals (a detail view, an expanded
|
|
366
|
+
// row) a moment to actually render before narrating it.
|
|
367
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
368
|
+
if (tourGenerationRef.current !== myGeneration) return;
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
reportMiss({ attempted: step.target, route: currentRoute });
|
|
372
|
+
}
|
|
298
373
|
}
|
|
299
374
|
|
|
300
375
|
if (wasRealtimeListening && rtSocketRef.current?.readyState === WebSocket.OPEN) {
|
|
@@ -332,10 +407,18 @@ export function Copilot({
|
|
|
332
407
|
setLastQuestion(q);
|
|
333
408
|
setQuestion("");
|
|
334
409
|
try {
|
|
410
|
+
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
411
|
+
liveMapRef.current = liveScan.byId;
|
|
335
412
|
const res = await fetch(endpoint, {
|
|
336
413
|
method: "POST",
|
|
337
414
|
headers: { "content-type": "application/json" },
|
|
338
|
-
body: JSON.stringify({
|
|
415
|
+
body: JSON.stringify({
|
|
416
|
+
route: pathname,
|
|
417
|
+
question: q,
|
|
418
|
+
visible: collectVisible(),
|
|
419
|
+
history: historyRef.current,
|
|
420
|
+
liveElements: liveScan.elements,
|
|
421
|
+
}),
|
|
339
422
|
});
|
|
340
423
|
const data = await res.json().catch(() => null);
|
|
341
424
|
handleVerb(data);
|
|
@@ -550,12 +633,20 @@ export function Copilot({
|
|
|
550
633
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
551
634
|
if (rtMicMutedRef.current) return;
|
|
552
635
|
|
|
553
|
-
// Barge-in: while the agent is speaking a real conversational
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
636
|
+
// Barge-in: while the agent is speaking a real conversational reply,
|
|
637
|
+
// still thinking about one, OR mid-tour, keep listening to the mic
|
|
638
|
+
// locally even though it isn't being sent yet, and cut the agent
|
|
639
|
+
// off the instant the user starts talking again instead of making
|
|
640
|
+
// them wait — including during a guided tour, which now cancels the
|
|
641
|
+
// rest of the walkthrough on interruption (see triggerBargeIn)
|
|
642
|
+
// instead of being talked-over-proof by design, the way a real
|
|
643
|
+
// person giving a tour stops when you have a question. The
|
|
644
|
+
// "rt-thinking" half matters just as much as "rt-speaking": an LLM
|
|
645
|
+
// turn can easily take a couple of seconds with nothing playing
|
|
646
|
+
// yet, and without this the mic was completely deaf during that
|
|
647
|
+
// whole window — found live as "not listening while speaking... no
|
|
648
|
+
// interrupting system", not just a missed nice-to-have.
|
|
649
|
+
if (rtStateRef.current === "rt-speaking" || rtStateRef.current === "rt-thinking") {
|
|
559
650
|
const rms = computeRms(e.inputBuffer.getChannelData(0));
|
|
560
651
|
if (rms > BARGE_IN_RMS_THRESHOLD) triggerBargeIn();
|
|
561
652
|
return;
|
|
@@ -601,6 +692,7 @@ export function Copilot({
|
|
|
601
692
|
}
|
|
602
693
|
setRtStatus("rt-listening");
|
|
603
694
|
setCaption("");
|
|
695
|
+
sendFreshContext(); // refresh before the user starts talking again, not after
|
|
604
696
|
}
|
|
605
697
|
|
|
606
698
|
function disarmThinkingWatchdog() {
|
|
@@ -642,13 +734,26 @@ export function Copilot({
|
|
|
642
734
|
disarmThinkingWatchdog();
|
|
643
735
|
stopScheduledRtAudio();
|
|
644
736
|
rtAudioDoneArrivingRef.current = true;
|
|
737
|
+
if (touringRef.current) {
|
|
738
|
+
// Interrupting mid-guide cancels the whole rest of the tour, not
|
|
739
|
+
// just the current step — the way a real person giving a tour
|
|
740
|
+
// stops and answers your question instead of continuing to talk
|
|
741
|
+
// over you. Without resolving the current step's own pending
|
|
742
|
+
// promise here, runTour only notices the cancellation via its own
|
|
743
|
+
// 15s-per-step fallback timeout instead of right away.
|
|
744
|
+
tourGenerationRef.current++;
|
|
745
|
+
touringRef.current = false;
|
|
746
|
+
setTourStep(null);
|
|
747
|
+
rtTourAudioDoneRef.current?.();
|
|
748
|
+
rtTourAudioDoneRef.current = null;
|
|
749
|
+
}
|
|
645
750
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "barge_in" }));
|
|
646
751
|
setRtStatus("rt-listening");
|
|
647
752
|
setCaption("");
|
|
648
753
|
}
|
|
649
754
|
|
|
650
755
|
ws.onopen = () => {
|
|
651
|
-
|
|
756
|
+
sendFreshContext();
|
|
652
757
|
setRtStatus("rt-listening");
|
|
653
758
|
rtStartingRef.current = false;
|
|
654
759
|
};
|
|
@@ -718,7 +823,17 @@ export function Copilot({
|
|
|
718
823
|
// exactly the way a silently-dropped response used to leave it.
|
|
719
824
|
disarmThinkingWatchdog();
|
|
720
825
|
setAnswer(msg.message ?? "Something went wrong.");
|
|
721
|
-
if (
|
|
826
|
+
if (touringRef.current) {
|
|
827
|
+
// A tour step's own speakStreamed() failed server-side (see
|
|
828
|
+
// realtime-server.ts's "speak" handler). Without resolving this
|
|
829
|
+
// step's pending promise here, runTour's `await
|
|
830
|
+
// speakOverRealtime(step.text)` only recovers via its own 15s
|
|
831
|
+
// fallback timeout — found live as a guide that goes badly
|
|
832
|
+
// quiet for long stretches, one step at a time.
|
|
833
|
+
rtAudioDoneArrivingRef.current = true;
|
|
834
|
+
rtTourAudioDoneRef.current?.();
|
|
835
|
+
rtTourAudioDoneRef.current = null;
|
|
836
|
+
} else {
|
|
722
837
|
setRtStatus("rt-listening");
|
|
723
838
|
setCaption("");
|
|
724
839
|
}
|
|
@@ -807,14 +922,26 @@ export function Copilot({
|
|
|
807
922
|
|
|
808
923
|
{(transcript.length > 0 || userCaption || answer || busy) && (
|
|
809
924
|
<div className="cairn-stack">
|
|
810
|
-
{transcript.
|
|
811
|
-
<
|
|
812
|
-
|
|
813
|
-
|
|
925
|
+
{transcript.length > 0 && (
|
|
926
|
+
<button
|
|
927
|
+
type="button"
|
|
928
|
+
className="cairn-history-toggle"
|
|
929
|
+
onClick={() => setHistoryExpanded((v) => !v)}
|
|
930
|
+
aria-expanded={historyExpanded}
|
|
814
931
|
>
|
|
815
|
-
{
|
|
816
|
-
|
|
817
|
-
|
|
932
|
+
{historyExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
933
|
+
{historyExpanded ? "Hide earlier" : `${transcript.length} earlier`}
|
|
934
|
+
</button>
|
|
935
|
+
)}
|
|
936
|
+
{historyExpanded &&
|
|
937
|
+
transcript.map((entry) => (
|
|
938
|
+
<div
|
|
939
|
+
className={entry.role === "user" ? "cairn-bubble cairn-bubble-user cairn-bubble-past" : "cairn-bubble cairn-bubble-agent cairn-bubble-past"}
|
|
940
|
+
key={entry.id}
|
|
941
|
+
>
|
|
942
|
+
{entry.role === "agent" ? <span className="cairn-bubble-text">{entry.text}</span> : entry.text}
|
|
943
|
+
</div>
|
|
944
|
+
))}
|
|
818
945
|
{userCaption && (
|
|
819
946
|
<div className="cairn-bubble cairn-bubble-user" key={`u-${userCaption}`}>
|
|
820
947
|
{userCaption}
|
|
@@ -1184,6 +1311,26 @@ const COPILOT_STYLES = `
|
|
|
1184
1311
|
text-transform: uppercase;
|
|
1185
1312
|
color: rgba(11, 13, 18, 0.48);
|
|
1186
1313
|
}
|
|
1314
|
+
.cairn-history-toggle {
|
|
1315
|
+
align-self: center;
|
|
1316
|
+
display: inline-flex;
|
|
1317
|
+
align-items: center;
|
|
1318
|
+
gap: 3px;
|
|
1319
|
+
border: none;
|
|
1320
|
+
background: none;
|
|
1321
|
+
padding: 2px 8px;
|
|
1322
|
+
font: inherit;
|
|
1323
|
+
font-size: 11px;
|
|
1324
|
+
font-weight: 600;
|
|
1325
|
+
color: rgba(11, 13, 18, 0.4);
|
|
1326
|
+
cursor: pointer;
|
|
1327
|
+
border-radius: 999px;
|
|
1328
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
1329
|
+
}
|
|
1330
|
+
.cairn-history-toggle:hover {
|
|
1331
|
+
background: rgba(11, 13, 18, 0.05);
|
|
1332
|
+
color: rgba(11, 13, 18, 0.6);
|
|
1333
|
+
}
|
|
1187
1334
|
.cairn-thinking {
|
|
1188
1335
|
display: inline-flex;
|
|
1189
1336
|
gap: 4px;
|
package/src/realtime-server.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import http from "node:http";
|
|
29
29
|
import { WebSocket, WebSocketServer } from "ws";
|
|
30
|
-
import type { HistoryTurn, Manifest, VerbResponse } from "@cairnvibe/core";
|
|
30
|
+
import type { HistoryTurn, LiveElement, Manifest, VerbResponse } from "@cairnvibe/core";
|
|
31
31
|
import { buildSystemPrompt, createVerbLLM, resolveVerb, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
|
|
32
32
|
import { DeepgramSpeakStream } from "./tts-stream";
|
|
33
33
|
|
|
@@ -95,7 +95,7 @@ export function createRealtimeServer(options: CreateRealtimeServerOptions): http
|
|
|
95
95
|
return httpServer;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
interface ConnectionDeps {
|
|
98
|
+
export interface ConnectionDeps {
|
|
99
99
|
deepgramApiKey: string;
|
|
100
100
|
sttModel: string;
|
|
101
101
|
ttsVoice: string;
|
|
@@ -109,7 +109,10 @@ interface ConnectionDeps {
|
|
|
109
109
|
const MAX_HISTORY_TURNS = 8; // 4 exchanges — enough for "the first one"/"do that instead" without growing the prompt unbounded over a long call
|
|
110
110
|
|
|
111
111
|
async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promise<void> {
|
|
112
|
-
|
|
112
|
+
// liveElements refreshes on every "context" resend (the client sends one
|
|
113
|
+
// on route changes and each time it's about to start listening again),
|
|
114
|
+
// so a live scan from several turns ago never lingers into a later one.
|
|
115
|
+
let context: { route: string; visible: string[]; liveElements: LiveElement[] } = { route: "/", visible: [], liveElements: [] };
|
|
113
116
|
// Unlike the stateless HTTP path (which needs the client to resend
|
|
114
117
|
// history every request), a realtime connection is already stateful —
|
|
115
118
|
// one WebSocket per call — so this is accumulated here directly rather
|
|
@@ -202,8 +205,13 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
202
205
|
});
|
|
203
206
|
}
|
|
204
207
|
|
|
208
|
+
// Accumulates Deepgram "Results" transcript segments across one utterance
|
|
209
|
+
// — see handleDeepgramMessage for why this can't just react to every
|
|
210
|
+
// is_final.
|
|
211
|
+
const turnState = { buffer: "" };
|
|
212
|
+
|
|
205
213
|
dg.on("message", (data) => {
|
|
206
|
-
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history);
|
|
214
|
+
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
|
|
207
215
|
});
|
|
208
216
|
|
|
209
217
|
dg.on("error", (err) => {
|
|
@@ -221,7 +229,11 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
221
229
|
try {
|
|
222
230
|
const msg = JSON.parse(data.toString());
|
|
223
231
|
if (msg.type === "context") {
|
|
224
|
-
context = {
|
|
232
|
+
context = {
|
|
233
|
+
route: String(msg.route ?? "/"),
|
|
234
|
+
visible: Array.isArray(msg.visible) ? msg.visible : [],
|
|
235
|
+
liveElements: parseLiveElements(msg.liveElements),
|
|
236
|
+
};
|
|
225
237
|
} else if (msg.type === "end") {
|
|
226
238
|
client.close();
|
|
227
239
|
} else if (msg.type === "barge_in") {
|
|
@@ -233,7 +245,20 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
233
245
|
// client falling back to a separate buffered REST call. No STT/verb
|
|
234
246
|
// resolution involved; the client already resolved the tour steps
|
|
235
247
|
// itself and just needs this text spoken.
|
|
236
|
-
|
|
248
|
+
//
|
|
249
|
+
// Caught explicitly, unlike a normal turn's speakStreamed call (see
|
|
250
|
+
// handleDeepgramMessage) — this one isn't inside that function's own
|
|
251
|
+
// try/catch, and an uncaught rejection here previously vanished
|
|
252
|
+
// silently: the client's speakOverRealtime() promise for this step
|
|
253
|
+
// never resolves except via its own 15s fallback timeout, with
|
|
254
|
+
// nothing telling the user anything went wrong in the meantime —
|
|
255
|
+
// found live as a tour that goes badly quiet for stretches at a
|
|
256
|
+
// time. A real "error" message lets the client's tour-step handler
|
|
257
|
+
// (index.tsx's ws.onmessage) unstick itself immediately instead.
|
|
258
|
+
speakStreamed(msg.text).catch((err) => {
|
|
259
|
+
console.error("[cairn realtime] speakStreamed failed for a tour step:", err);
|
|
260
|
+
safeSend(client, { type: "error", message: "Something went wrong narrating that step." });
|
|
261
|
+
});
|
|
237
262
|
}
|
|
238
263
|
} catch {
|
|
239
264
|
// Ignore malformed control messages — never crash the relay on bad client input.
|
|
@@ -250,13 +275,15 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
250
275
|
});
|
|
251
276
|
}
|
|
252
277
|
|
|
253
|
-
async function handleDeepgramMessage(
|
|
278
|
+
export async function handleDeepgramMessage(
|
|
254
279
|
raw: string,
|
|
255
280
|
client: WebSocket,
|
|
256
281
|
deps: ConnectionDeps,
|
|
257
|
-
getContext: () => { route: string; visible: string[] },
|
|
282
|
+
getContext: () => { route: string; visible: string[]; liveElements: LiveElement[] },
|
|
258
283
|
speakStreamed: (text: string) => Promise<void>,
|
|
259
284
|
history: HistoryTurn[],
|
|
285
|
+
turnState: { buffer: string },
|
|
286
|
+
getGeneration: () => number,
|
|
260
287
|
): Promise<void> {
|
|
261
288
|
let msg: any;
|
|
262
289
|
try {
|
|
@@ -265,34 +292,86 @@ async function handleDeepgramMessage(
|
|
|
265
292
|
return;
|
|
266
293
|
}
|
|
267
294
|
|
|
295
|
+
if (msg.type === "UtteranceEnd") {
|
|
296
|
+
// A second, independent "the user is truly done" signal Deepgram sends
|
|
297
|
+
// after utterance_end_ms of silence — a safety net for the rare case a
|
|
298
|
+
// Results message never carries speech_final:true, so a turn can't get
|
|
299
|
+
// permanently stuck with real transcript sitting in the buffer forever.
|
|
300
|
+
if (turnState.buffer) await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
268
304
|
if (msg.type !== "Results") return;
|
|
269
305
|
const transcript: string | undefined = msg.channel?.alternatives?.[0]?.transcript;
|
|
270
306
|
if (!transcript) return;
|
|
271
307
|
|
|
272
308
|
if (!msg.is_final) {
|
|
273
|
-
safeSend(client, { type: "interim", text: transcript });
|
|
309
|
+
safeSend(client, { type: "interim", text: turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript });
|
|
274
310
|
return;
|
|
275
311
|
}
|
|
276
312
|
|
|
313
|
+
// is_final means this chunk of transcript is stable and won't be
|
|
314
|
+
// revised — it does NOT mean the user is done talking. Deepgram can (and
|
|
315
|
+
// routinely does) finalize several chunks of one continuous utterance in
|
|
316
|
+
// a row with no real pause between them. Only speech_final (endpointing
|
|
317
|
+
// actually detected a pause) means the turn is genuinely over. Found
|
|
318
|
+
// live, not theoretical: treating every is_final as a separate finished
|
|
319
|
+
// question fired two independent LLM+TTS turns for one utterance — the
|
|
320
|
+
// literal cause of both the duplicated transcript entries ("hello" /
|
|
321
|
+
// "hello" with no reply in between) and the agent audibly speaking
|
|
322
|
+
// twice, overlapping.
|
|
323
|
+
turnState.buffer = turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript;
|
|
324
|
+
if (!msg.speech_final) {
|
|
325
|
+
safeSend(client, { type: "interim", text: turnState.buffer });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Everything from here on (the LLM call, TTS streaming) can fail in ways
|
|
334
|
+
* that have nothing to do with a malformed message — a flaky provider
|
|
335
|
+
* call, a rate limit, a dropped upstream connection. handleDeepgramMessage
|
|
336
|
+
* is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
|
|
337
|
+
* uncaught throw here previously vanished into an unhandled rejection: the
|
|
338
|
+
* client had already been told "final" (entering its "thinking" state) and
|
|
339
|
+
* then simply never heard from the server again for this turn — stuck
|
|
340
|
+
* indefinitely with the mic never resuming. Every path out of the try
|
|
341
|
+
* block now sends the client something that ends the turn.
|
|
342
|
+
*
|
|
343
|
+
* myGeneration is captured before the (potentially slow) LLM call and
|
|
344
|
+
* re-checked before the verb/speech actually goes out — a barge-in that
|
|
345
|
+
* happens while this turn is still "thinking" bumps the generation, and
|
|
346
|
+
* without this check the now-stale response would still land on the
|
|
347
|
+
* client after the user had already moved on to a new question.
|
|
348
|
+
*/
|
|
349
|
+
async function finalizeTurn(
|
|
350
|
+
turnState: { buffer: string },
|
|
351
|
+
client: WebSocket,
|
|
352
|
+
deps: ConnectionDeps,
|
|
353
|
+
getContext: () => { route: string; visible: string[]; liveElements: LiveElement[] },
|
|
354
|
+
speakStreamed: (text: string) => Promise<void>,
|
|
355
|
+
history: HistoryTurn[],
|
|
356
|
+
getGeneration: () => number,
|
|
357
|
+
): Promise<void> {
|
|
358
|
+
const transcript = turnState.buffer;
|
|
359
|
+
turnState.buffer = "";
|
|
360
|
+
const myGeneration = getGeneration();
|
|
277
361
|
safeSend(client, { type: "final", text: transcript });
|
|
278
362
|
|
|
279
|
-
// Everything from here on (the LLM call, TTS streaming) can fail in ways
|
|
280
|
-
// that have nothing to do with a malformed message — a flaky provider
|
|
281
|
-
// call, a rate limit, a dropped upstream connection. This whole function
|
|
282
|
-
// is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
|
|
283
|
-
// uncaught throw here previously vanished into an unhandled rejection:
|
|
284
|
-
// the client had already been told "final" (entering its "thinking"
|
|
285
|
-
// state) and then simply never heard from the server again for this
|
|
286
|
-
// turn — stuck indefinitely with the mic never resuming. Every path out
|
|
287
|
-
// of this try block now sends the client something that ends the turn.
|
|
288
363
|
try {
|
|
289
|
-
const { route, visible } = getContext();
|
|
364
|
+
const { route, visible, liveElements } = getContext();
|
|
290
365
|
const verb = await resolveVerb(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
|
|
291
366
|
route,
|
|
292
367
|
question: transcript,
|
|
293
368
|
visible,
|
|
369
|
+
liveElements,
|
|
294
370
|
history,
|
|
295
371
|
});
|
|
372
|
+
|
|
373
|
+
if (myGeneration !== getGeneration()) return; // superseded by a barge-in while this turn was resolving
|
|
374
|
+
|
|
296
375
|
// Sent immediately — before speech synthesis even starts — so
|
|
297
376
|
// highlight/navigate/do execute in the browser right away instead of
|
|
298
377
|
// waiting on audio. The agent visibly acts while it's still about to
|
|
@@ -312,8 +391,10 @@ async function handleDeepgramMessage(
|
|
|
312
391
|
}
|
|
313
392
|
} catch (err) {
|
|
314
393
|
console.error("[cairn realtime] failed to resolve/speak this turn:", err);
|
|
315
|
-
|
|
316
|
-
|
|
394
|
+
if (myGeneration === getGeneration()) {
|
|
395
|
+
safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
|
|
396
|
+
safeSend(client, { type: "turn_complete" });
|
|
397
|
+
}
|
|
317
398
|
}
|
|
318
399
|
}
|
|
319
400
|
|
|
@@ -322,6 +403,28 @@ function safeSend(client: WebSocket, message: ServerMessage): void {
|
|
|
322
403
|
client.send(JSON.stringify(message));
|
|
323
404
|
}
|
|
324
405
|
|
|
406
|
+
/** Defensive parse for the client's self-reported live DOM scan — same
|
|
407
|
+
* untrusted-input treatment `visible` already gets on this control-message
|
|
408
|
+
* path (no CopilotRequestSchema here, unlike the HTTP handler), just
|
|
409
|
+
* shaped-checked so a malformed entry can't reach the LLM prompt oddly. */
|
|
410
|
+
function parseLiveElements(raw: unknown): LiveElement[] {
|
|
411
|
+
if (!Array.isArray(raw)) return [];
|
|
412
|
+
const elements: LiveElement[] = [];
|
|
413
|
+
for (const entry of raw) {
|
|
414
|
+
if (
|
|
415
|
+
entry &&
|
|
416
|
+
typeof entry === "object" &&
|
|
417
|
+
typeof (entry as any).id === "string" &&
|
|
418
|
+
typeof (entry as any).role === "string" &&
|
|
419
|
+
typeof (entry as any).label === "string"
|
|
420
|
+
) {
|
|
421
|
+
elements.push({ id: (entry as any).id, role: (entry as any).role, label: (entry as any).label });
|
|
422
|
+
}
|
|
423
|
+
if (elements.length >= 60) break;
|
|
424
|
+
}
|
|
425
|
+
return elements;
|
|
426
|
+
}
|
|
427
|
+
|
|
325
428
|
/** A short text form of any verb for the history log — not shown to the
|
|
326
429
|
* user, just fed back to the model on later turns so it knows what it
|
|
327
430
|
* already did/said. */
|