@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
|
-
|
|
250
|
-
|
|
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:
|
|
285
|
+
description: isActuallyUpdate ? `Updated: ${prompt}` : prompt,
|
|
269
286
|
content: fixedFileContents,
|
|
270
|
-
createdAt:
|
|
287
|
+
createdAt: isActuallyUpdate ? (new Date()) : new Date(),
|
|
271
288
|
lastAccessed: new Date(),
|
|
272
|
-
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|