@tpitre/story-ui 1.3.0 → 1.4.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.
@@ -7,6 +7,7 @@ import { loadUserConfig, validateConfig } from '../../story-generator/configLoad
7
7
  import { setupProductionGitignore } from '../../story-generator/productionGitignoreManager.js';
8
8
  import { getInMemoryStoryService } from '../../story-generator/inMemoryStoryService.js';
9
9
  import { extractAndValidateCodeBlock, createFallbackStory } from '../../story-generator/validateStory.js';
10
+ import { StoryTracker } from '../../story-generator/storyTracker.js';
10
11
  const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages';
11
12
  const CLAUDE_MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514';
12
13
  // Legacy constants - now using dynamic discovery
@@ -178,6 +179,8 @@ export async function generateStoryFromPrompt(req, res) {
178
179
  const gitignoreManager = setupProductionGitignore(config);
179
180
  const storyService = getInMemoryStoryService(config);
180
181
  const isProduction = gitignoreManager.isProductionMode();
182
+ // Initialize story tracker for managing updates vs new creations
183
+ const storyTracker = new StoryTracker(config);
181
184
  // Check if this is an update to an existing story
182
185
  const isUpdate = fileName && conversation && conversation.length > 2;
183
186
  // Build prompt with conversation context if available
@@ -244,15 +247,29 @@ export async function generateStoryFromPrompt(req, res) {
244
247
  const title = config.storyPrefix + prettyPrompt;
245
248
  return p1 + title + p3;
246
249
  });
250
+ // Check if there's an existing story with this title or prompt
251
+ const existingByTitle = storyTracker.findByTitle(aiTitle);
252
+ const existingByPrompt = storyTracker.findByPrompt(prompt);
253
+ const existingStory = existingByTitle || existingByPrompt;
247
254
  // Generate unique ID and filename
248
255
  let hash, finalFileName, storyId;
