@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/dist/index.js
CHANGED
|
@@ -8,11 +8,28 @@ const navigation_1 = require("next/navigation");
|
|
|
8
8
|
const lucide_react_1 = require("lucide-react");
|
|
9
9
|
const context_collector_1 = require("./context-collector");
|
|
10
10
|
const element_ladder_1 = require("./element-ladder");
|
|
11
|
+
const runtime_scan_1 = require("./runtime-scan");
|
|
11
12
|
const verb_executor_1 = require("./verb-executor");
|
|
12
13
|
function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, reportMissesEndpoint, transcribeEndpoint, speakEndpoint, realtimeUrl, persona = "Cairn", }) {
|
|
13
14
|
const pathname = (0, navigation_1.usePathname)() ?? "/";
|
|
15
|
+
// Mirrors `pathname` for use inside long-lived closures (a realtime
|
|
16
|
+
// session's handlers are all created once, when the connection opens —
|
|
17
|
+
// same staleness reason runTour tracks its own `currentRoute` locally
|
|
18
|
+
// rather than trusting its closure's `pathname` after a mid-tour
|
|
19
|
+
// navigation).
|
|
20
|
+
const pathnameRef = (0, react_1.useRef)(pathname);
|
|
21
|
+
(0, react_1.useEffect)(() => {
|
|
22
|
+
pathnameRef.current = pathname;
|
|
23
|
+
sendFreshContext(); // no-op if no realtime session is open
|
|
24
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
|
+
}, [pathname]);
|
|
14
26
|
const router = (0, navigation_1.useRouter)();
|
|
15
27
|
const [open, setOpen] = (0, react_1.useState)(false);
|
|
28
|
+
// Collapsed by default so the panel only ever shows the current exchange
|
|
29
|
+
// — the full archived transcript (built up over a long conversation)
|
|
30
|
+
// stays out of the way behind an explicit toggle instead of always being
|
|
31
|
+
// visible inline, which made the panel grow uncomfortably tall.
|
|
32
|
+
const [historyExpanded, setHistoryExpanded] = (0, react_1.useState)(false);
|
|
16
33
|
const [question, setQuestion] = (0, react_1.useState)("");
|
|
17
34
|
const [answer, setAnswer] = (0, react_1.useState)(null);
|
|
18
35
|
const [status, setStatus] = (0, react_1.useState)("idle");
|
|
@@ -40,16 +57,19 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
40
57
|
const [rtMicMuted, setRtMicMuted] = (0, react_1.useState)(false);
|
|
41
58
|
const [rtSpeakerMuted, setRtSpeakerMuted] = (0, react_1.useState)(false);
|
|
42
59
|
// Set while a "tour" verb's steps are being narrated/highlighted one at a
|
|
43
|
-
// time — drives the step-progress caption and blocks the input
|
|
44
|
-
// typed
|
|
60
|
+
// time — drives the step-progress caption and blocks the *typed* input so
|
|
61
|
+
// a typed question can't collide with the walkthrough (a voice
|
|
62
|
+
// interruption is handled separately — see touringRef/triggerBargeIn).
|
|
45
63
|
const [tourStep, setTourStep] = (0, react_1.useState)(null);
|
|
46
|
-
const tourGenerationRef = (0, react_1.useRef)(0); // bumped to cancel an in-progress tour (e.g. widget closed) without extra flags
|
|
64
|
+
const tourGenerationRef = (0, react_1.useRef)(0); // bumped to cancel an in-progress tour (e.g. widget closed, or a voice barge-in) without extra flags
|
|
47
65
|
// Mirrors whether a tour is running, for use inside the mic's
|
|
48
66
|
// onaudioprocess callback (a stale closure over React state there would
|
|
49
67
|
// miss a tour that started after the callback was created) — a tour
|
|
50
|
-
// reuses "rt-speaking" to hold the mic off
|
|
51
|
-
// barge-in-able
|
|
52
|
-
//
|
|
68
|
+
// reuses "rt-speaking" to hold the mic off between steps, but IS
|
|
69
|
+
// barge-in-able like a real conversational reply (see the RMS check
|
|
70
|
+
// below): interrupting mid-tour cancels the rest of the walkthrough,
|
|
71
|
+
// the way a real person giving a tour stops when you have a question
|
|
72
|
+
// instead of talking over you.
|
|
53
73
|
const touringRef = (0, react_1.useRef)(false);
|
|
54
74
|
// Resolver for "this tour step's audio has fully finished playing" when
|
|
55
75
|
// narrating over an already-open realtime session (see maybeResumeListening
|
|
@@ -62,6 +82,19 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
62
82
|
// keeps its own history server-side instead, since that connection is
|
|
63
83
|
// already stateful).
|
|
64
84
|
const historyRef = (0, react_1.useRef)([]);
|
|
85
|
+
// A background scanner that keeps a live inventory of what's actually
|
|
86
|
+
// clickable on screen right now (runtime-scan.ts) — running continuously
|
|
87
|
+
// via a MutationObserver so there's never a pause to "go look at the
|
|
88
|
+
// page" right when a verb needs to click something. `liveMapRef` freezes
|
|
89
|
+
// one snapshot of it per turn (set alongside every context/question send,
|
|
90
|
+
// below) so a background rescan landing mid-flight can't shift what an id
|
|
91
|
+
// resolves to between when a request went out and its response came back.
|
|
92
|
+
const liveRegistryRef = (0, react_1.useRef)((0, runtime_scan_1.createLiveElementRegistry)());
|
|
93
|
+
const liveMapRef = (0, react_1.useRef)(new Map());
|
|
94
|
+
(0, react_1.useEffect)(() => {
|
|
95
|
+
liveRegistryRef.current.start();
|
|
96
|
+
return () => liveRegistryRef.current.stop();
|
|
97
|
+
}, []);
|
|
65
98
|
const mediaRecorderRef = (0, react_1.useRef)(null);
|
|
66
99
|
const audioChunksRef = (0, react_1.useRef)([]);
|
|
67
100
|
const transcribeInFlightRef = (0, react_1.useRef)(false);
|
|
@@ -149,6 +182,26 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
149
182
|
rtStateRef.current = next;
|
|
150
183
|
setStatus(next);
|
|
151
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Refreshes the server's picture of route/visible/liveElements over an
|
|
187
|
+
* already-open realtime connection. Beyond the initial connect, called
|
|
188
|
+
* on every route change and whenever the mic is about to start listening
|
|
189
|
+
* again — a real, pre-existing gap this closes as a side effect: the
|
|
190
|
+
* server's context previously updated only once, at connection open, so
|
|
191
|
+
* navigating mid-call (via a "navigate" verb, or the user clicking
|
|
192
|
+
* around) left the server answering every later turn as if the user were
|
|
193
|
+
* still on the original page. Reads pathnameRef, not the closure's
|
|
194
|
+
* `pathname`, so it's correct even called from a handler created once at
|
|
195
|
+
* connection-open time.
|
|
196
|
+
*/
|
|
197
|
+
function sendFreshContext() {
|
|
198
|
+
const ws = rtSocketRef.current;
|
|
199
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
200
|
+
return;
|
|
201
|
+
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
202
|
+
liveMapRef.current = liveScan.byId;
|
|
203
|
+
ws.send(JSON.stringify({ type: "context", route: pathnameRef.current, visible: (0, context_collector_1.collectVisible)(), liveElements: liveScan.elements }));
|
|
204
|
+
}
|
|
152
205
|
function reportMiss(context) {
|
|
153
206
|
(0, element_ladder_1.logMiss)(context);
|
|
154
207
|
if (reportMissesEndpoint) {
|
|
@@ -171,6 +224,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
171
224
|
onDo,
|
|
172
225
|
onTour: (steps) => void runTour(steps),
|
|
173
226
|
registeredActions,
|
|
227
|
+
liveElements: liveMapRef.current,
|
|
174
228
|
});
|
|
175
229
|
}
|
|
176
230
|
/**
|
|
@@ -180,12 +234,14 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
180
234
|
* actually showing you around instead of one paragraph naming several
|
|
181
235
|
* buttons at once with nothing highlighted.
|
|
182
236
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
237
|
+
* During a live realtime session, narration reuses the same streaming
|
|
238
|
+
* Speak connection a normal conversational reply uses (see
|
|
239
|
+
* speakOverRealtime below) instead of a separate buffered REST call —
|
|
240
|
+
* otherwise falls back to speakEndpoint. Either way, the mic is held off
|
|
241
|
+
* between steps (mirrors "rt-speaking") so it doesn't pick up the tour's
|
|
242
|
+
* own narration — but it's still listening for a real interruption:
|
|
243
|
+
* talking during a step cancels the rest of the tour via triggerBargeIn,
|
|
244
|
+
* the same as interrupting a normal spoken reply.
|
|
189
245
|
*/
|
|
190
246
|
async function runTour(steps) {
|
|
191
247
|
const myGeneration = ++tourGenerationRef.current;
|
|
@@ -231,11 +287,25 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
231
287
|
return;
|
|
232
288
|
}
|
|
233
289
|
if (step.target) {
|
|
234
|
-
|
|
235
|
-
|
|
290
|
+
// A fresh scan, not the tour's starting liveMapRef snapshot — a
|
|
291
|
+
// step after a mid-tour navigation targets elements on a page
|
|
292
|
+
// that didn't exist when the tour began.
|
|
293
|
+
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
294
|
+
const el = (0, element_ladder_1.findElement)(step.target, liveScan.byId);
|
|
295
|
+
if (el) {
|
|
236
296
|
(0, element_ladder_1.highlightElement)(el);
|
|
237
|
-
|
|
297
|
+
if (step.click) {
|
|
298
|
+
el.click();
|
|
299
|
+
// Give whatever the click reveals (a detail view, an expanded
|
|
300
|
+
// row) a moment to actually render before narrating it.
|
|
301
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
302
|
+
if (tourGenerationRef.current !== myGeneration)
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
238
307
|
reportMiss({ attempted: step.target, route: currentRoute });
|
|
308
|
+
}
|
|
239
309
|
}
|
|
240
310
|
if (wasRealtimeListening && rtSocketRef.current?.readyState === WebSocket.OPEN) {
|
|
241
311
|
// Already have a live streaming connection open — reuse it
|
|
@@ -276,10 +346,18 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
276
346
|
setLastQuestion(q);
|
|
277
347
|
setQuestion("");
|
|
278
348
|
try {
|
|
349
|
+
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
350
|
+
liveMapRef.current = liveScan.byId;
|
|
279
351
|
const res = await fetch(endpoint, {
|
|
280
352
|
method: "POST",
|
|
281
353
|
headers: { "content-type": "application/json" },
|
|
282
|
-
body: JSON.stringify({
|
|
354
|
+
body: JSON.stringify({
|
|
355
|
+
route: pathname,
|
|
356
|
+
question: q,
|
|
357
|
+
visible: (0, context_collector_1.collectVisible)(),
|
|
358
|
+
history: historyRef.current,
|
|
359
|
+
liveElements: liveScan.elements,
|
|
360
|
+
}),
|
|
283
361
|
});
|
|
284
362
|
const data = await res.json().catch(() => null);
|
|
285
363
|
handleVerb(data);
|
|
@@ -503,12 +581,20 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
503
581
|
return;
|
|
504
582
|
if (rtMicMutedRef.current)
|
|
505
583
|
return;
|
|
506
|
-
// Barge-in: while the agent is speaking a real conversational
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
584
|
+
// Barge-in: while the agent is speaking a real conversational reply,
|
|
585
|
+
// still thinking about one, OR mid-tour, keep listening to the mic
|
|
586
|
+
// locally even though it isn't being sent yet, and cut the agent
|
|
587
|
+
// off the instant the user starts talking again instead of making
|
|
588
|
+
// them wait — including during a guided tour, which now cancels the
|
|
589
|
+
// rest of the walkthrough on interruption (see triggerBargeIn)
|
|
590
|
+
// instead of being talked-over-proof by design, the way a real
|
|
591
|
+
// person giving a tour stops when you have a question. The
|
|
592
|
+
// "rt-thinking" half matters just as much as "rt-speaking": an LLM
|
|
593
|
+
// turn can easily take a couple of seconds with nothing playing
|
|
594
|
+
// yet, and without this the mic was completely deaf during that
|
|
595
|
+
// whole window — found live as "not listening while speaking... no
|
|
596
|
+
// interrupting system", not just a missed nice-to-have.
|
|
597
|
+
if (rtStateRef.current === "rt-speaking" || rtStateRef.current === "rt-thinking") {
|
|
512
598
|
const rms = computeRms(e.inputBuffer.getChannelData(0));
|
|
513
599
|
if (rms > BARGE_IN_RMS_THRESHOLD)
|
|
514
600
|
triggerBargeIn();
|
|
@@ -555,6 +641,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
555
641
|
}
|
|
556
642
|
setRtStatus("rt-listening");
|
|
557
643
|
setCaption("");
|
|
644
|
+
sendFreshContext(); // refresh before the user starts talking again, not after
|
|
558
645
|
}
|
|
559
646
|
function disarmThinkingWatchdog() {
|
|
560
647
|
if (rtThinkingWatchdogRef.current) {
|
|
@@ -593,13 +680,26 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
593
680
|
disarmThinkingWatchdog();
|
|
594
681
|
stopScheduledRtAudio();
|
|
595
682
|
rtAudioDoneArrivingRef.current = true;
|
|
683
|
+
if (touringRef.current) {
|
|
684
|
+
// Interrupting mid-guide cancels the whole rest of the tour, not
|
|
685
|
+
// just the current step — the way a real person giving a tour
|
|
686
|
+
// stops and answers your question instead of continuing to talk
|
|
687
|
+
// over you. Without resolving the current step's own pending
|
|
688
|
+
// promise here, runTour only notices the cancellation via its own
|
|
689
|
+
// 15s-per-step fallback timeout instead of right away.
|
|
690
|
+
tourGenerationRef.current++;
|
|
691
|
+
touringRef.current = false;
|
|
692
|
+
setTourStep(null);
|
|
693
|
+
rtTourAudioDoneRef.current?.();
|
|
694
|
+
rtTourAudioDoneRef.current = null;
|
|
695
|
+
}
|
|
596
696
|
if (ws.readyState === WebSocket.OPEN)
|
|
597
697
|
ws.send(JSON.stringify({ type: "barge_in" }));
|
|
598
698
|
setRtStatus("rt-listening");
|
|
599
699
|
setCaption("");
|
|
600
700
|
}
|
|
601
701
|
ws.onopen = () => {
|
|
602
|
-
|
|
702
|
+
sendFreshContext();
|
|
603
703
|
setRtStatus("rt-listening");
|
|
604
704
|
rtStartingRef.current = false;
|
|
605
705
|
};
|
|
@@ -672,7 +772,18 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
672
772
|
// exactly the way a silently-dropped response used to leave it.
|
|
673
773
|
disarmThinkingWatchdog();
|
|
674
774
|
setAnswer(msg.message ?? "Something went wrong.");
|
|
675
|
-
if (
|
|
775
|
+
if (touringRef.current) {
|
|
776
|
+
// A tour step's own speakStreamed() failed server-side (see
|
|
777
|
+
// realtime-server.ts's "speak" handler). Without resolving this
|
|
778
|
+
// step's pending promise here, runTour's `await
|
|
779
|
+
// speakOverRealtime(step.text)` only recovers via its own 15s
|
|
780
|
+
// fallback timeout — found live as a guide that goes badly
|
|
781
|
+
// quiet for long stretches, one step at a time.
|
|
782
|
+
rtAudioDoneArrivingRef.current = true;
|
|
783
|
+
rtTourAudioDoneRef.current?.();
|
|
784
|
+
rtTourAudioDoneRef.current = null;
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
676
787
|
setRtStatus("rt-listening");
|
|
677
788
|
setCaption("");
|
|
678
789
|
}
|
|
@@ -742,7 +853,8 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
742
853
|
"rt-thinking": "Thinking…",
|
|
743
854
|
"rt-speaking": "Speaking…",
|
|
744
855
|
};
|
|
745
|
-
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`, ref: panelRef, children: [(transcript.length > 0 || userCaption || answer || busy) && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-stack", children: [transcript.
|
|
856
|
+
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`, ref: panelRef, children: [(transcript.length > 0 || userCaption || answer || busy) && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-stack", children: [transcript.length > 0 && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "cairn-history-toggle", onClick: () => setHistoryExpanded((v) => !v), "aria-expanded": historyExpanded, children: [historyExpanded ? (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronUp, { size: 12 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: 12 }), historyExpanded ? "Hide earlier" : `${transcript.length} earlier`] })), historyExpanded &&
|
|
857
|
+
transcript.map((entry) => ((0, jsx_runtime_1.jsx)("div", { className: entry.role === "user" ? "cairn-bubble cairn-bubble-user cairn-bubble-past" : "cairn-bubble cairn-bubble-agent cairn-bubble-past", children: entry.role === "agent" ? (0, jsx_runtime_1.jsx)("span", { className: "cairn-bubble-text", children: entry.text }) : entry.text }, entry.id))), 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) => {
|
|
746
858
|
e.preventDefault();
|
|
747
859
|
const trimmed = question.trim();
|
|
748
860
|
if (trimmed)
|
|
@@ -994,6 +1106,26 @@ const COPILOT_STYLES = `
|
|
|
994
1106
|
text-transform: uppercase;
|
|
995
1107
|
color: rgba(11, 13, 18, 0.48);
|
|
996
1108
|
}
|
|
1109
|
+
.cairn-history-toggle {
|
|
1110
|
+
align-self: center;
|
|
1111
|
+
display: inline-flex;
|
|
1112
|
+
align-items: center;
|
|
1113
|
+
gap: 3px;
|
|
1114
|
+
border: none;
|
|
1115
|
+
background: none;
|
|
1116
|
+
padding: 2px 8px;
|
|
1117
|
+
font: inherit;
|
|
1118
|
+
font-size: 11px;
|
|
1119
|
+
font-weight: 600;
|
|
1120
|
+
color: rgba(11, 13, 18, 0.4);
|
|
1121
|
+
cursor: pointer;
|
|
1122
|
+
border-radius: 999px;
|
|
1123
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
1124
|
+
}
|
|
1125
|
+
.cairn-history-toggle:hover {
|
|
1126
|
+
background: rgba(11, 13, 18, 0.05);
|
|
1127
|
+
color: rgba(11, 13, 18, 0.6);
|
|
1128
|
+
}
|
|
997
1129
|
.cairn-thinking {
|
|
998
1130
|
display: inline-flex;
|
|
999
1131
|
gap: 4px;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import type { HistoryTurn, LiveElement, Manifest } from "@cairnvibe/core";
|
|
4
|
+
import { createVerbLLM, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
|
|
4
5
|
export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions {
|
|
5
6
|
manifest: Manifest;
|
|
6
7
|
deepgramApiKey: string;
|
|
@@ -8,3 +9,20 @@ export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions
|
|
|
8
9
|
ttsVoice?: string;
|
|
9
10
|
}
|
|
10
11
|
export declare function createRealtimeServer(options: CreateRealtimeServerOptions): http.Server;
|
|
12
|
+
export interface ConnectionDeps {
|
|
13
|
+
deepgramApiKey: string;
|
|
14
|
+
sttModel: string;
|
|
15
|
+
ttsVoice: string;
|
|
16
|
+
llm: ReturnType<typeof createVerbLLM>;
|
|
17
|
+
systemPrompt: string;
|
|
18
|
+
manifest: Manifest;
|
|
19
|
+
registeredActions: string[];
|
|
20
|
+
capability: CapabilityTier;
|
|
21
|
+
}
|
|
22
|
+
export declare function handleDeepgramMessage(raw: string, client: WebSocket, deps: ConnectionDeps, getContext: () => {
|
|
23
|
+
route: string;
|
|
24
|
+
visible: string[];
|
|
25
|
+
liveElements: LiveElement[];
|
|
26
|
+
}, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
|
|
27
|
+
buffer: string;
|
|
28
|
+
}, getGeneration: () => number): Promise<void>;
|
package/dist/realtime-server.js
CHANGED
|
@@ -30,6 +30,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
30
30
|
};
|
|
31
31
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
32
|
exports.createRealtimeServer = createRealtimeServer;
|
|
33
|
+
exports.handleDeepgramMessage = handleDeepgramMessage;
|
|
33
34
|
const node_http_1 = __importDefault(require("node:http"));
|
|
34
35
|
const ws_1 = require("ws");
|
|
35
36
|
const server_1 = require("./server");
|
|
@@ -73,7 +74,10 @@ function createRealtimeServer(options) {
|
|
|
73
74
|
}
|
|
74
75
|
const MAX_HISTORY_TURNS = 8; // 4 exchanges — enough for "the first one"/"do that instead" without growing the prompt unbounded over a long call
|
|
75
76
|
async function handleConnection(client, deps) {
|
|
76
|
-
|
|
77
|
+
// liveElements refreshes on every "context" resend (the client sends one
|
|
78
|
+
// on route changes and each time it's about to start listening again),
|
|
79
|
+
// so a live scan from several turns ago never lingers into a later one.
|
|
80
|
+
let context = { route: "/", visible: [], liveElements: [] };
|
|
77
81
|
// Unlike the stateless HTTP path (which needs the client to resend
|
|
78
82
|
// history every request), a realtime connection is already stateful —
|
|
79
83
|
// one WebSocket per call — so this is accumulated here directly rather
|
|
@@ -155,8 +159,12 @@ async function handleConnection(client, deps) {
|
|
|
155
159
|
stream.flush();
|
|
156
160
|
});
|
|
157
161
|
}
|
|
162
|
+
// Accumulates Deepgram "Results" transcript segments across one utterance
|
|
163
|
+
// — see handleDeepgramMessage for why this can't just react to every
|
|
164
|
+
// is_final.
|
|
165
|
+
const turnState = { buffer: "" };
|
|
158
166
|
dg.on("message", (data) => {
|
|
159
|
-
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history);
|
|
167
|
+
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
|
|
160
168
|
});
|
|
161
169
|
dg.on("error", (err) => {
|
|
162
170
|
console.error("[cairn realtime] Deepgram STT connection error:", err);
|
|
@@ -174,7 +182,11 @@ async function handleConnection(client, deps) {
|
|
|
174
182
|
try {
|
|
175
183
|
const msg = JSON.parse(data.toString());
|
|
176
184
|
if (msg.type === "context") {
|
|
177
|
-
context = {
|
|
185
|
+
context = {
|
|
186
|
+
route: String(msg.route ?? "/"),
|
|
187
|
+
visible: Array.isArray(msg.visible) ? msg.visible : [],
|
|
188
|
+
liveElements: parseLiveElements(msg.liveElements),
|
|
189
|
+
};
|
|
178
190
|
}
|
|
179
191
|
else if (msg.type === "end") {
|
|
180
192
|
client.close();
|
|
@@ -189,7 +201,20 @@ async function handleConnection(client, deps) {
|
|
|
189
201
|
// client falling back to a separate buffered REST call. No STT/verb
|
|
190
202
|
// resolution involved; the client already resolved the tour steps
|
|
191
203
|
// itself and just needs this text spoken.
|
|
192
|
-
|
|
204
|
+
//
|
|
205
|
+
// Caught explicitly, unlike a normal turn's speakStreamed call (see
|
|
206
|
+
// handleDeepgramMessage) — this one isn't inside that function's own
|
|
207
|
+
// try/catch, and an uncaught rejection here previously vanished
|
|
208
|
+
// silently: the client's speakOverRealtime() promise for this step
|
|
209
|
+
// never resolves except via its own 15s fallback timeout, with
|
|
210
|
+
// nothing telling the user anything went wrong in the meantime —
|
|
211
|
+
// found live as a tour that goes badly quiet for stretches at a
|
|
212
|
+
// time. A real "error" message lets the client's tour-step handler
|
|
213
|
+
// (index.tsx's ws.onmessage) unstick itself immediately instead.
|
|
214
|
+
speakStreamed(msg.text).catch((err) => {
|
|
215
|
+
console.error("[cairn realtime] speakStreamed failed for a tour step:", err);
|
|
216
|
+
safeSend(client, { type: "error", message: "Something went wrong narrating that step." });
|
|
217
|
+
});
|
|
193
218
|
}
|
|
194
219
|
}
|
|
195
220
|
catch {
|
|
@@ -206,7 +231,7 @@ async function handleConnection(client, deps) {
|
|
|
206
231
|
speakStream?.close();
|
|
207
232
|
});
|
|
208
233
|
}
|
|
209
|
-
async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history) {
|
|
234
|
+
async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration) {
|
|
210
235
|
let msg;
|
|
211
236
|
try {
|
|
212
237
|
msg = JSON.parse(raw);
|
|
@@ -214,33 +239,74 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
|
|
|
214
239
|
catch {
|
|
215
240
|
return;
|
|
216
241
|
}
|
|
242
|
+
if (msg.type === "UtteranceEnd") {
|
|
243
|
+
// A second, independent "the user is truly done" signal Deepgram sends
|
|
244
|
+
// after utterance_end_ms of silence — a safety net for the rare case a
|
|
245
|
+
// Results message never carries speech_final:true, so a turn can't get
|
|
246
|
+
// permanently stuck with real transcript sitting in the buffer forever.
|
|
247
|
+
if (turnState.buffer)
|
|
248
|
+
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
217
251
|
if (msg.type !== "Results")
|
|
218
252
|
return;
|
|
219
253
|
const transcript = msg.channel?.alternatives?.[0]?.transcript;
|
|
220
254
|
if (!transcript)
|
|
221
255
|
return;
|
|
222
256
|
if (!msg.is_final) {
|
|
223
|
-
safeSend(client, { type: "interim", text: transcript });
|
|
257
|
+
safeSend(client, { type: "interim", text: turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript });
|
|
224
258
|
return;
|
|
225
259
|
}
|
|
260
|
+
// is_final means this chunk of transcript is stable and won't be
|
|
261
|
+
// revised — it does NOT mean the user is done talking. Deepgram can (and
|
|
262
|
+
// routinely does) finalize several chunks of one continuous utterance in
|
|
263
|
+
// a row with no real pause between them. Only speech_final (endpointing
|
|
264
|
+
// actually detected a pause) means the turn is genuinely over. Found
|
|
265
|
+
// live, not theoretical: treating every is_final as a separate finished
|
|
266
|
+
// question fired two independent LLM+TTS turns for one utterance — the
|
|
267
|
+
// literal cause of both the duplicated transcript entries ("hello" /
|
|
268
|
+
// "hello" with no reply in between) and the agent audibly speaking
|
|
269
|
+
// twice, overlapping.
|
|
270
|
+
turnState.buffer = turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript;
|
|
271
|
+
if (!msg.speech_final) {
|
|
272
|
+
safeSend(client, { type: "interim", text: turnState.buffer });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Everything from here on (the LLM call, TTS streaming) can fail in ways
|
|
279
|
+
* that have nothing to do with a malformed message — a flaky provider
|
|
280
|
+
* call, a rate limit, a dropped upstream connection. handleDeepgramMessage
|
|
281
|
+
* is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
|
|
282
|
+
* uncaught throw here previously vanished into an unhandled rejection: the
|
|
283
|
+
* client had already been told "final" (entering its "thinking" state) and
|
|
284
|
+
* then simply never heard from the server again for this turn — stuck
|
|
285
|
+
* indefinitely with the mic never resuming. Every path out of the try
|
|
286
|
+
* block now sends the client something that ends the turn.
|
|
287
|
+
*
|
|
288
|
+
* myGeneration is captured before the (potentially slow) LLM call and
|
|
289
|
+
* re-checked before the verb/speech actually goes out — a barge-in that
|
|
290
|
+
* happens while this turn is still "thinking" bumps the generation, and
|
|
291
|
+
* without this check the now-stale response would still land on the
|
|
292
|
+
* client after the user had already moved on to a new question.
|
|
293
|
+
*/
|
|
294
|
+
async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration) {
|
|
295
|
+
const transcript = turnState.buffer;
|
|
296
|
+
turnState.buffer = "";
|
|
297
|
+
const myGeneration = getGeneration();
|
|
226
298
|
safeSend(client, { type: "final", text: transcript });
|
|
227
|
-
// Everything from here on (the LLM call, TTS streaming) can fail in ways
|
|
228
|
-
// that have nothing to do with a malformed message — a flaky provider
|
|
229
|
-
// call, a rate limit, a dropped upstream connection. This whole function
|
|
230
|
-
// is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
|
|
231
|
-
// uncaught throw here previously vanished into an unhandled rejection:
|
|
232
|
-
// the client had already been told "final" (entering its "thinking"
|
|
233
|
-
// state) and then simply never heard from the server again for this
|
|
234
|
-
// turn — stuck indefinitely with the mic never resuming. Every path out
|
|
235
|
-
// of this try block now sends the client something that ends the turn.
|
|
236
299
|
try {
|
|
237
|
-
const { route, visible } = getContext();
|
|
300
|
+
const { route, visible, liveElements } = getContext();
|
|
238
301
|
const verb = await (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
|
|
239
302
|
route,
|
|
240
303
|
question: transcript,
|
|
241
304
|
visible,
|
|
305
|
+
liveElements,
|
|
242
306
|
history,
|
|
243
307
|
});
|
|
308
|
+
if (myGeneration !== getGeneration())
|
|
309
|
+
return; // superseded by a barge-in while this turn was resolving
|
|
244
310
|
// Sent immediately — before speech synthesis even starts — so
|
|
245
311
|
// highlight/navigate/do execute in the browser right away instead of
|
|
246
312
|
// waiting on audio. The agent visibly acts while it's still about to
|
|
@@ -260,8 +326,10 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
|
|
|
260
326
|
}
|
|
261
327
|
catch (err) {
|
|
262
328
|
console.error("[cairn realtime] failed to resolve/speak this turn:", err);
|
|
263
|
-
|
|
264
|
-
|
|
329
|
+
if (myGeneration === getGeneration()) {
|
|
330
|
+
safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
|
|
331
|
+
safeSend(client, { type: "turn_complete" });
|
|
332
|
+
}
|
|
265
333
|
}
|
|
266
334
|
}
|
|
267
335
|
function safeSend(client, message) {
|
|
@@ -269,6 +337,27 @@ function safeSend(client, message) {
|
|
|
269
337
|
return;
|
|
270
338
|
client.send(JSON.stringify(message));
|
|
271
339
|
}
|
|
340
|
+
/** Defensive parse for the client's self-reported live DOM scan — same
|
|
341
|
+
* untrusted-input treatment `visible` already gets on this control-message
|
|
342
|
+
* path (no CopilotRequestSchema here, unlike the HTTP handler), just
|
|
343
|
+
* shaped-checked so a malformed entry can't reach the LLM prompt oddly. */
|
|
344
|
+
function parseLiveElements(raw) {
|
|
345
|
+
if (!Array.isArray(raw))
|
|
346
|
+
return [];
|
|
347
|
+
const elements = [];
|
|
348
|
+
for (const entry of raw) {
|
|
349
|
+
if (entry &&
|
|
350
|
+
typeof entry === "object" &&
|
|
351
|
+
typeof entry.id === "string" &&
|
|
352
|
+
typeof entry.role === "string" &&
|
|
353
|
+
typeof entry.label === "string") {
|
|
354
|
+
elements.push({ id: entry.id, role: entry.role, label: entry.label });
|
|
355
|
+
}
|
|
356
|
+
if (elements.length >= 60)
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
return elements;
|
|
360
|
+
}
|
|
272
361
|
/** A short text form of any verb for the history log — not shown to the
|
|
273
362
|
* user, just fed back to the model on later turns so it knows what it
|
|
274
363
|
* already did/said. */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { LiveElement } from "@cairnvibe/core";
|
|
2
|
+
export interface LiveScan {
|
|
3
|
+
elements: LiveElement[];
|
|
4
|
+
byId: Map<string, HTMLElement>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Scans the live DOM for interactive elements currently in the viewport.
|
|
8
|
+
* Returns both the bounded list to send to the model (`elements`, capped at
|
|
9
|
+
* MAX_ELEMENTS and MAX_LABEL_LENGTH — the actual privacy/payload backstop,
|
|
10
|
+
* mirrored server-side in CopilotRequestSchema) and the real elements it
|
|
11
|
+
* maps to, keyed by the same ids (`byId`) — resolve a verb's target by
|
|
12
|
+
* looking it up here, never by re-deriving a selector from the id string.
|
|
13
|
+
*/
|
|
14
|
+
export declare function scanInteractiveElements(root?: ParentNode): LiveScan;
|
|
15
|
+
export interface LiveElementRegistry {
|
|
16
|
+
/** Starts continuous background scanning — call once, typically on mount. */
|
|
17
|
+
start(): void;
|
|
18
|
+
stop(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Freezes the current scan for one request/response round trip. Call
|
|
21
|
+
* this once when a question is sent, and resolve that turn's verb
|
|
22
|
+
* against exactly this snapshot — not a fresh call — so a background
|
|
23
|
+
* rescan that lands mid-flight can't shift what an id resolves to
|
|
24
|
+
* between when the request went out and when the response comes back.
|
|
25
|
+
*/
|
|
26
|
+
getSnapshot(): LiveScan;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Keeps a scan continuously fresh in the background via a debounced
|
|
30
|
+
* MutationObserver (plus scroll/resize, since viewport membership changes
|
|
31
|
+
* without any DOM mutation) instead of only scanning at the moment a
|
|
32
|
+
* question is asked — so the agent never has to pause to "go look at the
|
|
33
|
+
* page" right when it needs to click something; a sub-agent gathering
|
|
34
|
+
* context while the main conversation keeps moving.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createLiveElementRegistry(): LiveElementRegistry;
|