everydaycats commited on
Commit
d92d9d3
Β·
verified Β·
1 Parent(s): 202c817

Update apps/storylines.js

Browse files
Files changed (1) hide show
  1. apps/storylines.js +47 -52
apps/storylines.js CHANGED
@@ -7,13 +7,12 @@ const router = express.Router();
7
  // ─────────────────────────────────────────────
8
  // CONSTANTS
9
  // ─────────────────────────────────────────────
10
- const DAILY_IMAGE_LIMITS = { quick: 20, premium: 5 }; // per user per day
11
  const ADMIN_KEY = process.env.STORYLINE_ADMIN_KEY;
12
 
13
  // ─────────────────────────────────────────────
14
  // IN-MEMORY IMAGE USAGE TRACKER
15
  // { [device_id]: { date: "YYYY-MM-DD", quick: n, premium: n } }
16
- // Keys untouched for 2 days are pruned automatically
17
  // ─────────────────────────────────────────────
18
  const imageUsage = new Map();
19
 
@@ -22,8 +21,6 @@ const todayStr = () => new Date().toISOString().slice(0, 10);
22
  const getUsage = (device_id) => {
23
  const today = todayStr();
24
  const entry = imageUsage.get(device_id);
25
-
26
- // New day or new user β€” reset
27
  if (!entry || entry.date !== today) {
28
  const fresh = { date: today, quick: 0, premium: 0, lastSeen: Date.now() };
29
  imageUsage.set(device_id, fresh);
@@ -46,7 +43,7 @@ const pruneUsageCache = () => {
46
  if (entry.lastSeen < cutoff) imageUsage.delete(id);
47
  }
48
  };
49
- setInterval(pruneUsageCache, 60 * 60 * 1000); // run every hour
50
 
51
  // ─────────────────────────────────────────────
52
  // MIDDLEWARE: Admin guard
@@ -54,24 +51,22 @@ setInterval(pruneUsageCache, 60 * 60 * 1000); // run every hour
54
  const adminOnly = (req, res, next) => {
55
  const key = req.headers['x-admin-key'];
56
  if (!ADMIN_KEY || key !== ADMIN_KEY)
57
- return res.status(403).json({ success: false, error: "Forbidden." });
58
  next();
59
  };
60
 
61
  // ─────────────────────────────────────────────
62
  // STORY SYSTEM PROMPT BUILDER
63
- // Pulls active characters from the chapter and
64
- // instructs the model to respond as each of them
65
  // ─────────────────────────────────────────────
66
- const buildStorySystemPrompt = (chapter, characters) => {
67
- const charList = characters.map(c =>
68
- `- ${c.name} (${c.personality || "no description"})`
69
- ).join('\n');
70
 
71
- return `You are the narrative engine of a light novel called "${chapter.story_title}".
72
 
73
  CURRENT CHAPTER: "${chapter.title}"
74
- CHAPTER CONTEXT: ${chapter.context || "No additional context."}
75
 
76
  ACTIVE CHARACTERS IN SCENE:
77
  ${charList}
@@ -103,40 +98,45 @@ EXAMPLE OUTPUT:
103
 
104
  // ─────────────────────────────────────────────
105
  // POST /storyline/interact
106
- // Main story interaction β€” user sends input,
107
- // gets back array of character responses
108
  // ─────────────────────────────────────────────
109
  router.post('/interact', async (req, res) => {
110
  try {
111
  const { device_id, chapter_id, user_input, history = [] } = req.body;
112
 
113
  if (!device_id || !chapter_id || !user_input)
114
- return res.status(400).json({ success: false, error: "device_id, chapter_id and user_input are required." });
 
 
 
 
 
 
115
 
116
- // Fetch chapter + its characters from Supabase
117
  const { data: chapter, error: chErr } = await supabase
118
  .from('storyline_chapters')
119
- .select('*, storyline_characters(*)')
120
  .eq('id', chapter_id)
121
  .single();
122
 
123
  if (chErr || !chapter)
124
- return res.status(404).json({ success: false, error: "Chapter not found." });
125
 
126
  const characters = chapter.storyline_characters || [];
 
127
 
128
- // Build conversation history into the prompt
129
- const historyBlock = history.length > 0
130
- ? `\nRECENT HISTORY (last ${history.length} exchanges):\n` +
131
- history.map(h => `[${h.role}]: ${h.content}`).join('\n')
132
- : '';
133
 
134
  const userPrompt = `${historyBlock}\n\n[PLAYER ACTION]: ${user_input}`;
135
 
136
  const aiResult = await generateCompletion({
137
- model: "maverick",
138
  prompt: userPrompt,
139
- system_prompt: buildStorySystemPrompt(chapter, characters),
140
  });
141
 
142
  if (!aiResult.success) throw new Error(aiResult.error);
@@ -149,28 +149,25 @@ router.post('/interact', async (req, res) => {
149
  responses: parsed.responses || [],
150
  model_used: aiResult.model_used,
151
  });
152
-
153
  } catch (err) {
154
- console.error("[STORY INTERACT ERROR]", err.message);
155
  res.status(500).json({ success: false, error: err.message });
156
  }
157
  });
158
 
159
  // ─────────────────────────────────────────────
160
  // POST /storyline/generate-image
161
- // Generates a scene image, enforces daily limits
162
  // ─────────────────────────────────────────────
163
  router.post('/generate-image', async (req, res) => {
164
  try {
165
- const { device_id, prompt, tier = "quick" } = req.body;
166
 
167
  if (!device_id || !prompt)
168
- return res.status(400).json({ success: false, error: "device_id and prompt required." });
169
 
170
- if (!["quick", "premium"].includes(tier))
171
  return res.status(400).json({ success: false, error: "tier must be 'quick' or 'premium'." });
172
 
173
- // Check daily limit
174
  const usage = getUsage(device_id);
175
  const limit = DAILY_IMAGE_LIMITS[tier];
176
 
@@ -195,16 +192,14 @@ router.post('/generate-image', async (req, res) => {
195
  model_used: result.model_used,
196
  usage: { quick: updated.quick, premium: updated.premium, limits: DAILY_IMAGE_LIMITS },
197
  });
198
-
199
  } catch (err) {
200
- console.error("[IMAGE GEN ERROR]", err.message);
201
  res.status(500).json({ success: false, error: err.message });
202
  }
203
  });
204
 
205
  // ─────────────────────────────────────────────
206
  // GET /storyline/stories
207
- // Fetch all published stories (public)
208
  // ─────────────────────────────────────────────
209
  router.get('/stories', async (req, res) => {
210
  try {
@@ -221,10 +216,8 @@ router.get('/stories', async (req, res) => {
221
  }
222
  });
223
 
224
-
225
  // ─────────────────────────────────────────────
226
  // ADMIN: GET /storyline/admin/stories
227
- // Fetch ALL stories including drafts
228
  // ─────────────────────────────────────────────
229
  router.get('/admin/stories', adminOnly, async (req, res) => {
230
  try {
@@ -242,7 +235,6 @@ router.get('/admin/stories', adminOnly, async (req, res) => {
242
 
243
  // ─────────────────────────────────────────────
244
  // GET /storyline/stories/:story_id/chapters
245
- // Fetch chapters for a story (public)
246
  // ─────────────────────────────────────────────
247
  router.get('/stories/:story_id/chapters', async (req, res) => {
248
  try {
@@ -262,12 +254,11 @@ router.get('/stories/:story_id/chapters', async (req, res) => {
262
 
263
  // ─────────────────────────────────────────────
264
  // ADMIN: POST /storyline/admin/stories
265
- // Create a new story
266
  // ─────────────────────────────────────────────
267
  router.post('/admin/stories', adminOnly, async (req, res) => {
268
  try {
269
  const { title, description, cover_image, genre, published = false } = req.body;
270
- if (!title) return res.status(400).json({ success: false, error: "title required." });
271
 
272
  const { data, error } = await supabase
273
  .from('storylines')
@@ -284,15 +275,13 @@ router.post('/admin/stories', adminOnly, async (req, res) => {
284
 
285
  // ─────────────���───────────────────────────────
286
  // ADMIN: PATCH /storyline/admin/stories/:id
287
- // Update a story
288
  // ─────────────────────────────────────────────
289
  router.patch('/admin/stories/:id', adminOnly, async (req, res) => {
290
  try {
291
  const { id } = req.params;
292
- const updates = req.body;
293
  const { data, error } = await supabase
294
  .from('storylines')
295
- .update(updates)
296
  .eq('id', id)
297
  .select()
298
  .single();
@@ -306,7 +295,6 @@ router.patch('/admin/stories/:id', adminOnly, async (req, res) => {
306
 
307
  // ─────────────────────────────────────────────
308
  // ADMIN: DELETE /storyline/admin/stories/:id
309
- // Delete a story and its chapters (cascade expected in DB)
310
  // ─────────────────────────────────────────────
311
  router.delete('/admin/stories/:id', adminOnly, async (req, res) => {
312
  try {
@@ -321,12 +309,12 @@ router.delete('/admin/stories/:id', adminOnly, async (req, res) => {
321
 
322
  // ─────────────────────────────────────────────
323
  // ADMIN: POST /storyline/admin/chapters
324
- // Add a chapter (with characters) to a story
325
  // ─────────────────────────────────────────────
326
  router.post('/admin/chapters', adminOnly, async (req, res) => {
327
  try {
328
  const { story_id, title, order, context, characters = [] } = req.body;
329
- if (!story_id || !title) return res.status(400).json({ success: false, error: "story_id and title required." });
 
330
 
331
  const { data: chapter, error: chErr } = await supabase
332
  .from('storyline_chapters')
@@ -336,7 +324,6 @@ router.post('/admin/chapters', adminOnly, async (req, res) => {
336
 
337
  if (chErr) throw chErr;
338
 
339
- // Insert characters if provided
340
  if (characters.length > 0) {
341
  const charRows = characters.map(c => ({ ...c, chapter_id: chapter.id, story_id }));
342
  const { error: charErr } = await supabase.from('storyline_characters').insert(charRows);
@@ -349,33 +336,41 @@ router.post('/admin/chapters', adminOnly, async (req, res) => {
349
  }
350
  });
351
 
352
-
 
 
353
  router.patch('/admin/chapters/:id', adminOnly, async (req, res) => {
354
  try {
355
  const { id } = req.params;
356
  const { title, order, content, context, characters } = req.body;
 
357
  const { data: chapter, error } = await supabase
358
  .from('storyline_chapters')
359
  .update({ title, order, content, context })
360
  .eq('id', id)
361
  .select()
362
  .single();
 
363
  if (error) throw error;
364
 
365
  if (characters) {
366
  await supabase.from('storyline_characters').delete().eq('chapter_id', id);
367
  if (characters.length > 0) {
368
- const rows = characters.map(c => ({ ...c, chapter_id: id, story_id: chapter.story_id }));
 
 
 
 
369
  await supabase.from('storyline_characters').insert(rows);
370
  }
371
  }
 
372
  res.json({ success: true, data: chapter });
373
  } catch (err) {
374
  res.status(500).json({ success: false, error: err.message });
375
  }
376
  });
377
 
378
-
379
  // ─────────────────────────────────────────────
380
  // ADMIN: DELETE /storyline/admin/chapters/:id
381
  // ─────────────────────────────────────────────
 
7
  // ─────────────────────────────────────────────
8
  // CONSTANTS
9
  // ─────────────────────────────────────────────
10
+ const DAILY_IMAGE_LIMITS = { quick: 20, premium: 5 };
11
  const ADMIN_KEY = process.env.STORYLINE_ADMIN_KEY;
12
 
13
  // ─────────────────────────────────────────────
14
  // IN-MEMORY IMAGE USAGE TRACKER
15
  // { [device_id]: { date: "YYYY-MM-DD", quick: n, premium: n } }
 
16
  // ─────────────────────────────────────────────
17
  const imageUsage = new Map();
18
 
 
21
  const getUsage = (device_id) => {
22
  const today = todayStr();
23
  const entry = imageUsage.get(device_id);
 
 
24
  if (!entry || entry.date !== today) {
25
  const fresh = { date: today, quick: 0, premium: 0, lastSeen: Date.now() };
26
  imageUsage.set(device_id, fresh);
 
43
  if (entry.lastSeen < cutoff) imageUsage.delete(id);
44
  }
45
  };
46
+ setInterval(pruneUsageCache, 60 * 60 * 1000);
47
 
48
  // ─────────────────────────────────────────────
49
  // MIDDLEWARE: Admin guard
 
51
  const adminOnly = (req, res, next) => {
52
  const key = req.headers['x-admin-key'];
53
  if (!ADMIN_KEY || key !== ADMIN_KEY)
54
+ return res.status(403).json({ success: false, error: 'Forbidden.' });
55
  next();
56
  };
57
 
58
  // ─────────────────────────────────────────────
59
  // STORY SYSTEM PROMPT BUILDER
 
 
60
  // ─────────────────────────────────────────────
61
+ const buildStorySystemPrompt = (chapter, characters, storyTitle) => {
62
+ const charList = characters
63
+ .map(c => `- ${c.name} (${c.personality || 'no description'})`)
64
+ .join('\n');
65
 
66
+ return `You are the narrative engine of a light novel called "${storyTitle}".
67
 
68
  CURRENT CHAPTER: "${chapter.title}"
69
+ CHAPTER CONTEXT: ${chapter.context || 'No additional context.'}
70
 
71
  ACTIVE CHARACTERS IN SCENE:
72
  ${charList}
 
98
 
99
  // ─────────────────────────────────────────────
100
  // POST /storyline/interact
 
 
101
  // ─────────────────────────────────────────────
102
  router.post('/interact', async (req, res) => {
103
  try {
104
  const { device_id, chapter_id, user_input, history = [] } = req.body;
105
 
106
  if (!device_id || !chapter_id || !user_input)
107
+ return res.status(400).json({
108
+ success: false,
109
+ error: 'device_id, chapter_id and user_input are required.',
110
+ });
111
+
112
+ // Cap history server-side regardless of what the client sends
113
+ const cappedHistory = history.slice(-10);
114
 
115
+ // Fetch chapter + parent story title + characters
116
  const { data: chapter, error: chErr } = await supabase
117
  .from('storyline_chapters')
118
+ .select('*, storylines(title), storyline_characters(*)')
119
  .eq('id', chapter_id)
120
  .single();
121
 
122
  if (chErr || !chapter)
123
+ return res.status(404).json({ success: false, error: 'Chapter not found.' });
124
 
125
  const characters = chapter.storyline_characters || [];
126
+ const storyTitle = chapter.storylines?.title ?? 'Unknown Story';
127
 
128
+ const historyBlock =
129
+ cappedHistory.length > 0
130
+ ? `\nRECENT HISTORY (last ${cappedHistory.length} exchanges):\n` +
131
+ cappedHistory.map(h => `[${h.role}]: ${h.content}`).join('\n')
132
+ : '';
133
 
134
  const userPrompt = `${historyBlock}\n\n[PLAYER ACTION]: ${user_input}`;
135
 
136
  const aiResult = await generateCompletion({
137
+ model: 'maverick',
138
  prompt: userPrompt,
139
+ system_prompt: buildStorySystemPrompt(chapter, characters, storyTitle),
140
  });
141
 
142
  if (!aiResult.success) throw new Error(aiResult.error);
 
149
  responses: parsed.responses || [],
150
  model_used: aiResult.model_used,
151
  });
 
152
  } catch (err) {
153
+ console.error('[STORY INTERACT ERROR]', err.message);
154
  res.status(500).json({ success: false, error: err.message });
155
  }
156
  });
