@tpitre/story-ui 1.0.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,289 @@
1
+ import fetch from 'node-fetch';
2
+ import { generateStory } from '../../story-generator/generateStory.js';
3
+ import crypto from 'crypto';
4
+ import { discoverComponents } from '../../story-generator/componentDiscovery.js';
5
+ import { buildClaudePrompt as buildFlexiblePrompt } from '../../story-generator/promptGenerator.js';
6
+ import { loadUserConfig, validateConfig } from '../../story-generator/configLoader.js';
7
+ import { setupProductionGitignore } from '../../story-generator/productionGitignoreManager.js';
8
+ import { getInMemoryStoryService } from '../../story-generator/inMemoryStoryService.js';
9
+ const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages';
10
+ const CLAUDE_MODEL = process.env.CLAUDE_MODEL || 'claude-3-opus-20240229';
11
+ // Legacy constants - now using dynamic discovery
12
+ const COMPONENT_LIST = [];
13
+ const SAMPLE_STORY = '';
14
+ // Legacy component reference - now using dynamic discovery
15
+ const COMPONENT_REFERENCE = '';
16
+ // Legacy function - now uses flexible system
17
+ function buildClaudePrompt(userPrompt) {
18
+ const config = loadUserConfig();
19
+ const components = discoverComponents(config);
20
+ return buildFlexiblePrompt(userPrompt, config, components);
21
+ }
22
+ // Enhanced function that includes conversation context
23
+ function buildClaudePromptWithContext(userPrompt, config, conversation) {
24
+ const components = discoverComponents(config);
25
+ // If no conversation context, use the standard prompt
26
+ if (!conversation || conversation.length <= 1) {
27
+ return buildFlexiblePrompt(userPrompt, config, components);
28
+ }
29
+ // Extract conversation context for modifications
30
+ const conversationContext = conversation
31
+ .slice(0, -1) // Remove the current message (last one)
32
+ .map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`)
33
+ .join('\n\n');
34
+ // Get the base prompt
35
+ const basePrompt = buildFlexiblePrompt(userPrompt, config, components);
36
+ // Add conversation context to the prompt
37
+ const contextualPrompt = basePrompt.replace('User request:', `CONVERSATION CONTEXT (for modifications/updates):
38
+ ${conversationContext}
39
+
40
+ IMPORTANT: The user is asking to modify/update the story based on the above conversation.
41
+ - Keep the SAME layout structure (number of columns, grid setup) unless explicitly asked to change it
42
+ - Only modify the specific aspects mentioned in the latest request
43
+ - Maintain the overall story concept from the original request
44
+
45
+ Current modification request:`);
46
+ return contextualPrompt;
47
+ }
48
+ function slugify(str) {
49
+ return str
50
+ .toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, '-')
52
+ .replace(/^-+|-+$/g, '');
53
+ }
54
+ function extractCodeBlock(text) {
55
+ // More flexible code block extraction - accept various language identifiers
56
+ const codeBlock = text.match(/```(?:tsx|jsx|typescript|ts|js|javascript)?([\s\S]*?)```/i);
57
+ return codeBlock ? codeBlock[1].trim() : null;
58
+ }
59
+ async function callClaude(prompt) {
60
+ const apiKey = process.env.CLAUDE_API_KEY;
61
+ if (!apiKey)
62
+ throw new Error('Claude API key not set');
63
+ const response = await fetch(CLAUDE_API_URL, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'x-api-key': apiKey,
67
+ 'content-type': 'application/json',
68
+ 'anthropic-version': '2023-06-01',
69
+ },
70
+ body: JSON.stringify({
71
+ model: CLAUDE_MODEL,
72
+ max_tokens: 1024,
73
+ messages: [{ role: 'user', content: prompt }],
74
+ }),
75
+ });
76
+ const data = await response.json();
77
+ // Try to extract the main content
78
+ return data?.content?.[0]?.text || data?.completion || '';
79
+ }
80
+ function cleanPromptForTitle(prompt) {
81
+ // Remove common leading phrases (case-insensitive)
82
+ const leadingPhrases = [
83
+ /^generate (a|an|the)? /i,
84
+ /^build (a|an|the)? /i,
85
+ /^create (a|an|the)? /i,
86
+ /^make (a|an|the)? /i,
87
+ /^design (a|an|the)? /i,
88
+ /^show (me )?(a|an|the)? /i,
89
+ /^write (a|an|the)? /i,
90
+ /^produce (a|an|the)? /i,
91
+ /^construct (a|an|the)? /i,
92
+ /^draft (a|an|the)? /i,
93
+ /^compose (a|an|the)? /i,
94
+ /^implement (a|an|the)? /i,
95
+ /^build out (a|an|the)? /i,
96
+ /^add (a|an|the)? /i,
97
+ /^render (a|an|the)? /i,
98
+ /^display (a|an|the)? /i,
99
+ ];
100
+ let cleaned = prompt.trim();
101
+ for (const regex of leadingPhrases) {
102
+ cleaned = cleaned.replace(regex, '');
103
+ }
104
+ // More careful punctuation handling - preserve meaningful punctuation in quotes
105
+ return cleaned
106
+ // Replace problematic characters but preserve quoted content structure
107
+ .replace(/[^\w\s'"?!-]/g, ' ') // Keep letters, numbers, spaces, quotes, and basic punctuation
108
+ .replace(/\s+/g, ' ') // Normalize whitespace
109
+ .trim()
110
+ .replace(/\b\w/g, c => c.toUpperCase()); // Capitalize words
111
+ }
112
+ async function getClaudeTitle(userPrompt) {
113
+ const titlePrompt = [
114
+ "Given the following UI description, generate a short, clear, human-friendly title suitable for a Storybook navigation item.",
115
+ "Requirements:",
116
+ "- Do not include words like 'Generate', 'Build', or 'Create'",
117
+ "- Keep it under 50 characters",
118
+ "- Use simple, clear language",
119
+ "- Avoid special characters that could break code (use letters, numbers, spaces, hyphens, and basic punctuation only)",
120
+ '',
121
+ 'UI description:',
122
+ userPrompt,
123
+ '',
124
+ 'Title:'
125
+ ].join('\n');
126
+ const aiText = await callClaude(titlePrompt);
127
+ // Take the first non-empty line, trim, and remove quotes if present
128
+ const lines = aiText.split('\n').map(l => l.trim()).filter(Boolean);
129
+ if (lines.length > 0) {
130
+ let title = lines[0].replace(/^['\"]|['\"]$/g, '').trim();
131
+ // Additional sanitization for safety
132
+ title = title
133
+ .replace(/[^\w\s'"?!-]/g, ' ') // Remove problematic characters
134
+ .replace(/\s+/g, ' ') // Normalize whitespace
135
+ .trim()
136
+ .slice(0, 50); // Limit length
137
+ return title;
138
+ }
139
+ return '';
140
+ }
141
+ function escapeTitleForTS(title) {
142
+ // Escape all characters that could break TypeScript string literals
143
+ return title
144
+ .replace(/\\/g, '\\\\') // Escape backslashes
145
+ .replace(/"/g, '\\"') // Escape double quotes
146
+ .replace(/'/g, "\\'") // Escape single quotes
147
+ .replace(/`/g, '\\`') // Escape backticks
148
+ .replace(/\n/g, '\\n') // Escape newlines
149
+ .replace(/\r/g, '\\r') // Escape carriage returns
150
+ .replace(/\t/g, '\\t'); // Escape tabs
151
+ }
152
+ function fileNameFromTitle(title, hash) {
153
+ // Lowercase, replace spaces/special chars with dashes, remove quotes, truncate
154
+ let base = title
155
+ .toLowerCase()
156
+ .replace(/[^a-z0-9]+/g, '-')
157
+ .replace(/^-+|-+$/g, '')
158
+ .replace(/"|'/g, '')
159
+ .slice(0, 60);
160
+ return `${base}-${hash}.stories.tsx`;
161
+ }
162
+ export async function generateStoryFromPrompt(req, res) {
163
+ const { prompt, fileName, conversation } = req.body;
164
+ if (!prompt)
165
+ return res.status(400).json({ error: 'Missing prompt' });
166
+ try {
167
+ // Load and validate configuration
168
+ const config = loadUserConfig();
169
+ const validation = validateConfig(config);
170
+ if (!validation.isValid) {
171
+ return res.status(400).json({
172
+ error: 'Configuration validation failed',
173
+ details: validation.errors
174
+ });
175
+ }
176
+ // Set up production-ready environment
177
+ const gitignoreManager = setupProductionGitignore(config);
178
+ const storyService = getInMemoryStoryService(config);
179
+ const isProduction = gitignoreManager.isProductionMode();
180
+ // Check if this is an update to an existing story
181
+ const isUpdate = fileName && conversation && conversation.length > 2;
182
+ // Build prompt with conversation context if available
183
+ const fullPrompt = buildClaudePromptWithContext(prompt, config, conversation);
184
+ console.log('Layout configuration:', JSON.stringify(config.layoutRules, null, 2));
185
+ console.log('Claude prompt:', fullPrompt);
186
+ const aiText = await callClaude(fullPrompt);
187
+ console.log('Claude raw response:', aiText);
188
+ let fileContents = extractCodeBlock(aiText);
189
+ if (!fileContents) {
190
+ // Fallback: try to extract from first import statement onward
191
+ const importIdx = aiText.indexOf('import');
192
+ if (importIdx !== -1) {
193
+ fileContents = aiText.slice(importIdx).trim();
194
+ }
195
+ }
196
+ if (!fileContents || !fileContents.startsWith('import')) {
197
+ console.error('No valid code block or import found in Claude response. Skipping file write.');
198
+ return res.status(500).json({ error: 'Claude did not return a valid code block.' });
199
+ }
200
+ // Generate title based on conversation context
201
+ let aiTitle;
202
+ if (isUpdate) {
203
+ // For updates, try to keep the original title or modify it slightly
204
+ const originalPrompt = conversation.find((msg) => msg.role === 'user')?.content || prompt;
205
+ aiTitle = await getClaudeTitle(originalPrompt);
206
+ }
207
+ else {
208
+ aiTitle = await getClaudeTitle(prompt);
209
+ }
210
+ if (!aiTitle || aiTitle.length < 2) {
211
+ // Fallback to cleaned prompt if Claude fails
212
+ aiTitle = cleanPromptForTitle(prompt);
213
+ }
214
+ // Escape the title for TypeScript
215
+ const prettyPrompt = escapeTitleForTS(aiTitle);
216
+ const fixedFileContents = fileContents.replace(/(export default \{\s*\n\s*title:\s*["'])([^"']+)(["'])/, (match, p1, _p2, p3) => {
217
+ const title = config.storyPrefix + prettyPrompt;
218
+ return p1 + title + p3;
219
+ });
220
+ // Generate unique ID and filename
221
+ let hash, finalFileName, storyId;
222
+ if (isUpdate && fileName) {
223
+ // For updates, use existing fileName and ID
224
+ finalFileName = fileName;
225
+ // Extract hash from existing fileName if possible
226
+ const hashMatch = fileName.match(/-([a-f0-9]{8})(?:\.stories\.tsx)?$/);
227
+ hash = hashMatch ? hashMatch[1] : crypto.createHash('sha1').update(prompt).digest('hex').slice(0, 8);
228
+ storyId = `story-${hash}`;
229
+ }
230
+ else {
231
+ // For new stories, generate new IDs
232
+ hash = crypto.createHash('sha1').update(prompt).digest('hex').slice(0, 8);
233
+ finalFileName = fileName || fileNameFromTitle(aiTitle, hash);
234
+ storyId = `story-${hash}`;
235
+ }
236
+ if (isProduction) {
237
+ // Production: Store in memory
238
+ const generatedStory = {
239
+ id: storyId,
240
+ title: aiTitle,
241
+ description: isUpdate ? `Updated: ${prompt}` : prompt,
242
+ content: fixedFileContents,
243
+ createdAt: isUpdate ? (new Date()) : new Date(),
244
+ lastAccessed: new Date(),
245
+ prompt: isUpdate ? conversation.map((msg) => `${msg.role}: ${msg.content}`).join('\n\n') : prompt,
246
+ components: extractComponentsFromContent(fixedFileContents)
247
+ };
248
+ storyService.storeStory(generatedStory);
249
+ console.log(`Story ${isUpdate ? 'updated' : 'stored'} in memory: ${storyId}`);
250
+ res.json({
251
+ success: true,
252
+ fileName: finalFileName,
253
+ storyId,
254
+ title: aiTitle,
255
+ story: fileContents,
256
+ environment: 'production',
257
+ storage: 'in-memory',
258
+ isUpdate
259
+ });
260
+ }
261
+ else {
262
+ // Development: Write to file system
263
+ const outPath = generateStory({ fileContents: fixedFileContents, fileName: finalFileName });
264
+ console.log(`Story ${isUpdate ? 'updated' : 'written'} to:`, outPath);
265
+ res.json({
266
+ success: true,
267
+ fileName: finalFileName,
268
+ outPath,
269
+ title: aiTitle,
270
+ story: fileContents,
271
+ environment: 'development',
272
+ storage: 'file-system',
273
+ isUpdate
274
+ });
275
+ }
276
+ }
277
+ catch (err) {
278
+ res.status(500).json({ error: err.message || 'Story generation failed' });
279
+ }
280
+ }
281
+ /**
282
+ * Extracts component names from story content
283
+ */
284
+ function extractComponentsFromContent(content) {
285
+ const componentMatches = content.match(/<[A-Z][A-Za-z0-9]*\s/g);
286
+ if (!componentMatches)
287
+ return [];
288
+ return Array.from(new Set(componentMatches.map(match => match.replace(/[<\s]/g, ''))));
289
+ }
@@ -0,0 +1,141 @@
1
+ import { getInMemoryStoryService } from '../../story-generator/inMemoryStoryService.js';
2
+ import { STORY_UI_CONFIG } from '../../story-ui.config.js';
3
+ /**
4
+ * Get all stories metadata
5
+ */
6
+ export function getStoriesMetadata(req, res) {
7
+ try {
8
+ const storyService = getInMemoryStoryService(STORY_UI_CONFIG);
9
+ const metadata = storyService.getStoryMetadata();
10
+ res.json({
11
+ success: true,
12
+ stories: metadata,
13
+ count: metadata.length
14
+ });
15
+ }
16
+ catch (error) {
17
+ res.status(500).json({
18
+ success: false,
19
+ error: 'Failed to retrieve stories metadata'
20
+ });
21
+ }
22
+ }
23
+ /**
24
+ * Get a specific story by ID
25
+ */
26
+ export function getStoryById(req, res) {
27
+ try {
28
+ const { id } = req.params;
29
+ const storyService = getInMemoryStoryService(STORY_UI_CONFIG);
30
+ const story = storyService.getStory(id);
31
+ if (!story) {
32
+ return res.status(404).json({
33
+ success: false,
34
+ error: 'Story not found'
35
+ });
36
+ }
37
+ res.json({
38
+ success: true,
39
+ story
40
+ });
41
+ }
42
+ catch (error) {
43
+ res.status(500).json({
44
+ success: false,
45
+ error: 'Failed to retrieve story'
46
+ });
47
+ }
48
+ }
49
+ /**
50
+ * Get story content for Storybook integration
51
+ */
52
+ export function getStoryContent(req, res) {
53
+ try {
54
+ const { id } = req.params;
55
+ const storyService = getInMemoryStoryService(STORY_UI_CONFIG);
56
+ const content = storyService.getStoryContent(id);
57
+ if (!content) {
58
+ return res.status(404).json({
59
+ success: false,
60
+ error: 'Story content not found'
61
+ });
62
+ }
63
+ // Return as TypeScript/JSX content
64
+ res.setHeader('Content-Type', 'text/plain');
65
+ res.send(content);
66
+ }
67
+ catch (error) {
68
+ res.status(500).json({
69
+ success: false,
70
+ error: 'Failed to retrieve story content'
71
+ });
72
+ }
73
+ }
74
+ /**
75
+ * Delete a story by ID
76
+ */
77
+ export function deleteStory(req, res) {
78
+ try {
79
+ const { id } = req.params;
80
+ const storyService = getInMemoryStoryService(STORY_UI_CONFIG);
81
+ const deleted = storyService.deleteStory(id);
82
+ if (!deleted) {
83
+ return res.status(404).json({
84
+ success: false,
85
+ error: 'Story not found'
86
+ });
87
+ }
88
+ res.json({
89
+ success: true,
90
+ message: 'Story deleted successfully'
91
+ });
92
+ }
93
+ catch (error) {
94
+ res.status(500).json({
95
+ success: false,
96
+ error: 'Failed to delete story'
97
+ });
98
+ }
99
+ }
100
+ /**
101
+ * Clear all stories
102
+ */
103
+ export function clearAllStories(req, res) {
104
+ try {
105
+ const storyService = getInMemoryStoryService(STORY_UI_CONFIG);
106
+ storyService.clearAllStories();
107
+ res.json({
108
+ success: true,
109
+ message: 'All stories cleared successfully'
110
+ });
111
+ }
112
+ catch (error) {
113
+ res.status(500).json({
114
+ success: false,
115
+ error: 'Failed to clear stories'
116
+ });
117
+ }
118
+ }
119
+ /**
120
+ * Get memory usage statistics
121
+ */
122
+ export function getMemoryStats(req, res) {
123
+ try {
124
+ const storyService = getInMemoryStoryService(STORY_UI_CONFIG);
125
+ const stats = storyService.getMemoryStats();
126
+ res.json({
127
+ success: true,
128
+ stats: {
129
+ ...stats,
130
+ totalSizeMB: Math.round(stats.totalSizeBytes / 1024 / 1024 * 100) / 100,
131
+ averageSizeKB: Math.round(stats.averageSizeBytes / 1024 * 100) / 100
132
+ }
133
+ });
134
+ }
135
+ catch (error) {
136
+ res.status(500).json({
137
+ success: false,
138
+ error: 'Failed to retrieve memory statistics'
139
+ });
140
+ }
141
+ }
@@ -0,0 +1,147 @@
1
+ import { getStorySyncService } from '../../story-generator/storySync.js';
2
+ import { loadUserConfig } from '../../story-generator/configLoader.js';
3
+ /**
4
+ * Get all synchronized stories (from both file system and memory)
5
+ */
6
+ export async function getSyncedStories(req, res) {
7
+ try {
8
+ const config = loadUserConfig();
9
+ const syncService = getStorySyncService(config);
10
+ const stories = await syncService.getAllStories();
11
+ res.json({
12
+ success: true,
13
+ stories: stories.map(story => ({
14
+ id: story.id,
15
+ title: story.title,
16
+ fileName: story.fileName,
17
+ description: story.description,
18
+ createdAt: story.createdAt,
19
+ lastAccessed: story.lastAccessed,
20
+ source: story.source
21
+ })),
22
+ count: stories.length
23
+ });
24
+ }
25
+ catch (error) {
26
+ res.status(500).json({
27
+ success: false,
28
+ error: 'Failed to retrieve synchronized stories'
29
+ });
30
+ }
31
+ }
32
+ /**
33
+ * Delete a story from both file system and memory
34
+ */
35
+ export async function deleteSyncedStory(req, res) {
36
+ try {
37
+ const { id } = req.params;
38
+ const config = loadUserConfig();
39
+ const syncService = getStorySyncService(config);
40
+ const deleted = await syncService.deleteStory(id);
41
+ if (!deleted) {
42
+ return res.status(404).json({
43
+ success: false,
44
+ error: 'Story not found'
45
+ });
46
+ }
47
+ res.json({
48
+ success: true,
49
+ message: 'Story deleted successfully from all sources'
50
+ });
51
+ }
52
+ catch (error) {
53
+ res.status(500).json({
54
+ success: false,
55
+ error: 'Failed to delete story'
56
+ });
57
+ }
58
+ }
59
+ /**
60
+ * Clear all stories from both file system and memory
61
+ */
62
+ export async function clearAllSyncedStories(req, res) {
63
+ try {
64
+ const config = loadUserConfig();
65
+ const syncService = getStorySyncService(config);
66
+ await syncService.clearAllStories();
67
+ res.json({
68
+ success: true,
69
+ message: 'All stories cleared successfully'
70
+ });
71
+ }
72
+ catch (error) {
73
+ res.status(500).json({
74
+ success: false,
75
+ error: 'Failed to clear stories'
76
+ });
77
+ }
78
+ }
79
+ /**
80
+ * Sync chat history with actual stories
81
+ */
82
+ export async function syncChatHistory(req, res) {
83
+ try {
84
+ const config = loadUserConfig();
85
+ const syncService = getStorySyncService(config);
86
+ const syncResult = await syncService.syncChatHistory();
87
+ res.json({
88
+ success: true,
89
+ ...syncResult
90
+ });
91
+ }
92
+ catch (error) {
93
+ res.status(500).json({
94
+ success: false,
95
+ error: 'Failed to sync chat history'
96
+ });
97
+ }
98
+ }
99
+ /**
100
+ * Validate that a chat session corresponds to an actual story
101
+ */
102
+ export async function validateChatSession(req, res) {
103
+ try {
104
+ const { id } = req.params;
105
+ const config = loadUserConfig();
106
+ const syncService = getStorySyncService(config);
107
+ const isValid = await syncService.validateChatSession(id);
108
+ res.json({
109
+ success: true,
110
+ isValid,
111
+ message: isValid ? 'Chat session is valid' : 'Chat session has no corresponding story'
112
+ });
113
+ }
114
+ catch (error) {
115
+ res.status(500).json({
116
+ success: false,
117
+ error: 'Failed to validate chat session'
118
+ });
119
+ }
120
+ }
121
+ /**
122
+ * Get a specific synced story by ID
123
+ */
124
+ export async function getSyncedStoryById(req, res) {
125
+ try {
126
+ const { id } = req.params;
127
+ const config = loadUserConfig();
128
+ const syncService = getStorySyncService(config);
129
+ const story = await syncService.getStory(id);
130
+ if (!story) {
131
+ return res.status(404).json({
132
+ success: false,
133
+ error: 'Story not found'
134
+ });
135
+ }
136
+ res.json({
137
+ success: true,
138
+ story
139
+ });
140
+ }
141
+ catch (error) {
142
+ res.status(500).json({
143
+ success: false,
144
+ error: 'Failed to retrieve story'
145
+ });
146
+ }
147
+ }