@tpitre/story-ui 3.4.2 → 3.5.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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared helper functions for story generation routes.
3
+ * These utilities are used by both generateStory.ts and generateStoryStream.ts
4
+ * to eliminate code duplication and ensure consistent behavior.
5
+ */
6
+ import { ImageContent } from '../../story-generator/llm-providers/types.js';
7
+ /**
8
+ * Slugify a string for use in filenames and identifiers.
9
+ */
10
+ export declare function slugify(str: string): string;
11
+ /**
12
+ * Extract code block from LLM response text.
13
+ * Accepts various language identifiers (tsx, jsx, typescript, etc.)
14
+ */
15
+ export declare function extractCodeBlock(text: string): string | null;
16
+ /**
17
+ * Call the LLM service with optional vision support.
18
+ * Automatically handles provider configuration and image attachment.
19
+ */
20
+ export declare function callLLM(messages: {
21
+ role: 'user' | 'assistant';
22
+ content: string;
23
+ }[], images?: ImageContent[]): Promise<string>;
24
+ /**
25
+ * Clean a user prompt to create a readable title.
26
+ * Removes common leading phrases and normalizes formatting.
27
+ */
28
+ export declare function cleanPromptForTitle(prompt: string): string;
29
+ /**
30
+ * Generate a title using the LLM service.
31
+ * Falls back to empty string on failure.
32
+ */
33
+ export declare function getLLMTitle(userPrompt: string): Promise<string>;
34
+ /**
35
+ * Escape a title string for use in TypeScript string literals.
36
+ */
37
+ export declare function escapeTitleForTS(title: string): string;
38
+ /**
39
+ * Extract component imports from code for a specific import path.
40
+ */
41
+ export declare function extractImportsFromCode(code: string, importPath: string): string[];
42
+ /**
43
+ * Generate a filename from a story title and hash.
44
+ */
45
+ export declare function fileNameFromTitle(title: string, hash: string): string;
46
+ /**
47
+ * Find a similar icon name from the allowed icons set.
48
+ * Used for providing suggestions when an invalid icon is used.
49
+ */
50
+ export declare function findSimilarIcon(iconName: string, allowedIcons: Set<string>): string | null;
51
+ //# sourceMappingURL=storyHelpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storyHelpers.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/storyHelpers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAWH,OAAO,EAAE,YAAY,EAAE,MAAM,8CAA8C,CAAC;AAE5E;;GAEG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQ3C;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG5D;AAED;;;GAGG;AACH,wBAAsB,OAAO,CAC3B,QAAQ,EAAE;IAAE,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,EAC3D,MAAM,CAAC,EAAE,YAAY,EAAE,GACtB,OAAO,CAAC,MAAM,CAAC,CA6BjB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAkC1D;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOrE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAStD;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAajF;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAgBrE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAwB1F"}
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Shared helper functions for story generation routes.
3
+ * These utilities are used by both generateStory.ts and generateStoryStream.ts
4
+ * to eliminate code duplication and ensure consistent behavior.
5
+ */
6
+ import { logger } from '../../story-generator/logger.js';
7
+ import { chatCompletion, generateTitle as llmGenerateTitle, isProviderConfigured, getProviderInfo, chatCompletionWithImages, buildMessageWithImages } from '../../story-generator/llm-providers/story-llm-service.js';
8
+ /**
9
+ * Slugify a string for use in filenames and identifiers.
10
+ */
11
+ export function slugify(str) {
12
+ if (!str || typeof str !== 'string') {
13
+ return 'untitled';
14
+ }
15
+ return str
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-+|-+$/g, '');
19
+ }
20
+ /**
21
+ * Extract code block from LLM response text.
22
+ * Accepts various language identifiers (tsx, jsx, typescript, etc.)
23
+ */
24
+ export function extractCodeBlock(text) {
25
+ const codeBlock = text.match(/```(?:tsx|jsx|typescript|ts|js|javascript)?([\s\S]*?)```/i);
26
+ return codeBlock ? codeBlock[1].trim() : null;
27
+ }
28
+ /**
29
+ * Call the LLM service with optional vision support.
30
+ * Automatically handles provider configuration and image attachment.
31
+ */
32
+ export async function callLLM(messages, images) {
33
+ if (!isProviderConfigured()) {
34
+ throw new Error('No LLM provider configured. Please set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY.');
35
+ }
36
+ const providerInfo = getProviderInfo();
37
+ logger.debug(`Using ${providerInfo.currentProvider} (${providerInfo.currentModel}) for story generation`);
38
+ if (images && images.length > 0) {
39
+ if (!providerInfo.supportsVision) {
40
+ throw new Error(`${providerInfo.currentProvider} does not support vision. Please configure a vision-capable provider.`);
41
+ }
42
+ logger.log(`🖼️ Using vision-capable chat with ${images.length} image(s)`);
43
+ const messagesWithImages = messages.map((msg, index) => {
44
+ if (msg.role === 'user' && index === 0) {
45
+ return {
46
+ role: msg.role,
47
+ content: buildMessageWithImages(msg.content, images)
48
+ };
49
+ }
50
+ return msg;
51
+ });
52
+ return await chatCompletionWithImages(messagesWithImages, { maxTokens: 8192 });
53
+ }
54
+ return await chatCompletion(messages, { maxTokens: 8192 });
55
+ }
56
+ /**
57
+ * Clean a user prompt to create a readable title.
58
+ * Removes common leading phrases and normalizes formatting.
59
+ */
60
+ export function cleanPromptForTitle(prompt) {
61
+ if (!prompt || typeof prompt !== 'string') {
62
+ return 'Untitled Story';
63
+ }
64
+ const leadingPhrases = [
65
+ /^generate (a|an|the)? /i,
66
+ /^build (a|an|the)? /i,
67
+ /^create (a|an|the)? /i,
68
+ /^make (a|an|the)? /i,
69
+ /^design (a|an|the)? /i,
70
+ /^show (me )?(a|an|the)? /i,
71
+ /^write (a|an|the)? /i,
72
+ /^produce (a|an|the)? /i,
73
+ /^construct (a|an|the)? /i,
74
+ /^draft (a|an|the)? /i,
75
+ /^compose (a|an|the)? /i,
76
+ /^implement (a|an|the)? /i,
77
+ /^build out (a|an|the)? /i,
78
+ /^add (a|an|the)? /i,
79
+ /^render (a|an|the)? /i,
80
+ /^display (a|an|the)? /i,
81
+ ];
82
+ let cleaned = prompt.trim();
83
+ for (const regex of leadingPhrases) {
84
+ cleaned = cleaned.replace(regex, '');
85
+ }
86
+ return cleaned
87
+ .replace(/[^\w\s'"?!-]/g, ' ')
88
+ .replace(/\s+/g, ' ')
89
+ .trim()
90
+ .replace(/\b\w/g, c => c.toUpperCase());
91
+ }
92
+ /**
93
+ * Generate a title using the LLM service.
94
+ * Falls back to empty string on failure.
95
+ */
96
+ export async function getLLMTitle(userPrompt) {
97
+ try {
98
+ return await llmGenerateTitle(userPrompt);
99
+ }
100
+ catch (error) {
101
+ logger.warn('Failed to generate title via LLM, using fallback', { error });
102
+ return '';
103
+ }
104
+ }
105
+ /**
106
+ * Escape a title string for use in TypeScript string literals.
107
+ */
108
+ export function escapeTitleForTS(title) {
109
+ return title
110
+ .replace(/\\/g, '\\\\')
111
+ .replace(/"/g, '\\"')
112
+ .replace(/'/g, "\\'")
113
+ .replace(/`/g, '\\`')
114
+ .replace(/\n/g, '\\n')
115
+ .replace(/\r/g, '\\r')
116
+ .replace(/\t/g, '\\t');
117
+ }
118
+ /**
119
+ * Extract component imports from code for a specific import path.
120
+ */
121
+ export function extractImportsFromCode(code, importPath) {
122
+ const imports = [];
123
+ const escapedPath = importPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
124
+ const importRegex = new RegExp(`import\\s*{([^}]+)}\\s*from\\s*['"]${escapedPath}['"]`, 'g');
125
+ let match;
126
+ while ((match = importRegex.exec(code)) !== null) {
127
+ const importList = match[1];
128
+ const components = importList.split(',').map(comp => comp.trim());
129
+ imports.push(...components);
130
+ }
131
+ return imports;
132
+ }
133
+ /**
134
+ * Generate a filename from a story title and hash.
135
+ */
136
+ export function fileNameFromTitle(title, hash) {
137
+ if (!title || typeof title !== 'string') {
138
+ title = 'untitled';
139
+ }
140
+ if (!hash || typeof hash !== 'string') {
141
+ hash = 'default';
142
+ }
143
+ const base = title
144
+ .toLowerCase()
145
+ .replace(/[^a-z0-9]+/g, '-')
146
+ .replace(/^-+|-+$/g, '')
147
+ .replace(/"|'/g, '')
148
+ .slice(0, 60);
149
+ return `${base}-${hash}.stories.tsx`;
150
+ }
151
+ /**
152
+ * Find a similar icon name from the allowed icons set.
153
+ * Used for providing suggestions when an invalid icon is used.
154
+ */
155
+ export function findSimilarIcon(iconName, allowedIcons) {
156
+ if (!iconName || typeof iconName !== 'string') {
157
+ return null;
158
+ }
159
+ const iconLower = iconName.toLowerCase();
160
+ for (const allowed of allowedIcons) {
161
+ const allowedLower = allowed.toLowerCase();
162
+ // Check if core words match
163
+ if (iconLower.includes('commit') && allowedLower.includes('commit'))
164
+ return allowed;
165
+ if (iconLower.includes('branch') && allowedLower.includes('branch'))
166
+ return allowed;
167
+ if (iconLower.includes('merge') && allowedLower.includes('merge'))
168
+ return allowed;
169
+ if (iconLower.includes('pull') && allowedLower.includes('pull'))
170
+ return allowed;
171
+ if (iconLower.includes('push') && allowedLower.includes('push'))
172
+ return allowed;
173
+ if (iconLower.includes('star') && allowedLower.includes('star'))
174
+ return allowed;
175
+ if (iconLower.includes('user') && allowedLower.includes('user'))
176
+ return allowed;
177
+ if (iconLower.includes('search') && allowedLower.includes('search'))
178
+ return allowed;
179
+ if (iconLower.includes('settings') && allowedLower.includes('settings'))
180
+ return allowed;
181
+ if (iconLower.includes('home') && allowedLower.includes('home'))
182
+ return allowed;
183
+ }
184
+ return null;
185
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"generateStory.d.ts","sourceRoot":"","sources":["../../story-generator/generateStory.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AA6BtD,wBAAgB,aAAa,CAAC,EAC5B,YAAY,EACZ,QAAQ,EACR,MAAM,EACP,EAAE;IACD,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,aAAa,CAAC;CACvB,UAmBA"}
1
+ {"version":3,"file":"generateStory.d.ts","sourceRoot":"","sources":["../../story-generator/generateStory.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAsBtD,wBAAgB,aAAa,CAAC,EAC5B,YAAY,EACZ,QAAQ,EACR,MAAM,EACP,EAAE;IACD,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,aAAa,CAAC;CACvB,UAmBA"}
@@ -1,11 +1,5 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- function slugify(str) {
4
- return str
5
- .toLowerCase()
6
- .replace(/[^a-z0-9]+/g, '-')
7
- .replace(/^-+|-+$/g, '');
8
- }
9
3
  /**
10
4
  * Check if the current working directory is the Story UI package itself.
11
5
  * This prevents accidentally generating stories in the package source code.
@@ -41,8 +35,3 @@ export function generateStory({ fileContents, fileName, config }) {
41
35
  fs.writeFileSync(outPath, fileContents, 'utf-8');
42
36
  return outPath;
43
37
  }
44
- // Mock usage:
45
- // generateStory({
46
- // title: 'Login Form',
47
- // jsx: '<al-input label="Email"></al-input>\n<al-input label="Password" type="password"></al-input>\n<al-button>Login</al-button>'
48
- // });
@@ -1 +1 @@
1
- {"version":3,"file":"StoryUIPanel.d.ts","sourceRoot":"","sources":["../../../templates/StoryUI/StoryUIPanel.tsx"],"names":[],"mappings":"AAiTA,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,2BAA2B,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACtD;CACF;AA6oCD,iBAAS,YAAY,4CA8sCpB;AAED,eAAe,YAAY,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,CAAC"}
1
+ {"version":3,"file":"StoryUIPanel.d.ts","sourceRoot":"","sources":["../../../templates/StoryUI/StoryUIPanel.tsx"],"names":[],"mappings":"AAkTA,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,2BAA2B,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACtD;CACF;AA6oCD,iBAAS,YAAY,4CAmtCpB;AAED,eAAe,YAAY,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,CAAC"}
@@ -1104,6 +1104,7 @@ function StoryUIPanel() {
1104
1104
  file,
1105
1105
  preview,
1106
1106
  base64,
1107
+ mediaType: file.type || 'image/png',
1107
1108
  });
1108
1109
  }
1109
1110
  catch (err) {
@@ -1182,6 +1183,7 @@ function StoryUIPanel() {
1182
1183
  file,
1183
1184
  preview,
1184
1185
  base64,
1186
+ mediaType: file.type || 'image/png',
1185
1187
  });
1186
1188
  }
1187
1189
  catch (err) {
@@ -1236,6 +1238,7 @@ function StoryUIPanel() {
1236
1238
  file: renamedFile,
1237
1239
  preview,
1238
1240
  base64,
1241
+ mediaType: file.type || 'image/png',
1239
1242
  });
1240
1243
  }
1241
1244
  catch (err) {
@@ -1874,7 +1877,9 @@ function StoryUIPanel() {
1874
1877
  maxWidth: '200px'
1875
1878
  }, children: availableProviders
1876
1879
  .find(p => p.type === selectedProvider)
1877
- ?.models.map(model => (_jsx("option", { value: model, children: model }, model))) })] })] }))] }), _jsxs("div", { style: STYLES.chatContainer, children: [error && (_jsx("div", { style: STYLES.errorMessage, children: error })), conversation.length === 0 && !loading && (_jsxs("div", { style: STYLES.emptyState, children: [_jsx("div", { style: STYLES.emptyStateTitle, children: "Start a new conversation" }), _jsx("div", { style: STYLES.emptyStateSubtitle, children: "Describe the UI component you'd like to create" })] })), conversation.map((msg, i) => (_jsx("div", { style: STYLES.messageContainer, children: _jsxs("div", { style: msg.role === 'user' ? STYLES.userMessage : STYLES.aiMessage, children: [msg.role === 'ai' ? renderMarkdown(msg.content) : msg.content, msg.role === 'user' && msg.attachedImages && msg.attachedImages.length > 0 && (_jsx("div", { style: STYLES.userMessageImages, children: msg.attachedImages.map((img) => (_jsx("img", { src: img.preview, alt: "attached", style: STYLES.userMessageImage }, img.id))) }))] }) }, i))), loading && (_jsx("div", { style: STYLES.messageContainer, children: streamingState ? (_jsx(StreamingProgressMessage, { streamingData: streamingState })) : (_jsxs("div", { style: STYLES.loadingMessage, children: [_jsx("span", { children: "Generating story" }), _jsx("span", { className: "loading-dots" })] })) })), _jsx("div", { ref: chatEndRef })] }), _jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", multiple: true, style: { display: 'none' }, onChange: handleFileSelect }), attachedImages.length > 0 && (_jsxs("div", { style: STYLES.imagePreviewContainer, children: [_jsxs("span", { style: STYLES.imagePreviewLabel, children: [_jsx("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" }) }), attachedImages.length, " image", attachedImages.length > 1 ? 's' : '', " attached"] }), attachedImages.map((img) => (_jsxs("div", { style: STYLES.imagePreviewItem, children: [_jsx("img", { src: img.preview, alt: "preview", style: STYLES.imagePreviewImg }), _jsx("button", { type: "button", style: STYLES.imageRemoveButton, onClick: () => removeAttachedImage(img.id), title: "Remove image", children: "\u00D7" })] }, img.id)))] })), _jsxs("form", { onSubmit: handleSend, style: {
1880
+ ?.models.map(model => (_jsx("option", { value: model, children: model }, model))) })] })] }))] }), _jsxs("div", { style: STYLES.chatContainer, children: [error && (_jsx("div", { style: STYLES.errorMessage, children: error })), conversation.length === 0 && !loading && (_jsxs("div", { style: STYLES.emptyState, children: [_jsx("div", { style: STYLES.emptyStateTitle, children: "Start a new conversation" }), _jsx("div", { style: STYLES.emptyStateSubtitle, children: "Describe the UI component you'd like to create" })] })), conversation.map((msg, i) => (_jsx("div", { style: STYLES.messageContainer, children: _jsxs("div", { style: msg.role === 'user' ? STYLES.userMessage : STYLES.aiMessage, children: [msg.role === 'ai' ? renderMarkdown(msg.content) : msg.content, msg.role === 'user' && msg.attachedImages && msg.attachedImages.length > 0 && (_jsx("div", { style: STYLES.userMessageImages, children: msg.attachedImages.map((img) => (_jsx("img", { src: img.base64
1881
+ ? `data:${img.mediaType || 'image/png'};base64,${img.base64}`
1882
+ : img.preview, alt: "attached", style: STYLES.userMessageImage }, img.id))) }))] }) }, i))), loading && (_jsx("div", { style: STYLES.messageContainer, children: streamingState ? (_jsx(StreamingProgressMessage, { streamingData: streamingState })) : (_jsxs("div", { style: STYLES.loadingMessage, children: [_jsx("span", { children: "Generating story" }), _jsx("span", { className: "loading-dots" })] })) })), _jsx("div", { ref: chatEndRef })] }), _jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", multiple: true, style: { display: 'none' }, onChange: handleFileSelect }), attachedImages.length > 0 && (_jsxs("div", { style: STYLES.imagePreviewContainer, children: [_jsxs("span", { style: STYLES.imagePreviewLabel, children: [_jsx("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" }) }), attachedImages.length, " image", attachedImages.length > 1 ? 's' : '', " attached"] }), attachedImages.map((img) => (_jsxs("div", { style: STYLES.imagePreviewItem, children: [_jsx("img", { src: img.preview, alt: "preview", style: STYLES.imagePreviewImg }), _jsx("button", { type: "button", style: STYLES.imageRemoveButton, onClick: () => removeAttachedImage(img.id), title: "Remove image", children: "\u00D7" })] }, img.id)))] })), _jsxs("form", { onSubmit: handleSend, style: {
1878
1883
  ...STYLES.inputForm,
1879
1884
  ...(attachedImages.length > 0 ? {
1880
1885
  marginTop: 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpitre/story-ui",
3
- "version": "3.4.2",
3
+ "version": "3.5.0",
4
4
  "description": "AI-powered Storybook story generator with dynamic component discovery",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -138,6 +138,7 @@ interface AttachedImage {
138
138
  file: File;
139
139
  preview: string;
140
140
  base64?: string;
141
+ mediaType?: string; // Store MIME type for data URL reconstruction after localStorage restore
141
142
  }
142
143
 
143
144
  // Provider info from /story-ui/providers API
@@ -1544,6 +1545,7 @@ function StoryUIPanel() {
1544
1545
  file,
1545
1546
  preview,
1546
1547
  base64,
1548
+ mediaType: file.type || 'image/png',
1547
1549
  });
1548
1550
  } catch (err) {
1549
1551
  errors.push(`${file.name}: Failed to process`);
@@ -1638,6 +1640,7 @@ function StoryUIPanel() {
1638
1640
  file,
1639
1641
  preview,
1640
1642
  base64,
1643
+ mediaType: file.type || 'image/png',
1641
1644
  });
1642
1645
  } catch (err) {
1643
1646
  errors.push(`${file.name}: Failed to process`);
@@ -1703,6 +1706,7 @@ function StoryUIPanel() {
1703
1706
  file: renamedFile,
1704
1707
  preview,
1705
1708
  base64,
1709
+ mediaType: file.type || 'image/png',
1706
1710
  });
1707
1711
  } catch (err) {
1708
1712
  errors.push('Failed to process pasted image');
@@ -2548,7 +2552,9 @@ function StoryUIPanel() {
2548
2552
  {msg.attachedImages.map((img) => (
2549
2553
  <img
2550
2554
  key={img.id}
2551
- src={img.preview}
2555
+ src={img.base64
2556
+ ? `data:${img.mediaType || 'image/png'};base64,${img.base64}`
2557
+ : img.preview}
2552
2558
  alt="attached"
2553
2559
  style={STYLES.userMessageImage}
2554
2560
  />
@@ -38,7 +38,101 @@ declare global {
38
38
  * Into:
39
39
  * <Button>Click</Button>
40
40
  */
41
- const extractUsageCode = (fullStoryCode: string): string => {
41
+ const extractUsageCode = (fullStoryCode: string, variantName?: string): string => {
42
+ // Helper function to generate JSX from args
43
+ const generateJsxFromArgs = (argsStr: string, componentName: string): string | null => {
44
+ try {
45
+ // Extract children if present
46
+ const childrenMatch = argsStr.match(/children:\s*['"`]([^'"`]+)['"`]/);
47
+ const children = childrenMatch ? childrenMatch[1] : '';
48
+
49
+ // Extract other props (simplified)
50
+ const propsStr = argsStr
51
+ .replace(/children:\s*['"`][^'"`]*['"`],?/, '') // Remove children
52
+ .replace(/^\{|\}$/g, '') // Remove braces
53
+ .trim();
54
+
55
+ if (children) {
56
+ if (propsStr) {
57
+ return `<${componentName} ${propsStr.replace(/,\s*$/, '')}>${children}</${componentName}>`;
58
+ }
59
+ return `<${componentName}>${children}</${componentName}>`;
60
+ } else if (propsStr) {
61
+ return `<${componentName} ${propsStr.replace(/,\s*$/, '')} />`;
62
+ }
63
+ return `<${componentName} />`;
64
+ } catch {
65
+ return null;
66
+ }
67
+ };
68
+
69
+ // Get the component name from meta
70
+ const componentMatch = fullStoryCode.match(/component:\s*([A-Z][A-Za-z0-9]*)/);
71
+ const componentName = componentMatch ? componentMatch[1] : null;
72
+
73
+ // If we have a variant name, try to find that specific variant's args or render
74
+ if (variantName) {
75
+ // Normalize variant name for matching:
76
+ // - "primary" -> "Primary"
77
+ // - "full-width" -> "FullWidth" (kebab-case to PascalCase)
78
+ const normalizedVariant = variantName
79
+ .split('-')
80
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
81
+ .join('');
82
+
83
+ // Pattern A: export const Primary: Story = { args: {...} }
84
+ // Match the specific variant's export block
85
+ const variantExportRegex = new RegExp(
86
+ `export\\s+const\\s+${normalizedVariant}\\s*(?::\\s*Story)?\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*;`,
87
+ 'i'
88
+ );
89
+ const variantExportMatch = fullStoryCode.match(variantExportRegex);
90
+
91
+ if (variantExportMatch) {
92
+ const variantBlock = variantExportMatch[1];
93
+
94
+ // Try to extract render function from this variant
95
+ const renderWithParensMatch = variantBlock.match(/render:\s*\([^)]*\)\s*=>\s*\(\s*([\s\S]*?)\s*\)\s*[,}]/);
96
+ if (renderWithParensMatch) {
97
+ return renderWithParensMatch[1].trim().replace(/,\s*$/, '');
98
+ }
99
+
100
+ const renderNoParensMatch = variantBlock.match(/render:\s*\([^)]*\)\s*=>\s*(<[A-Z][^,}]*(?:\/>|<\/[A-Za-z.]+>))/s);
101
+ if (renderNoParensMatch) {
102
+ return renderNoParensMatch[1].trim();
103
+ }
104
+
105
+ // Try to extract args from this variant
106
+ const argsMatch = variantBlock.match(/args:\s*(\{[\s\S]*?\})\s*[,}]/);
107
+ if (argsMatch && componentName) {
108
+ const result = generateJsxFromArgs(argsMatch[1], componentName);
109
+ if (result) return result;
110
+ }
111
+ }
112
+
113
+ // Pattern B: Arrow function variant: export const Primary = () => <Component...>
114
+ const arrowVariantRegex = new RegExp(
115
+ `export\\s+const\\s+${normalizedVariant}\\s*=\\s*\\(\\)\\s*=>\\s*\\(\\s*([\\s\\S]*?)\\s*\\)\\s*;`,
116
+ 'i'
117
+ );
118
+ const arrowVariantMatch = fullStoryCode.match(arrowVariantRegex);
119
+ if (arrowVariantMatch) {
120
+ return arrowVariantMatch[1].trim().replace(/,\s*$/, '');
121
+ }
122
+
123
+ // Pattern C: Arrow function without parens: export const Primary = () => <Component...>;
124
+ const arrowNoParensRegex = new RegExp(
125
+ `export\\s+const\\s+${normalizedVariant}\\s*=\\s*\\(\\)\\s*=>\\s*(<[A-Z][^;]*(?:\\/>|<\\/[A-Za-z.]+>))\\s*;`,
126
+ 'is'
127
+ );
128
+ const arrowNoParensMatch = fullStoryCode.match(arrowNoParensRegex);
129
+ if (arrowNoParensMatch) {
130
+ return arrowNoParensMatch[1].trim();
131
+ }
132
+ }
133
+
134
+ // Fallback: Try generic patterns (for Default or when variant not specified)
135
+
42
136
  // Try to extract JSX from render function: render: () => (<JSX>) or render: () => <JSX>
43
137
  // Pattern 1: render: () => (\n <Component...>\n)
44
138
  const renderWithParensMatch = fullStoryCode.match(/render:\s*\([^)]*\)\s*=>\s*\(\s*([\s\S]*?)\s*\)\s*[,}]/);
@@ -70,37 +164,9 @@ const extractUsageCode = (fullStoryCode: string): string => {
70
164
  // Pattern 5: Look for args-based stories with component prop spreading
71
165
  // e.g., args: { children: 'Click me', color: 'blue' }
72
166
  const argsMatch = fullStoryCode.match(/args:\s*(\{[\s\S]*?\})\s*[,}]/);
73
- if (argsMatch) {
74
- // Try to find the component from the meta
75
- const componentMatch = fullStoryCode.match(/component:\s*([A-Z][A-Za-z0-9]*)/);
76
- if (componentMatch) {
77
- const componentName = componentMatch[1];
78
- try {
79
- // Parse the args to generate JSX
80
- const argsStr = argsMatch[1];
81
- // Extract children if present
82
- const childrenMatch = argsStr.match(/children:\s*['"`]([^'"`]+)['"`]/);
83
- const children = childrenMatch ? childrenMatch[1] : '';
84
-
85
- // Extract other props (simplified)
86
- const propsStr = argsStr
87
- .replace(/children:\s*['"`][^'"`]*['"`],?/, '') // Remove children
88
- .replace(/^\{|\}$/g, '') // Remove braces
89
- .trim();
90
-
91
- if (children) {
92
- if (propsStr) {
93
- return `<${componentName} ${propsStr.replace(/,\s*$/, '')}>${children}</${componentName}>`;
94
- }
95
- return `<${componentName}>${children}</${componentName}>`;
96
- } else if (propsStr) {
97
- return `<${componentName} ${propsStr.replace(/,\s*$/, '')} />`;
98
- }
99
- return `<${componentName} />`;
100
- } catch {
101
- // Fall through to return full code
102
- }
103
- }
167
+ if (argsMatch && componentName) {
168
+ const result = generateJsxFromArgs(argsMatch[1], componentName);
169
+ if (result) return result;
104
170
  }
105
171
 
106
172
  // Pattern 6: Look for any JSX block starting with < and ending with /> or </Component>
@@ -297,14 +363,33 @@ const SourceCodePanel: React.FC<{ active?: boolean }> = ({ active }) => {
297
363
  const [copied, setCopied] = useState(false);
298
364
  const [showFullCode, setShowFullCode] = useState(false);
299
365
 
300
- // Memoize the usage code extraction
366
+ // Get the current story ID
367
+ const currentStoryId = state?.storyId;
368
+
369
+ // Extract variant name from story ID (e.g., "generated-button--primary" -> "primary", "generated-button--full-width" -> "full-width")
370
+ const currentVariant = useMemo(() => {
371
+ if (!currentStoryId) return undefined;
372
+ // Match everything after the last -- (variant can contain hyphens like "full-width")
373
+ const variantMatch = currentStoryId.match(/--([a-z0-9-]+)$/i);
374
+ return variantMatch ? variantMatch[1] : undefined;
375
+ }, [currentStoryId]);
376
+
377
+ // Memoize the usage code extraction with variant awareness
378
+ const usageCode = useMemo(() => {
379
+ if (!sourceCode) return '';
380
+ return extractUsageCode(sourceCode, currentVariant);
381
+ }, [sourceCode, currentVariant]);
382
+
301
383
  const displayCode = useMemo(() => {
302
384
  if (!sourceCode) return '';
303
- return showFullCode ? sourceCode : extractUsageCode(sourceCode);
304
- }, [sourceCode, showFullCode]);
385
+ return showFullCode ? sourceCode : usageCode;
386
+ }, [sourceCode, showFullCode, usageCode]);
305
387
 
306
- // Get the current story ID
307
- const currentStoryId = state?.storyId;
388
+ // Check if there's different usage code (for showing toggle button)
389
+ // This should remain true even when showing full code
390
+ const hasUsageCode = useMemo(() => {
391
+ return sourceCode && usageCode && usageCode !== sourceCode;
392
+ }, [sourceCode, usageCode]);
308
393
 
309
394
  // Try to get source code from the story
310
395
  useEffect(() => {
@@ -340,11 +425,81 @@ const SourceCodePanel: React.FC<{ active?: boolean }> = ({ active }) => {
340
425
  let cachedCode = topWindow.__STORY_UI_GENERATED_CODE__?.[currentStoryId] ||
341
426
  window.__STORY_UI_GENERATED_CODE__?.[currentStoryId];
342
427
 
428
+ console.log('[Source Code Panel DEBUG] Looking for code:', {
429
+ currentStoryId,
430
+ foundInWindowCache: !!cachedCode,
431
+ windowCacheKeys: Object.keys(topWindow.__STORY_UI_GENERATED_CODE__ || {}),
432
+ });
433
+
343
434
  // If not in memory cache, check localStorage (survives page navigation)
344
435
  if (!cachedCode) {
345
436
  try {
346
437
  const stored = JSON.parse(localStorage.getItem('storyui_generated_code') || '{}');
347
- cachedCode = stored[currentStoryId];
438
+
439
+ console.log('[Source Code Panel DEBUG] localStorage lookup:', {
440
+ localStorageKeys: Object.keys(stored),
441
+ localStorageKeyCount: Object.keys(stored).length,
442
+ });
443
+
444
+ // Try multiple key formats since Storybook IDs differ from our storage keys
445
+ // Storybook ID format: "generated-componentname--variant" or "generated/componentname--variant"
446
+ // Our storage keys: "ComponentName", "ComponentName.stories.tsx", "story-hash123", etc.
447
+ const keysToTry: string[] = [currentStoryId];
448
+
449
+ // Extract component name and base story ID from Storybook ID
450
+ // e.g., "generated-simple-test-button--primary" -> baseId: "generated-simple-test-button--default", component: "simpletestbutton"
451
+ const match = currentStoryId.match(/^(generated[-\/]?.+?)(?:--(.*))?$/i);
452
+ if (match) {
453
+ const baseId = match[1];
454
+ const variant = match[2];
455
+
456
+ // Try base story ID with --default variant (this is what we store)
457
+ if (variant && variant !== 'default') {
458
+ keysToTry.push(`${baseId}--default`);
459
+ }
460
+ // Also try just the base ID without any variant
461
+ keysToTry.push(baseId);
462
+
463
+ // Extract component name (e.g., "generated-simple-test-button" -> "simpletestbutton")
464
+ const componentMatch = baseId.match(/^generated[-\/]?(.+)$/i);
465
+ if (componentMatch) {
466
+ const componentNameLower = componentMatch[1].replace(/-/g, '');
467
+ keysToTry.push(componentNameLower);
468
+ // Try PascalCase version (e.g., "simpletestbutton" -> "Simpletestbutton")
469
+ const pascalCase = componentNameLower.charAt(0).toUpperCase() + componentNameLower.slice(1);
470
+ keysToTry.push(pascalCase);
471
+ keysToTry.push(`${pascalCase}.stories.tsx`);
472
+
473
+ // Try with spaces converted to title case (e.g., "Simple Test Button" -> "SimpleTestButton")
474
+ const words = componentMatch[1].split('-');
475
+ if (words.length > 1) {
476
+ const titleCase = words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
477
+ keysToTry.push(titleCase);
478
+ // Also try with space-separated title (what we store)
479
+ const spacedTitle = words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
480
+ keysToTry.push(spacedTitle);
481
+ }
482
+ }
483
+ }
484
+
485
+ // Also try the story title if available
486
+ if (storyTitle) {
487
+ keysToTry.push(storyTitle);
488
+ keysToTry.push(storyTitle.replace(/\s+/g, ''));
489
+ keysToTry.push(`${storyTitle.replace(/\s+/g, '')}.stories.tsx`);
490
+ }
491
+
492
+ console.log('[Source Code Panel DEBUG] trying keys:', keysToTry);
493
+
494
+ // Try each key format
495
+ for (const key of keysToTry) {
496
+ if (stored[key]) {
497
+ cachedCode = stored[key];
498
+ console.log('[Source Code Panel DEBUG] found code with key:', key, 'codeLength:', cachedCode?.length);
499
+ break;
500
+ }
501
+ }
502
+
348
503
  // Restore to memory cache if found
349
504
  if (cachedCode) {
350
505
  if (!topWindow.__STORY_UI_GENERATED_CODE__) {
@@ -357,6 +512,11 @@ const SourceCodePanel: React.FC<{ active?: boolean }> = ({ active }) => {
357
512
  }
358
513
  }
359
514
 
515
+ console.log('[Source Code Panel DEBUG] final result:', {
516
+ foundCode: !!cachedCode,
517
+ codeLength: cachedCode?.length,
518
+ });
519
+
360
520
  if (cachedCode) {
361
521
  setSourceCode(cachedCode);
362
522
  } else {
@@ -441,9 +601,6 @@ const SourceCodePanel: React.FC<{ active?: boolean }> = ({ active }) => {
441
601
  );
442
602
  }
443
603
 
444
- // Check if extraction was successful (displayCode is different from sourceCode)
445
- const hasUsageCode = displayCode !== sourceCode;
446
-
447
604
  return (
448
605
  <div style={styles.container}>
449
606
  <div style={styles.header}>