157
 
158
  // ─────────────────────────────────────────────
159
  // POST /storyline/generate-image
 
160
  // ─────────────────────────────────────────────
161
  router.post('/generate-image', async (req, res) => {
162
  try {
163
+ const { device_id, prompt, tier = 'quick' } = req.body;
164
 
165
  if (!device_id || !prompt)
166
+ return res.status(400).json({ success: false, error: 'device_id and prompt required.' });
167
 
168
+ if (!['quick', 'premium'].includes(tier))
169
  return res.status(400).json({ success: false, error: "tier must be 'quick' or 'premium'." });
170
 
 
171
  const usage = getUsage(device_id);
172
  const limit = DAILY_IMAGE_LIMITS[tier];
173
 
 
192
  model_used: result.model_used,
193
  usage: { quick: updated.quick, premium: updated.premium, limits: DAILY_IMAGE_LIMITS },
194
  });
 
195
  } catch (err) {
196
+ console.error('[IMAGE GEN ERROR]', err.message);
197
  res.status(500).json({ success: false, error: err.message });
198
  }
199
  });
200
 
201
  // ─────────────────────────────────────────────
202
  // GET /storyline/stories
 
203
  // ─────────────────────────────────────────────
204
  router.get('/stories', async (req, res) => {
205
  try {
 
216
  }
217
  });
