@voicethere/agent 0.5.3 → 0.5.5
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/templates/game-sync/agent.js +11428 -191
- package/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +21 -1
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/voice-showcase/agent.js +1397 -0
- package/package.json +11 -11
- package/templates/README.md +19 -11
- package/templates/game-sync-protocol.ts +65 -0
- package/templates/game-sync-redis.ts +121 -0
- package/templates/game-sync-sim.ts +122 -0
- package/templates/game-sync-world-layout.ts +194 -0
- package/templates/game-sync.ts +482 -300
- package/templates/voice-showcase/agent.ts +119 -0
- package/templates/voice-showcase/conversation.ts +531 -0
- package/templates/voice-showcase/fun-facts.ts +24 -0
- package/templates/voice-showcase/recipes.ts +57 -0
- package/templates/voice-showcase/weather.ts +356 -0
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// dist/runtime.js
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
|
|
9
|
+
// dist/session-serial-queue.js
|
|
10
|
+
var SessionSerialQueueCancellationError = class extends Error {
|
|
11
|
+
sessionId;
|
|
12
|
+
generation;
|
|
13
|
+
code = "SESSION_SERIAL_QUEUE_CANCELLED";
|
|
14
|
+
constructor(sessionId, generation) {
|
|
15
|
+
super(`session serial queue cancelled (sessionId=${sessionId}, generation=${generation})`);
|
|
16
|
+
this.sessionId = sessionId;
|
|
17
|
+
this.generation = generation;
|
|
18
|
+
this.name = "SessionSerialQueueCancellationError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var SessionSerialQueue = class {
|
|
22
|
+
sessions = /* @__PURE__ */ new Map();
|
|
23
|
+
/** Monotonic token source — never reused across clear/enqueue cycles. */
|
|
24
|
+
nextGeneration = 1;
|
|
25
|
+
/**
|
|
26
|
+
* Enqueue work for `sessionId`. Returns a promise that settles when this
|
|
27
|
+
* task finishes, is skipped as stale, or is cancelled by {@link clear}.
|
|
28
|
+
* Fire-and-forget callers may ignore the return value (compat).
|
|
29
|
+
*/
|
|
30
|
+
enqueue(sessionId, task) {
|
|
31
|
+
const state = this.ensureState(sessionId);
|
|
32
|
+
const captured = state;
|
|
33
|
+
const generation = captured.generation;
|
|
34
|
+
captured.pending += 1;
|
|
35
|
+
const previous = captured.tail;
|
|
36
|
+
let taskAbort = null;
|
|
37
|
+
const settled = previous.catch(() => void 0).then(async () => {
|
|
38
|
+
if (this.sessions.get(sessionId) !== captured) {
|
|
39
|
+
return "cancelled";
|
|
40
|
+
}
|
|
41
|
+
const abort = new AbortController();
|
|
42
|
+
taskAbort = abort;
|
|
43
|
+
captured.abort = abort;
|
|
44
|
+
const context = {
|
|
45
|
+
sessionId,
|
|
46
|
+
generation,
|
|
47
|
+
signal: abort.signal
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
await Promise.resolve(task(abort.signal, context));
|
|
51
|
+
if (this.sessions.get(sessionId) !== captured) {
|
|
52
|
+
return "cancelled";
|
|
53
|
+
}
|
|
54
|
+
return "completed";
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (abort.signal.aborted || this.sessions.get(sessionId) !== captured || error instanceof SessionSerialQueueCancellationError) {
|
|
57
|
+
return "cancelled";
|
|
58
|
+
}
|
|
59
|
+
return "failed";
|
|
60
|
+
} finally {
|
|
61
|
+
if (captured.abort === abort) {
|
|
62
|
+
captured.abort = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}).finally(() => {
|
|
66
|
+
captured.pending = Math.max(0, captured.pending - 1);
|
|
67
|
+
if (taskAbort && captured.abort === taskAbort) {
|
|
68
|
+
captured.abort = null;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
captured.tail = settled;
|
|
72
|
+
return settled;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Invalidate queued + running work for `sessionId`. Aborts the live AbortSignal,
|
|
76
|
+
* drops the map entry, and leaves a fresh identity for the next enqueue.
|
|
77
|
+
* Old in-flight `finally` blocks only touch their captured state object.
|
|
78
|
+
*/
|
|
79
|
+
clear(sessionId) {
|
|
80
|
+
const old = this.sessions.get(sessionId);
|
|
81
|
+
if (!old)
|
|
82
|
+
return;
|
|
83
|
+
old.abort?.abort();
|
|
84
|
+
old.abort = null;
|
|
85
|
+
this.sessions.delete(sessionId);
|
|
86
|
+
}
|
|
87
|
+
clearAll() {
|
|
88
|
+
for (const sessionId of [...this.sessions.keys()]) {
|
|
89
|
+
this.clear(sessionId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
hasPending(sessionId) {
|
|
93
|
+
return (this.sessions.get(sessionId)?.pending ?? 0) > 0;
|
|
94
|
+
}
|
|
95
|
+
/** Live generation token for a session, or `undefined` when cleared. */
|
|
96
|
+
generationOf(sessionId) {
|
|
97
|
+
return this.sessions.get(sessionId)?.generation;
|
|
98
|
+
}
|
|
99
|
+
/** True when `generation` is the live map entry for `sessionId`. */
|
|
100
|
+
isCurrentGeneration(sessionId, generation) {
|
|
101
|
+
return this.sessions.get(sessionId)?.generation === generation;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* True when a queue row exists for the session (registered since first
|
|
105
|
+
* enqueue, until {@link clear}). Not the same as {@link hasPending}.
|
|
106
|
+
*/
|
|
107
|
+
isLive(sessionId) {
|
|
108
|
+
return this.sessions.has(sessionId);
|
|
109
|
+
}
|
|
110
|
+
/** Registered session rows (must not grow without matching `clear` calls). */
|
|
111
|
+
get activeSessionCount() {
|
|
112
|
+
return this.sessions.size;
|
|
113
|
+
}
|
|
114
|
+
ensureState(sessionId) {
|
|
115
|
+
const existing = this.sessions.get(sessionId);
|
|
116
|
+
if (existing)
|
|
117
|
+
return existing;
|
|
118
|
+
const created = {
|
|
119
|
+
generation: this.nextGeneration++,
|
|
120
|
+
tail: Promise.resolve(),
|
|
121
|
+
abort: null,
|
|
122
|
+
pending: 0
|
|
123
|
+
};
|
|
124
|
+
this.sessions.set(sessionId, created);
|
|
125
|
+
return created;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// dist/runtime.js
|
|
130
|
+
var SESSION_START_INIT_DELAY_ENABLED_ENV = "AGENT_SESSION_START_INIT_DELAY_ENABLED";
|
|
131
|
+
var SESSION_START_INIT_DELAY_MS_ENV = "AGENT_SESSION_START_INIT_DELAY_MS";
|
|
132
|
+
var DEFAULT_SESSION_START_INIT_DELAY_MS = 500;
|
|
133
|
+
function isRecordingControlAckMessage(value) {
|
|
134
|
+
if (!value || typeof value !== "object")
|
|
135
|
+
return false;
|
|
136
|
+
const msg = value;
|
|
137
|
+
return msg.type === "recording_control_ack" && typeof msg.requestId === "string";
|
|
138
|
+
}
|
|
139
|
+
function isWebhookMessage(value) {
|
|
140
|
+
if (!value || typeof value !== "object")
|
|
141
|
+
return false;
|
|
142
|
+
const msg = value;
|
|
143
|
+
return msg.type === "webhook" && typeof msg.eventId === "string" && typeof msg.projectId === "string";
|
|
144
|
+
}
|
|
145
|
+
function coerceInboundBinary(value) {
|
|
146
|
+
if (!value)
|
|
147
|
+
return null;
|
|
148
|
+
if (Buffer.isBuffer(value))
|
|
149
|
+
return value;
|
|
150
|
+
if (value instanceof Uint8Array) {
|
|
151
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
152
|
+
}
|
|
153
|
+
if (value instanceof ArrayBuffer)
|
|
154
|
+
return Buffer.from(value);
|
|
155
|
+
if (ArrayBuffer.isView(value)) {
|
|
156
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
157
|
+
}
|
|
158
|
+
if (typeof value === "object") {
|
|
159
|
+
const maybeBufferLike = value;
|
|
160
|
+
if (maybeBufferLike.type === "Buffer" && Array.isArray(maybeBufferLike.data)) {
|
|
161
|
+
return Buffer.from(maybeBufferLike.data);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
function normalizeWebhookHeaders(value) {
|
|
167
|
+
if (!value || typeof value !== "object")
|
|
168
|
+
return {};
|
|
169
|
+
const out = {};
|
|
170
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
171
|
+
if (typeof raw === "string") {
|
|
172
|
+
out[key] = raw;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function isParentMessage(value) {
|
|
178
|
+
if (!value || typeof value !== "object")
|
|
179
|
+
return false;
|
|
180
|
+
const msg = value;
|
|
181
|
+
return msg.type === "session_start" || msg.type === "speech_event" || msg.type === "session_end" || msg.type === "data_channel_message" || msg.type === "data_channel_binary" || msg.type === "idle_timeout" || msg.type === "recording_control_ack" || msg.type === "webhook";
|
|
182
|
+
}
|
|
183
|
+
function isSessionScopedParentMessage(value) {
|
|
184
|
+
return isParentMessage(value) && !isWebhookMessage(value);
|
|
185
|
+
}
|
|
186
|
+
function parseDataChannelPayload(raw) {
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(raw);
|
|
189
|
+
} catch {
|
|
190
|
+
return raw;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
var peerEnvBySessionId = /* @__PURE__ */ new Map();
|
|
194
|
+
var endedSessionIds = /* @__PURE__ */ new Set();
|
|
195
|
+
var pendingRecordingAcks = /* @__PURE__ */ new Map();
|
|
196
|
+
function handleRecordingControlAck(message) {
|
|
197
|
+
const pending = pendingRecordingAcks.get(message.requestId);
|
|
198
|
+
if (!pending)
|
|
199
|
+
return;
|
|
200
|
+
clearTimeout(pending.timer);
|
|
201
|
+
pendingRecordingAcks.delete(message.requestId);
|
|
202
|
+
pending.resolve({
|
|
203
|
+
ok: message.ok,
|
|
204
|
+
reason: message.reason,
|
|
205
|
+
requestId: message.requestId
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function clearPendingRecordingAcksForSession(sessionId, reason) {
|
|
209
|
+
for (const [requestId, pending] of pendingRecordingAcks) {
|
|
210
|
+
if (pending.sessionId !== sessionId)
|
|
211
|
+
continue;
|
|
212
|
+
clearTimeout(pending.timer);
|
|
213
|
+
pendingRecordingAcks.delete(requestId);
|
|
214
|
+
pending.resolve({ ok: false, reason, requestId });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
var sessionExecutionContext = new AsyncLocalStorage();
|
|
218
|
+
var agentLogSessionContext = new AsyncLocalStorage();
|
|
219
|
+
var inboundQueueAuthority = null;
|
|
220
|
+
var childUnhandledRejectionGuardInstalled = false;
|
|
221
|
+
function normalizeRejectionReason(reason) {
|
|
222
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
223
|
+
}
|
|
224
|
+
function installChildUnhandledRejectionGuard() {
|
|
225
|
+
if (childUnhandledRejectionGuardInstalled) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
childUnhandledRejectionGuardInstalled = true;
|
|
229
|
+
process.on("unhandledRejection", (reason) => {
|
|
230
|
+
const err = normalizeRejectionReason(reason);
|
|
231
|
+
const store = sessionExecutionContext.getStore();
|
|
232
|
+
const sessionId = store?.sessionId ?? agentLogSessionContext.getStore() ?? "";
|
|
233
|
+
if (!allowOutboundForSession(sessionId || void 0)) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
agentLog("error", `unhandledRejection: ${err.message}`, sessionId || void 0);
|
|
237
|
+
sendParentMessage({
|
|
238
|
+
type: "agent_error",
|
|
239
|
+
sessionId,
|
|
240
|
+
message: err.message,
|
|
241
|
+
stack: err.stack
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function allowOutboundForSession(sessionId) {
|
|
246
|
+
if (!sessionId) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
const store = sessionExecutionContext.getStore();
|
|
250
|
+
const queue = inboundQueueAuthority;
|
|
251
|
+
if (store && store.sessionId === sessionId) {
|
|
252
|
+
if (!queue)
|
|
253
|
+
return !endedSessionIds.has(sessionId);
|
|
254
|
+
return queue.isCurrentGeneration(sessionId, store.generation);
|
|
255
|
+
}
|
|
256
|
+
if (endedSessionIds.has(sessionId))
|
|
257
|
+
return false;
|
|
258
|
+
if (!queue)
|
|
259
|
+
return true;
|
|
260
|
+
return queue.isLive(sessionId);
|
|
261
|
+
}
|
|
262
|
+
function sendParentMessage(message) {
|
|
263
|
+
const sessionId = message && typeof message === "object" && "sessionId" in message && typeof message.sessionId === "string" ? message.sessionId : void 0;
|
|
264
|
+
if (!allowOutboundForSession(sessionId))
|
|
265
|
+
return;
|
|
266
|
+
process.send?.(message);
|
|
267
|
+
}
|
|
268
|
+
function parseBooleanEnv(value, defaultValue) {
|
|
269
|
+
if (value === void 0)
|
|
270
|
+
return defaultValue;
|
|
271
|
+
const normalized = value.trim().toLowerCase();
|
|
272
|
+
if (normalized === "")
|
|
273
|
+
return defaultValue;
|
|
274
|
+
if (["0", "false", "off", "no"].includes(normalized))
|
|
275
|
+
return false;
|
|
276
|
+
if (["1", "true", "on", "yes"].includes(normalized))
|
|
277
|
+
return true;
|
|
278
|
+
return defaultValue;
|
|
279
|
+
}
|
|
280
|
+
function parseNonNegativeIntegerEnv(value, defaultValue) {
|
|
281
|
+
if (value === void 0)
|
|
282
|
+
return defaultValue;
|
|
283
|
+
const parsed = Number(value);
|
|
284
|
+
if (!Number.isFinite(parsed) || parsed < 0)
|
|
285
|
+
return defaultValue;
|
|
286
|
+
return Math.floor(parsed);
|
|
287
|
+
}
|
|
288
|
+
function resolveSessionStartInitDelayMs() {
|
|
289
|
+
const enabled = parseBooleanEnv(process.env[SESSION_START_INIT_DELAY_ENABLED_ENV], true);
|
|
290
|
+
if (!enabled)
|
|
291
|
+
return 0;
|
|
292
|
+
return parseNonNegativeIntegerEnv(process.env[SESSION_START_INIT_DELAY_MS_ENV], DEFAULT_SESSION_START_INIT_DELAY_MS);
|
|
293
|
+
}
|
|
294
|
+
async function handleWebhookMessage(message, handlers) {
|
|
295
|
+
if (!handlers.onWebhook)
|
|
296
|
+
return;
|
|
297
|
+
const body = coerceInboundBinary(message.body);
|
|
298
|
+
if (!body) {
|
|
299
|
+
agentLog("warn", "webhook ipc dropped: body is not binary");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const ctx = {
|
|
303
|
+
eventId: message.eventId,
|
|
304
|
+
projectId: message.projectId,
|
|
305
|
+
method: typeof message.method === "string" ? message.method : "POST",
|
|
306
|
+
path: typeof message.path === "string" ? message.path : "",
|
|
307
|
+
headers: normalizeWebhookHeaders(message.headers),
|
|
308
|
+
body,
|
|
309
|
+
contentType: typeof message.contentType === "string" ? message.contentType : null,
|
|
310
|
+
receivedAt: typeof message.receivedAt === "string" ? message.receivedAt : "",
|
|
311
|
+
sessionIds: Array.isArray(message.sessionIds) ? message.sessionIds.filter((id) => typeof id === "string" && id.length > 0) : []
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
const started = Date.now();
|
|
315
|
+
await handlers.onWebhook(ctx);
|
|
316
|
+
sendParentMessage({
|
|
317
|
+
type: "webhook_handled",
|
|
318
|
+
projectId: message.projectId,
|
|
319
|
+
eventId: message.eventId,
|
|
320
|
+
durationMs: Date.now() - started
|
|
321
|
+
});
|
|
322
|
+
} catch (error) {
|
|
323
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
324
|
+
await runErrorHook(handlers, {
|
|
325
|
+
sessionId: "",
|
|
326
|
+
projectId: message.projectId,
|
|
327
|
+
env: process.env,
|
|
328
|
+
error: err
|
|
329
|
+
});
|
|
330
|
+
sendParentMessage({
|
|
331
|
+
type: "agent_error",
|
|
332
|
+
sessionId: "",
|
|
333
|
+
message: err.message,
|
|
334
|
+
stack: err.stack
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function handleParentMessage(message, handlers) {
|
|
339
|
+
switch (message.type) {
|
|
340
|
+
case "session_start":
|
|
341
|
+
endedSessionIds.delete(message.sessionId);
|
|
342
|
+
peerEnvBySessionId.set(message.sessionId, message.env);
|
|
343
|
+
const sessionStartInitDelayMs = resolveSessionStartInitDelayMs();
|
|
344
|
+
if (sessionStartInitDelayMs > 0) {
|
|
345
|
+
await new Promise((resolve) => setTimeout(resolve, sessionStartInitDelayMs));
|
|
346
|
+
}
|
|
347
|
+
await (handlers.onClientJoin ?? handlers.onSessionStart)?.({
|
|
348
|
+
sessionId: message.sessionId,
|
|
349
|
+
env: message.env,
|
|
350
|
+
recordingAvailable: message.recordingAvailable ?? false
|
|
351
|
+
});
|
|
352
|
+
sendParentMessage({
|
|
353
|
+
type: "session_start_ack",
|
|
354
|
+
sessionId: message.sessionId
|
|
355
|
+
});
|
|
356
|
+
break;
|
|
357
|
+
case "speech_event":
|
|
358
|
+
await handlers.onSpeechEvent?.({ sessionId: message.sessionId }, message.event);
|
|
359
|
+
if (message.event.type === "user_speech_final" && typeof message.event.text === "string" && message.event.text.trim()) {
|
|
360
|
+
await handlers.onUserSpeechFinal?.({
|
|
361
|
+
sessionId: message.sessionId,
|
|
362
|
+
text: message.event.text.trim()
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
case "data_channel_message":
|
|
367
|
+
await handlers.onDataChannelMessage?.({
|
|
368
|
+
sessionId: message.sessionId,
|
|
369
|
+
message: parseDataChannelPayload(message.payload),
|
|
370
|
+
raw: message.payload,
|
|
371
|
+
rawBinary: null,
|
|
372
|
+
channel: "control"
|
|
373
|
+
});
|
|
374
|
+
break;
|
|
375
|
+
case "data_channel_binary":
|
|
376
|
+
await handlers.onDataChannelBinary?.({
|
|
377
|
+
sessionId: message.sessionId,
|
|
378
|
+
message: null,
|
|
379
|
+
raw: null,
|
|
380
|
+
rawBinary: message.data,
|
|
381
|
+
channel: message.channel ?? "sync"
|
|
382
|
+
});
|
|
383
|
+
break;
|
|
384
|
+
case "session_end":
|
|
385
|
+
clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
|
|
386
|
+
peerEnvBySessionId.delete(message.sessionId);
|
|
387
|
+
await (handlers.onClientLeave ?? handlers.onSessionEnd)?.({
|
|
388
|
+
sessionId: message.sessionId
|
|
389
|
+
});
|
|
390
|
+
break;
|
|
391
|
+
case "idle_timeout":
|
|
392
|
+
await runIdleTimeoutHook(handlers, message);
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function defineAgent(handlers) {
|
|
397
|
+
installChildUnhandledRejectionGuard();
|
|
398
|
+
const inboundBySession = new SessionSerialQueue();
|
|
399
|
+
inboundQueueAuthority = inboundBySession;
|
|
400
|
+
const agentStartReady = runAgentStartHook(handlers);
|
|
401
|
+
process.on("message", (message) => {
|
|
402
|
+
if (isRecordingControlAckMessage(message)) {
|
|
403
|
+
handleRecordingControlAck(message);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (isWebhookMessage(message)) {
|
|
407
|
+
void agentStartReady.then(() => handleWebhookMessage(message, handlers));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (!isSessionScopedParentMessage(message))
|
|
411
|
+
return;
|
|
412
|
+
if (message.type === "session_end") {
|
|
413
|
+
endedSessionIds.add(message.sessionId);
|
|
414
|
+
clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
|
|
415
|
+
inboundBySession.clear(message.sessionId);
|
|
416
|
+
}
|
|
417
|
+
if (message.type === "session_start") {
|
|
418
|
+
endedSessionIds.delete(message.sessionId);
|
|
419
|
+
inboundBySession.clear(message.sessionId);
|
|
420
|
+
}
|
|
421
|
+
inboundBySession.enqueue(message.sessionId, async (_signal, context) => {
|
|
422
|
+
await agentStartReady;
|
|
423
|
+
try {
|
|
424
|
+
await sessionExecutionContext.run({
|
|
425
|
+
sessionId: message.sessionId,
|
|
426
|
+
generation: context.generation
|
|
427
|
+
}, async () => agentLogSessionContext.run(message.sessionId, async () => {
|
|
428
|
+
try {
|
|
429
|
+
await handleParentMessage(message, handlers);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
432
|
+
const env = peerEnvBySessionId.get(message.sessionId) ?? buildIdleEnv(message.sessionId);
|
|
433
|
+
await runErrorHook(handlers, {
|
|
434
|
+
sessionId: message.sessionId,
|
|
435
|
+
projectId: env.PROJECT_ID,
|
|
436
|
+
buildId: env.BUILD_ID,
|
|
437
|
+
env,
|
|
438
|
+
error: err,
|
|
439
|
+
customerContext: parseCustomerContext(env.AGENT_CUSTOMER_CONTEXT)
|
|
440
|
+
});
|
|
441
|
+
sendParentMessage({
|
|
442
|
+
type: "agent_error",
|
|
443
|
+
sessionId: message.sessionId,
|
|
444
|
+
message: err.message,
|
|
445
|
+
stack: err.stack
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}));
|
|
449
|
+
} finally {
|
|
450
|
+
if (message.type === "session_end" && inboundBySession.isCurrentGeneration(message.sessionId, context.generation)) {
|
|
451
|
+
inboundBySession.clear(message.sessionId);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
async function runAgentStartHook(handlers) {
|
|
458
|
+
if (!handlers.onAgentStart)
|
|
459
|
+
return;
|
|
460
|
+
try {
|
|
461
|
+
await handlers.onAgentStart({
|
|
462
|
+
env: process.env
|
|
463
|
+
});
|
|
464
|
+
} catch (error) {
|
|
465
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
466
|
+
agentLog("error", `onAgentStart failed: ${err.message}`);
|
|
467
|
+
sendParentMessage({
|
|
468
|
+
type: "agent_error",
|
|
469
|
+
sessionId: "",
|
|
470
|
+
message: err.message,
|
|
471
|
+
stack: err.stack
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function parseCustomerContext(raw) {
|
|
476
|
+
if (!raw?.trim())
|
|
477
|
+
return void 0;
|
|
478
|
+
try {
|
|
479
|
+
const parsed = JSON.parse(raw);
|
|
480
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
481
|
+
return parsed;
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
}
|
|
485
|
+
return void 0;
|
|
486
|
+
}
|
|
487
|
+
async function runErrorHook(handlers, ctx) {
|
|
488
|
+
if (!handlers.errorHook)
|
|
489
|
+
return;
|
|
490
|
+
try {
|
|
491
|
+
await handlers.errorHook(ctx);
|
|
492
|
+
} catch (hookError) {
|
|
493
|
+
const message = hookError instanceof Error ? hookError.message : String(hookError);
|
|
494
|
+
agentLog("error", `errorHook failed: ${message}`, ctx.sessionId);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async function runIdleTimeoutHook(handlers, message) {
|
|
498
|
+
const onIdleTimeout = handlers.onIdleTimeout;
|
|
499
|
+
agentLog("info", `idle_timeout ipc received (maxGraceMs=${message.maxGraceMs}, onIdleTimeout=${typeof onIdleTimeout === "function"})`, message.sessionId);
|
|
500
|
+
if (!onIdleTimeout) {
|
|
501
|
+
sendParentMessage({
|
|
502
|
+
type: "idle_timeout_done",
|
|
503
|
+
sessionId: message.sessionId
|
|
504
|
+
});
|
|
505
|
+
agentLog("info", "idle_timeout_done ipc sent (no onIdleTimeout handler)", message.sessionId);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const env = peerEnvBySessionId.get(message.sessionId) ?? buildIdleEnv(message.sessionId);
|
|
509
|
+
const idleTimeoutSeconds = Number(env.IDLE_TIMEOUT_SEC) || 0;
|
|
510
|
+
const ctx = {
|
|
511
|
+
sessionId: message.sessionId,
|
|
512
|
+
projectId: env.PROJECT_ID,
|
|
513
|
+
buildId: env.BUILD_ID,
|
|
514
|
+
env,
|
|
515
|
+
idleTimeoutSeconds
|
|
516
|
+
};
|
|
517
|
+
let error;
|
|
518
|
+
try {
|
|
519
|
+
await onIdleTimeout(ctx);
|
|
520
|
+
} catch (hookError) {
|
|
521
|
+
error = hookError instanceof Error ? hookError.message : String(hookError);
|
|
522
|
+
agentLog("error", `onIdleTimeout failed: ${error}`, message.sessionId);
|
|
523
|
+
}
|
|
524
|
+
sendParentMessage({
|
|
525
|
+
type: "idle_timeout_done",
|
|
526
|
+
sessionId: message.sessionId,
|
|
527
|
+
error
|
|
528
|
+
});
|
|
529
|
+
agentLog("info", error ? `idle_timeout_done ipc sent (onIdleTimeout error: ${error})` : "idle_timeout_done ipc sent (onIdleTimeout completed)", message.sessionId);
|
|
530
|
+
}
|
|
531
|
+
function buildIdleEnv(sessionId) {
|
|
532
|
+
return {
|
|
533
|
+
SESSION_ID: sessionId,
|
|
534
|
+
...process.env.PROJECT_ID ? { PROJECT_ID: process.env.PROJECT_ID } : {},
|
|
535
|
+
...process.env.BUILD_ID ? { BUILD_ID: process.env.BUILD_ID } : {},
|
|
536
|
+
...process.env.IDLE_TIMEOUT_SEC ? { IDLE_TIMEOUT_SEC: process.env.IDLE_TIMEOUT_SEC } : {}
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function speak(sessionId, text) {
|
|
540
|
+
sendParentMessage({ type: "speak", sessionId, text });
|
|
541
|
+
}
|
|
542
|
+
function sendToClient(sessionId, payload) {
|
|
543
|
+
sendParentMessage({ type: "send_to_client", sessionId, payload });
|
|
544
|
+
}
|
|
545
|
+
var AGENT_LOG_MESSAGE_MAX_CHARS = 2048;
|
|
546
|
+
var AGENT_LOG_FIELDS_MAX_CHARS = 8192;
|
|
547
|
+
function truncateAgentLogMessage(message) {
|
|
548
|
+
if (message.length <= AGENT_LOG_MESSAGE_MAX_CHARS) {
|
|
549
|
+
return message;
|
|
550
|
+
}
|
|
551
|
+
const suffix = "\u2026[truncated]";
|
|
552
|
+
return message.slice(0, AGENT_LOG_MESSAGE_MAX_CHARS - suffix.length) + suffix;
|
|
553
|
+
}
|
|
554
|
+
function isPlainObject(value) {
|
|
555
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
556
|
+
}
|
|
557
|
+
function sanitizeAgentLogFields(fields) {
|
|
558
|
+
if (Object.keys(fields).length === 0) {
|
|
559
|
+
return void 0;
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
const serialized = JSON.stringify(fields);
|
|
563
|
+
if (serialized.length <= AGENT_LOG_FIELDS_MAX_CHARS) {
|
|
564
|
+
return fields;
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
_agentLogFieldsTruncated: true,
|
|
568
|
+
_originalBytes: serialized.length,
|
|
569
|
+
_preview: serialized.slice(0, AGENT_LOG_FIELDS_MAX_CHARS - 80) + "\u2026[truncated]"
|
|
570
|
+
};
|
|
571
|
+
} catch {
|
|
572
|
+
return { _agentLogFieldsError: "not_serializable" };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function buildAgentLogPayload(level, message, fields, sessionId) {
|
|
576
|
+
const resolvedSessionId = sessionId ?? sessionExecutionContext.getStore()?.sessionId ?? agentLogSessionContext.getStore();
|
|
577
|
+
const sanitizedFields = fields ? sanitizeAgentLogFields(fields) : void 0;
|
|
578
|
+
return {
|
|
579
|
+
type: "log",
|
|
580
|
+
level,
|
|
581
|
+
message: truncateAgentLogMessage(message),
|
|
582
|
+
ts: Date.now(),
|
|
583
|
+
...resolvedSessionId ? { sessionId: resolvedSessionId } : {},
|
|
584
|
+
...sanitizedFields ? { fields: sanitizedFields } : {}
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function agentLog(level, message, fieldsOrSessionId, sessionId) {
|
|
588
|
+
let fields;
|
|
589
|
+
let resolvedSessionId;
|
|
590
|
+
if (typeof fieldsOrSessionId === "string") {
|
|
591
|
+
resolvedSessionId = fieldsOrSessionId;
|
|
592
|
+
} else if (isPlainObject(fieldsOrSessionId)) {
|
|
593
|
+
fields = fieldsOrSessionId;
|
|
594
|
+
resolvedSessionId = sessionId;
|
|
595
|
+
} else {
|
|
596
|
+
resolvedSessionId = sessionId;
|
|
597
|
+
}
|
|
598
|
+
sendParentMessage(buildAgentLogPayload(level, message, fields, resolvedSessionId));
|
|
599
|
+
}
|
|
600
|
+
function parseChatText(message) {
|
|
601
|
+
if (!message || typeof message !== "object")
|
|
602
|
+
return null;
|
|
603
|
+
const record = message;
|
|
604
|
+
if (record.type !== "chat" || typeof record.text !== "string")
|
|
605
|
+
return null;
|
|
606
|
+
const trimmed = record.text.trim();
|
|
607
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// templates/voice-showcase/recipes.ts
|
|
611
|
+
var RECIPES = [
|
|
612
|
+
{
|
|
613
|
+
title: "Quick garlic pasta",
|
|
614
|
+
keywords: ["pasta", "noodle", "spaghetti", "italian"],
|
|
615
|
+
steps: "Boil pasta until al dente. Saut\xE9 minced garlic in olive oil, toss with pasta, parmesan, and black pepper. Serve hot."
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
title: "Simple vegetable soup",
|
|
619
|
+
keywords: ["soup", "broth", "stew"],
|
|
620
|
+
steps: "Saut\xE9 onion and carrot in a pot. Add vegetable stock, diced potatoes, and simmer twenty minutes. Season with salt and herbs."
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
title: "Easy breakfast scramble",
|
|
624
|
+
keywords: ["breakfast", "eggs", "morning", "brunch"],
|
|
625
|
+
steps: "Whisk three eggs with a splash of milk. Cook in a buttered pan with spinach and cheese. Fold and serve with toast."
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
title: "Classic chocolate chip cookies",
|
|
629
|
+
keywords: ["cookie", "cookies", "dessert", "sweet", "bake"],
|
|
630
|
+
steps: "Cream butter and sugar, mix in flour, egg, and chocolate chips. Drop spoonfuls on a tray and bake at one seventy five Celsius for ten minutes."
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
title: "Fresh garden salad",
|
|
634
|
+
keywords: ["salad", "greens", "vegetable", "healthy"],
|
|
635
|
+
steps: "Toss mixed greens with cherry tomatoes, cucumber, and feta. Dress with olive oil, lemon juice, salt, and pepper."
|
|
636
|
+
}
|
|
637
|
+
];
|
|
638
|
+
var DEFAULT_RECIPE = RECIPES[0];
|
|
639
|
+
function pickRecipe(utterance) {
|
|
640
|
+
const lower = utterance.toLowerCase();
|
|
641
|
+
for (const recipe of RECIPES) {
|
|
642
|
+
if (recipe.keywords.some((kw) => lower.includes(kw))) {
|
|
643
|
+
return recipe;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return DEFAULT_RECIPE;
|
|
647
|
+
}
|
|
648
|
+
function formatRecipeSpeech(recipe) {
|
|
649
|
+
return `${recipe.title}. ${recipe.steps}`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// templates/voice-showcase/fun-facts.ts
|
|
653
|
+
var FUN_FACTS = [
|
|
654
|
+
"Honey never spoils \u2014 archaeologists have found edible honey in ancient Egyptian tombs.",
|
|
655
|
+
"Octopuses have three hearts and blue blood.",
|
|
656
|
+
"A day on Venus is longer than a year on Venus.",
|
|
657
|
+
"Bananas are berries, but strawberries are not.",
|
|
658
|
+
"The Eiffel Tower can grow about six inches taller in summer heat.",
|
|
659
|
+
"Sharks existed before trees appeared on Earth."
|
|
660
|
+
];
|
|
661
|
+
var factIndex = 0;
|
|
662
|
+
function pickFunFact() {
|
|
663
|
+
const fact = FUN_FACTS[factIndex % FUN_FACTS.length];
|
|
664
|
+
factIndex += 1;
|
|
665
|
+
return fact;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// templates/voice-showcase/weather.ts
|
|
669
|
+
var GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search";
|
|
670
|
+
var FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
|
|
671
|
+
var FETCH_TIMEOUT_MS = 8e3;
|
|
672
|
+
var DIGIT_WORDS = {
|
|
673
|
+
zero: "0",
|
|
674
|
+
oh: "0",
|
|
675
|
+
o: "0",
|
|
676
|
+
one: "1",
|
|
677
|
+
two: "2",
|
|
678
|
+
three: "3",
|
|
679
|
+
four: "4",
|
|
680
|
+
five: "5",
|
|
681
|
+
six: "6",
|
|
682
|
+
seven: "7",
|
|
683
|
+
eight: "8",
|
|
684
|
+
nine: "9"
|
|
685
|
+
};
|
|
686
|
+
var COUNTRY_ALIASES = {
|
|
687
|
+
thailand: "Thailand",
|
|
688
|
+
us: "United States",
|
|
689
|
+
usa: "United States",
|
|
690
|
+
america: "United States",
|
|
691
|
+
"united states": "United States",
|
|
692
|
+
"united states of america": "United States",
|
|
693
|
+
uk: "United Kingdom",
|
|
694
|
+
britain: "United Kingdom",
|
|
695
|
+
england: "United Kingdom",
|
|
696
|
+
"united kingdom": "United Kingdom",
|
|
697
|
+
"great britain": "United Kingdom",
|
|
698
|
+
germany: "Germany",
|
|
699
|
+
france: "France",
|
|
700
|
+
spain: "Spain",
|
|
701
|
+
italy: "Italy",
|
|
702
|
+
japan: "Japan",
|
|
703
|
+
china: "China",
|
|
704
|
+
india: "India",
|
|
705
|
+
australia: "Australia",
|
|
706
|
+
canada: "Canada",
|
|
707
|
+
brazil: "Brazil",
|
|
708
|
+
mexico: "Mexico",
|
|
709
|
+
netherlands: "Netherlands",
|
|
710
|
+
holland: "Netherlands",
|
|
711
|
+
"the netherlands": "Netherlands",
|
|
712
|
+
belgium: "Belgium",
|
|
713
|
+
switzerland: "Switzerland",
|
|
714
|
+
sweden: "Sweden",
|
|
715
|
+
norway: "Norway",
|
|
716
|
+
denmark: "Denmark",
|
|
717
|
+
finland: "Finland",
|
|
718
|
+
poland: "Poland",
|
|
719
|
+
portugal: "Portugal",
|
|
720
|
+
greece: "Greece",
|
|
721
|
+
turkey: "Turkey",
|
|
722
|
+
egypt: "Egypt",
|
|
723
|
+
"south africa": "South Africa",
|
|
724
|
+
"new zealand": "New Zealand",
|
|
725
|
+
ireland: "Ireland",
|
|
726
|
+
singapore: "Singapore",
|
|
727
|
+
malaysia: "Malaysia",
|
|
728
|
+
indonesia: "Indonesia",
|
|
729
|
+
vietnam: "Vietnam",
|
|
730
|
+
philippines: "Philippines",
|
|
731
|
+
"south korea": "South Korea",
|
|
732
|
+
korea: "South Korea",
|
|
733
|
+
taiwan: "Taiwan",
|
|
734
|
+
"hong kong": "Hong Kong",
|
|
735
|
+
israel: "Israel",
|
|
736
|
+
uae: "United Arab Emirates",
|
|
737
|
+
"united arab emirates": "United Arab Emirates",
|
|
738
|
+
"saudi arabia": "Saudi Arabia",
|
|
739
|
+
pakistan: "Pakistan",
|
|
740
|
+
bangladesh: "Bangladesh",
|
|
741
|
+
nigeria: "Nigeria",
|
|
742
|
+
kenya: "Kenya",
|
|
743
|
+
argentina: "Argentina",
|
|
744
|
+
chile: "Chile",
|
|
745
|
+
colombia: "Colombia",
|
|
746
|
+
peru: "Peru",
|
|
747
|
+
austria: "Austria",
|
|
748
|
+
"czech republic": "Czechia",
|
|
749
|
+
czechia: "Czechia",
|
|
750
|
+
romania: "Romania",
|
|
751
|
+
hungary: "Hungary",
|
|
752
|
+
ukraine: "Ukraine"
|
|
753
|
+
};
|
|
754
|
+
function matchCountryName(text) {
|
|
755
|
+
const key = text.trim().toLowerCase().replace(/[.,!?]+$/g, "").replace(/\s+/g, " ");
|
|
756
|
+
if (!key) return null;
|
|
757
|
+
return COUNTRY_ALIASES[key] ?? null;
|
|
758
|
+
}
|
|
759
|
+
function spokenDigitsToPostal(text) {
|
|
760
|
+
const tokens = text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
761
|
+
const digits = [];
|
|
762
|
+
for (const token of tokens) {
|
|
763
|
+
if (/^\d$/.test(token)) {
|
|
764
|
+
digits.push(token);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const mapped = DIGIT_WORDS[token];
|
|
768
|
+
if (mapped) {
|
|
769
|
+
digits.push(mapped);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (digits.length >= 4 && digits.length <= 6) {
|
|
773
|
+
return digits.join("");
|
|
774
|
+
}
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
function splitTrailingCountry(text) {
|
|
778
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
779
|
+
if (words.length < 2) return null;
|
|
780
|
+
for (let n = Math.min(3, words.length - 1); n >= 1; n -= 1) {
|
|
781
|
+
const tail = words.slice(-n).join(" ");
|
|
782
|
+
const country = matchCountryName(tail);
|
|
783
|
+
if (country) {
|
|
784
|
+
return { rest: words.slice(0, -n).join(" "), country };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
function cityFromRemainder(rest) {
|
|
790
|
+
const trimmed = rest.trim().replace(/[.,!?;:]+$/g, "").replace(/\s+(?:in the|in|of)$/i, "").trim();
|
|
791
|
+
if (!trimmed) return null;
|
|
792
|
+
if (/^\d{4,6}(-\d{4})?$/.test(trimmed)) {
|
|
793
|
+
return trimmed;
|
|
794
|
+
}
|
|
795
|
+
const spoken = spokenDigitsToPostal(trimmed);
|
|
796
|
+
if (spoken) return spoken;
|
|
797
|
+
if (trimmed.length >= 2 && trimmed.length <= 60) {
|
|
798
|
+
return trimmed;
|
|
799
|
+
}
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
function wmoCodeToPhrase(code) {
|
|
803
|
+
if (code === 0) return "clear sky";
|
|
804
|
+
if (code <= 3) return "partly cloudy";
|
|
805
|
+
if (code <= 48) return "foggy";
|
|
806
|
+
if (code <= 57) return "drizzle";
|
|
807
|
+
if (code <= 67) return "rain";
|
|
808
|
+
if (code <= 77) return "snow";
|
|
809
|
+
if (code <= 82) return "rain showers";
|
|
810
|
+
if (code <= 86) return "snow showers";
|
|
811
|
+
if (code <= 99) return "thunderstorm";
|
|
812
|
+
return "variable conditions";
|
|
813
|
+
}
|
|
814
|
+
function formatWeatherSpeech(result) {
|
|
815
|
+
const temp = Math.round(result.temperatureC);
|
|
816
|
+
const wind = Math.round(result.windKmh);
|
|
817
|
+
return `In ${result.place}, it is ${temp} degrees Celsius with ${result.condition} and winds around ${wind} kilometers per hour.`;
|
|
818
|
+
}
|
|
819
|
+
function parseLocationUtterance(utterance) {
|
|
820
|
+
const text = utterance.trim();
|
|
821
|
+
if (!text) return null;
|
|
822
|
+
const countryOnly = matchCountryName(text);
|
|
823
|
+
if (countryOnly) {
|
|
824
|
+
return { country: countryOnly };
|
|
825
|
+
}
|
|
826
|
+
const trailing = splitTrailingCountry(text);
|
|
827
|
+
if (trailing) {
|
|
828
|
+
const city = cityFromRemainder(trailing.rest);
|
|
829
|
+
if (city) {
|
|
830
|
+
return { city, country: trailing.country };
|
|
831
|
+
}
|
|
832
|
+
return { country: trailing.country };
|
|
833
|
+
}
|
|
834
|
+
const inMatch = text.match(
|
|
835
|
+
/^(?:in\s+)?(.+?)\s+in\s+([a-zA-Z][\w\s.-]{1,40})$/i
|
|
836
|
+
);
|
|
837
|
+
if (inMatch) {
|
|
838
|
+
const country = matchCountryName(inMatch[2]) ?? inMatch[2].trim();
|
|
839
|
+
return { city: inMatch[1].trim(), country };
|
|
840
|
+
}
|
|
841
|
+
const commaMatch = text.match(/^(.+?),\s*([a-zA-Z][\w\s.-]{1,40})$/);
|
|
842
|
+
if (commaMatch) {
|
|
843
|
+
const country = matchCountryName(commaMatch[2]) ?? commaMatch[2].trim();
|
|
844
|
+
return { city: commaMatch[1].trim(), country };
|
|
845
|
+
}
|
|
846
|
+
const countryMatch = text.match(
|
|
847
|
+
/^(.+?)\s+(?:country\s+)?([a-zA-Z][\w\s.-]{2,40})$/i
|
|
848
|
+
);
|
|
849
|
+
if (countryMatch && countryMatch[2].split(/\s+/).length <= 3) {
|
|
850
|
+
const city = countryMatch[1].trim();
|
|
851
|
+
const countryRaw = countryMatch[2].trim();
|
|
852
|
+
const country = matchCountryName(countryRaw) ?? countryRaw;
|
|
853
|
+
if (city.length >= 2 && country.length >= 2) {
|
|
854
|
+
return { city, country };
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (/^\d{4,6}(-\d{4})?$/.test(text)) {
|
|
858
|
+
return { city: text };
|
|
859
|
+
}
|
|
860
|
+
const spokenPostal = spokenDigitsToPostal(text);
|
|
861
|
+
if (spokenPostal) {
|
|
862
|
+
return { city: spokenPostal };
|
|
863
|
+
}
|
|
864
|
+
if (text.length >= 2 && text.length <= 60) {
|
|
865
|
+
return { city: text };
|
|
866
|
+
}
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
async function geocodeLocation(city, country, fetchFn = fetch) {
|
|
870
|
+
const query = country ? `${city}, ${country}` : city;
|
|
871
|
+
const url = new URL(GEOCODE_URL);
|
|
872
|
+
url.searchParams.set("name", query);
|
|
873
|
+
url.searchParams.set("count", "1");
|
|
874
|
+
url.searchParams.set("language", "en");
|
|
875
|
+
url.searchParams.set("format", "json");
|
|
876
|
+
const response = await fetchFn(url.toString(), {
|
|
877
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
878
|
+
});
|
|
879
|
+
if (!response.ok) return null;
|
|
880
|
+
const data = await response.json();
|
|
881
|
+
const hit = data.results?.[0];
|
|
882
|
+
if (!hit || typeof hit.latitude !== "number" || typeof hit.longitude !== "number") {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
name: hit.name ?? city,
|
|
887
|
+
country: hit.country ?? country ?? "",
|
|
888
|
+
latitude: hit.latitude,
|
|
889
|
+
longitude: hit.longitude
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
async function fetchCurrentWeather(geo, fetchFn = fetch) {
|
|
893
|
+
const url = new URL(FORECAST_URL);
|
|
894
|
+
url.searchParams.set("latitude", String(geo.latitude));
|
|
895
|
+
url.searchParams.set("longitude", String(geo.longitude));
|
|
896
|
+
url.searchParams.set("current", "temperature_2m,weather_code,wind_speed_10m");
|
|
897
|
+
url.searchParams.set("wind_speed_unit", "kmh");
|
|
898
|
+
const response = await fetchFn(url.toString(), {
|
|
899
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
900
|
+
});
|
|
901
|
+
if (!response.ok) return null;
|
|
902
|
+
const data = await response.json();
|
|
903
|
+
const current = data.current;
|
|
904
|
+
if (!current || typeof current.temperature_2m !== "number" || typeof current.weather_code !== "number") {
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
const place = geo.country ? `${geo.name}, ${geo.country}` : geo.name;
|
|
908
|
+
return {
|
|
909
|
+
place,
|
|
910
|
+
temperatureC: current.temperature_2m,
|
|
911
|
+
condition: wmoCodeToPhrase(current.weather_code),
|
|
912
|
+
windKmh: current.wind_speed_10m ?? 0
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
async function lookupWeather(city, country, fetchFn = fetch) {
|
|
916
|
+
const geo = await geocodeLocation(city, country, fetchFn);
|
|
917
|
+
if (!geo) return null;
|
|
918
|
+
return fetchCurrentWeather(geo, fetchFn);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// templates/voice-showcase/conversation.ts
|
|
922
|
+
var GREETING = "Hi and welcome to the Voicethere voice chat, may I know your name?";
|
|
923
|
+
var HUMAN_ESCALATION_REPLY = "This is only a showcase conversation and unfortunately there is no human support connected.";
|
|
924
|
+
var NAME_DECLINE_REPLY = "OK we will continue without your name";
|
|
925
|
+
var MENU_ITEMS = [
|
|
926
|
+
{ id: 1, label: "Check the weather" },
|
|
927
|
+
{ id: 2, label: "Count" },
|
|
928
|
+
{ id: 3, label: "Hear a recipe" },
|
|
929
|
+
{ id: 4, label: "Hear a fun fact" }
|
|
930
|
+
];
|
|
931
|
+
var MENU_CHAT_TEXT = `Here is our menu:
|
|
932
|
+
1. Check the weather
|
|
933
|
+
2. Count
|
|
934
|
+
3. Hear a recipe
|
|
935
|
+
4. Hear a fun fact`;
|
|
936
|
+
function createInitialState() {
|
|
937
|
+
return {
|
|
938
|
+
phase: "listeningForName",
|
|
939
|
+
nameDeclined: false,
|
|
940
|
+
weatherRetries: 0,
|
|
941
|
+
countFailures: 0
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
function buildMenuMessages() {
|
|
945
|
+
return [
|
|
946
|
+
{ type: "chat_reply", text: MENU_CHAT_TEXT },
|
|
947
|
+
{
|
|
948
|
+
type: "menu",
|
|
949
|
+
items: MENU_ITEMS.map((item) => ({ id: item.id, label: item.label }))
|
|
950
|
+
}
|
|
951
|
+
];
|
|
952
|
+
}
|
|
953
|
+
function speakAndChat(text) {
|
|
954
|
+
return {
|
|
955
|
+
speakLines: [text],
|
|
956
|
+
messages: [{ type: "chat_reply", text }]
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
function isHumanEscalation(utterance) {
|
|
960
|
+
const lower = utterance.toLowerCase();
|
|
961
|
+
const patterns = [
|
|
962
|
+
/\bhuman\b/,
|
|
963
|
+
/\boperator\b/,
|
|
964
|
+
/\breal\s+person\b/,
|
|
965
|
+
/\btalk\s+to\s+(?:a\s+)?(?:human|person|someone|agent)\b/,
|
|
966
|
+
/\bcustomer\s+support\b/,
|
|
967
|
+
/\bspeak\s+to\s+(?:a\s+)?(?:human|person|someone|agent)\b/,
|
|
968
|
+
/\bneed\s+(?:a\s+)?(?:human|person|agent)\b/,
|
|
969
|
+
/\bconnect\s+me\s+(?:to|with)\b/,
|
|
970
|
+
/\blive\s+agent\b/,
|
|
971
|
+
/\brepresentative\b/
|
|
972
|
+
];
|
|
973
|
+
return patterns.some((p) => p.test(lower));
|
|
974
|
+
}
|
|
975
|
+
function isNameDecline(utterance) {
|
|
976
|
+
const lower = utterance.toLowerCase().trim();
|
|
977
|
+
const declinePhrases = [
|
|
978
|
+
"i do not want to say my name",
|
|
979
|
+
"i don't want to say my name",
|
|
980
|
+
"i don't want to say",
|
|
981
|
+
"i do not want to say",
|
|
982
|
+
"i'd rather not",
|
|
983
|
+
"id rather not",
|
|
984
|
+
"prefer not",
|
|
985
|
+
"skip",
|
|
986
|
+
"anonymous",
|
|
987
|
+
"none"
|
|
988
|
+
];
|
|
989
|
+
if (declinePhrases.some((p) => lower.includes(p))) return true;
|
|
990
|
+
if (/\bno\b/i.test(utterance) && !/\bknow\b/i.test(utterance)) {
|
|
991
|
+
const words = lower.split(/\s+/);
|
|
992
|
+
if (words.includes("no")) return true;
|
|
993
|
+
}
|
|
994
|
+
return false;
|
|
995
|
+
}
|
|
996
|
+
function extractName(utterance) {
|
|
997
|
+
const trimmed = utterance.trim();
|
|
998
|
+
const patterns = [/(?:my name is|i'm|i am|call me)\s+(.+)/i];
|
|
999
|
+
for (const pattern of patterns) {
|
|
1000
|
+
const match = trimmed.match(pattern);
|
|
1001
|
+
if (match?.[1]) {
|
|
1002
|
+
return sanitizeName(match[1]);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (trimmed.length > 0 && trimmed.length <= 60) {
|
|
1006
|
+
return sanitizeName(trimmed);
|
|
1007
|
+
}
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
function sanitizeName(raw) {
|
|
1011
|
+
let name = raw.trim().replace(/[.,!?;:]+$/g, "").trim();
|
|
1012
|
+
if (name.length > 40) {
|
|
1013
|
+
name = name.slice(0, 40).trim();
|
|
1014
|
+
}
|
|
1015
|
+
return name;
|
|
1016
|
+
}
|
|
1017
|
+
function helloAfterName(state) {
|
|
1018
|
+
if (state.name && !state.nameDeclined) {
|
|
1019
|
+
return `Hello, ${state.name}, how can I help you today? I just sent you our menu, what do you want to do?`;
|
|
1020
|
+
}
|
|
1021
|
+
return "Hello, how can I help you today? I just sent you our menu, what do you want to do?";
|
|
1022
|
+
}
|
|
1023
|
+
function transitionAfterName(state, name, declined) {
|
|
1024
|
+
const next = {
|
|
1025
|
+
...state,
|
|
1026
|
+
phase: "awaitingMenuChoice",
|
|
1027
|
+
nameDeclined: declined,
|
|
1028
|
+
name: declined ? void 0 : name ?? void 0
|
|
1029
|
+
};
|
|
1030
|
+
const lines = [];
|
|
1031
|
+
const messages = [];
|
|
1032
|
+
if (declined) {
|
|
1033
|
+
const decline = speakAndChat(NAME_DECLINE_REPLY);
|
|
1034
|
+
lines.push(...decline.speakLines);
|
|
1035
|
+
messages.push(...decline.messages);
|
|
1036
|
+
} else if (name) {
|
|
1037
|
+
const thanks = speakAndChat(`Great, thank you ${name}`);
|
|
1038
|
+
lines.push(...thanks.speakLines);
|
|
1039
|
+
messages.push(...thanks.messages);
|
|
1040
|
+
}
|
|
1041
|
+
const hello = speakAndChat(helloAfterName(next));
|
|
1042
|
+
lines.push(...hello.speakLines);
|
|
1043
|
+
messages.push(...hello.messages);
|
|
1044
|
+
messages.push(...buildMenuMessages());
|
|
1045
|
+
return { state: next, speakLines: lines, messages };
|
|
1046
|
+
}
|
|
1047
|
+
function parseMenuChoice(utterance) {
|
|
1048
|
+
const lower = utterance.toLowerCase().trim();
|
|
1049
|
+
if (/\bmenu\b/.test(lower) || /\bhelp\b/.test(lower) || /\bgo\s+back\b/.test(lower) || /\bstart\s+over\b/.test(lower)) {
|
|
1050
|
+
return "menu";
|
|
1051
|
+
}
|
|
1052
|
+
if (lower === "1" || /\bweather\b/.test(lower) || /\bfirst\b/.test(lower) || /\bcheck\s+the\s+weather\b/.test(lower)) {
|
|
1053
|
+
return "weather";
|
|
1054
|
+
}
|
|
1055
|
+
if (lower === "2" || /\bcount\b/.test(lower) || /\bsecond\b/.test(lower)) {
|
|
1056
|
+
return "count";
|
|
1057
|
+
}
|
|
1058
|
+
if (lower === "3" || /\brecipe\b/.test(lower) || /\bthird\b/.test(lower)) {
|
|
1059
|
+
return "recipe";
|
|
1060
|
+
}
|
|
1061
|
+
if (lower === "4" || /\bfun\s+fact\b/.test(lower) || /\bfact\b/.test(lower) || /\bfourth\b/.test(lower)) {
|
|
1062
|
+
return "fun_fact";
|
|
1063
|
+
}
|
|
1064
|
+
return null;
|
|
1065
|
+
}
|
|
1066
|
+
var WORD_TO_NUMBER = {
|
|
1067
|
+
one: 1,
|
|
1068
|
+
two: 2,
|
|
1069
|
+
three: 3,
|
|
1070
|
+
four: 4,
|
|
1071
|
+
five: 5,
|
|
1072
|
+
six: 6,
|
|
1073
|
+
seven: 7,
|
|
1074
|
+
eight: 8,
|
|
1075
|
+
nine: 9,
|
|
1076
|
+
ten: 10
|
|
1077
|
+
};
|
|
1078
|
+
function parseCountNumber(utterance) {
|
|
1079
|
+
const trimmed = utterance.trim().toLowerCase();
|
|
1080
|
+
const digit = trimmed.match(/\b(\d+)\b/);
|
|
1081
|
+
if (digit) {
|
|
1082
|
+
const n = Number(digit[1]);
|
|
1083
|
+
if (Number.isFinite(n)) return n;
|
|
1084
|
+
}
|
|
1085
|
+
for (const [word, value] of Object.entries(WORD_TO_NUMBER)) {
|
|
1086
|
+
if (new RegExp(`\\b${word}\\b`).test(trimmed)) {
|
|
1087
|
+
return value;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
function formatCountingSpeech(n) {
|
|
1093
|
+
const parts = [];
|
|
1094
|
+
for (let i = 1; i <= n; i += 1) {
|
|
1095
|
+
parts.push(String(i));
|
|
1096
|
+
}
|
|
1097
|
+
return parts.join(", ");
|
|
1098
|
+
}
|
|
1099
|
+
function resendMenu(state) {
|
|
1100
|
+
const menu = speakAndChat("Here is the menu again.");
|
|
1101
|
+
return {
|
|
1102
|
+
state: { ...state, phase: "awaitingMenuChoice" },
|
|
1103
|
+
speakLines: menu.speakLines,
|
|
1104
|
+
messages: [...menu.messages, ...buildMenuMessages()]
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
function returnToMenu(state, line) {
|
|
1108
|
+
const spoken = speakAndChat(line);
|
|
1109
|
+
return {
|
|
1110
|
+
state: {
|
|
1111
|
+
...state,
|
|
1112
|
+
phase: "awaitingMenuChoice",
|
|
1113
|
+
weatherRetries: 0,
|
|
1114
|
+
countFailures: 0,
|
|
1115
|
+
weatherCity: void 0,
|
|
1116
|
+
weatherCountry: void 0
|
|
1117
|
+
},
|
|
1118
|
+
speakLines: spoken.speakLines,
|
|
1119
|
+
messages: [...spoken.messages, ...buildMenuMessages()]
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
function handleUtterance(state, utterance) {
|
|
1123
|
+
const text = utterance.trim();
|
|
1124
|
+
if (!text) {
|
|
1125
|
+
return { state, speakLines: [], messages: [] };
|
|
1126
|
+
}
|
|
1127
|
+
if (isHumanEscalation(text)) {
|
|
1128
|
+
const reply = speakAndChat(HUMAN_ESCALATION_REPLY);
|
|
1129
|
+
return {
|
|
1130
|
+
state,
|
|
1131
|
+
speakLines: reply.speakLines,
|
|
1132
|
+
messages: reply.messages
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
if (state.phase !== "listeningForName" && parseMenuChoice(text) === "menu") {
|
|
1136
|
+
return resendMenu({
|
|
1137
|
+
...state,
|
|
1138
|
+
phase: "awaitingMenuChoice",
|
|
1139
|
+
weatherRetries: 0,
|
|
1140
|
+
countFailures: 0,
|
|
1141
|
+
weatherCity: void 0,
|
|
1142
|
+
weatherCountry: void 0
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
switch (state.phase) {
|
|
1146
|
+
case "listeningForName": {
|
|
1147
|
+
if (isNameDecline(text)) {
|
|
1148
|
+
return transitionAfterName(state, null, true);
|
|
1149
|
+
}
|
|
1150
|
+
const name = extractName(text);
|
|
1151
|
+
return transitionAfterName(state, name, false);
|
|
1152
|
+
}
|
|
1153
|
+
case "awaitingMenuChoice": {
|
|
1154
|
+
const choice = parseMenuChoice(text);
|
|
1155
|
+
if (choice === "menu") return resendMenu(state);
|
|
1156
|
+
if (choice === "weather") {
|
|
1157
|
+
const ask = speakAndChat(
|
|
1158
|
+
"Sure. Please tell me a city or ZIP code and the country."
|
|
1159
|
+
);
|
|
1160
|
+
return {
|
|
1161
|
+
state: {
|
|
1162
|
+
...state,
|
|
1163
|
+
phase: "weatherAwaitingLocation",
|
|
1164
|
+
weatherRetries: 0,
|
|
1165
|
+
weatherCity: void 0,
|
|
1166
|
+
weatherCountry: void 0
|
|
1167
|
+
},
|
|
1168
|
+
speakLines: ask.speakLines,
|
|
1169
|
+
messages: ask.messages
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
if (choice === "count") {
|
|
1173
|
+
const ask = speakAndChat(
|
|
1174
|
+
"Pick a number from 1 to 10 and I will count up to it."
|
|
1175
|
+
);
|
|
1176
|
+
return {
|
|
1177
|
+
state: {
|
|
1178
|
+
...state,
|
|
1179
|
+
phase: "countAwaitingNumber",
|
|
1180
|
+
countFailures: 0
|
|
1181
|
+
},
|
|
1182
|
+
speakLines: ask.speakLines,
|
|
1183
|
+
messages: ask.messages
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
if (choice === "recipe") {
|
|
1187
|
+
const ask = speakAndChat(
|
|
1188
|
+
"What do you fancy? Try pasta, soup, breakfast, cookies, or salad."
|
|
1189
|
+
);
|
|
1190
|
+
return {
|
|
1191
|
+
state: { ...state, phase: "recipeAwaitingChoice" },
|
|
1192
|
+
speakLines: ask.speakLines,
|
|
1193
|
+
messages: ask.messages
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
if (choice === "fun_fact") {
|
|
1197
|
+
const fact = pickFunFact();
|
|
1198
|
+
return returnToMenu(state, `Here is a fun fact. ${fact}`);
|
|
1199
|
+
}
|
|
1200
|
+
const retry = speakAndChat(
|
|
1201
|
+
"I did not catch that. Pick 1 through 4 from the menu, or say weather, count, recipe, or fun fact."
|
|
1202
|
+
);
|
|
1203
|
+
return {
|
|
1204
|
+
state,
|
|
1205
|
+
speakLines: retry.speakLines,
|
|
1206
|
+
messages: retry.messages
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
case "weatherAwaitingLocation": {
|
|
1210
|
+
const parsed = parseLocationUtterance(text);
|
|
1211
|
+
let city = parsed?.city || state.weatherCity;
|
|
1212
|
+
let country = parsed?.country || state.weatherCountry;
|
|
1213
|
+
if (state.weatherCity && !country) {
|
|
1214
|
+
const followUp = matchCountryName(text) ?? (parsed?.city ? matchCountryName(parsed.city) : null);
|
|
1215
|
+
if (followUp) {
|
|
1216
|
+
city = state.weatherCity;
|
|
1217
|
+
country = followUp;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (!city) {
|
|
1221
|
+
const ask = speakAndChat(
|
|
1222
|
+
"Please tell me a city or ZIP code and the country."
|
|
1223
|
+
);
|
|
1224
|
+
return {
|
|
1225
|
+
state: { ...state, phase: "weatherAwaitingLocation" },
|
|
1226
|
+
speakLines: ask.speakLines,
|
|
1227
|
+
messages: ask.messages
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
if (!country) {
|
|
1231
|
+
return {
|
|
1232
|
+
state: {
|
|
1233
|
+
...state,
|
|
1234
|
+
phase: "weatherAwaitingLocation",
|
|
1235
|
+
weatherCity: city
|
|
1236
|
+
},
|
|
1237
|
+
speakLines: ["Got it. Which country is that in?"],
|
|
1238
|
+
messages: [
|
|
1239
|
+
{ type: "chat_reply", text: "Got it. Which country is that in?" }
|
|
1240
|
+
]
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
state: { ...state, weatherCity: city, weatherCountry: country },
|
|
1245
|
+
speakLines: [],
|
|
1246
|
+
messages: [],
|
|
1247
|
+
pendingWeather: { city, country }
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
case "countAwaitingNumber": {
|
|
1251
|
+
const n = parseCountNumber(text);
|
|
1252
|
+
if (n === null || n < 1 || n > 10) {
|
|
1253
|
+
const failures = state.countFailures + 1;
|
|
1254
|
+
if (failures >= 2) {
|
|
1255
|
+
return returnToMenu(state, "Sorry, I cannot do this.");
|
|
1256
|
+
}
|
|
1257
|
+
const retry = speakAndChat(
|
|
1258
|
+
"I did not understand you. Please say a number from 1 to 10."
|
|
1259
|
+
);
|
|
1260
|
+
return {
|
|
1261
|
+
state: { ...state, countFailures: failures },
|
|
1262
|
+
speakLines: retry.speakLines,
|
|
1263
|
+
messages: retry.messages
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
const counting = formatCountingSpeech(n);
|
|
1267
|
+
return returnToMenu(state, `Counting: ${counting}.`);
|
|
1268
|
+
}
|
|
1269
|
+
case "recipeAwaitingChoice": {
|
|
1270
|
+
const recipe = pickRecipe(text);
|
|
1271
|
+
const speech = formatRecipeSpeech(recipe);
|
|
1272
|
+
return returnToMenu(state, speech);
|
|
1273
|
+
}
|
|
1274
|
+
default:
|
|
1275
|
+
return { state, speakLines: [], messages: [] };
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
function applyWeatherSuccess(state, weather) {
|
|
1279
|
+
const line = formatWeatherSpeech(weather);
|
|
1280
|
+
return returnToMenu(state, line);
|
|
1281
|
+
}
|
|
1282
|
+
function applyWeatherFailure(state) {
|
|
1283
|
+
const retries = state.weatherRetries + 1;
|
|
1284
|
+
if (retries >= 2) {
|
|
1285
|
+
return returnToMenu(
|
|
1286
|
+
{ ...state, weatherRetries: retries },
|
|
1287
|
+
"Sorry, I could not look up the weather right now."
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
const retry = speakAndChat(
|
|
1291
|
+
"I could not find that location. Please try again with a city or ZIP and country."
|
|
1292
|
+
);
|
|
1293
|
+
return {
|
|
1294
|
+
state: {
|
|
1295
|
+
...state,
|
|
1296
|
+
phase: "weatherAwaitingLocation",
|
|
1297
|
+
weatherRetries: retries,
|
|
1298
|
+
weatherCity: void 0,
|
|
1299
|
+
weatherCountry: void 0
|
|
1300
|
+
},
|
|
1301
|
+
speakLines: retry.speakLines,
|
|
1302
|
+
messages: retry.messages
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
async function resolveWeatherTurn(state, city, country, fetchFn) {
|
|
1306
|
+
try {
|
|
1307
|
+
const weather = await lookupWeather(city, country, fetchFn);
|
|
1308
|
+
if (!weather) {
|
|
1309
|
+
return applyWeatherFailure(state);
|
|
1310
|
+
}
|
|
1311
|
+
return applyWeatherSuccess(state, weather);
|
|
1312
|
+
} catch {
|
|
1313
|
+
return applyWeatherFailure(state);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// templates/voice-showcase/agent.ts
|
|
1318
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
1319
|
+
function getState(sessionId) {
|
|
1320
|
+
let state = sessions.get(sessionId);
|
|
1321
|
+
if (!state) {
|
|
1322
|
+
state = createInitialState();
|
|
1323
|
+
sessions.set(sessionId, state);
|
|
1324
|
+
}
|
|
1325
|
+
return state;
|
|
1326
|
+
}
|
|
1327
|
+
function relaySpeechEvent(sessionId, event) {
|
|
1328
|
+
sendToClient(sessionId, {
|
|
1329
|
+
type: "agent_event",
|
|
1330
|
+
event: event.type,
|
|
1331
|
+
text: event.text,
|
|
1332
|
+
raw: event
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
function deliverMessages(sessionId, messages) {
|
|
1336
|
+
for (const message of messages) {
|
|
1337
|
+
sendToClient(sessionId, message);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
function speakLines(sessionId, lines) {
|
|
1341
|
+
for (const line of lines) {
|
|
1342
|
+
speak(sessionId, line);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
async function applyTurn(sessionId, result) {
|
|
1346
|
+
sessions.set(sessionId, result.state);
|
|
1347
|
+
speakLines(sessionId, result.speakLines);
|
|
1348
|
+
deliverMessages(sessionId, result.messages);
|
|
1349
|
+
if (result.pendingWeather) {
|
|
1350
|
+
const weatherResult = await resolveWeatherTurn(
|
|
1351
|
+
result.state,
|
|
1352
|
+
result.pendingWeather.city,
|
|
1353
|
+
result.pendingWeather.country
|
|
1354
|
+
);
|
|
1355
|
+
sessions.set(sessionId, weatherResult.state);
|
|
1356
|
+
speakLines(sessionId, weatherResult.speakLines);
|
|
1357
|
+
deliverMessages(sessionId, weatherResult.messages);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
async function onUserText(sessionId, text) {
|
|
1361
|
+
const state = getState(sessionId);
|
|
1362
|
+
const result = handleUtterance(state, text);
|
|
1363
|
+
await applyTurn(sessionId, result);
|
|
1364
|
+
}
|
|
1365
|
+
defineAgent({
|
|
1366
|
+
onSessionStart({ sessionId }) {
|
|
1367
|
+
sessions.set(sessionId, createInitialState());
|
|
1368
|
+
sendToClient(sessionId, {
|
|
1369
|
+
type: "agent_event",
|
|
1370
|
+
event: "session_start",
|
|
1371
|
+
sessionId
|
|
1372
|
+
});
|
|
1373
|
+
speak(sessionId, GREETING);
|
|
1374
|
+
sendToClient(sessionId, { type: "chat_reply", text: GREETING });
|
|
1375
|
+
agentLog("info", `voice-showcase session_start ${sessionId}`);
|
|
1376
|
+
},
|
|
1377
|
+
onSpeechEvent({ sessionId }, event) {
|
|
1378
|
+
relaySpeechEvent(sessionId, event);
|
|
1379
|
+
},
|
|
1380
|
+
onUserSpeechFinal({ sessionId, text }) {
|
|
1381
|
+
void onUserText(sessionId, text);
|
|
1382
|
+
},
|
|
1383
|
+
onDataChannelMessage(ctx) {
|
|
1384
|
+
const text = parseChatText(ctx.message);
|
|
1385
|
+
if (!text) return;
|
|
1386
|
+
void onUserText(ctx.sessionId, text);
|
|
1387
|
+
},
|
|
1388
|
+
onSessionEnd({ sessionId }) {
|
|
1389
|
+
sessions.delete(sessionId);
|
|
1390
|
+
sendToClient(sessionId, {
|
|
1391
|
+
type: "agent_event",
|
|
1392
|
+
event: "session_end",
|
|
1393
|
+
sessionId
|
|
1394
|
+
});
|
|
1395
|
+
agentLog("info", `voice-showcase session_end ${sessionId}`);
|
|
1396
|
+
}
|
|
1397
|
+
});
|