@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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversational voice showcase — greeting, name, menu (weather, count, recipe, fun fact).
|
|
3
|
+
*
|
|
4
|
+
* Build:
|
|
5
|
+
* npx @voicethere/agent build --entry templates/voice-showcase/agent.ts
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
agentLog,
|
|
9
|
+
defineAgent,
|
|
10
|
+
parseChatText,
|
|
11
|
+
sendToClient,
|
|
12
|
+
speak,
|
|
13
|
+
type SpeechEvent,
|
|
14
|
+
} from "@voicethere/agent";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createInitialState,
|
|
18
|
+
GREETING,
|
|
19
|
+
handleUtterance,
|
|
20
|
+
resolveWeatherTurn,
|
|
21
|
+
type ConversationState,
|
|
22
|
+
type OutboundMessage,
|
|
23
|
+
} from "./conversation.js";
|
|
24
|
+
|
|
25
|
+
const sessions = new Map<string, ConversationState>();
|
|
26
|
+
|
|
27
|
+
function getState(sessionId: string): ConversationState {
|
|
28
|
+
let state = sessions.get(sessionId);
|
|
29
|
+
if (!state) {
|
|
30
|
+
state = createInitialState();
|
|
31
|
+
sessions.set(sessionId, state);
|
|
32
|
+
}
|
|
33
|
+
return state;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function relaySpeechEvent(sessionId: string, event: SpeechEvent): void {
|
|
37
|
+
sendToClient(sessionId, {
|
|
38
|
+
type: "agent_event",
|
|
39
|
+
event: event.type,
|
|
40
|
+
text: event.text,
|
|
41
|
+
raw: event,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function deliverMessages(sessionId: string, messages: OutboundMessage[]): void {
|
|
46
|
+
for (const message of messages) {
|
|
47
|
+
sendToClient(sessionId, message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function speakLines(sessionId: string, lines: string[]): void {
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
speak(sessionId, line);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function applyTurn(
|
|
58
|
+
sessionId: string,
|
|
59
|
+
result: Awaited<ReturnType<typeof handleUtterance>>,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
sessions.set(sessionId, result.state);
|
|
62
|
+
speakLines(sessionId, result.speakLines);
|
|
63
|
+
deliverMessages(sessionId, result.messages);
|
|
64
|
+
|
|
65
|
+
if (result.pendingWeather) {
|
|
66
|
+
const weatherResult = await resolveWeatherTurn(
|
|
67
|
+
result.state,
|
|
68
|
+
result.pendingWeather.city,
|
|
69
|
+
result.pendingWeather.country,
|
|
70
|
+
);
|
|
71
|
+
sessions.set(sessionId, weatherResult.state);
|
|
72
|
+
speakLines(sessionId, weatherResult.speakLines);
|
|
73
|
+
deliverMessages(sessionId, weatherResult.messages);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function onUserText(sessionId: string, text: string): Promise<void> {
|
|
78
|
+
const state = getState(sessionId);
|
|
79
|
+
const result = handleUtterance(state, text);
|
|
80
|
+
await applyTurn(sessionId, result);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
defineAgent({
|
|
84
|
+
onSessionStart({ sessionId }) {
|
|
85
|
+
sessions.set(sessionId, createInitialState());
|
|
86
|
+
sendToClient(sessionId, {
|
|
87
|
+
type: "agent_event",
|
|
88
|
+
event: "session_start",
|
|
89
|
+
sessionId,
|
|
90
|
+
});
|
|
91
|
+
speak(sessionId, GREETING);
|
|
92
|
+
sendToClient(sessionId, { type: "chat_reply", text: GREETING });
|
|
93
|
+
agentLog("info", `voice-showcase session_start ${sessionId}`);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
onSpeechEvent({ sessionId }, event: SpeechEvent) {
|
|
97
|
+
relaySpeechEvent(sessionId, event);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
onUserSpeechFinal({ sessionId, text }) {
|
|
101
|
+
void onUserText(sessionId, text);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
onDataChannelMessage(ctx) {
|
|
105
|
+
const text = parseChatText(ctx.message);
|
|
106
|
+
if (!text) return;
|
|
107
|
+
void onUserText(ctx.sessionId, text);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
onSessionEnd({ sessionId }) {
|
|
111
|
+
sessions.delete(sessionId);
|
|
112
|
+
sendToClient(sessionId, {
|
|
113
|
+
type: "agent_event",
|
|
114
|
+
event: "session_end",
|
|
115
|
+
sessionId,
|
|
116
|
+
});
|
|
117
|
+
agentLog("info", `voice-showcase session_end ${sessionId}`);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure conversation state machine for the voice-showcase template.
|
|
3
|
+
* Tests import this module directly — no defineAgent dependency.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { formatRecipeSpeech, pickRecipe } from "./recipes.js";
|
|
7
|
+
import { pickFunFact } from "./fun-facts.js";
|
|
8
|
+
import {
|
|
9
|
+
formatWeatherSpeech,
|
|
10
|
+
lookupWeather,
|
|
11
|
+
matchCountryName,
|
|
12
|
+
parseLocationUtterance,
|
|
13
|
+
type FetchFn,
|
|
14
|
+
type WeatherResult,
|
|
15
|
+
} from "./weather.js";
|
|
16
|
+
|
|
17
|
+
export const GREETING =
|
|
18
|
+
"Hi and welcome to the Voicethere voice chat, may I know your name?";
|
|
19
|
+
|
|
20
|
+
export const HUMAN_ESCALATION_REPLY =
|
|
21
|
+
"This is only a showcase conversation and unfortunately there is no human support connected.";
|
|
22
|
+
|
|
23
|
+
export const NAME_DECLINE_REPLY = "OK we will continue without your name";
|
|
24
|
+
|
|
25
|
+
export const MENU_ITEMS = [
|
|
26
|
+
{ id: 1, label: "Check the weather" },
|
|
27
|
+
{ id: 2, label: "Count" },
|
|
28
|
+
{ id: 3, label: "Hear a recipe" },
|
|
29
|
+
{ id: 4, label: "Hear a fun fact" },
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export const MENU_CHAT_TEXT = `Here is our menu:
|
|
33
|
+
1. Check the weather
|
|
34
|
+
2. Count
|
|
35
|
+
3. Hear a recipe
|
|
36
|
+
4. Hear a fun fact`;
|
|
37
|
+
|
|
38
|
+
export type ConversationPhase =
|
|
39
|
+
| "listeningForName"
|
|
40
|
+
| "awaitingMenuChoice"
|
|
41
|
+
| "weatherAwaitingLocation"
|
|
42
|
+
| "countAwaitingNumber"
|
|
43
|
+
| "recipeAwaitingChoice";
|
|
44
|
+
|
|
45
|
+
export interface ConversationState {
|
|
46
|
+
phase: ConversationPhase;
|
|
47
|
+
name?: string;
|
|
48
|
+
nameDeclined: boolean;
|
|
49
|
+
weatherCity?: string;
|
|
50
|
+
weatherCountry?: string;
|
|
51
|
+
weatherRetries: number;
|
|
52
|
+
countFailures: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface OutboundMessage {
|
|
56
|
+
type: "chat_reply" | "menu" | "agent_event";
|
|
57
|
+
text?: string;
|
|
58
|
+
event?: string;
|
|
59
|
+
items?: Array<{ id: number; label: string }>;
|
|
60
|
+
sessionId?: string;
|
|
61
|
+
raw?: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ConversationTurnResult {
|
|
65
|
+
state: ConversationState;
|
|
66
|
+
speakLines: string[];
|
|
67
|
+
messages: OutboundMessage[];
|
|
68
|
+
pendingWeather?: { city: string; country?: string };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createInitialState(): ConversationState {
|
|
72
|
+
return {
|
|
73
|
+
phase: "listeningForName",
|
|
74
|
+
nameDeclined: false,
|
|
75
|
+
weatherRetries: 0,
|
|
76
|
+
countFailures: 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function buildMenuMessages(): OutboundMessage[] {
|
|
81
|
+
return [
|
|
82
|
+
{ type: "chat_reply", text: MENU_CHAT_TEXT },
|
|
83
|
+
{
|
|
84
|
+
type: "menu",
|
|
85
|
+
items: MENU_ITEMS.map((item) => ({ id: item.id, label: item.label })),
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function speakAndChat(text: string): {
|
|
91
|
+
speakLines: string[];
|
|
92
|
+
messages: OutboundMessage[];
|
|
93
|
+
} {
|
|
94
|
+
return {
|
|
95
|
+
speakLines: [text],
|
|
96
|
+
messages: [{ type: "chat_reply", text }],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isHumanEscalation(utterance: string): boolean {
|
|
101
|
+
const lower = utterance.toLowerCase();
|
|
102
|
+
const patterns = [
|
|
103
|
+
/\bhuman\b/,
|
|
104
|
+
/\boperator\b/,
|
|
105
|
+
/\breal\s+person\b/,
|
|
106
|
+
/\btalk\s+to\s+(?:a\s+)?(?:human|person|someone|agent)\b/,
|
|
107
|
+
/\bcustomer\s+support\b/,
|
|
108
|
+
/\bspeak\s+to\s+(?:a\s+)?(?:human|person|someone|agent)\b/,
|
|
109
|
+
/\bneed\s+(?:a\s+)?(?:human|person|agent)\b/,
|
|
110
|
+
/\bconnect\s+me\s+(?:to|with)\b/,
|
|
111
|
+
/\blive\s+agent\b/,
|
|
112
|
+
/\brepresentative\b/,
|
|
113
|
+
];
|
|
114
|
+
return patterns.some((p) => p.test(lower));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isNameDecline(utterance: string): boolean {
|
|
118
|
+
const lower = utterance.toLowerCase().trim();
|
|
119
|
+
const declinePhrases = [
|
|
120
|
+
"i do not want to say my name",
|
|
121
|
+
"i don't want to say my name",
|
|
122
|
+
"i don't want to say",
|
|
123
|
+
"i do not want to say",
|
|
124
|
+
"i'd rather not",
|
|
125
|
+
"id rather not",
|
|
126
|
+
"prefer not",
|
|
127
|
+
"skip",
|
|
128
|
+
"anonymous",
|
|
129
|
+
"none",
|
|
130
|
+
];
|
|
131
|
+
if (declinePhrases.some((p) => lower.includes(p))) return true;
|
|
132
|
+
if (/\bno\b/i.test(utterance) && !/\bknow\b/i.test(utterance)) {
|
|
133
|
+
const words = lower.split(/\s+/);
|
|
134
|
+
if (words.includes("no")) return true;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function extractName(utterance: string): string | null {
|
|
140
|
+
const trimmed = utterance.trim();
|
|
141
|
+
const patterns = [/(?:my name is|i'm|i am|call me)\s+(.+)/i];
|
|
142
|
+
for (const pattern of patterns) {
|
|
143
|
+
const match = trimmed.match(pattern);
|
|
144
|
+
if (match?.[1]) {
|
|
145
|
+
return sanitizeName(match[1]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (trimmed.length > 0 && trimmed.length <= 60) {
|
|
149
|
+
return sanitizeName(trimmed);
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sanitizeName(raw: string): string {
|
|
155
|
+
let name = raw
|
|
156
|
+
.trim()
|
|
157
|
+
.replace(/[.,!?;:]+$/g, "")
|
|
158
|
+
.trim();
|
|
159
|
+
if (name.length > 40) {
|
|
160
|
+
name = name.slice(0, 40).trim();
|
|
161
|
+
}
|
|
162
|
+
return name;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function helloAfterName(state: ConversationState): string {
|
|
166
|
+
if (state.name && !state.nameDeclined) {
|
|
167
|
+
return `Hello, ${state.name}, how can I help you today? I just sent you our menu, what do you want to do?`;
|
|
168
|
+
}
|
|
169
|
+
return "Hello, how can I help you today? I just sent you our menu, what do you want to do?";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function transitionAfterName(
|
|
173
|
+
state: ConversationState,
|
|
174
|
+
name: string | null,
|
|
175
|
+
declined: boolean,
|
|
176
|
+
): ConversationTurnResult {
|
|
177
|
+
const next: ConversationState = {
|
|
178
|
+
...state,
|
|
179
|
+
phase: "awaitingMenuChoice",
|
|
180
|
+
nameDeclined: declined,
|
|
181
|
+
name: declined ? undefined : (name ?? undefined),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const lines: string[] = [];
|
|
185
|
+
const messages: OutboundMessage[] = [];
|
|
186
|
+
|
|
187
|
+
if (declined) {
|
|
188
|
+
const decline = speakAndChat(NAME_DECLINE_REPLY);
|
|
189
|
+
lines.push(...decline.speakLines);
|
|
190
|
+
messages.push(...decline.messages);
|
|
191
|
+
} else if (name) {
|
|
192
|
+
const thanks = speakAndChat(`Great, thank you ${name}`);
|
|
193
|
+
lines.push(...thanks.speakLines);
|
|
194
|
+
messages.push(...thanks.messages);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const hello = speakAndChat(helloAfterName(next));
|
|
198
|
+
lines.push(...hello.speakLines);
|
|
199
|
+
messages.push(...hello.messages);
|
|
200
|
+
messages.push(...buildMenuMessages());
|
|
201
|
+
|
|
202
|
+
return { state: next, speakLines: lines, messages };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type MenuChoice =
|
|
206
|
+
"weather" | "count" | "recipe" | "fun_fact" | "menu" | null;
|
|
207
|
+
|
|
208
|
+
export function parseMenuChoice(utterance: string): MenuChoice {
|
|
209
|
+
const lower = utterance.toLowerCase().trim();
|
|
210
|
+
if (
|
|
211
|
+
/\bmenu\b/.test(lower) ||
|
|
212
|
+
/\bhelp\b/.test(lower) ||
|
|
213
|
+
/\bgo\s+back\b/.test(lower) ||
|
|
214
|
+
/\bstart\s+over\b/.test(lower)
|
|
215
|
+
) {
|
|
216
|
+
return "menu";
|
|
217
|
+
}
|
|
218
|
+
if (
|
|
219
|
+
lower === "1" ||
|
|
220
|
+
/\bweather\b/.test(lower) ||
|
|
221
|
+
/\bfirst\b/.test(lower) ||
|
|
222
|
+
/\bcheck\s+the\s+weather\b/.test(lower)
|
|
223
|
+
) {
|
|
224
|
+
return "weather";
|
|
225
|
+
}
|
|
226
|
+
if (lower === "2" || /\bcount\b/.test(lower) || /\bsecond\b/.test(lower)) {
|
|
227
|
+
return "count";
|
|
228
|
+
}
|
|
229
|
+
if (lower === "3" || /\brecipe\b/.test(lower) || /\bthird\b/.test(lower)) {
|
|
230
|
+
return "recipe";
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
lower === "4" ||
|
|
234
|
+
/\bfun\s+fact\b/.test(lower) ||
|
|
235
|
+
/\bfact\b/.test(lower) ||
|
|
236
|
+
/\bfourth\b/.test(lower)
|
|
237
|
+
) {
|
|
238
|
+
return "fun_fact";
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const WORD_TO_NUMBER: Record<string, number> = {
|
|
244
|
+
one: 1,
|
|
245
|
+
two: 2,
|
|
246
|
+
three: 3,
|
|
247
|
+
four: 4,
|
|
248
|
+
five: 5,
|
|
249
|
+
six: 6,
|
|
250
|
+
seven: 7,
|
|
251
|
+
eight: 8,
|
|
252
|
+
nine: 9,
|
|
253
|
+
ten: 10,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export function parseCountNumber(utterance: string): number | null {
|
|
257
|
+
const trimmed = utterance.trim().toLowerCase();
|
|
258
|
+
const digit = trimmed.match(/\b(\d+)\b/);
|
|
259
|
+
if (digit) {
|
|
260
|
+
const n = Number(digit[1]);
|
|
261
|
+
if (Number.isFinite(n)) return n;
|
|
262
|
+
}
|
|
263
|
+
for (const [word, value] of Object.entries(WORD_TO_NUMBER)) {
|
|
264
|
+
if (new RegExp(`\\b${word}\\b`).test(trimmed)) {
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function formatCountingSpeech(n: number): string {
|
|
272
|
+
const parts: string[] = [];
|
|
273
|
+
for (let i = 1; i <= n; i += 1) {
|
|
274
|
+
parts.push(String(i));
|
|
275
|
+
}
|
|
276
|
+
return parts.join(", ");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function resendMenu(state: ConversationState): ConversationTurnResult {
|
|
280
|
+
const menu = speakAndChat("Here is the menu again.");
|
|
281
|
+
return {
|
|
282
|
+
state: { ...state, phase: "awaitingMenuChoice" },
|
|
283
|
+
speakLines: menu.speakLines,
|
|
284
|
+
messages: [...menu.messages, ...buildMenuMessages()],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function returnToMenu(
|
|
289
|
+
state: ConversationState,
|
|
290
|
+
line: string,
|
|
291
|
+
): ConversationTurnResult {
|
|
292
|
+
const spoken = speakAndChat(line);
|
|
293
|
+
return {
|
|
294
|
+
state: {
|
|
295
|
+
...state,
|
|
296
|
+
phase: "awaitingMenuChoice",
|
|
297
|
+
weatherRetries: 0,
|
|
298
|
+
countFailures: 0,
|
|
299
|
+
weatherCity: undefined,
|
|
300
|
+
weatherCountry: undefined,
|
|
301
|
+
},
|
|
302
|
+
speakLines: spoken.speakLines,
|
|
303
|
+
messages: [...spoken.messages, ...buildMenuMessages()],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function handleUtterance(
|
|
308
|
+
state: ConversationState,
|
|
309
|
+
utterance: string,
|
|
310
|
+
): ConversationTurnResult {
|
|
311
|
+
const text = utterance.trim();
|
|
312
|
+
if (!text) {
|
|
313
|
+
return { state, speakLines: [], messages: [] };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (isHumanEscalation(text)) {
|
|
317
|
+
const reply = speakAndChat(HUMAN_ESCALATION_REPLY);
|
|
318
|
+
return {
|
|
319
|
+
state,
|
|
320
|
+
speakLines: reply.speakLines,
|
|
321
|
+
messages: reply.messages,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (state.phase !== "listeningForName" && parseMenuChoice(text) === "menu") {
|
|
326
|
+
return resendMenu({
|
|
327
|
+
...state,
|
|
328
|
+
phase: "awaitingMenuChoice",
|
|
329
|
+
weatherRetries: 0,
|
|
330
|
+
countFailures: 0,
|
|
331
|
+
weatherCity: undefined,
|
|
332
|
+
weatherCountry: undefined,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
switch (state.phase) {
|
|
337
|
+
case "listeningForName": {
|
|
338
|
+
if (isNameDecline(text)) {
|
|
339
|
+
return transitionAfterName(state, null, true);
|
|
340
|
+
}
|
|
341
|
+
const name = extractName(text);
|
|
342
|
+
return transitionAfterName(state, name, false);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
case "awaitingMenuChoice": {
|
|
346
|
+
const choice = parseMenuChoice(text);
|
|
347
|
+
if (choice === "menu") return resendMenu(state);
|
|
348
|
+
if (choice === "weather") {
|
|
349
|
+
const ask = speakAndChat(
|
|
350
|
+
"Sure. Please tell me a city or ZIP code and the country.",
|
|
351
|
+
);
|
|
352
|
+
return {
|
|
353
|
+
state: {
|
|
354
|
+
...state,
|
|
355
|
+
phase: "weatherAwaitingLocation",
|
|
356
|
+
weatherRetries: 0,
|
|
357
|
+
weatherCity: undefined,
|
|
358
|
+
weatherCountry: undefined,
|
|
359
|
+
},
|
|
360
|
+
speakLines: ask.speakLines,
|
|
361
|
+
messages: ask.messages,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (choice === "count") {
|
|
365
|
+
const ask = speakAndChat(
|
|
366
|
+
"Pick a number from 1 to 10 and I will count up to it.",
|
|
367
|
+
);
|
|
368
|
+
return {
|
|
369
|
+
state: {
|
|
370
|
+
...state,
|
|
371
|
+
phase: "countAwaitingNumber",
|
|
372
|
+
countFailures: 0,
|
|
373
|
+
},
|
|
374
|
+
speakLines: ask.speakLines,
|
|
375
|
+
messages: ask.messages,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (choice === "recipe") {
|
|
379
|
+
const ask = speakAndChat(
|
|
380
|
+
"What do you fancy? Try pasta, soup, breakfast, cookies, or salad.",
|
|
381
|
+
);
|
|
382
|
+
return {
|
|
383
|
+
state: { ...state, phase: "recipeAwaitingChoice" },
|
|
384
|
+
speakLines: ask.speakLines,
|
|
385
|
+
messages: ask.messages,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (choice === "fun_fact") {
|
|
389
|
+
const fact = pickFunFact();
|
|
390
|
+
return returnToMenu(state, `Here is a fun fact. ${fact}`);
|
|
391
|
+
}
|
|
392
|
+
const retry = speakAndChat(
|
|
393
|
+
"I did not catch that. Pick 1 through 4 from the menu, or say weather, count, recipe, or fun fact.",
|
|
394
|
+
);
|
|
395
|
+
return {
|
|
396
|
+
state,
|
|
397
|
+
speakLines: retry.speakLines,
|
|
398
|
+
messages: retry.messages,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
case "weatherAwaitingLocation": {
|
|
403
|
+
const parsed = parseLocationUtterance(text);
|
|
404
|
+
let city = parsed?.city || state.weatherCity;
|
|
405
|
+
let country = parsed?.country || state.weatherCountry;
|
|
406
|
+
|
|
407
|
+
// Country-only follow-up: "Thailand" must not overwrite a stored ZIP as city.
|
|
408
|
+
if (state.weatherCity && !country) {
|
|
409
|
+
const followUp =
|
|
410
|
+
matchCountryName(text) ??
|
|
411
|
+
(parsed?.city ? matchCountryName(parsed.city) : null);
|
|
412
|
+
if (followUp) {
|
|
413
|
+
city = state.weatherCity;
|
|
414
|
+
country = followUp;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!city) {
|
|
419
|
+
const ask = speakAndChat(
|
|
420
|
+
"Please tell me a city or ZIP code and the country.",
|
|
421
|
+
);
|
|
422
|
+
return {
|
|
423
|
+
state: { ...state, phase: "weatherAwaitingLocation" },
|
|
424
|
+
speakLines: ask.speakLines,
|
|
425
|
+
messages: ask.messages,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!country) {
|
|
430
|
+
return {
|
|
431
|
+
state: {
|
|
432
|
+
...state,
|
|
433
|
+
phase: "weatherAwaitingLocation",
|
|
434
|
+
weatherCity: city,
|
|
435
|
+
},
|
|
436
|
+
speakLines: ["Got it. Which country is that in?"],
|
|
437
|
+
messages: [
|
|
438
|
+
{ type: "chat_reply", text: "Got it. Which country is that in?" },
|
|
439
|
+
],
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
state: { ...state, weatherCity: city, weatherCountry: country },
|
|
445
|
+
speakLines: [],
|
|
446
|
+
messages: [],
|
|
447
|
+
pendingWeather: { city, country },
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
case "countAwaitingNumber": {
|
|
452
|
+
const n = parseCountNumber(text);
|
|
453
|
+
if (n === null || n < 1 || n > 10) {
|
|
454
|
+
const failures = state.countFailures + 1;
|
|
455
|
+
if (failures >= 2) {
|
|
456
|
+
return returnToMenu(state, "Sorry, I cannot do this.");
|
|
457
|
+
}
|
|
458
|
+
const retry = speakAndChat(
|
|
459
|
+
"I did not understand you. Please say a number from 1 to 10.",
|
|
460
|
+
);
|
|
461
|
+
return {
|
|
462
|
+
state: { ...state, countFailures: failures },
|
|
463
|
+
speakLines: retry.speakLines,
|
|
464
|
+
messages: retry.messages,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
const counting = formatCountingSpeech(n);
|
|
468
|
+
return returnToMenu(state, `Counting: ${counting}.`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
case "recipeAwaitingChoice": {
|
|
472
|
+
const recipe = pickRecipe(text);
|
|
473
|
+
const speech = formatRecipeSpeech(recipe);
|
|
474
|
+
return returnToMenu(state, speech);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
default:
|
|
478
|
+
return { state, speakLines: [], messages: [] };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function applyWeatherSuccess(
|
|
483
|
+
state: ConversationState,
|
|
484
|
+
weather: WeatherResult,
|
|
485
|
+
): ConversationTurnResult {
|
|
486
|
+
const line = formatWeatherSpeech(weather);
|
|
487
|
+
return returnToMenu(state, line);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function applyWeatherFailure(
|
|
491
|
+
state: ConversationState,
|
|
492
|
+
): ConversationTurnResult {
|
|
493
|
+
const retries = state.weatherRetries + 1;
|
|
494
|
+
if (retries >= 2) {
|
|
495
|
+
return returnToMenu(
|
|
496
|
+
{ ...state, weatherRetries: retries },
|
|
497
|
+
"Sorry, I could not look up the weather right now.",
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const retry = speakAndChat(
|
|
501
|
+
"I could not find that location. Please try again with a city or ZIP and country.",
|
|
502
|
+
);
|
|
503
|
+
return {
|
|
504
|
+
state: {
|
|
505
|
+
...state,
|
|
506
|
+
phase: "weatherAwaitingLocation",
|
|
507
|
+
weatherRetries: retries,
|
|
508
|
+
weatherCity: undefined,
|
|
509
|
+
weatherCountry: undefined,
|
|
510
|
+
},
|
|
511
|
+
speakLines: retry.speakLines,
|
|
512
|
+
messages: retry.messages,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export async function resolveWeatherTurn(
|
|
517
|
+
state: ConversationState,
|
|
518
|
+
city: string,
|
|
519
|
+
country: string | undefined,
|
|
520
|
+
fetchFn?: FetchFn,
|
|
521
|
+
): Promise<ConversationTurnResult> {
|
|
522
|
+
try {
|
|
523
|
+
const weather = await lookupWeather(city, country, fetchFn);
|
|
524
|
+
if (!weather) {
|
|
525
|
+
return applyWeatherFailure(state);
|
|
526
|
+
}
|
|
527
|
+
return applyWeatherSuccess(state, weather);
|
|
528
|
+
} catch {
|
|
529
|
+
return applyWeatherFailure(state);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Short fun facts for the voice showcase menu. */
|
|
2
|
+
|
|
3
|
+
export const FUN_FACTS: readonly string[] = [
|
|
4
|
+
"Honey never spoils — archaeologists have found edible honey in ancient Egyptian tombs.",
|
|
5
|
+
"Octopuses have three hearts and blue blood.",
|
|
6
|
+
"A day on Venus is longer than a year on Venus.",
|
|
7
|
+
"Bananas are berries, but strawberries are not.",
|
|
8
|
+
"The Eiffel Tower can grow about six inches taller in summer heat.",
|
|
9
|
+
"Sharks existed before trees appeared on Earth.",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
let factIndex = 0;
|
|
13
|
+
|
|
14
|
+
/** Pick the next fun fact (rotates through the list). */
|
|
15
|
+
export function pickFunFact(): string {
|
|
16
|
+
const fact = FUN_FACTS[factIndex % FUN_FACTS.length]!;
|
|
17
|
+
factIndex += 1;
|
|
18
|
+
return fact;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Reset rotation (for tests). */
|
|
22
|
+
export function resetFunFactIndex(): void {
|
|
23
|
+
factIndex = 0;
|
|
24
|
+
}
|