@tpitre/story-ui 4.13.0 → 4.13.2

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,19 +0,0 @@
1
- /**
2
- * Voice Canvas Render Endpoint
3
- *
4
- * Lightweight SSE endpoint for ephemeral voice-to-UI generation.
5
- * Returns self-contained HTML instead of full .stories.tsx files.
6
- * Designed for sub-2-second streaming response times.
7
- *
8
- * Key differences from generateStoryStream:
9
- * - No file I/O (ephemeral, in-memory only)
10
- * - No self-healing/validation loop
11
- * - No story boilerplate (imports, meta, decorators)
12
- * - Compact system prompt (~500 tokens vs ~3000)
13
- * - Defaults to fast models (Haiku, 4o-mini, Flash)
14
- * - Streams HTML chunks directly for live canvas rendering
15
- * - Section-tagged HTML for targeted edits (edit one section without regenerating all)
16
- */
17
- import { Request, Response } from 'express';
18
- export declare function voiceRenderStream(req: Request, res: Response): Promise<void>;
19
- //# sourceMappingURL=voiceRender.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"voiceRender.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/voiceRender.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AA4I5C,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAkNlF"}
@@ -1,329 +0,0 @@
1
- /**
2
- * Voice Canvas Render Endpoint
3
- *
4
- * Lightweight SSE endpoint for ephemeral voice-to-UI generation.
5
- * Returns self-contained HTML instead of full .stories.tsx files.
6
- * Designed for sub-2-second streaming response times.
7
- *
8
- * Key differences from generateStoryStream:
9
- * - No file I/O (ephemeral, in-memory only)
10
- * - No self-healing/validation loop
11
- * - No story boilerplate (imports, meta, decorators)
12
- * - Compact system prompt (~500 tokens vs ~3000)
13
- * - Defaults to fast models (Haiku, 4o-mini, Flash)
14
- * - Streams HTML chunks directly for live canvas rendering
15
- * - Section-tagged HTML for targeted edits (edit one section without regenerating all)
16
- */
17
- import * as cheerio from 'cheerio';
18
- import { loadUserConfig } from '../../story-generator/configLoader.js';
19
- import { logger } from '../../story-generator/logger.js';
20
- import { getProviderRegistry, initializeFromEnv, } from '../../story-generator/llm-providers/index.js';
21
- // Fast model defaults per provider
22
- const FAST_MODELS = {
23
- claude: 'claude-haiku-4-5-20251001',
24
- openai: 'gpt-4o-mini',
25
- gemini: 'gemini-2.0-flash',
26
- };
27
- // --- Section utilities ---
28
- /** Check if HTML contains data-section attributes */
29
- function hasSections(html) {
30
- return /data-section="[^"]+"/i.test(html);
31
- }
32
- /** Extract list of section IDs from HTML */
33
- function extractSections(html) {
34
- const $ = cheerio.load(html, { xml: false });
35
- const sections = [];
36
- $('[data-section]').each((_, el) => {
37
- const id = $(el).attr('data-section');
38
- if (id)
39
- sections.push(id);
40
- });
41
- return sections;
42
- }
43
- /** Replace a single section in the full HTML by its data-section ID */
44
- function spliceSection(fullHtml, sectionId, newSectionHtml) {
45
- const $ = cheerio.load(fullHtml, { xml: false });
46
- const target = $(`[data-section="${sectionId}"]`);
47
- if (target.length === 0) {
48
- // Section not found — append the new section at the end
49
- $('body').append(newSectionHtml);
50
- }
51
- else {
52
- target.replaceWith(newSectionHtml);
53
- }
54
- return $('body').html() || fullHtml;
55
- }
56
- /** Extract all sections as a Map of sectionId -> outerHTML */
57
- function extractSectionsMap(html) {
58
- const $ = cheerio.load(html, { xml: false });
59
- const map = new Map();
60
- $('[data-section]').each((_, el) => {
61
- const id = $(el).attr('data-section');
62
- if (id) {
63
- map.set(id, $.html(el) || '');
64
- }
65
- });
66
- return map;
67
- }
68
- /** Extract the data-section ID from a returned HTML fragment */
69
- function extractReturnedSectionId(html) {
70
- const match = html.match(/data-section="([^"]+)"/i);
71
- return match ? match[1] : null;
72
- }
73
- // --- Prompt builders ---
74
- function buildVoiceSystemPrompt(designSystem, editMode = false) {
75
- const baseRules = `You are a rapid UI prototyping engine. You generate self-contained HTML that visually represents UI components.
76
-
77
- DESIGN SYSTEM: ${designSystem}
78
-
79
- RULES:
80
- - Return ONLY the HTML content for the <body>. No doctype, html, head, or body tags.
81
- - Use inline styles that match the ${designSystem} design system aesthetic (colors, spacing, typography, border-radius, shadows).
82
- - Make it look polished and production-ready — proper padding, margins, font sizes.
83
- - Use modern CSS: flexbox, grid, gap, border-radius, box-shadow, transitions.
84
- - Use placeholder images from https://placehold.co/ when images are needed.
85
- - Use a dark theme if the design system supports it, otherwise light theme.
86
- - Keep HTML compact. No comments, no unnecessary wrappers.
87
- - Respond to spatial commands: "move X above Y", "add X next to Y", "remove X", "make X bigger".
88
- - Never include <script> tags or JavaScript.
89
- - Never wrap your response in markdown code fences.`;
90
- if (editMode) {
91
- return `${baseRules}
92
-
93
- EDIT MODE — CRITICAL RULES:
94
- - You are editing an EXISTING layout. The current HTML has sections tagged with data-section attributes.
95
- - Return ONLY the single <div data-section="..."> that needs to change. Nothing else.
96
- - Keep the same data-section attribute value on the returned div.
97
- - Do NOT return sections that are unchanged.
98
- - If the user asks to remove a section, return an empty string.
99
- - If the user asks to add a new section, return a new <div data-section="new-descriptive-name"> with the content.
100
- - Preserve exact styling of unchanged elements within the section you return.`;
101
- }
102
- return `${baseRules}
103
-
104
- SECTION TAGGING:
105
- - Wrap each logical section of the UI in a container div with a data-section attribute.
106
- - Use semantic names: header, hero, content, media, actions, footer, form, pricing, features, stats, testimonials, navigation, sidebar, etc.
107
- - Example: <div data-section="header" style="...">...</div>
108
- - Each top-level section MUST have a unique data-section value.
109
- - This enables targeted edits — users can modify one section without regenerating the entire layout.`;
110
- }
111
- function getProvider(providerType) {
112
- let initialized = false;
113
- if (!initialized) {
114
- initializeFromEnv();
115
- initialized = true;
116
- }
117
- const registry = getProviderRegistry();
118
- if (providerType) {
119
- const provider = registry.get(providerType);
120
- if (provider?.isConfigured())
121
- return provider;
122
- }
123
- const configured = registry.getConfiguredProviders();
124
- if (configured.length > 0)
125
- return configured[0];
126
- throw new Error('No LLM provider configured');
127
- }
128
- export async function voiceRenderStream(req, res) {
129
- const { prompt, currentHtml, designSystem, conversation, provider: providerType, model: requestedModel, useFastModel = true, } = req.body;
130
- if (!prompt) {
131
- res.status(400).json({ error: 'prompt is required' });
132
- return;
133
- }
134
- // SSE headers
135
- res.writeHead(200, {
136
- 'Content-Type': 'text/event-stream',
137
- 'Cache-Control': 'no-cache',
138
- Connection: 'keep-alive',
139
- 'Access-Control-Allow-Origin': '*',
140
- });
141
- const sendEvent = (event, data) => {
142
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
143
- };
144
- try {
145
- // Detect design system from config if not provided
146
- let ds = designSystem || 'Mantine';
147
- if (!designSystem) {
148
- try {
149
- const config = loadUserConfig();
150
- if (config?.importPath) {
151
- if (config.importPath.includes('mantine'))
152
- ds = 'Mantine';
153
- else if (config.importPath.includes('vuetify'))
154
- ds = 'Vuetify';
155
- else if (config.importPath.includes('chakra'))
156
- ds = 'Chakra UI';
157
- else if (config.importPath.includes('mui') || config.importPath.includes('material'))
158
- ds = 'Material UI';
159
- else if (config.importPath.includes('flowbite'))
160
- ds = 'Flowbite';
161
- else if (config.importPath.includes('shoelace'))
162
- ds = 'Shoelace';
163
- }
164
- }
165
- catch {
166
- // Use default
167
- }
168
- }
169
- // Determine if this is a section-aware edit
170
- const isEditMode = !!currentHtml && hasSections(currentHtml);
171
- const sectionList = isEditMode ? extractSections(currentHtml) : [];
172
- sendEvent('status', {
173
- phase: 'starting',
174
- editMode: isEditMode,
175
- message: isEditMode ? 'Editing section...' : 'Generating UI...',
176
- });
177
- const provider = getProvider(providerType);
178
- // Select model: explicit > fast default > provider default
179
- let model;
180
- if (requestedModel) {
181
- model = requestedModel;
182
- }
183
- else if (useFastModel && FAST_MODELS[provider.type]) {
184
- model = FAST_MODELS[provider.type];
185
- }
186
- else {
187
- model = provider.getConfig().model;
188
- }
189
- // Build system prompt with edit mode awareness
190
- const systemPrompt = buildVoiceSystemPrompt(ds, isEditMode);
191
- const messages = [];
192
- // Add conversation history for incremental updates
193
- if (conversation && conversation.length > 0) {
194
- for (const msg of conversation) {
195
- messages.push({ role: msg.role, content: msg.content });
196
- }
197
- }
198
- // Build the user message
199
- let userMessage;
200
- if (isEditMode) {
201
- // Section-aware edit: show full HTML as context, list available sections
202
- userMessage = `Current UI HTML (with sections: ${sectionList.join(', ')}):\n\`\`\`html\n${currentHtml}\n\`\`\`\n\nModification request: ${prompt}\n\nReturn ONLY the single <div data-section="..."> that needs to change. Do NOT return unchanged sections.`;
203
- }
204
- else if (currentHtml) {
205
- // Non-sectioned edit (legacy fallback): return complete HTML
206
- userMessage = `Current UI HTML:\n\`\`\`html\n${currentHtml}\n\`\`\`\n\nModification request: ${prompt}\n\nReturn the complete updated HTML with data-section attributes on each top-level section.`;
207
- }
208
- else {
209
- userMessage = prompt;
210
- }
211
- messages.push({ role: 'user', content: userMessage });
212
- sendEvent('status', {
213
- phase: 'streaming',
214
- message: isEditMode ? 'AI is editing section...' : 'AI is rendering...',
215
- });
216
- // Stream the response
217
- let fullResponse = '';
218
- const startTime = Date.now();
219
- if (!provider.chatStream) {
220
- // Fallback for providers without streaming
221
- const response = await provider.chat(messages, {
222
- model,
223
- maxTokens: isEditMode ? 2048 : 4096,
224
- temperature: 0.3,
225
- systemPrompt,
226
- });
227
- fullResponse = response.content;
228
- sendEvent('html_chunk', { content: fullResponse });
229
- }
230
- else {
231
- const stream = provider.chatStream(messages, {
232
- model,
233
- maxTokens: isEditMode ? 2048 : 4096,
234
- temperature: 0.3,
235
- systemPrompt,
236
- });
237
- for await (const chunk of stream) {
238
- if (chunk.type === 'text' && chunk.content) {
239
- fullResponse += chunk.content;
240
- sendEvent('html_chunk', { content: chunk.content });
241
- }
242
- else if (chunk.type === 'error') {
243
- sendEvent('error', { message: chunk.error });
244
- break;
245
- }
246
- else if (chunk.type === 'done') {
247
- // Stream complete
248
- }
249
- }
250
- }
251
- const elapsed = Date.now() - startTime;
252
- // Clean up the response — strip any markdown fences the LLM might add
253
- let cleanHtml = fullResponse.trim();
254
- // Strip leading fence with optional language tag and whitespace
255
- cleanHtml = cleanHtml.replace(/^```(?:html|xml)?\s*\n?/i, '');
256
- // Strip trailing fence
257
- cleanHtml = cleanHtml.replace(/\n?```\s*$/i, '');
258
- // Also strip mid-response fence artifacts (LLM sometimes wraps inner sections)
259
- cleanHtml = cleanHtml.replace(/```(?:html|xml)?\s*\n/gi, '').replace(/\n```/g, '');
260
- cleanHtml = cleanHtml.trim();
261
- // If edit mode: splice the returned section back into the full HTML
262
- let finalHtml = cleanHtml;
263
- let editedSection = null;
264
- if (isEditMode && currentHtml) {
265
- if (cleanHtml === '' || cleanHtml.toLowerCase() === 'removed') {
266
- // Section removal — try to detect which section from the prompt
267
- // The LLM should return empty string for removals
268
- finalHtml = currentHtml;
269
- editedSection = 'removed';
270
- }
271
- else {
272
- const returnedSectionId = extractReturnedSectionId(cleanHtml);
273
- if (returnedSectionId) {
274
- finalHtml = spliceSection(currentHtml, returnedSectionId, cleanHtml);
275
- editedSection = returnedSectionId;
276
- logger.log(`Section edit: spliced section "${returnedSectionId}" (${cleanHtml.length} chars)`);
277
- }
278
- else if (hasSections(cleanHtml)) {
279
- // LLM returned full HTML with sections — diff to find ALL changed sections
280
- const originalSections = extractSectionsMap(currentHtml);
281
- const returnedSections = extractSectionsMap(cleanHtml);
282
- const changedIds = [];
283
- // Start from the original HTML, then splice each changed section
284
- finalHtml = currentHtml;
285
- for (const [id, html] of returnedSections) {
286
- if (!originalSections.has(id) || originalSections.get(id) !== html) {
287
- finalHtml = spliceSection(finalHtml, id, html);
288
- changedIds.push(id);
289
- }
290
- }
291
- if (changedIds.length > 0) {
292
- editedSection = changedIds.join(',');
293
- logger.log(`Section edit: diffed and spliced ${changedIds.length} changed section(s): "${editedSection}"`);
294
- }
295
- else {
296
- // Nothing changed or couldn't detect — use full replacement
297
- finalHtml = cleanHtml;
298
- logger.log('Section edit fallback: LLM returned full HTML, no diff detected, using as full replacement');
299
- }
300
- }
301
- else {
302
- // LLM didn't return a sectioned div — treat as full replacement (fallback)
303
- finalHtml = cleanHtml;
304
- logger.log('Section edit fallback: LLM returned unsectioned HTML, using as full replacement');
305
- }
306
- }
307
- }
308
- sendEvent('complete', {
309
- html: finalHtml,
310
- editedSection,
311
- metrics: {
312
- timeMs: elapsed,
313
- model,
314
- provider: provider.type,
315
- editMode: isEditMode,
316
- sectionsInLayout: sectionList.length,
317
- },
318
- });
319
- logger.log(`Voice render complete: ${elapsed}ms using ${provider.type}/${model}${isEditMode ? ` (section edit)` : ''}`);
320
- }
321
- catch (error) {
322
- const message = error instanceof Error ? error.message : String(error);
323
- logger.error('Voice render error:', message);
324
- sendEvent('error', { message });
325
- }
326
- finally {
327
- res.end();
328
- }
329
- }