@tpitre/story-ui 4.13.3 → 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.
- package/dist/mcp-server/routes/canvasGenerate.d.ts.map +1 -1
- package/dist/mcp-server/routes/canvasGenerate.js +24 -8
- package/dist/templates/StoryUI/StoryUIPanel.d.ts.map +1 -1
- package/dist/templates/StoryUI/StoryUIPanel.js +11 -3
- package/dist/templates/StoryUI/StoryUIPanel.tsx +12 -3
- package/dist/templates/StoryUI/voice/VoiceCanvas.d.ts +22 -1
- package/dist/templates/StoryUI/voice/VoiceCanvas.d.ts.map +1 -1
- package/dist/templates/StoryUI/voice/VoiceCanvas.js +38 -10
- package/dist/templates/StoryUI/voice/VoiceCanvas.tsx +44 -11
- package/dist/templates/StoryUI/voice/types.d.ts +1 -1
- package/dist/templates/StoryUI/voice/types.d.ts.map +1 -1
- package/dist/templates/StoryUI/voice/types.ts +2 -1
- package/dist/templates/StoryUI/voice/voiceCommands.d.ts +4 -1
- package/dist/templates/StoryUI/voice/voiceCommands.d.ts.map +1 -1
- package/dist/templates/StoryUI/voice/voiceCommands.js +40 -7
- package/dist/templates/StoryUI/voice/voiceCommands.ts +42 -6
- package/package.json +1 -1
- package/templates/StoryUI/StoryUIPanel.tsx +12 -3
- package/templates/StoryUI/voice/VoiceCanvas.tsx +44 -11
- package/templates/StoryUI/voice/types.ts +2 -1
- package/templates/StoryUI/voice/voiceCommands.ts +42 -6
|
@@ -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;
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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":"StoryUIPanel.d.ts","sourceRoot":"","sources":["../../../templates/StoryUI/StoryUIPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,oBAAoB,CAAC;AAskC5B,UAAU,iBAAiB;IACzB,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC3B;AAED,iBAAS,YAAY,CAAC,EAAE,OAAO,EAAE,EAAE,iBAAiB,
|
|
1
|
+
{"version":3,"file":"StoryUIPanel.d.ts","sourceRoot":"","sources":["../../../templates/StoryUI/StoryUIPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,oBAAoB,CAAC;AAskC5B,UAAU,iBAAiB;IACzB,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC3B;AAED,iBAAS,YAAY,CAAC,EAAE,OAAO,EAAE,EAAE,iBAAiB,2CAm4CnD;AAED,eAAe,YAAY,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,CAAC"}
|
|
@@ -821,6 +821,7 @@ function StoryUIPanel({ mcpPort }) {
|
|
|
821
821
|
const panelGeneratedStoryIds = useRef(new Set());
|
|
822
822
|
const voiceModeActiveRef = useRef(false);
|
|
823
823
|
const canvasModeRef = useRef(panelMode === 'canvas');
|
|
824
|
+
const voiceCanvasRef = useRef(null);
|
|
824
825
|
const knownStoryIds = useRef(new Set());
|
|
825
826
|
const isPollingInitialized = useRef(false);
|
|
826
827
|
// Set port override if provided
|
|
@@ -1545,7 +1546,14 @@ function StoryUIPanel({ mcpPort }) {
|
|
|
1545
1546
|
dispatch({ type: 'SET_CONVERSATION', payload: chat.conversation });
|
|
1546
1547
|
dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
|
|
1547
1548
|
};
|
|
1548
|
-
const handleNewChat = () =>
|
|
1549
|
+
const handleNewChat = () => {
|
|
1550
|
+
dispatch({ type: 'NEW_CHAT' });
|
|
1551
|
+
// When on Voice Canvas, also clear the canvas state (abort generation,
|
|
1552
|
+
// reset code, blank the iframe, clear conversation history)
|
|
1553
|
+
if (panelMode === 'canvas') {
|
|
1554
|
+
voiceCanvasRef.current?.clear();
|
|
1555
|
+
}
|
|
1556
|
+
};
|
|
1549
1557
|
// Voice input handlers
|
|
1550
1558
|
const handleVoiceTranscript = useCallback((text) => {
|
|
1551
1559
|
// Append transcript to current input (user may be speaking in segments)
|
|
@@ -1779,7 +1787,7 @@ function StoryUIPanel({ mcpPort }) {
|
|
|
1779
1787
|
// ============================================
|
|
1780
1788
|
// Render
|
|
1781
1789
|
// ============================================
|
|
1782
|
-
return (_jsxs("div", { className: `sui-root ${state.isDarkMode ? 'dark' : ''}`, children: [_jsxs("aside", { className: `sui-sidebar ${state.sidebarOpen ? '' : 'collapsed'}`, "aria-label": "Chat history", children: [state.sidebarOpen && (_jsxs("div", { className: "sui-sidebar-content", children: [_jsxs("button", { className: "sui-button sui-button-ghost", onClick: () => dispatch({ type: 'TOGGLE_SIDEBAR' }), style: { width: '100%', marginBottom: '12px', justifyContent: 'flex-start' }, children: [Icons.panelLeft, _jsx("span", { style: { marginLeft: '8px' }, children: "Hide sidebar" })] }), _jsxs("button", { className: "sui-button sui-button-default", onClick: handleNewChat, style: { width: '100%', marginBottom: '16px' }, children: [Icons.plus, _jsx("span", { children:
|
|
1790
|
+
return (_jsxs("div", { className: `sui-root ${state.isDarkMode ? 'dark' : ''}`, children: [_jsxs("aside", { className: `sui-sidebar ${state.sidebarOpen ? '' : 'collapsed'}`, "aria-label": "Chat history", children: [state.sidebarOpen && (_jsxs("div", { className: "sui-sidebar-content", children: [_jsxs("button", { className: "sui-button sui-button-ghost", onClick: () => dispatch({ type: 'TOGGLE_SIDEBAR' }), style: { width: '100%', marginBottom: '12px', justifyContent: 'flex-start' }, children: [Icons.panelLeft, _jsx("span", { style: { marginLeft: '8px' }, children: "Hide sidebar" })] }), _jsxs("button", { className: "sui-button sui-button-default", onClick: handleNewChat, style: { width: '100%', marginBottom: '16px' }, children: [Icons.plus, _jsx("span", { children: panelMode === 'canvas' ? 'New Canvas' : 'New Chat' })] }), _jsx("div", { className: "sui-sidebar-chats", children: [...state.recentChats].sort((a, b) => {
|
|
1783
1791
|
// Match Storybook sidebar order (from /index.json); alphabetical fallback
|
|
1784
1792
|
const posA = storybookOrder.get(a.title.toLowerCase()) ?? Infinity;
|
|
1785
1793
|
const posB = storybookOrder.get(b.title.toLowerCase()) ?? Infinity;
|
|
@@ -1807,7 +1815,7 @@ function StoryUIPanel({ mcpPort }) {
|
|
|
1807
1815
|
const enabled = e.target.checked;
|
|
1808
1816
|
dispatch({ type: 'SET_USE_STORYBOOK_MCP', payload: enabled });
|
|
1809
1817
|
saveStorybookMcpPref(enabled);
|
|
1810
|
-
}, "aria-label": "Use Storybook MCP context" }), _jsx("span", { className: "sui-toggle-slider" })] })] }) }))] })] }), panelMode === 'canvas' ? (_jsx(VoiceCanvas, { apiBase: getApiBase(), provider: state.selectedProvider, model: state.selectedModel, onSave: (result) => {
|
|
1818
|
+
}, "aria-label": "Use Storybook MCP context" }), _jsx("span", { className: "sui-toggle-slider" })] })] }) }))] })] }), panelMode === 'canvas' ? (_jsx(VoiceCanvas, { ref: voiceCanvasRef, apiBase: getApiBase(), provider: state.selectedProvider, model: state.selectedModel, onSave: (result) => {
|
|
1811
1819
|
// Track the saved story — use fileName stem as chatId (consistent with manifest entry IDs)
|
|
1812
1820
|
const chatId = result.fileName.replace(/\.stories\.[a-z]+$/, '') || Date.now().toString();
|
|
1813
1821
|
const newSession = {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import React, { useState, useEffect, useRef, useCallback, useReducer } from 'react';
|
|
10
10
|
import './StoryUIPanel.css';
|
|
11
11
|
import { VoiceControls } from './voice/VoiceControls.js';
|
|
12
|
-
import { VoiceCanvas } from './voice/VoiceCanvas.js';
|
|
12
|
+
import { VoiceCanvas, type VoiceCanvasHandle } from './voice/VoiceCanvas.js';
|
|
13
13
|
import type { VoiceCommand } from './voice/types.js';
|
|
14
14
|
|
|
15
15
|
// ============================================
|
|
@@ -1182,6 +1182,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
|
|
|
1182
1182
|
const panelGeneratedStoryIds = useRef<Set<string>>(new Set());
|
|
1183
1183
|
const voiceModeActiveRef = useRef(false);
|
|
1184
1184
|
const canvasModeRef = useRef(panelMode === 'canvas');
|
|
1185
|
+
const voiceCanvasRef = useRef<VoiceCanvasHandle>(null);
|
|
1185
1186
|
const knownStoryIds = useRef<Set<string>>(new Set());
|
|
1186
1187
|
const isPollingInitialized = useRef(false);
|
|
1187
1188
|
|
|
@@ -1913,7 +1914,14 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
|
|
|
1913
1914
|
dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
|
|
1914
1915
|
};
|
|
1915
1916
|
|
|
1916
|
-
const handleNewChat = () =>
|
|
1917
|
+
const handleNewChat = () => {
|
|
1918
|
+
dispatch({ type: 'NEW_CHAT' });
|
|
1919
|
+
// When on Voice Canvas, also clear the canvas state (abort generation,
|
|
1920
|
+
// reset code, blank the iframe, clear conversation history)
|
|
1921
|
+
if (panelMode === 'canvas') {
|
|
1922
|
+
voiceCanvasRef.current?.clear();
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1917
1925
|
|
|
1918
1926
|
// Voice input handlers
|
|
1919
1927
|
const handleVoiceTranscript = useCallback((text: string) => {
|
|
@@ -2159,7 +2167,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
|
|
|
2159
2167
|
{/* New Chat */}
|
|
2160
2168
|
<button className="sui-button sui-button-default" onClick={handleNewChat} style={{ width: '100%', marginBottom: '16px' }}>
|
|
2161
2169
|
{Icons.plus}
|
|
2162
|
-
<span>New Chat</span>
|
|
2170
|
+
<span>{panelMode === 'canvas' ? 'New Canvas' : 'New Chat'}</span>
|
|
2163
2171
|
</button>
|
|
2164
2172
|
|
|
2165
2173
|
{/* Chat history */}
|
|
@@ -2368,6 +2376,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
|
|
|
2368
2376
|
|
|
2369
2377
|
{panelMode === 'canvas' ? (
|
|
2370
2378
|
<VoiceCanvas
|
|
2379
|
+
ref={voiceCanvasRef}
|
|
2371
2380
|
apiBase={getApiBase()}
|
|
2372
2381
|
provider={state.selectedProvider}
|
|
2373
2382
|
model={state.selectedModel}
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoiceCanvas v5 — Storybook iframe + postMessage
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* - LLM generates JSX code string
|
|
6
|
+
* - Server writes a STATIC react-live story template ONCE on first use
|
|
7
|
+
* (voice-canvas.stories.tsx never changes after creation — no HMR cascade)
|
|
8
|
+
* - Preview renders in a Storybook iframe (full decorator chain = correct Mantine theme)
|
|
9
|
+
* - Code updates on generate / undo / redo are delivered via:
|
|
10
|
+
* 1. localStorage (persists across iframe reloads)
|
|
11
|
+
* 2. window.postMessage (instant in-place update, no iframe reload needed)
|
|
12
|
+
*
|
|
13
|
+
* This means undo/redo has ZERO file I/O and ZERO HMR, so the outer
|
|
14
|
+
* StoryUIPanel is never accidentally reset.
|
|
15
|
+
*/
|
|
16
|
+
import React from 'react';
|
|
1
17
|
export interface VoiceCanvasProps {
|
|
2
18
|
apiBase: string;
|
|
3
19
|
provider?: string;
|
|
@@ -11,5 +27,10 @@ export interface VoiceCanvasProps {
|
|
|
11
27
|
}) => void;
|
|
12
28
|
onError?: (error: string) => void;
|
|
13
29
|
}
|
|
14
|
-
|
|
30
|
+
/** Imperative handle exposed to parent via ref — used by "New Chat" button */
|
|
31
|
+
export interface VoiceCanvasHandle {
|
|
32
|
+
/** Clear the canvas: abort generation, reset all state, blank the iframe */
|
|
33
|
+
clear: () => void;
|
|
34
|
+
}
|
|
35
|
+
export declare const VoiceCanvas: React.ForwardRefExoticComponent<VoiceCanvasProps & React.RefAttributes<VoiceCanvasHandle>>;
|
|
15
36
|
//# sourceMappingURL=VoiceCanvas.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"VoiceCanvas.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/VoiceCanvas.tsx"],"names":[],"mappings":"
|
|
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"}
|
|
@@ -14,14 +14,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
14
14
|
* This means undo/redo has ZERO file I/O and ZERO HMR, so the outer
|
|
15
15
|
* StoryUIPanel is never accidentally reset.
|
|
16
16
|
*/
|
|
17
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
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__';
|
|
21
22
|
const LS_PROMPT_KEY = '__voice_canvas_prompt__';
|
|
22
23
|
const IFRAME_ORIGIN = window.location.origin;
|
|
23
24
|
// ── Component ─────────────────────────────────────────────────
|
|
24
|
-
export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
|
|
25
|
+
export const VoiceCanvas = React.forwardRef(function VoiceCanvas({ apiBase, provider, model, onSave, onError, }, ref) {
|
|
25
26
|
// ── Code + history ───────────────────────────────────────────
|
|
26
27
|
const [currentCode, setCurrentCode] = useState('');
|
|
27
28
|
const [undoStack, setUndoStack] = useState([]);
|
|
@@ -194,6 +195,12 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
|
|
|
194
195
|
const canRedo = redoStack.length > 0;
|
|
195
196
|
// ── Clear ─────────────────────────────────────────────────────
|
|
196
197
|
const clear = useCallback(() => {
|
|
198
|
+
// Abort any in-flight generation so it doesn't land after the reset
|
|
199
|
+
if (abortRef.current) {
|
|
200
|
+
abortRef.current.abort();
|
|
201
|
+
abortRef.current = null;
|
|
202
|
+
}
|
|
203
|
+
generationCounterRef.current += 1; // invalidate any pending finally-block
|
|
197
204
|
const current = currentCodeRef.current;
|
|
198
205
|
if (current.trim()) {
|
|
199
206
|
setUndoStack(prev => [...prev.slice(-19), current]);
|
|
@@ -204,8 +211,12 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
|
|
|
204
211
|
iframeLoadedRef.current = false;
|
|
205
212
|
conversationRef.current = [];
|
|
206
213
|
setErrorMessage('');
|
|
214
|
+
setIsGenerating(false);
|
|
215
|
+
setStatusText('');
|
|
207
216
|
setPendingTranscript('');
|
|
208
217
|
pendingTranscriptRef.current = '';
|
|
218
|
+
setLastPrompt('');
|
|
219
|
+
lastPromptRef.current = '';
|
|
209
220
|
try {
|
|
210
221
|
localStorage.removeItem(LS_KEY);
|
|
211
222
|
}
|
|
@@ -214,6 +225,8 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
|
|
|
214
225
|
localStorage.removeItem(LS_PROMPT_KEY);
|
|
215
226
|
}
|
|
216
227
|
catch { }
|
|
228
|
+
// Force the iframe to remount — it will read empty localStorage and show the placeholder
|
|
229
|
+
setIframeKey(k => k + 1);
|
|
217
230
|
}, []);
|
|
218
231
|
// ── Save ───────────────────────────────────────────────────────
|
|
219
232
|
// No dialog — saves immediately using the last voice/text prompt as the title.
|
|
@@ -305,30 +318,43 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
|
|
|
305
318
|
pendingTranscriptRef.current = accumulated;
|
|
306
319
|
setPendingTranscript(accumulated);
|
|
307
320
|
setInterimText('');
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
310
|
-
if (
|
|
321
|
+
const command = parseVoiceCommand(final);
|
|
322
|
+
if (command) {
|
|
323
|
+
if (command.type === 'clear') {
|
|
311
324
|
clear();
|
|
312
325
|
pendingTranscriptRef.current = '';
|
|
313
326
|
setPendingTranscript('');
|
|
314
327
|
return;
|
|
315
328
|
}
|
|
316
|
-
if (
|
|
329
|
+
if (command.type === 'undo') {
|
|
317
330
|
undo();
|
|
318
331
|
pendingTranscriptRef.current = '';
|
|
319
332
|
setPendingTranscript('');
|
|
320
333
|
return;
|
|
321
334
|
}
|
|
322
|
-
if (
|
|
335
|
+
if (command.type === 'redo') {
|
|
323
336
|
redo();
|
|
324
337
|
pendingTranscriptRef.current = '';
|
|
325
338
|
setPendingTranscript('');
|
|
326
339
|
return;
|
|
327
340
|
}
|
|
328
|
-
if (
|
|
341
|
+
if (command.type === 'stop') {
|
|
329
342
|
stopListeningRef.current();
|
|
330
343
|
return;
|
|
331
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
|
|
332
358
|
}
|
|
333
359
|
if (abortRef.current)
|
|
334
360
|
abortRef.current.abort();
|
|
@@ -406,7 +432,7 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
|
|
|
406
432
|
isListeningRef.current = false;
|
|
407
433
|
setIsListening(false);
|
|
408
434
|
}
|
|
409
|
-
}, [clear, undo, redo, scheduleIntent]);
|
|
435
|
+
}, [clear, undo, redo, scheduleIntent, saveStory]);
|
|
410
436
|
// ── Voice: stop ────────────────────────────────────────────────
|
|
411
437
|
const stopListening = useCallback(() => {
|
|
412
438
|
isListeningRef.current = false;
|
|
@@ -505,6 +531,8 @@ export function VoiceCanvas({ apiBase, provider, model, onSave, onError, }) {
|
|
|
505
531
|
if (!speechSupported) {
|
|
506
532
|
return (_jsx("div", { className: "sui-canvas-container", children: _jsxs("div", { className: "sui-canvas-unsupported", children: [_jsxs("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", x2: "12", y1: "19", y2: "22" }), _jsx("line", { x1: "2", x2: "22", y1: "2", y2: "22", stroke: "currentColor", strokeWidth: "1.5" })] }), _jsx("h2", { className: "sui-canvas-unsupported-title", children: "Voice not available" }), _jsx("p", { className: "sui-canvas-unsupported-desc", children: "Voice Canvas requires the Web Speech API, which isn't supported in this browser. Try Chrome or Edge for the full experience." })] }) }));
|
|
507
533
|
}
|
|
534
|
+
// ── Imperative handle (for parent "New Canvas" button) ──────────
|
|
535
|
+
useImperativeHandle(ref, () => ({ clear }), [clear]);
|
|
508
536
|
// ── Render ─────────────────────────────────────────────────────
|
|
509
537
|
return (_jsxs("div", { className: "sui-canvas-container", children: [_jsxs("div", { className: "sui-canvas-preview", children: [!storyReady && !isGenerating && (_jsxs("div", { className: "sui-canvas-empty", children: [_jsx("div", { className: "sui-canvas-empty-icon", children: _jsxs("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", x2: "12", y1: "19", y2: "22" })] }) }), _jsx("h2", { className: "sui-canvas-empty-title", children: "Voice Canvas" }), _jsx("p", { className: "sui-canvas-empty-desc", children: "Speak to build interfaces live with your design system components." }), _jsx("p", { className: "sui-canvas-empty-hint", children: "Try: \"Create a product card with an image, title, price, and buy button\"" })] })), !storyReady && isGenerating && (_jsxs("div", { className: "sui-canvas-progress", children: [_jsx("div", { className: "sui-canvas-progress-spinner" }), _jsx("span", { className: "sui-canvas-progress-text", children: statusText || 'Building...' })] })), storyReady && (_jsxs("div", { className: "sui-canvas-live-wrapper", children: [isGenerating && (_jsxs("div", { className: "sui-canvas-regen-overlay", children: [_jsx("div", { className: "sui-canvas-progress-spinner sui-canvas-progress-spinner--sm" }), _jsx("span", { children: statusText || 'Regenerating...' })] })), _jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Voice Canvas Preview", className: "sui-canvas-iframe", onLoad: handleIframeLoad }, iframeKey)] })), !isGenerating && errorMessage && (_jsxs("div", { className: "sui-canvas-error", children: [_jsx("span", { children: errorMessage }), _jsx("button", { type: "button", className: "sui-canvas-error-dismiss", onClick: () => setErrorMessage(''), "aria-label": "Dismiss error", children: "\u00D7" })] })), savedMessage && (_jsxs("div", { className: "sui-canvas-saved-toast", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("polyline", { points: "20 6 9 17 4 12" }) }), _jsxs("span", { children: ["Saved: ", savedMessage] })] }))] }), statusText && !isGenerating && (_jsx("div", { className: "sui-canvas-status-bar", children: _jsx("span", { className: "sui-canvas-explanation", children: statusText }) })), _jsxs("div", { className: `sui-canvas-bar ${isListening ? 'sui-canvas-bar--active' : ''}`, children: [_jsxs("div", { className: "sui-canvas-bar-left", children: [_jsxs("button", { type: "button", className: `sui-canvas-mic ${isListening ? 'sui-canvas-mic--active' : ''}`, onClick: toggleListening, "aria-label": isListening ? 'Stop voice input' : 'Start voice input', children: [_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", x2: "12", y1: "19", y2: "22" })] }), isListening && _jsx("span", { className: "sui-canvas-mic-pulse" })] }), _jsx("div", { className: "sui-canvas-transcript", children: isGenerating ? (_jsx("span", { className: "sui-canvas-status-rendering", children: statusText || 'Building interface...' })) : interimText ? (_jsxs("span", { className: "sui-canvas-status-interim", children: [pendingTranscript ? pendingTranscript + ' ' : '', interimText] })) : pendingTranscript ? (_jsx("span", { className: "sui-canvas-status-final", children: pendingTranscript })) : isListening ? (_jsx("span", { className: "sui-canvas-status-listening", children: "Listening... describe what you want to build" })) : lastPrompt ? (_jsxs("span", { className: "sui-canvas-status-hint", title: lastPrompt, children: ["\u2713 ", lastPrompt.length > 72 ? lastPrompt.slice(0, 69) + '…' : lastPrompt] })) : (_jsx("span", { className: "sui-canvas-status-hint", children: "Click the mic and describe what to build" })) })] }), _jsxs("div", { className: "sui-canvas-bar-right", children: [canUndo && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: undo, title: "Undo (Cmd+Z)", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("polyline", { points: "1 4 1 10 7 10" }), _jsx("path", { d: "M3.51 15a9 9 0 1 0 2.13-9.36L1 10" })] }) })), canRedo && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: redo, title: "Redo (Cmd+Shift+Z)", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("polyline", { points: "23 4 23 10 17 10" }), _jsx("path", { d: "M20.49 15a9 9 0 1 1-2.13-9.36L23 10" })] }) })), hasContent && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: saveStory, title: "Save as story", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("path", { d: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" }), _jsx("polyline", { points: "17 21 17 13 7 13 7 21" }), _jsx("polyline", { points: "7 3 7 8 15 8" })] }) })), hasContent && (_jsx("button", { type: "button", className: "sui-canvas-action", onClick: clear, title: "Clear canvas", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("path", { d: "M3 6h18" }), _jsx("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }), _jsx("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" })] }) }))] })] })] }));
|
|
510
|
-
}
|
|
538
|
+
});
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* This means undo/redo has ZERO file I/O and ZERO HMR, so the outer
|
|
14
14
|
* StoryUIPanel is never accidentally reset.
|
|
15
15
|
*/
|
|
16
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
16
|
+
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
17
|
+
import { parseVoiceCommand } from './voiceCommands.js';
|
|
17
18
|
|
|
18
19
|
// ── Constants ─────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -34,15 +35,22 @@ export interface VoiceCanvasProps {
|
|
|
34
35
|
onError?: (error: string) => void;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Imperative handle exposed to parent via ref — used by "New Chat" button */
|
|
39
|
+
export interface VoiceCanvasHandle {
|
|
40
|
+
/** Clear the canvas: abort generation, reset all state, blank the iframe */
|
|
41
|
+
clear: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
// ── Component ─────────────────────────────────────────────────
|
|
38
45
|
|
|
39
|
-
export
|
|
46
|
+
export const VoiceCanvas = React.forwardRef<VoiceCanvasHandle, VoiceCanvasProps>(
|
|
47
|
+
function VoiceCanvas({
|
|
40
48
|
apiBase,
|
|
41
49
|
provider,
|
|
42
50
|
model,
|
|
43
51
|
onSave,
|
|
44
52
|
onError,
|
|
45
|
-
}: VoiceCanvasProps) {
|
|
53
|
+
}: VoiceCanvasProps, ref) {
|
|
46
54
|
// ── Code + history ───────────────────────────────────────────
|
|
47
55
|
const [currentCode, setCurrentCode] = useState('');
|
|
48
56
|
const [undoStack, setUndoStack] = useState<string[]>([]);
|
|
@@ -239,6 +247,13 @@ export function VoiceCanvas({
|
|
|
239
247
|
// ── Clear ─────────────────────────────────────────────────────
|
|
240
248
|
|
|
241
249
|
const clear = useCallback(() => {
|
|
250
|
+
// Abort any in-flight generation so it doesn't land after the reset
|
|
251
|
+
if (abortRef.current) {
|
|
252
|
+
abortRef.current.abort();
|
|
253
|
+
abortRef.current = null;
|
|
254
|
+
}
|
|
255
|
+
generationCounterRef.current += 1; // invalidate any pending finally-block
|
|
256
|
+
|
|
242
257
|
const current = currentCodeRef.current;
|
|
243
258
|
if (current.trim()) {
|
|
244
259
|
setUndoStack(prev => [...prev.slice(-19), current]);
|
|
@@ -249,10 +264,16 @@ export function VoiceCanvas({
|
|
|
249
264
|
iframeLoadedRef.current = false;
|
|
250
265
|
conversationRef.current = [];
|
|
251
266
|
setErrorMessage('');
|
|
267
|
+
setIsGenerating(false);
|
|
268
|
+
setStatusText('');
|
|
252
269
|
setPendingTranscript('');
|
|
253
270
|
pendingTranscriptRef.current = '';
|
|
271
|
+
setLastPrompt('');
|
|
272
|
+
lastPromptRef.current = '';
|
|
254
273
|
try { localStorage.removeItem(LS_KEY); } catch {}
|
|
255
274
|
try { localStorage.removeItem(LS_PROMPT_KEY); } catch {}
|
|
275
|
+
// Force the iframe to remount — it will read empty localStorage and show the placeholder
|
|
276
|
+
setIframeKey(k => k + 1);
|
|
256
277
|
}, []);
|
|
257
278
|
|
|
258
279
|
// ── Save ───────────────────────────────────────────────────────
|
|
@@ -354,20 +375,27 @@ export function VoiceCanvas({
|
|
|
354
375
|
setPendingTranscript(accumulated);
|
|
355
376
|
setInterimText('');
|
|
356
377
|
|
|
357
|
-
const
|
|
358
|
-
if (
|
|
359
|
-
if (
|
|
378
|
+
const command = parseVoiceCommand(final);
|
|
379
|
+
if (command) {
|
|
380
|
+
if (command.type === 'clear') {
|
|
360
381
|
clear(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
|
|
361
382
|
}
|
|
362
|
-
if (
|
|
383
|
+
if (command.type === 'undo') {
|
|
363
384
|
undo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
|
|
364
385
|
}
|
|
365
|
-
if (
|
|
386
|
+
if (command.type === 'redo') {
|
|
366
387
|
redo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
|
|
367
388
|
}
|
|
368
|
-
if (
|
|
389
|
+
if (command.type === 'stop') {
|
|
369
390
|
stopListeningRef.current(); return;
|
|
370
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
|
|
371
399
|
}
|
|
372
400
|
|
|
373
401
|
if (abortRef.current) abortRef.current.abort();
|
|
@@ -441,7 +469,7 @@ export function VoiceCanvas({
|
|
|
441
469
|
isListeningRef.current = false;
|
|
442
470
|
setIsListening(false);
|
|
443
471
|
}
|
|
444
|
-
}, [clear, undo, redo, scheduleIntent]);
|
|
472
|
+
}, [clear, undo, redo, scheduleIntent, saveStory]);
|
|
445
473
|
|
|
446
474
|
// ── Voice: stop ────────────────────────────────────────────────
|
|
447
475
|
|
|
@@ -556,6 +584,10 @@ export function VoiceCanvas({
|
|
|
556
584
|
);
|
|
557
585
|
}
|
|
558
586
|
|
|
587
|
+
// ── Imperative handle (for parent "New Canvas" button) ──────────
|
|
588
|
+
|
|
589
|
+
useImperativeHandle(ref, () => ({ clear }), [clear]);
|
|
590
|
+
|
|
559
591
|
// ── Render ─────────────────────────────────────────────────────
|
|
560
592
|
|
|
561
593
|
return (
|
|
@@ -740,4 +772,5 @@ export function VoiceCanvas({
|
|
|
740
772
|
</div>
|
|
741
773
|
</div>
|
|
742
774
|
);
|
|
743
|
-
}
|
|
775
|
+
});
|
|
776
|
+
|
|
@@ -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":"AAEA,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,OAAO,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,cAAc,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,cAAc,GACtB,aAAa,GACb,WAAW,GACX,SAAS,GACT,eAAe,GACf,SAAS,GACT,qBAAqB,GACrB,wBAAwB,GACxB,eAAe,CAAC;AAEpB,MAAM,WAAW,oBAAoB;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,iBAAiB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,mBAAmB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACnD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACvC;AAED,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE,OAAO,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IACzB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AAGD,MAAM,MAAM,gBAAgB,GACxB,MAAM,GACN,MAAM,GACN,OAAO,GACP,MAAM,GACN,UAAU,GACV,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../templates/StoryUI/voice/types.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|
-
*
|
|
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;
|
|
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
|
-
*
|
|
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
|
-
//
|
|
39
|
-
if (normalized.split(/\s+/).length
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
46
|
-
if (normalized.split(/\s+/).length
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import React, { useState, useEffect, useRef, useCallback, useReducer } from 'react';
|
|
10
10
|
import './StoryUIPanel.css';
|
|
11
11
|
import { VoiceControls } from './voice/VoiceControls.js';
|
|
12
|
-
import { VoiceCanvas } from './voice/VoiceCanvas.js';
|
|
12
|
+
import { VoiceCanvas, type VoiceCanvasHandle } from './voice/VoiceCanvas.js';
|
|
13
13
|
import type { VoiceCommand } from './voice/types.js';
|
|
14
14
|
|
|
15
15
|
// ============================================
|
|
@@ -1182,6 +1182,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
|
|
|
1182
1182
|
const panelGeneratedStoryIds = useRef<Set<string>>(new Set());
|
|
1183
1183
|
const voiceModeActiveRef = useRef(false);
|
|
1184
1184
|
const canvasModeRef = useRef(panelMode === 'canvas');
|
|
1185
|
+
const voiceCanvasRef = useRef<VoiceCanvasHandle>(null);
|
|
1185
1186
|
const knownStoryIds = useRef<Set<string>>(new Set());
|
|
1186
1187
|
const isPollingInitialized = useRef(false);
|
|
1187
1188
|
|
|
@@ -1913,7 +1914,14 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
|
|
|
1913
1914
|
dispatch({ type: 'SET_ACTIVE_CHAT', payload: { id: chat.id, title: chat.title } });
|
|
1914
1915
|
};
|
|
1915
1916
|
|
|
1916
|
-
const handleNewChat = () =>
|
|
1917
|
+
const handleNewChat = () => {
|
|
1918
|
+
dispatch({ type: 'NEW_CHAT' });
|
|
1919
|
+
// When on Voice Canvas, also clear the canvas state (abort generation,
|
|
1920
|
+
// reset code, blank the iframe, clear conversation history)
|
|
1921
|
+
if (panelMode === 'canvas') {
|
|
1922
|
+
voiceCanvasRef.current?.clear();
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1917
1925
|
|
|
1918
1926
|
// Voice input handlers
|
|
1919
1927
|
const handleVoiceTranscript = useCallback((text: string) => {
|
|
@@ -2159,7 +2167,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
|
|
|
2159
2167
|
{/* New Chat */}
|
|
2160
2168
|
<button className="sui-button sui-button-default" onClick={handleNewChat} style={{ width: '100%', marginBottom: '16px' }}>
|
|
2161
2169
|
{Icons.plus}
|
|
2162
|
-
<span>New Chat</span>
|
|
2170
|
+
<span>{panelMode === 'canvas' ? 'New Canvas' : 'New Chat'}</span>
|
|
2163
2171
|
</button>
|
|
2164
2172
|
|
|
2165
2173
|
{/* Chat history */}
|
|
@@ -2368,6 +2376,7 @@ function StoryUIPanel({ mcpPort }: StoryUIPanelProps) {
|
|
|
2368
2376
|
|
|
2369
2377
|
{panelMode === 'canvas' ? (
|
|
2370
2378
|
<VoiceCanvas
|
|
2379
|
+
ref={voiceCanvasRef}
|
|
2371
2380
|
apiBase={getApiBase()}
|
|
2372
2381
|
provider={state.selectedProvider}
|
|
2373
2382
|
model={state.selectedModel}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* This means undo/redo has ZERO file I/O and ZERO HMR, so the outer
|
|
14
14
|
* StoryUIPanel is never accidentally reset.
|
|
15
15
|
*/
|
|
16
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
16
|
+
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
17
|
+
import { parseVoiceCommand } from './voiceCommands.js';
|
|
17
18
|
|
|
18
19
|
// ── Constants ─────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -34,15 +35,22 @@ export interface VoiceCanvasProps {
|
|
|
34
35
|
onError?: (error: string) => void;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Imperative handle exposed to parent via ref — used by "New Chat" button */
|
|
39
|
+
export interface VoiceCanvasHandle {
|
|
40
|
+
/** Clear the canvas: abort generation, reset all state, blank the iframe */
|
|
41
|
+
clear: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
// ── Component ─────────────────────────────────────────────────
|
|
38
45
|
|
|
39
|
-
export
|
|
46
|
+
export const VoiceCanvas = React.forwardRef<VoiceCanvasHandle, VoiceCanvasProps>(
|
|
47
|
+
function VoiceCanvas({
|
|
40
48
|
apiBase,
|
|
41
49
|
provider,
|
|
42
50
|
model,
|
|
43
51
|
onSave,
|
|
44
52
|
onError,
|
|
45
|
-
}: VoiceCanvasProps) {
|
|
53
|
+
}: VoiceCanvasProps, ref) {
|
|
46
54
|
// ── Code + history ───────────────────────────────────────────
|
|
47
55
|
const [currentCode, setCurrentCode] = useState('');
|
|
48
56
|
const [undoStack, setUndoStack] = useState<string[]>([]);
|
|
@@ -239,6 +247,13 @@ export function VoiceCanvas({
|
|
|
239
247
|
// ── Clear ─────────────────────────────────────────────────────
|
|
240
248
|
|
|
241
249
|
const clear = useCallback(() => {
|
|
250
|
+
// Abort any in-flight generation so it doesn't land after the reset
|
|
251
|
+
if (abortRef.current) {
|
|
252
|
+
abortRef.current.abort();
|
|
253
|
+
abortRef.current = null;
|
|
254
|
+
}
|
|
255
|
+
generationCounterRef.current += 1; // invalidate any pending finally-block
|
|
256
|
+
|
|
242
257
|
const current = currentCodeRef.current;
|
|
243
258
|
if (current.trim()) {
|
|
244
259
|
setUndoStack(prev => [...prev.slice(-19), current]);
|
|
@@ -249,10 +264,16 @@ export function VoiceCanvas({
|
|
|
249
264
|
iframeLoadedRef.current = false;
|
|
250
265
|
conversationRef.current = [];
|
|
251
266
|
setErrorMessage('');
|
|
267
|
+
setIsGenerating(false);
|
|
268
|
+
setStatusText('');
|
|
252
269
|
setPendingTranscript('');
|
|
253
270
|
pendingTranscriptRef.current = '';
|
|
271
|
+
setLastPrompt('');
|
|
272
|
+
lastPromptRef.current = '';
|
|
254
273
|
try { localStorage.removeItem(LS_KEY); } catch {}
|
|
255
274
|
try { localStorage.removeItem(LS_PROMPT_KEY); } catch {}
|
|
275
|
+
// Force the iframe to remount — it will read empty localStorage and show the placeholder
|
|
276
|
+
setIframeKey(k => k + 1);
|
|
256
277
|
}, []);
|
|
257
278
|
|
|
258
279
|
// ── Save ───────────────────────────────────────────────────────
|
|
@@ -354,20 +375,27 @@ export function VoiceCanvas({
|
|
|
354
375
|
setPendingTranscript(accumulated);
|
|
355
376
|
setInterimText('');
|
|
356
377
|
|
|
357
|
-
const
|
|
358
|
-
if (
|
|
359
|
-
if (
|
|
378
|
+
const command = parseVoiceCommand(final);
|
|
379
|
+
if (command) {
|
|
380
|
+
if (command.type === 'clear') {
|
|
360
381
|
clear(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
|
|
361
382
|
}
|
|
362
|
-
if (
|
|
383
|
+
if (command.type === 'undo') {
|
|
363
384
|
undo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
|
|
364
385
|
}
|
|
365
|
-
if (
|
|
386
|
+
if (command.type === 'redo') {
|
|
366
387
|
redo(); pendingTranscriptRef.current = ''; setPendingTranscript(''); return;
|
|
367
388
|
}
|
|
368
|
-
if (
|
|
389
|
+
if (command.type === 'stop') {
|
|
369
390
|
stopListeningRef.current(); return;
|
|
370
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
|
|
371
399
|
}
|
|
372
400
|
|
|
373
401
|
if (abortRef.current) abortRef.current.abort();
|
|
@@ -441,7 +469,7 @@ export function VoiceCanvas({
|
|
|
441
469
|
isListeningRef.current = false;
|
|
442
470
|
setIsListening(false);
|
|
443
471
|
}
|
|
444
|
-
}, [clear, undo, redo, scheduleIntent]);
|
|
472
|
+
}, [clear, undo, redo, scheduleIntent, saveStory]);
|
|
445
473
|
|
|
446
474
|
// ── Voice: stop ────────────────────────────────────────────────
|
|
447
475
|
|
|
@@ -556,6 +584,10 @@ export function VoiceCanvas({
|
|
|
556
584
|
);
|
|
557
585
|
}
|
|
558
586
|
|
|
587
|
+
// ── Imperative handle (for parent "New Canvas" button) ──────────
|
|
588
|
+
|
|
589
|
+
useImperativeHandle(ref, () => ({ clear }), [clear]);
|
|
590
|
+
|
|
559
591
|
// ── Render ─────────────────────────────────────────────────────
|
|
560
592
|
|
|
561
593
|
return (
|
|
@@ -740,4 +772,5 @@ export function VoiceCanvas({
|
|
|
740
772
|
</div>
|
|
741
773
|
</div>
|
|
742
774
|
);
|
|
743
|
-
}
|
|
775
|
+
});
|
|
776
|
+
|
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
46
|
-
if (normalized.split(/\s+/).length
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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;
|