@cairnvibe/sdk 0.2.4 → 0.2.6
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/index.js +62 -20
- package/dist/realtime-server.d.ts +19 -2
- package/dist/realtime-server.js +75 -15
- package/dist/server.js +38 -13
- package/dist/verb-executor.js +45 -5
- package/package.json +1 -1
- package/src/index.tsx +61 -20
- package/src/realtime-server.ts +89 -16
- package/src/server.ts +38 -13
- package/src/verb-executor.ts +45 -5
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import type { HistoryTurn, 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,19 @@ 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
|
+
}, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
|
|
26
|
+
buffer: string;
|
|
27
|
+
}, 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");
|
|
@@ -155,8 +156,12 @@ async function handleConnection(client, deps) {
|
|
|
155
156
|
stream.flush();
|
|
156
157
|
});
|
|
157
158
|
}
|
|
159
|
+
// Accumulates Deepgram "Results" transcript segments across one utterance
|
|
160
|
+
// — see handleDeepgramMessage for why this can't just react to every
|
|
161
|
+
// is_final.
|
|
162
|
+
const turnState = { buffer: "" };
|
|
158
163
|
dg.on("message", (data) => {
|
|
159
|
-
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history);
|
|
164
|
+
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
|
|
160
165
|
});
|
|
161
166
|
dg.on("error", (err) => {
|
|
162
167
|
console.error("[cairn realtime] Deepgram STT connection error:", err);
|
|
@@ -189,7 +194,20 @@ async function handleConnection(client, deps) {
|
|
|
189
194
|
// client falling back to a separate buffered REST call. No STT/verb
|
|
190
195
|
// resolution involved; the client already resolved the tour steps
|
|
191
196
|
// itself and just needs this text spoken.
|
|
192
|
-
|
|
197
|
+
//
|
|
198
|
+
// Caught explicitly, unlike a normal turn's speakStreamed call (see
|
|
199
|
+
// handleDeepgramMessage) — this one isn't inside that function's own
|
|
200
|
+
// try/catch, and an uncaught rejection here previously vanished
|
|
201
|
+
// silently: the client's speakOverRealtime() promise for this step
|
|
202
|
+
// never resolves except via its own 15s fallback timeout, with
|
|
203
|
+
// nothing telling the user anything went wrong in the meantime —
|
|
204
|
+
// found live as a tour that goes badly quiet for stretches at a
|
|
205
|
+
// time. A real "error" message lets the client's tour-step handler
|
|
206
|
+
// (index.tsx's ws.onmessage) unstick itself immediately instead.
|
|
207
|
+
speakStreamed(msg.text).catch((err) => {
|
|
208
|
+
console.error("[cairn realtime] speakStreamed failed for a tour step:", err);
|
|
209
|
+
safeSend(client, { type: "error", message: "Something went wrong narrating that step." });
|
|
210
|
+
});
|
|
193
211
|
}
|
|
194
212
|
}
|
|
195
213
|
catch {
|
|
@@ -206,7 +224,7 @@ async function handleConnection(client, deps) {
|
|
|
206
224
|
speakStream?.close();
|
|
207
225
|
});
|
|
208
226
|
}
|
|
209
|
-
async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history) {
|
|
227
|
+
async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration) {
|
|
210
228
|
let msg;
|
|
211
229
|
try {
|
|
212
230
|
msg = JSON.parse(raw);
|
|
@@ -214,25 +232,63 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
|
|
|
214
232
|
catch {
|
|
215
233
|
return;
|
|
216
234
|
}
|
|
235
|
+
if (msg.type === "UtteranceEnd") {
|
|
236
|
+
// A second, independent "the user is truly done" signal Deepgram sends
|
|
237
|
+
// after utterance_end_ms of silence — a safety net for the rare case a
|
|
238
|
+
// Results message never carries speech_final:true, so a turn can't get
|
|
239
|
+
// permanently stuck with real transcript sitting in the buffer forever.
|
|
240
|
+
if (turnState.buffer)
|
|
241
|
+
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
217
244
|
if (msg.type !== "Results")
|
|
218
245
|
return;
|
|
219
246
|
const transcript = msg.channel?.alternatives?.[0]?.transcript;
|
|
220
247
|
if (!transcript)
|
|
221
248
|
return;
|
|
222
249
|
if (!msg.is_final) {
|
|
223
|
-
safeSend(client, { type: "interim", text: transcript });
|
|
250
|
+
safeSend(client, { type: "interim", text: turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// is_final means this chunk of transcript is stable and won't be
|
|
254
|
+
// revised — it does NOT mean the user is done talking. Deepgram can (and
|
|
255
|
+
// routinely does) finalize several chunks of one continuous utterance in
|
|
256
|
+
// a row with no real pause between them. Only speech_final (endpointing
|
|
257
|
+
// actually detected a pause) means the turn is genuinely over. Found
|
|
258
|
+
// live, not theoretical: treating every is_final as a separate finished
|
|
259
|
+
// question fired two independent LLM+TTS turns for one utterance — the
|
|
260
|
+
// literal cause of both the duplicated transcript entries ("hello" /
|
|
261
|
+
// "hello" with no reply in between) and the agent audibly speaking
|
|
262
|
+
// twice, overlapping.
|
|
263
|
+
turnState.buffer = turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript;
|
|
264
|
+
if (!msg.speech_final) {
|
|
265
|
+
safeSend(client, { type: "interim", text: turnState.buffer });
|
|
224
266
|
return;
|
|
225
267
|
}
|
|
268
|
+
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Everything from here on (the LLM call, TTS streaming) can fail in ways
|
|
272
|
+
* that have nothing to do with a malformed message — a flaky provider
|
|
273
|
+
* call, a rate limit, a dropped upstream connection. handleDeepgramMessage
|
|
274
|
+
* is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
|
|
275
|
+
* uncaught throw here previously vanished into an unhandled rejection: the
|
|
276
|
+
* client had already been told "final" (entering its "thinking" state) and
|
|
277
|
+
* then simply never heard from the server again for this turn — stuck
|
|
278
|
+
* indefinitely with the mic never resuming. Every path out of the try
|
|
279
|
+
* block now sends the client something that ends the turn.
|
|
280
|
+
*
|
|
281
|
+
* myGeneration is captured before the (potentially slow) LLM call and
|
|
282
|
+
* re-checked before the verb/speech actually goes out — a barge-in that
|
|
283
|
+
* happens while this turn is still "thinking" bumps the generation, and
|
|
284
|
+
* without this check the now-stale response would still land on the
|
|
285
|
+
* client after the user had already moved on to a new question.
|
|
286
|
+
*/
|
|
287
|
+
async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration) {
|
|
288
|
+
const transcript = turnState.buffer;
|
|
289
|
+
turnState.buffer = "";
|
|
290
|
+
const myGeneration = getGeneration();
|
|
226
291
|
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
292
|
try {
|
|
237
293
|
const { route, visible } = getContext();
|
|
238
294
|
const verb = await (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
|
|
@@ -241,6 +297,8 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
|
|
|
241
297
|
visible,
|
|
242
298
|
history,
|
|
243
299
|
});
|
|
300
|
+
if (myGeneration !== getGeneration())
|
|
301
|
+
return; // superseded by a barge-in while this turn was resolving
|
|
244
302
|
// Sent immediately — before speech synthesis even starts — so
|
|
245
303
|
// highlight/navigate/do execute in the browser right away instead of
|
|
246
304
|
// waiting on audio. The agent visibly acts while it's still about to
|
|
@@ -260,8 +318,10 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
|
|
|
260
318
|
}
|
|
261
319
|
catch (err) {
|
|
262
320
|
console.error("[cairn realtime] failed to resolve/speak this turn:", err);
|
|
263
|
-
|
|
264
|
-
|
|
321
|
+
if (myGeneration === getGeneration()) {
|
|
322
|
+
safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
|
|
323
|
+
safeSend(client, { type: "turn_complete" });
|
|
324
|
+
}
|
|
265
325
|
}
|
|
266
326
|
}
|
|
267
327
|
function safeSend(client, message) {
|
package/dist/server.js
CHANGED
|
@@ -81,7 +81,22 @@ async function resolveVerb(llm, systemPrompt, manifest, registeredActions, capab
|
|
|
81
81
|
return { verb: "explain", text: "I can only explain and point things out here — I can't do that." };
|
|
82
82
|
}
|
|
83
83
|
if (parsedVerb.data.verb === "do" && !registeredActions.includes(parsedVerb.data.action)) {
|
|
84
|
-
|
|
84
|
+
// Not a manually registered action — the auto-discovery fallback: does
|
|
85
|
+
// "target" name a real element on the CURRENT page that the indexer
|
|
86
|
+
// itself found a real, mutating handler call on? If so, attach that
|
|
87
|
+
// call here (never something the model emitted itself — see
|
|
88
|
+
// ApiCallSchema's doc comment) so the client can execute exactly the
|
|
89
|
+
// same request a real click on that element would already make.
|
|
90
|
+
// Anything else — no target, an unknown target, or a real target with
|
|
91
|
+
// no discoverable apiCall (a client-only handler, a dynamic per-row
|
|
92
|
+
// URL — see manifest.ts's parseApiCall) — stays refused, same as before.
|
|
93
|
+
const target = parsedVerb.data.target;
|
|
94
|
+
const pageElements = manifest.pages.find((p) => p.route === input.route)?.elements ?? [];
|
|
95
|
+
const targetElement = target ? pageElements.find((e) => e.id === target) : undefined;
|
|
96
|
+
if (!targetElement?.apiCall) {
|
|
97
|
+
return { verb: "explain", text: "That action isn't available here." };
|
|
98
|
+
}
|
|
99
|
+
return { ...parsedVerb.data, apiCall: targetElement.apiCall };
|
|
85
100
|
}
|
|
86
101
|
// tour is allowed at every tier (see TIER_ALLOWED_VERBS) because
|
|
87
102
|
// highlighting-only steps never move the user — but a step carrying a
|
|
@@ -223,9 +238,11 @@ function buildVerbToolSchema(registeredActions) {
|
|
|
223
238
|
text: { type: "string", description: "Shown to the user. Required for explain." },
|
|
224
239
|
target: nullableString("Manifest element id. Required for highlight/open. For do, the id of what the action applies to, if it needs one. null (or omitted) if not applicable."),
|
|
225
240
|
route: nullableString("A route from the manifest. Required for navigate. null (or omitted) if not applicable."),
|
|
226
|
-
action: nullableString(
|
|
227
|
-
|
|
228
|
-
|
|
241
|
+
action: nullableString("Required for do. A short label for what's being done, e.g. \"archive-invoice\" " +
|
|
242
|
+
(registeredActions.length
|
|
243
|
+
? `— either one of this deployment's registered actions [${registeredActions.join(", ")}], or, for any other element from currentPageElements whose own description says it performs a real action, any short label describing it.`
|
|
244
|
+
: "for any element from currentPageElements whose own description says it performs a real action — no actions are separately registered in this deployment, but currentPageElements-driven actions still work.") +
|
|
245
|
+
" null (or omitted) if not applicable."),
|
|
229
246
|
steps: {
|
|
230
247
|
type: "array",
|
|
231
248
|
description: "Required for tour, 2-6 items. Each step is spoken/shown in order while highlighting its target (if any) — use this instead of explain when the answer genuinely covers several distinct elements, so the user sees what's being talked about instead of reading a wall of text.",
|
|
@@ -288,15 +305,23 @@ Always call ${VERB_TOOL_NAME} exactly once with one of these verbs:
|
|
|
288
305
|
spans more than one page (e.g. "how do I get from here to Settings and
|
|
289
306
|
turn on X"), a step may also carry a "route" to move there first — most
|
|
290
307
|
steps should NOT set this; only the step where the page actually changes.
|
|
291
|
-
- do:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
308
|
+
- do: trigger a real action. Two ways this is allowed — anything else, refuse:
|
|
309
|
+
1. One of this deployment's registered action ids: [${registeredActions.join(", ") || "none registered"}].
|
|
310
|
+
Put that exact id in "action".
|
|
311
|
+
2. Any element in "currentPageElements" whose own description says it
|
|
312
|
+
performs a real action (e.g. "Archives this invoice", "Starts a phone
|
|
313
|
+
call", "Submits the form") — put that element's id in "target" and a
|
|
314
|
+
short label describing what it does in "action". This only works for
|
|
315
|
+
an element on the CURRENT page (it must be in currentPageElements) and
|
|
316
|
+
only for an action that doesn't depend on which specific row/instance
|
|
317
|
+
— a generic page-level button, not "archive row 3 of this table". If
|
|
318
|
+
the user means one specific item among several repeated ones, that's
|
|
319
|
+
not currently supported through this path — use "explain" and say so,
|
|
320
|
+
don't guess at a specific instance.
|
|
321
|
+
If neither applies — the action isn't registered and isn't a real element
|
|
322
|
+
on this page, or it needs picking a specific instance — use "explain" and
|
|
323
|
+
say you can't do that from here. Never invent a target or action id that
|
|
324
|
+
isn't in currentPageElements or the registered list above.
|
|
300
325
|
|
|
301
326
|
Every "text" field (in explain, or per-step in tour, or the optional text on
|
|
302
327
|
any other verb) is read aloud AND shown on screen, so it must sound like a
|
package/dist/verb-executor.js
CHANGED
|
@@ -43,13 +43,33 @@ function dispatchVerb(verb, route, options) {
|
|
|
43
43
|
return;
|
|
44
44
|
case "do": {
|
|
45
45
|
const allowed = options.registeredActions ?? [];
|
|
46
|
-
if (
|
|
47
|
-
|
|
46
|
+
if (allowed.includes(verb.action)) {
|
|
47
|
+
// Explicit, developer-owned path — unchanged.
|
|
48
|
+
options.onDo?.(verb.action, verb.target);
|
|
49
|
+
if (verb.text)
|
|
50
|
+
options.onExplain(verb.text);
|
|
48
51
|
return;
|
|
49
52
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
if (verb.apiCall) {
|
|
54
|
+
// Auto-discovered path: a real, indexer-found handler call on this
|
|
55
|
+
// exact target element, attached server-side after looking the
|
|
56
|
+
// target up in the manifest — never something the model emitted
|
|
57
|
+
// itself (see ApiCallSchema's doc comment in @cairnvibe/core).
|
|
58
|
+
const el = verb.target ? (0, element_ladder_1.findElement)(verb.target) : null;
|
|
59
|
+
if (el)
|
|
60
|
+
(0, element_ladder_1.highlightElement)(el);
|
|
61
|
+
else if (verb.target)
|
|
62
|
+
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
|
|
63
|
+
if (verb.text)
|
|
64
|
+
options.onExplain(verb.text);
|
|
65
|
+
void executeApiCall(verb.apiCall).then((result) => {
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
options.onExplain("I tried to do that, but something went wrong — try again in a moment.");
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
options.onExplain(verb.text ?? "That action isn't available here.");
|
|
53
73
|
return;
|
|
54
74
|
}
|
|
55
75
|
case "tour":
|
|
@@ -65,3 +85,23 @@ function dispatchVerb(verb, route, options) {
|
|
|
65
85
|
return;
|
|
66
86
|
}
|
|
67
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Fires exactly the same request a real click on the target element would
|
|
90
|
+
* already make — same-origin only (apiCall.url is always relative, never a
|
|
91
|
+
* different host), and `credentials: "same-origin"` so the browser attaches
|
|
92
|
+
* the user's own real session cookies, the same way a manual click would.
|
|
93
|
+
* No body is sent: l1-scan.ts's static capture only ever traces method+url,
|
|
94
|
+
* never a request body (which usually depends on runtime state a build-time
|
|
95
|
+
* scan can't see) — fine for the common trigger-style action (an id already
|
|
96
|
+
* baked into the URL, no other payload needed), a real gap for one that
|
|
97
|
+
* requires one.
|
|
98
|
+
*/
|
|
99
|
+
async function executeApiCall(apiCall) {
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(apiCall.url, { method: apiCall.method, credentials: "same-origin" });
|
|
102
|
+
return { ok: res.ok, status: res.status };
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return { ok: false };
|
|
106
|
+
}
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cairnvibe/sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "In-app AI copilot — <Copilot/> for React/Next.js, <cairn-widget> for any framework — plus the server handlers and realtime voice relay behind them.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": { "access": "public" },
|
package/src/index.tsx
CHANGED
|
@@ -92,16 +92,19 @@ export function Copilot({
|
|
|
92
92
|
const [rtMicMuted, setRtMicMuted] = useState(false);
|
|
93
93
|
const [rtSpeakerMuted, setRtSpeakerMuted] = useState(false);
|
|
94
94
|
// 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
|
|
95
|
+
// time — drives the step-progress caption and blocks the *typed* input so
|
|
96
|
+
// a typed question can't collide with the walkthrough (a voice
|
|
97
|
+
// interruption is handled separately — see touringRef/triggerBargeIn).
|
|
97
98
|
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
|
|
99
|
+
const tourGenerationRef = useRef(0); // bumped to cancel an in-progress tour (e.g. widget closed, or a voice barge-in) without extra flags
|
|
99
100
|
// Mirrors whether a tour is running, for use inside the mic's
|
|
100
101
|
// onaudioprocess callback (a stale closure over React state there would
|
|
101
102
|
// 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
|
-
//
|
|
103
|
+
// reuses "rt-speaking" to hold the mic off between steps, but IS
|
|
104
|
+
// barge-in-able like a real conversational reply (see the RMS check
|
|
105
|
+
// below): interrupting mid-tour cancels the rest of the walkthrough,
|
|
106
|
+
// the way a real person giving a tour stops when you have a question
|
|
107
|
+
// instead of talking over you.
|
|
105
108
|
const touringRef = useRef(false);
|
|
106
109
|
// Resolver for "this tour step's audio has fully finished playing" when
|
|
107
110
|
// narrating over an already-open realtime session (see maybeResumeListening
|
|
@@ -242,19 +245,26 @@ export function Copilot({
|
|
|
242
245
|
* actually showing you around instead of one paragraph naming several
|
|
243
246
|
* buttons at once with nothing highlighted.
|
|
244
247
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
248
|
+
* During a live realtime session, narration reuses the same streaming
|
|
249
|
+
* Speak connection a normal conversational reply uses (see
|
|
250
|
+
* speakOverRealtime below) instead of a separate buffered REST call —
|
|
251
|
+
* otherwise falls back to speakEndpoint. Either way, the mic is held off
|
|
252
|
+
* between steps (mirrors "rt-speaking") so it doesn't pick up the tour's
|
|
253
|
+
* own narration — but it's still listening for a real interruption:
|
|
254
|
+
* talking during a step cancels the rest of the tour via triggerBargeIn,
|
|
255
|
+
* the same as interrupting a normal spoken reply.
|
|
251
256
|
*/
|
|
252
257
|
async function runTour(steps: TourStep[]) {
|
|
253
258
|
const myGeneration = ++tourGenerationRef.current;
|
|
254
259
|
const wasRealtimeListening = realtimeActive;
|
|
255
260
|
touringRef.current = true;
|
|
256
261
|
if (wasRealtimeListening) setRtStatus("rt-speaking");
|
|
257
|
-
archiveCurrentExchange()
|
|
262
|
+
// No archiveCurrentExchange() here: whatever triggered this tour (a typed
|
|
263
|
+
// ask() or a realtime "final") already archived the exchange *before*
|
|
264
|
+
// this one — by the time a tour's "verb" message arrives, the triggering
|
|
265
|
+
// question is the CURRENT turn, still live in userCaption for the whole
|
|
266
|
+
// tour. Archiving it again here would just duplicate it (verified live —
|
|
267
|
+
// this used to show the triggering question twice).
|
|
258
268
|
setAnswer(null);
|
|
259
269
|
// Tracked locally rather than reading the component's `pathname` —
|
|
260
270
|
// that's only current as of this render, and a step below can navigate
|
|
@@ -545,12 +555,20 @@ export function Copilot({
|
|
|
545
555
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
546
556
|
if (rtMicMutedRef.current) return;
|
|
547
557
|
|
|
548
|
-
// Barge-in: while the agent is speaking a real conversational
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
|
|
558
|
+
// Barge-in: while the agent is speaking a real conversational reply,
|
|
559
|
+
// still thinking about one, OR mid-tour, keep listening to the mic
|
|
560
|
+
// locally even though it isn't being sent yet, and cut the agent
|
|
561
|
+
// off the instant the user starts talking again instead of making
|
|
562
|
+
// them wait — including during a guided tour, which now cancels the
|
|
563
|
+
// rest of the walkthrough on interruption (see triggerBargeIn)
|
|
564
|
+
// instead of being talked-over-proof by design, the way a real
|
|
565
|
+
// person giving a tour stops when you have a question. The
|
|
566
|
+
// "rt-thinking" half matters just as much as "rt-speaking": an LLM
|
|
567
|
+
// turn can easily take a couple of seconds with nothing playing
|
|
568
|
+
// yet, and without this the mic was completely deaf during that
|
|
569
|
+
// whole window — found live as "not listening while speaking... no
|
|
570
|
+
// interrupting system", not just a missed nice-to-have.
|
|
571
|
+
if (rtStateRef.current === "rt-speaking" || rtStateRef.current === "rt-thinking") {
|
|
554
572
|
const rms = computeRms(e.inputBuffer.getChannelData(0));
|
|
555
573
|
if (rms > BARGE_IN_RMS_THRESHOLD) triggerBargeIn();
|
|
556
574
|
return;
|
|
@@ -637,6 +655,19 @@ export function Copilot({
|
|
|
637
655
|
disarmThinkingWatchdog();
|
|
638
656
|
stopScheduledRtAudio();
|
|
639
657
|
rtAudioDoneArrivingRef.current = true;
|
|
658
|
+
if (touringRef.current) {
|
|
659
|
+
// Interrupting mid-guide cancels the whole rest of the tour, not
|
|
660
|
+
// just the current step — the way a real person giving a tour
|
|
661
|
+
// stops and answers your question instead of continuing to talk
|
|
662
|
+
// over you. Without resolving the current step's own pending
|
|
663
|
+
// promise here, runTour only notices the cancellation via its own
|
|
664
|
+
// 15s-per-step fallback timeout instead of right away.
|
|
665
|
+
tourGenerationRef.current++;
|
|
666
|
+
touringRef.current = false;
|
|
667
|
+
setTourStep(null);
|
|
668
|
+
rtTourAudioDoneRef.current?.();
|
|
669
|
+
rtTourAudioDoneRef.current = null;
|
|
670
|
+
}
|
|
640
671
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "barge_in" }));
|
|
641
672
|
setRtStatus("rt-listening");
|
|
642
673
|
setCaption("");
|
|
@@ -713,7 +744,17 @@ export function Copilot({
|
|
|
713
744
|
// exactly the way a silently-dropped response used to leave it.
|
|
714
745
|
disarmThinkingWatchdog();
|
|
715
746
|
setAnswer(msg.message ?? "Something went wrong.");
|
|
716
|
-
if (
|
|
747
|
+
if (touringRef.current) {
|
|
748
|
+
// A tour step's own speakStreamed() failed server-side (see
|
|
749
|
+
// realtime-server.ts's "speak" handler). Without resolving this
|
|
750
|
+
// step's pending promise here, runTour's `await
|
|
751
|
+
// speakOverRealtime(step.text)` only recovers via its own 15s
|
|
752
|
+
// fallback timeout — found live as a guide that goes badly
|
|
753
|
+
// quiet for long stretches, one step at a time.
|
|
754
|
+
rtAudioDoneArrivingRef.current = true;
|
|
755
|
+
rtTourAudioDoneRef.current?.();
|
|
756
|
+
rtTourAudioDoneRef.current = null;
|
|
757
|
+
} else {
|
|
717
758
|
setRtStatus("rt-listening");
|
|
718
759
|
setCaption("");
|
|
719
760
|
}
|
package/src/realtime-server.ts
CHANGED
|
@@ -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;
|
|
@@ -202,8 +202,13 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
202
202
|
});
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// Accumulates Deepgram "Results" transcript segments across one utterance
|
|
206
|
+
// — see handleDeepgramMessage for why this can't just react to every
|
|
207
|
+
// is_final.
|
|
208
|
+
const turnState = { buffer: "" };
|
|
209
|
+
|
|
205
210
|
dg.on("message", (data) => {
|
|
206
|
-
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history);
|
|
211
|
+
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
|
|
207
212
|
});
|
|
208
213
|
|
|
209
214
|
dg.on("error", (err) => {
|
|
@@ -233,7 +238,20 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
233
238
|
// client falling back to a separate buffered REST call. No STT/verb
|
|
234
239
|
// resolution involved; the client already resolved the tour steps
|
|
235
240
|
// itself and just needs this text spoken.
|
|
236
|
-
|
|
241
|
+
//
|
|
242
|
+
// Caught explicitly, unlike a normal turn's speakStreamed call (see
|
|
243
|
+
// handleDeepgramMessage) — this one isn't inside that function's own
|
|
244
|
+
// try/catch, and an uncaught rejection here previously vanished
|
|
245
|
+
// silently: the client's speakOverRealtime() promise for this step
|
|
246
|
+
// never resolves except via its own 15s fallback timeout, with
|
|
247
|
+
// nothing telling the user anything went wrong in the meantime —
|
|
248
|
+
// found live as a tour that goes badly quiet for stretches at a
|
|
249
|
+
// time. A real "error" message lets the client's tour-step handler
|
|
250
|
+
// (index.tsx's ws.onmessage) unstick itself immediately instead.
|
|
251
|
+
speakStreamed(msg.text).catch((err) => {
|
|
252
|
+
console.error("[cairn realtime] speakStreamed failed for a tour step:", err);
|
|
253
|
+
safeSend(client, { type: "error", message: "Something went wrong narrating that step." });
|
|
254
|
+
});
|
|
237
255
|
}
|
|
238
256
|
} catch {
|
|
239
257
|
// Ignore malformed control messages — never crash the relay on bad client input.
|
|
@@ -250,13 +268,15 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
250
268
|
});
|
|
251
269
|
}
|
|
252
270
|
|
|
253
|
-
async function handleDeepgramMessage(
|
|
271
|
+
export async function handleDeepgramMessage(
|
|
254
272
|
raw: string,
|
|
255
273
|
client: WebSocket,
|
|
256
274
|
deps: ConnectionDeps,
|
|
257
275
|
getContext: () => { route: string; visible: string[] },
|
|
258
276
|
speakStreamed: (text: string) => Promise<void>,
|
|
259
277
|
history: HistoryTurn[],
|
|
278
|
+
turnState: { buffer: string },
|
|
279
|
+
getGeneration: () => number,
|
|
260
280
|
): Promise<void> {
|
|
261
281
|
let msg: any;
|
|
262
282
|
try {
|
|
@@ -265,26 +285,74 @@ async function handleDeepgramMessage(
|
|
|
265
285
|
return;
|
|
266
286
|
}
|
|
267
287
|
|
|
288
|
+
if (msg.type === "UtteranceEnd") {
|
|
289
|
+
// A second, independent "the user is truly done" signal Deepgram sends
|
|
290
|
+
// after utterance_end_ms of silence — a safety net for the rare case a
|
|
291
|
+
// Results message never carries speech_final:true, so a turn can't get
|
|
292
|
+
// permanently stuck with real transcript sitting in the buffer forever.
|
|
293
|
+
if (turnState.buffer) await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
268
297
|
if (msg.type !== "Results") return;
|
|
269
298
|
const transcript: string | undefined = msg.channel?.alternatives?.[0]?.transcript;
|
|
270
299
|
if (!transcript) return;
|
|
271
300
|
|
|
272
301
|
if (!msg.is_final) {
|
|
273
|
-
safeSend(client, { type: "interim", text: transcript });
|
|
302
|
+
safeSend(client, { type: "interim", text: turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// is_final means this chunk of transcript is stable and won't be
|
|
307
|
+
// revised — it does NOT mean the user is done talking. Deepgram can (and
|
|
308
|
+
// routinely does) finalize several chunks of one continuous utterance in
|
|
309
|
+
// a row with no real pause between them. Only speech_final (endpointing
|
|
310
|
+
// actually detected a pause) means the turn is genuinely over. Found
|
|
311
|
+
// live, not theoretical: treating every is_final as a separate finished
|
|
312
|
+
// question fired two independent LLM+TTS turns for one utterance — the
|
|
313
|
+
// literal cause of both the duplicated transcript entries ("hello" /
|
|
314
|
+
// "hello" with no reply in between) and the agent audibly speaking
|
|
315
|
+
// twice, overlapping.
|
|
316
|
+
turnState.buffer = turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript;
|
|
317
|
+
if (!msg.speech_final) {
|
|
318
|
+
safeSend(client, { type: "interim", text: turnState.buffer });
|
|
274
319
|
return;
|
|
275
320
|
}
|
|
276
321
|
|
|
322
|
+
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Everything from here on (the LLM call, TTS streaming) can fail in ways
|
|
327
|
+
* that have nothing to do with a malformed message — a flaky provider
|
|
328
|
+
* call, a rate limit, a dropped upstream connection. handleDeepgramMessage
|
|
329
|
+
* is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
|
|
330
|
+
* uncaught throw here previously vanished into an unhandled rejection: the
|
|
331
|
+
* client had already been told "final" (entering its "thinking" state) and
|
|
332
|
+
* then simply never heard from the server again for this turn — stuck
|
|
333
|
+
* indefinitely with the mic never resuming. Every path out of the try
|
|
334
|
+
* block now sends the client something that ends the turn.
|
|
335
|
+
*
|
|
336
|
+
* myGeneration is captured before the (potentially slow) LLM call and
|
|
337
|
+
* re-checked before the verb/speech actually goes out — a barge-in that
|
|
338
|
+
* happens while this turn is still "thinking" bumps the generation, and
|
|
339
|
+
* without this check the now-stale response would still land on the
|
|
340
|
+
* client after the user had already moved on to a new question.
|
|
341
|
+
*/
|
|
342
|
+
async function finalizeTurn(
|
|
343
|
+
turnState: { buffer: string },
|
|
344
|
+
client: WebSocket,
|
|
345
|
+
deps: ConnectionDeps,
|
|
346
|
+
getContext: () => { route: string; visible: string[] },
|
|
347
|
+
speakStreamed: (text: string) => Promise<void>,
|
|
348
|
+
history: HistoryTurn[],
|
|
349
|
+
getGeneration: () => number,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
const transcript = turnState.buffer;
|
|
352
|
+
turnState.buffer = "";
|
|
353
|
+
const myGeneration = getGeneration();
|
|
277
354
|
safeSend(client, { type: "final", text: transcript });
|
|
278
355
|
|
|
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
356
|
try {
|
|
289
357
|
const { route, visible } = getContext();
|
|
290
358
|
const verb = await resolveVerb(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
|
|
@@ -293,6 +361,9 @@ async function handleDeepgramMessage(
|
|
|
293
361
|
visible,
|
|
294
362
|
history,
|
|
295
363
|
});
|
|
364
|
+
|
|
365
|
+
if (myGeneration !== getGeneration()) return; // superseded by a barge-in while this turn was resolving
|
|
366
|
+
|
|
296
367
|
// Sent immediately — before speech synthesis even starts — so
|
|
297
368
|
// highlight/navigate/do execute in the browser right away instead of
|
|
298
369
|
// waiting on audio. The agent visibly acts while it's still about to
|
|
@@ -312,8 +383,10 @@ async function handleDeepgramMessage(
|
|
|
312
383
|
}
|
|
313
384
|
} catch (err) {
|
|
314
385
|
console.error("[cairn realtime] failed to resolve/speak this turn:", err);
|
|
315
|
-
|
|
316
|
-
|
|
386
|
+
if (myGeneration === getGeneration()) {
|
|
387
|
+
safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
|
|
388
|
+
safeSend(client, { type: "turn_complete" });
|
|
389
|
+
}
|
|
317
390
|
}
|
|
318
391
|
}
|
|
319
392
|
|