218
 
 
219
  // ─────────────────────────────────────────────
220
  // ADMIN: GET /storyline/admin/stories
 
221
  // ─────────────────────────────────────────────
222
  router.get('/admin/stories', adminOnly, async (req, res) => {
223
  try {
 
235
 
236
  // ─────────────────────────────────────────────
237
  // GET /storyline/stories/:story_id/chapters
 
238
  // ─────────────────────────────────────────────
239
  router.get('/stories/:story_id/chapters', async (req, res) => {
240
  try {
 
254
 
255
  // ─────────────────────────────────────────────
256
  // ADMIN: POST /storyline/admin/stories
 
257
  // ─────────────────────────────────────────────
258
  router.post('/admin/stories', adminOnly, async (req, res) => {
259
  try {
260
  const { title, description, cover_image, genre, published = false } = req.body;
261
+ if (!title) return res.status(400).json({ success: false, error: 'title required.' });
262
 
263
  const { data, error } = await supabase
264
  .from('storylines')
 
275
 
276
  // ─────────────���───────────────────────────────
277
  // ADMIN: PATCH /storyline/admin/stories/:id
 
278
  // ─────────────────────────────────────────────
279
  router.patch('/admin/stories/:id', adminOnly, async (req, res) => {
280
  try {
281
  const { id } = req.params;
 
282
  const { data, error } = await supabase
283
  .from('storylines')
284
+ .update(req.body)
285
  .eq('id', id)
286
  .select()
287
  .single();
 
295
 
296
  // ─────────────────────────────────────────────
297
  // ADMIN: DELETE /storyline/admin/stories/:id
 
298
  // ─────────────────────────────────────────────
299
  router.delete('/admin/stories/:id', adminOnly, async (req, res) => {
300
  try {
 
309
 
310
  // ─────────────────────────────────────────────
311
  // ADMIN: POST /storyline/admin/chapters
 
312
  // ─────────────────────────────────────────────
313
  router.post('/admin/chapters', adminOnly, async (req, res) => {
314
  try {
315
  const { story_id, title, order, context, characters = [] } = req.body;
316
+ if (!story_id || !title)
317
+ return res.status(400).json({ success: false, error: 'story_id and title required.' });
318
 
319
  const { data: chapter, error: chErr } = await supabase
320
  .from('storyline_chapters')
 
324
 
325
  if (chErr) throw chErr;
326
 
 
327
  if (characters.length > 0) {
328
  const charRows = characters.map(c => ({ ...c, chapter_id: chapter.id, story_id }));
329
  const { error: charErr } = await supabase.from('storyline_characters').insert(charRows);
 
336
  }
337
  });
338
 
339
+ // ─────────────────────────────────────────────
340
+ // ADMIN: PATCH /storyline/admin/chapters/:id
341
+ // ─────────────────────────────────────────────
342
  router.patch('/admin/chapters/:id', adminOnly, async (req, res) => {
343
  try {
344
  const { id } = req.params;
345
  const { title, order, content, context, characters } = req.body;
346
+
347
  const { data: chapter, error } = await supabase
348
  .from('storyline_chapters')
349
  .update({ title, order, content, context })
350
  .eq('id', id)
351
  .select()
352
  .single();
353
+
354
  if (error) throw error;
355
 
356
  if (characters) {
357
  await supabase.from('storyline_characters').delete().eq('chapter_id', id);
358
  if (characters.length > 0) {
359
+ const rows = characters.map(c => ({
360
+ ...c,
361
+ chapter_id: id,
362
+ story_id: chapter.story_id,
363
+ }));
364
  await supabase.from('storyline_characters').insert(rows);
365
  }
366
  }
367
+
368
  res.json({ success: true, data: chapter });
369
  } catch (err) {
370
  res.status(500).json({ success: false, error: err.message });
371
  }
372
  });
373
 
 
374
  // ─────────────────────────────────────────────
375
  // ADMIN: DELETE /storyline/admin/chapters/:id
376
  // ─────────────────────────────────────────────