249
- if (isUpdate && fileName) {
250
- // For updates, use existing fileName and ID
256
+ let isActuallyUpdate = false;
257
+ if (existingStory) {
258
+ // Use existing story's details to update instead of creating duplicate
259
+ console.log(`Found existing story "${existingStory.title}" - updating instead of creating new`);
260
+ hash = existingStory.hash;
261
+ finalFileName = existingStory.fileName;
262
+ storyId = existingStory.storyId;
263
+ isActuallyUpdate = true;
264
+ }
265
+ else if (isUpdate && fileName) {
266
+ // For conversation-based updates, use existing fileName and ID
251
267
  finalFileName = fileName;
252
268
  // Extract hash from existing fileName if possible
253
269
  const hashMatch = fileName.match(/-([a-f0-9]{8})(?:\.stories\.tsx)?$/);
254
270
  hash = hashMatch ? hashMatch[1] : crypto.createHash('sha1').update(prompt).digest('hex').slice(0, 8);
255
271
  storyId = `story-${hash}`;
272
+ isActuallyUpdate = true;
256
273
  }
257
274
  else {
258
275
  // For new stories, generate new IDs
@@ -265,15 +282,26 @@ export async function generateStoryFromPrompt(req, res) {
265
282
  const generatedStory = {
266
283
  id: storyId,
267
284
  title: aiTitle,
268
- description: isUpdate ? `Updated: ${prompt}` : prompt,
285
+ description: isActuallyUpdate ? `Updated: ${prompt}` : prompt,
269
286
  content: fixedFileContents,
270
- createdAt: isUpdate ? (new Date()) : new Date(),
287
+ createdAt: isActuallyUpdate ? (new Date()) : new Date(),
271
288
  lastAccessed: new Date(),
272
- prompt: isUpdate ? conversation.map((msg) => `${msg.role}: ${msg.content}`).join('\n\n') : prompt,
289
+ prompt: isActuallyUpdate ? conversation.map((msg) => `${msg.role}: ${msg.content}`).join('\n\n') : prompt,
273
290
  components: extractComponentsFromContent(fixedFileContents)
274
291
  };
275
292
  storyService.storeStory(generatedStory);
276
- console.log(`Story ${isUpdate ? 'updated' : 'stored'} in memory: ${storyId}`);
293
+ // Register with story tracker
294
+ const mapping = {
295
+ title: aiTitle,
296
+ fileName: finalFileName,
297
+ storyId,
298
+ hash,
299
+ createdAt: new Date().toISOString(),
300
+ updatedAt: new Date().toISOString(),
301
+ prompt
302
+ };
303
+ storyTracker.registerStory(mapping);
304
+ console.log(`Story ${isActuallyUpdate ? 'updated' : 'stored'} in memory: ${storyId}`);
277
305
  res.json({
278
306
  success: true,
279
307
  fileName: finalFileName,
@@ -282,7 +310,7 @@ export async function generateStoryFromPrompt(req, res) {
282
310
  story: fileContents,
283
311
  environment: 'production',
284
312
  storage: 'in-memory',
285
- isUpdate,
313
+ isUpdate: isActuallyUpdate,
286
314
  validation: {
287
315
  hasWarnings: hasValidationWarnings,
288
316
  errors: validationResult.errors || [],
@@ -293,7 +321,18 @@ export async function generateStoryFromPrompt(req, res) {
293
321
  else {
294
322
  // Development: Write to file system
295
323
  const outPath = generateStory({ fileContents: fixedFileContents, fileName: finalFileName });
296
- console.log(`Story ${isUpdate ? 'updated' : 'written'} to:`, outPath);
324
+ // Register with story tracker
325
+ const mapping = {
326
+ title: aiTitle,
327
+ fileName: finalFileName,
328
+ storyId,
329
+ hash,
330
+ createdAt: new Date().toISOString(),
331
+ updatedAt: new Date().toISOString(),
332
+ prompt
333
+ };
334
+ storyTracker.registerStory(mapping);
335
+ console.log(`Story ${isActuallyUpdate ? 'updated' : 'written'} to:`, outPath);
297
336
  res.json({
298
337
  success: true,
299
338
  fileName: finalFileName,
@@ -302,7 +341,7 @@ export async function generateStoryFromPrompt(req, res) {
302
341
  story: fileContents,
303
342
  environment: 'development',
304
343
  storage: 'file-system',
305
- isUpdate,
344
+ isUpdate: isActuallyUpdate,
306
345
  validation: {
307
346
  hasWarnings: hasValidationWarnings,
308
347
  errors: validationResult.errors || [],
@@ -0,0 +1,150 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ export class StoryTracker {
4
+ constructor(config) {
5
+ this.mappingFile = path.join(path.dirname(config.generatedStoriesPath), '.story-mappings.json');
6
+ this.mappings = new Map();
7
+ this.loadMappings();
8
+ }
9
+ /**
10
+ * Load existing mappings from disk
11
+ */
12
+ loadMappings() {
13
+ try {
14
+ if (fs.existsSync(this.mappingFile)) {
15
+ const data = JSON.parse(fs.readFileSync(this.mappingFile, 'utf-8'));
16
+ for (const mapping of data) {
17
+ this.mappings.set(mapping.title.toLowerCase(), mapping);
18
+ }
19
+ }
20
+ }
21
+ catch (error) {
22
+ console.warn('Failed to load story mappings:', error);
23
+ this.mappings = new Map();
24
+ }
25
+ }
26
+ /**
27
+ * Save mappings to disk
28
+ */
29
+ saveMappings() {
30
+ try {
31
+ const data = Array.from(this.mappings.values());
32
+ fs.writeFileSync(this.mappingFile, JSON.stringify(data, null, 2));
33
+ }
34
+ catch (error) {
35
+ console.error('Failed to save story mappings:', error);
36
+ }
37
+ }
38
+ /**
39
+ * Find an existing story by title
40
+ */
41
+ findByTitle(title) {
42
+ // Normalize the title for comparison
43
+ const normalizedTitle = title.toLowerCase().trim();
44
+ // Try exact match first
45
+ let mapping = this.mappings.get(normalizedTitle);
46
+ if (mapping)
47
+ return mapping;
48
+ // Try fuzzy matching for similar titles
49
+ for (const [key, value] of this.mappings) {
50
+ // Check if the key contains the normalized title or vice versa
51
+ if (key.includes(normalizedTitle) || normalizedTitle.includes(key)) {
52
+ return value;
53
+ }
54
+ // Check for very similar titles (e.g., "dashboard" vs "inventory dashboard")
55
+ const keywords = normalizedTitle.split(/\s+/);
56
+ const keyKeywords = key.split(/\s+/);
57
+ // If all keywords from the shorter title are in the longer one
58
+ const shortKeywords = keywords.length < keyKeywords.length ? keywords : keyKeywords;
59
+ const longKeywords = keywords.length < keyKeywords.length ? keyKeywords : keywords;
60
+ if (shortKeywords.every(word => longKeywords.includes(word))) {
61
+ return value;
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+ /**
67
+ * Find an existing story by prompt similarity
68
+ */
69
+ findByPrompt(prompt) {
70
+ const normalizedPrompt = prompt.toLowerCase().trim();
71
+ // Remove common prefixes like "generate a", "create a", etc.
72
+ const cleanPrompt = normalizedPrompt
73
+ .replace(/^(generate|create|build|make|design|show|write|produce|construct|draft|compose|implement|add|render|display)\s+(a|an|the)?\s*/i, '')
74
+ .trim();
75
+ // Try to find by similar prompts
76
+ for (const mapping of this.mappings.values()) {
77
+ const mappingPrompt = mapping.prompt.toLowerCase()
78
+ .replace(/^(generate|create|build|make|design|show|write|produce|construct|draft|compose|implement|add|render|display)\s+(a|an|the)?\s*/i, '')
79
+ .trim();
80
+ if (mappingPrompt === cleanPrompt) {
81
+ return mapping;
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+ /**
87
+ * Register a new or updated story
88
+ */
89
+ registerStory(mapping) {
90
+ const normalizedTitle = mapping.title.toLowerCase();
91
+ // Check if we're updating an existing story
92
+ const existing = this.findByTitle(mapping.title);
93
+ if (existing) {
94
+ // Update the existing mapping
95
+ mapping.createdAt = existing.createdAt;
96
+ mapping.updatedAt = new Date().toISOString();
97
+ }
98
+ else {
99
+ // New story
100
+ mapping.createdAt = new Date().toISOString();
101
+ mapping.updatedAt = mapping.createdAt;
102
+ }
103
+ this.mappings.set(normalizedTitle, mapping);
104
+ this.saveMappings();
105
+ }
106
+ /**
107
+ * Remove a story mapping
108
+ */
109
+ removeStory(titleOrFileName) {
110
+ // Try to find by title first
111
+ const byTitle = this.findByTitle(titleOrFileName);
112
+ if (byTitle) {
113
+ this.mappings.delete(byTitle.title.toLowerCase());
114
+ this.saveMappings();
115
+ return true;
116
+ }
117
+ // Try to find by filename
118
+ for (const [key, mapping] of this.mappings) {
119
+ if (mapping.fileName === titleOrFileName) {
120
+ this.mappings.delete(key);
121
+ this.saveMappings();
122
+ return true;
123
+ }
124
+ }
125
+ return false;
126
+ }
127
+ /**
128
+ * Get all story mappings
129
+ */
130
+ getAllMappings() {
131
+ return Array.from(this.mappings.values());
132
+ }
133
+ /**
134
+ * Clean up orphaned mappings (stories that no longer exist)
135
+ */
136
+ cleanupOrphaned(generatedStoriesPath) {
137
+ let removed = 0;
138
+ for (const [key, mapping] of this.mappings) {
139
+ const filePath = path.join(generatedStoriesPath, mapping.fileName);
140
+ if (!fs.existsSync(filePath)) {
141
+ this.mappings.delete(key);
142
+ removed++;
143
+ }
144
+ }
145
+ if (removed > 0) {
146
+ this.saveMappings();
147
+ }
148
+ return removed;
149
+ }
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpitre/story-ui",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "AI-powered Storybook story generator for any React component library",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",