@tpitre/story-ui 4.14.0 → 4.15.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"canvasGenerate.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/canvasGenerate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAW5C,eAAO,MAAM,qBAAqB,oCAAoC,CAAC;AAmIvE,wBAAgB,eAAe,IAAI,IAAI,CAuBtC;AAID;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAU/D;AAuBD,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,+CAwEtE"}
1
+ {"version":3,"file":"canvasGenerate.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/canvasGenerate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAW5C,eAAO,MAAM,qBAAqB,oCAAoC,CAAC;AAmIvE,wBAAgB,eAAe,IAAI,IAAI,CAuBtC;AAID;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAU/D;AAyCD,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,+CAwEtE"}
@@ -192,21 +192,37 @@ export function ensureVoiceCanvasStory(storiesDir) {
192
192
  }
193
193
  }
194
194
  // ── Code extraction ───────────────────────────────────────────
195
+ /**
196
+ * If the LLM forgot to add a render() call (required by react-live noInline mode),
197
+ * detect the last defined PascalCase component and append render(<ComponentName />).
198
+ * This prevents the "No-Inline evaluations must call render" error when voice input
199
+ * is ambiguous or short and the LLM skips the final line.
200
+ */
201
+ function ensureRenderCall(code) {
202
+ if (/\brender\s*\(/.test(code))
203
+ return code;
204
+ // Find the last PascalCase component/const defined in the code
205
+ const matches = [...code.matchAll(/(?:const|function)\s+([A-Z][A-Za-z0-9]*)/g)];
206
+ const componentName = matches.at(-1)?.[1] ?? 'Canvas';
207
+ return `${code}\nrender(<${componentName} />);`;
208
+ }
195
209
  /**
196
210
  * Extract the canvas component code from the LLM response.
197
211
  * Handles markdown code fences and stray text.
198
212
  */
199
213
  function extractCanvasCode(response) {
214
+ let code;
200
215
  // Prefer explicit code fence
201
216
  const fenceMatch = response.match(/```(?:jsx|tsx|js|ts)?\n([\s\S]+?)\n```/);
202
- if (fenceMatch)
203
- return fenceMatch[1].trim();
204
- // Fall back: find the Canvas component block
205
- const canvasMatch = response.match(/(const Canvas\s*=[\s\S]+?render\s*\(<Canvas\s*\/>?\);?\s*$)/m);
206
- if (canvasMatch)
207
- return canvasMatch[1].trim();
208
- // Last resort: return the whole response trimmed
209
- return response.trim();
217
+ if (fenceMatch) {
218
+ code = fenceMatch[1].trim();
219
+ }
220
+ else {
221
+ // Fall back: find the Canvas component block
222
+ const canvasMatch = response.match(/(const Canvas\s*=[\s\S]+?render\s*\(<Canvas\s*\/>?\);?\s*$)/m);
223
+ code = canvasMatch ? canvasMatch[1].trim() : response.trim();
224
+ }
225
+ return ensureRenderCall(code);
210
226
  }
211
227
  // ── Handler ───────────────────────────────────────────────────
212
228
  export async function canvasGenerateHandler(req, res) {
@@ -1 +1 @@
1
- {"version":3,"file":"VoiceCanvas.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/VoiceCanvas.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAwE,MAAM,OAAO,CAAC;AAW7F,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC7E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED,8EAA8E;AAC9E,MAAM,WAAW,iBAAiB;IAChC,4EAA4E;IAC5E,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAID,eAAO,MAAM,WAAW,4FAktBtB,CAAC"}
1
+ {"version":3,"file":"VoiceCanvas.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/VoiceCanvas.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAwE,MAAM,OAAO,CAAC;AAY7F,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC7E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED,8EAA8E;AAC9E,MAAM,WAAW,iBAAiB;IAChC,4EAA4E;IAC5E,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAID,eAAO,MAAM,WAAW,4FAytBtB,CAAC"}
@@ -15,6 +15,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  * StoryUIPanel is never accidentally reset.
16
16
  */
17
17
  import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
18
+ import { parseVoiceCommand } from './voiceCommands.js';
18
19
  // ── Constants ─────────────────────────────────────────────────
19
20
  const STORY_ID = 'generated-voice-canvas--default';
20
21
  const LS_KEY = '__voice_canvas_code__';
@@ -317,30 +318,43 @@ export const VoiceCanvas = React.forwardRef(function VoiceCanvas({ apiBase, prov
317
318
  pendingTranscriptRef.current = accumulated;
318
319
  setPendingTranscript(accumulated);
319
320
  setInterimText('');
320
- const trimmed = final.trim().toLowerCase();
321
- if (trimmed.split(/\s+/).length <= 3) {
322
- if (trimmed === 'clear' || trimmed === 'start over') {
321
+ const command = parseVoiceCommand(final);
322
+ if (command) {
323
+ if (command.type === 'clear') {
323
324
  clear();
324
325
  pendingTranscriptRef.current = '';
325
326
  setPendingTranscript('');
326
327
  return;
327
328
  }
328
- if (trimmed === 'undo') {
329
+ if (command.type === 'undo') {
329
330
  undo();
330
331
  pendingTranscriptRef.current = '';
331
332
  setPendingTranscript('');
332
333
  return;
333
334
  }
334
- if (trimmed === 'redo') {
335
+ if (command.type === 'redo') {
335
336
  redo();
336
337
  pendingTranscriptRef.current = '';
337
338
  setPendingTranscript('');
338
339
  return;
339
340
  }
340
- if (trimmed === 'stop' || trimmed === 'stop listening') {
341
+ if (command.type === 'stop') {
341
342
  stopListeningRef.current();
342
343
  return;
343
344
  }
345
+ if (command.type === 'save') {
346
+ saveStory();
347
+ pendingTranscriptRef.current = '';
348
+ setPendingTranscript('');
349
+ return;
350
+ }
351
+ if (command.type === 'new-chat') {
352
+ clear();
353
+ pendingTranscriptRef.current = '';
354
+ setPendingTranscript('');
355
+ return;
356
+ }
357
+ // 'submit' falls through to schedule an LLM generation below
344
358
  }
345
359
  if (abortRef.current)
346
360
  abortRef.current.abort();
@@ -418,7 +432,7 @@ export const VoiceCanvas = React.forwardRef(function VoiceCanvas({ apiBase, prov
418
432
  isListeningRef.current = false;
419
433
  setIsListening(false);
420
434
  }
421
- }, [clear, undo, redo, scheduleIntent]);
435
+ }, [clear, undo, redo, scheduleIntent, saveStory]);
422
436
  // ── Voice: stop ────────────────────────────────────────────────
423
437
  const stopListening = useCallback(() => {
424
438
  isListeningRef.current = false;
@@ -14,6 +14,7 @@
14
14
  * StoryUIPanel is never accidentally reset.
15
15
  */
16
16
  import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
17
+ import { parseVoiceCommand } from './voiceCommands.js';
17
18
 
18
19
  // ── Constants ─────────────────────────────────────────────────
19
20
 
@@ -374,20 +375,27 @@ function VoiceCanvas({
374
375
  setPendingTranscript(accumulated);
375
376
  setInterimText('');
376
377
 
377
- const trimmed = final.trim().toLowerCase();
378
- if (trimmed.split(/\s+/).length <= 3) {
379
- if (trimmed === 'clear' || trimmed === 'start over') {
378
+ const command = parseVoiceCommand(final);
379
+ if (command) {
380
+ if (command.type === 'clear') {
380
381
  clear(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
381
382
  }
382
- if (trimmed === 'undo') {
383
+ if (command.type === 'undo') {
383
384
  undo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
384
385
  }
385
- if (trimmed === 'redo') {
386
+ if (command.type === 'redo') {
386
387
  redo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
387
388
  }
388
- if (trimmed === 'stop' || trimmed === 'stop listening') {
389
+ if (command.type === 'stop') {
389
390
  stopListeningRef.current(); return;
390
391
  }
392
+ if (command.type === 'save') {
393
+ saveStory(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
394
+ }
395
+ if (command.type === 'new-chat') {
396
+ clear(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
397
+ }
398
+ // 'submit' falls through to schedule an LLM generation below
391
399
  }
392
400
 
393
401
  if (abortRef.current) abortRef.current.abort();
@@ -461,7 +469,7 @@ function VoiceCanvas({
461
469
  isListeningRef.current = false;
462
470
  setIsListening(false);
463
471
  }
464
- }, [clear, undo, redo, scheduleIntent]);
472
+ }, [clear, undo, redo, scheduleIntent, saveStory]);
465
473
 
466
474
  // ── Voice: stop ────────────────────────────────────────────────
467
475
 
@@ -30,7 +30,7 @@ export interface UseVoiceInputReturn {
30
30
  stop: () => void;
31
31
  toggle: () => void;
32
32
  }
33
- export type VoiceCommandType = 'undo' | 'redo' | 'clear' | 'stop' | 'new-chat' | 'submit';
33
+ export type VoiceCommandType = 'undo' | 'redo' | 'clear' | 'stop' | 'new-chat' | 'submit' | 'save';
34
34
  export interface VoiceCommand {
35
35
  type: VoiceCommandType;
36
36
  raw: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/types.ts"],"names":[],"mappings":"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"}
@@ -52,7 +52,8 @@ export type VoiceCommandType =
52
52
  | 'clear'
53
53
  | 'stop'
54
54
  | 'new-chat'
55
- | 'submit';
55
+ | 'submit'
56
+ | 'save';
56
57
 
57
58
  export interface VoiceCommand {
58
59
  type: VoiceCommandType;
@@ -2,7 +2,10 @@ import type { VoiceCommand } from './types.js';
2
2
  /**
3
3
  * Checks if a transcript matches a known voice command.
4
4
  * Returns the command if matched, null otherwise.
5
- * Only matches short, exact phrases — longer utterances are descriptions.
5
+ *
6
+ * Short utterances (≤4 words) use exact matching against COMMAND_MAP.
7
+ * Any-length utterances are also checked for save-intent substrings so natural
8
+ * phrases like "this is good, save it, stop listening" still trigger a save.
6
9
  */
7
10
  export declare function parseVoiceCommand(transcript: string): VoiceCommand | null;
8
11
  //# sourceMappingURL=voiceCommands.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"voiceCommands.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/voiceCommands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,YAAY,CAAC;AAoCjE;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAYzE"}
1
+ {"version":3,"file":"voiceCommands.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/voiceCommands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,YAAY,CAAC;AA8DjE;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAmBzE"}
@@ -27,20 +27,53 @@ const COMMAND_MAP = {
27
27
  'go': 'submit',
28
28
  'send it': 'submit',
29
29
  'generate that': 'submit',
30
+ // Save — short exact phrases
31
+ 'save': 'save',
32
+ 'save this': 'save',
33
+ 'save it': 'save',
34
+ 'save story': 'save',
35
+ 'save the story': 'save',
36
+ 'looks good': 'save',
37
+ 'that looks good': 'save',
38
+ 'this looks good': 'save',
39
+ 'this is good': 'save',
40
+ 'all good': 'save',
41
+ 'all done': 'save',
42
+ 'go ahead and save': 'save',
43
+ 'save and stop': 'save',
30
44
  };
45
+ // Save-intent phrases detected anywhere in a longer utterance.
46
+ // Lets natural speech like "this is good, save it, stop listening" trigger a
47
+ // save without the user needing to say an exact short phrase.
48
+ const SAVE_INTENT_PHRASES = [
49
+ 'save it',
50
+ 'save this',
51
+ 'save the story',
52
+ 'go ahead and save',
53
+ 'save and stop',
54
+ ];
31
55
  /**
32
56
  * Checks if a transcript matches a known voice command.
33
57
  * Returns the command if matched, null otherwise.
34
- * Only matches short, exact phrases — longer utterances are descriptions.
58
+ *
59
+ * Short utterances (≤4 words) use exact matching against COMMAND_MAP.
60
+ * Any-length utterances are also checked for save-intent substrings so natural
61
+ * phrases like "this is good, save it, stop listening" still trigger a save.
35
62
  */
36
63
  export function parseVoiceCommand(transcript) {
37
64
  const normalized = transcript.trim().toLowerCase().replace(/[.,!?]/g, '');
38
- // Only check short utterances (commands are 1-3 words)
39
- if (normalized.split(/\s+/).length > 4)
40
- return null;
41
- const commandType = COMMAND_MAP[normalized];
42
- if (commandType) {
43
- return { type: commandType, raw: transcript };
65
+ // Short exact-match commands (1-4 words)
66
+ if (normalized.split(/\s+/).length <= 4) {
67
+ const commandType = COMMAND_MAP[normalized];
68
+ if (commandType) {
69
+ return { type: commandType, raw: transcript };
70
+ }
71
+ }
72
+ // Save-intent phrases can appear anywhere in a longer utterance
73
+ for (const phrase of SAVE_INTENT_PHRASES) {
74
+ if (normalized.includes(phrase)) {
75
+ return { type: 'save', raw: transcript };
76
+ }
44
77
  }
45
78
  return null;
46
79
  }
@@ -32,22 +32,58 @@ const COMMAND_MAP: Record<string, VoiceCommandType> = {
32
32
  'go': 'submit',
33
33
  'send it': 'submit',
34
34
  'generate that': 'submit',
35
+
36
+ // Save — short exact phrases
37
+ 'save': 'save',
38
+ 'save this': 'save',
39
+ 'save it': 'save',
40
+ 'save story': 'save',
41
+ 'save the story': 'save',
42
+ 'looks good': 'save',
43
+ 'that looks good': 'save',
44
+ 'this looks good': 'save',
45
+ 'this is good': 'save',
46
+ 'all good': 'save',
47
+ 'all done': 'save',
48
+ 'go ahead and save': 'save',
49
+ 'save and stop': 'save',
35
50
  };
36
51
 
52
+ // Save-intent phrases detected anywhere in a longer utterance.
53
+ // Lets natural speech like "this is good, save it, stop listening" trigger a
54
+ // save without the user needing to say an exact short phrase.
55
+ const SAVE_INTENT_PHRASES = [
56
+ 'save it',
57
+ 'save this',
58
+ 'save the story',
59
+ 'go ahead and save',
60
+ 'save and stop',
61
+ ];
62
+
37
63
  /**
38
64
  * Checks if a transcript matches a known voice command.
39
65
  * Returns the command if matched, null otherwise.
40
- * Only matches short, exact phrases — longer utterances are descriptions.
66
+ *
67
+ * Short utterances (≤4 words) use exact matching against COMMAND_MAP.
68
+ * Any-length utterances are also checked for save-intent substrings so natural
69
+ * phrases like "this is good, save it, stop listening" still trigger a save.
41
70
  */
42
71
  export function parseVoiceCommand(transcript: string): VoiceCommand | null {
43
72
  const normalized = transcript.trim().toLowerCase().replace(/[.,!?]/g, '');
44
73
 
45
- // Only check short utterances (commands are 1-3 words)
46
- if (normalized.split(/\s+/).length > 4) return null;
74
+ // Short exact-match commands (1-4 words)
75
+ if (normalized.split(/\s+/).length <= 4) {
76
+ const commandType = COMMAND_MAP[normalized];
77
+ if (commandType) {
78
+ return { type: commandType, raw: transcript };
79
+ }
80
+ }
47
81
 
48
- const commandType = COMMAND_MAP[normalized];
49
- if (commandType) {
50
- return { type: commandType, raw: transcript };
82
+ // Save-intent phrases can appear anywhere in a longer utterance
83
+ for (const phrase of SAVE_INTENT_PHRASES) {
84
+ if (normalized.includes(phrase)) {
85
+ return { type: 'save', raw: transcript };
86
+ }
51
87
  }
52
88
 
53
89
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpitre/story-ui",
3
- "version": "4.14.0",
3
+ "version": "4.15.0",
4
4
  "description": "AI-powered Storybook story generator with dynamic component discovery",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,6 +14,7 @@
14
14
  * StoryUIPanel is never accidentally reset.
15
15
  */
16
16
  import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
17
+ import { parseVoiceCommand } from './voiceCommands.js';
17
18
 
18
19
  // ── Constants ─────────────────────────────────────────────────
19
20
 
@@ -374,20 +375,27 @@ function VoiceCanvas({
374
375
  setPendingTranscript(accumulated);
375
376
  setInterimText('');
376
377
 
377
- const trimmed = final.trim().toLowerCase();
378
- if (trimmed.split(/\s+/).length <= 3) {
379
- if (trimmed === 'clear' || trimmed === 'start over') {
378
+ const command = parseVoiceCommand(final);
379
+ if (command) {
380
+ if (command.type === 'clear') {
380
381
  clear(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
381
382
  }
382
- if (trimmed === 'undo') {
383
+ if (command.type === 'undo') {
383
384
  undo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
384
385
  }
385
- if (trimmed === 'redo') {
386
+ if (command.type === 'redo') {
386
387
  redo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
387
388
  }
388
- if (trimmed === 'stop' || trimmed === 'stop listening') {
389
+ if (command.type === 'stop') {
389
390
  stopListeningRef.current(); return;
390
391
  }
392
+ if (command.type === 'save') {
393
+ saveStory(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
394
+ }
395
+ if (command.type === 'new-chat') {
396
+ clear(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
397
+ }
398
+ // 'submit' falls through to schedule an LLM generation below
391
399
  }
392
400
 
393
401
  if (abortRef.current) abortRef.current.abort();
@@ -461,7 +469,7 @@ function VoiceCanvas({
461
469
  isListeningRef.current = false;
462
470
  setIsListening(false);
463
471
  }
464
- }, [clear, undo, redo, scheduleIntent]);
472
+ }, [clear, undo, redo, scheduleIntent, saveStory]);
465
473
 
466
474
  // ── Voice: stop ────────────────────────────────────────────────
467
475
 
@@ -52,7 +52,8 @@ export type VoiceCommandType =
52
52
  | 'clear'
53
53
  | 'stop'
54
54
  | 'new-chat'
55
- | 'submit';
55
+ | 'submit'
56
+ | 'save';
56
57
 
57
58
  export interface VoiceCommand {
58
59
  type: VoiceCommandType;
@@ -32,22 +32,58 @@ const COMMAND_MAP: Record<string, VoiceCommandType> = {
32
32
  'go': 'submit',
33
33
  'send it': 'submit',
34
34
  'generate that': 'submit',
35
+
36
+ // Save — short exact phrases
37
+ 'save': 'save',
38
+ 'save this': 'save',
39
+ 'save it': 'save',
40
+ 'save story': 'save',
41
+ 'save the story': 'save',
42
+ 'looks good': 'save',
43
+ 'that looks good': 'save',
44
+ 'this looks good': 'save',
45
+ 'this is good': 'save',
46
+ 'all good': 'save',
47
+ 'all done': 'save',
48
+ 'go ahead and save': 'save',
49
+ 'save and stop': 'save',
35
50
  };
36
51
 
52
+ // Save-intent phrases detected anywhere in a longer utterance.
53
+ // Lets natural speech like "this is good, save it, stop listening" trigger a
54
+ // save without the user needing to say an exact short phrase.
55
+ const SAVE_INTENT_PHRASES = [
56
+ 'save it',
57
+ 'save this',
58
+ 'save the story',
59
+ 'go ahead and save',
60
+ 'save and stop',
61
+ ];
62
+
37
63
  /**
38
64
  * Checks if a transcript matches a known voice command.
39
65
  * Returns the command if matched, null otherwise.
40
- * Only matches short, exact phrases — longer utterances are descriptions.
66
+ *
67
+ * Short utterances (≤4 words) use exact matching against COMMAND_MAP.
68
+ * Any-length utterances are also checked for save-intent substrings so natural
69
+ * phrases like "this is good, save it, stop listening" still trigger a save.
41
70
  */
42
71
  export function parseVoiceCommand(transcript: string): VoiceCommand | null {
43
72
  const normalized = transcript.trim().toLowerCase().replace(/[.,!?]/g, '');
44
73
 
45
- // Only check short utterances (commands are 1-3 words)
46
- if (normalized.split(/\s+/).length > 4) return null;
74
+ // Short exact-match commands (1-4 words)
75
+ if (normalized.split(/\s+/).length <= 4) {
76
+ const commandType = COMMAND_MAP[normalized];
77
+ if (commandType) {
78
+ return { type: commandType, raw: transcript };
79
+ }
80
+ }
47
81
 
48
- const commandType = COMMAND_MAP[normalized];
49
- if (commandType) {
50
- return { type: commandType, raw: transcript };
82
+ // Save-intent phrases can appear anywhere in a longer utterance
83
+ for (const phrase of SAVE_INTENT_PHRASES) {
84
+ if (normalized.includes(phrase)) {
85
+ return { type: 'save', raw: transcript };
86
+ }
51
87
  }
52
88
 
53
89
  return null;