everydaycats commited on
Commit
efee52c
Β·
verified Β·
1 Parent(s): 3de3eb6

Create storylines.js

Browse files
Files changed (1) hide show
  1. apps/storylines.js +347 -0
apps/storylines.js ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import { generateCompletion, generateImage } from '../ai_engine.js';
3
+ import { supabase } from '../config/supabaseClient.js';
4
+
5
+ const router = express.Router();
6
+
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
+
20
+ const todayStr = () => new Date().toISOString().slice(0, 10);
21
+
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);
30
+ return fresh;
31
+ }
32
+ entry.lastSeen = Date.now();
33
+ return entry;
34
+ };
35
+
36
+ const incrementUsage = (device_id, tier) => {
37
+ const entry = getUsage(device_id);
38
+ entry[tier] = (entry[tier] || 0) + 1;
39
+ imageUsage.set(device_id, entry);
40
+ };
41
+
42
+ // Prune keys not touched in 48 hours
43
+ const pruneUsageCache = () => {
44
+ const cutoff = Date.now() - 48 * 60 * 60 * 1000;
45
+ for (const [id, entry] of imageUsage.entries()) {
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
53
+ // ─────────────────────────────────────────────
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}
78
+
79
+ THE USER is the main character. They have just taken an action or spoken.
80
+
81
+ YOUR JOB:
82
+ Respond with a JSON object containing ONE key: "responses"
83
+ "responses" is an array of character reaction objects.
84
+ Each object has exactly one key β€” the character's name β€” mapped to their reaction string.
85
+
86
+ RULES:
87
+ - Only include characters who would realistically react to this specific input
88
+ - Reactions must feel natural, in-character, and emotionally alive
89
+ - Include physical actions using *asterisks* e.g. *slams table* or *avoids eye contact*
90
+ - You may group silent/background characters: e.g. {"The class": "*goes quiet*"}
91
+ - Keep each reaction to 1-3 sentences max
92
+ - Return ONLY the raw JSON. No markdown fences, no extra text.
93
+
94
+ EXAMPLE OUTPUT:
95
+ {
96
+ "responses": [
97
+ {"Andy": "*shouts* What the hell dude, I said we take that to the grave!"},
98
+ {"Amy": "*covers her mouth, clearly holding back laughter*"},
99
+ {"Jeff and Amy": "*exchange a look and burst out giggling*"}
100
+ ]
101
+ }`;
102
+ };
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);
143
+
144
+ const parsed = JSON.parse(aiResult.data);
145
+
146
+ res.json({
147
+ success: true,
148
+ chapter_id,
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
+
177
+ if (usage[tier] >= limit) {
178
+ return res.status(429).json({
179
+ success: false,
180
+ error: `Daily ${tier} image limit reached (${limit}/day). Resets tomorrow.`,
181
+ limit,
182
+ used: usage[tier],
183
+ });
184
+ }
185
+
186
+ const result = await generateImage({ prompt, tier });
187
+ if (!result.success) throw new Error(result.error);
188
+
189
+ incrementUsage(device_id, tier);
190
+
191
+ const updated = getUsage(device_id);
192
+ res.json({
193
+ success: true,
194
+ url: result.url,
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 {
211
+ const { data, error } = await supabase
212
+ .from('storylines')
213
+ .select('id, title, description, cover_image, genre, created_at')
214
+ .eq('published', true)
215
+ .order('created_at', { ascending: false });
216
+
217
+ if (error) throw error;
218
+ res.json({ success: true, data });
219
+ } catch (err) {
220
+ res.status(500).json({ success: false, error: err.message });
221
+ }
222
+ });
223
+
224
+ // ─────────────────────────────────────────────
225
+ // GET /storyline/stories/:story_id/chapters
226
+ // Fetch chapters for a story (public)
227
+ // ─────────────────────────────────────────────
228
+ router.get('/stories/:story_id/chapters', async (req, res) => {
229
+ try {
230
+ const { story_id } = req.params;
231
+ const { data, error } = await supabase
232
+ .from('storyline_chapters')
233
+ .select('id, title, order, context, storyline_characters(name, personality)')
234
+ .eq('story_id', story_id)
235
+ .order('order', { ascending: true });
236
+
237
+ if (error) throw error;
238
+ res.json({ success: true, data });
239
+ } catch (err) {
240
+ res.status(500).json({ success: false, error: err.message });
241
+ }
242
+ });
243
+
244
+ // ─────────────────────────────────────────────
245
+ // ADMIN: POST /storyline/admin/stories
246
+ // Create a new story
247
+ // ─────────────────────────────────────────────
248
+ router.post('/admin/stories', adminOnly, async (req, res) => {
249
+ try {
250
+ const { title, description, cover_image, genre, published = false } = req.body;
251
+ if (!title) return res.status(400).json({ success: false, error: "title required." });
252
+
253
+ const { data, error } = await supabase
254
+ .from('storylines')
255
+ .insert({ title, description, cover_image, genre, published })
256
+ .select()
257
+ .single();
258
+
259
+ if (error) throw error;
260
+ res.json({ success: true, data });
261
+ } catch (err) {
262
+ res.status(500).json({ success: false, error: err.message });
263
+ }
264
+ });
265
+
266
+ // ─────────────────────────────────────────────
267
+ // ADMIN: PATCH /storyline/admin/stories/:id
268
+ // Update a story
269
+ // ─────────────────────────────────────────────
270
+ router.patch('/admin/stories/:id', adminOnly, async (req, res) => {
271
+ try {
272
+ const { id } = req.params;
273
+ const updates = req.body;
274
+ const { data, error } = await supabase
275
+ .from('storylines')
276
+ .update(updates)
277
+ .eq('id', id)
278
+ .select()
279
+ .single();
280
+
281
+ if (error) throw error;
282
+ res.json({ success: true, data });
283
+ } catch (err) {
284
+ res.status(500).json({ success: false, error: err.message });
285
+ }
286
+ });
287
+
288
+ // ─────────────────────────────────────────────
289
+ // ADMIN: DELETE /storyline/admin/stories/:id
290
+ // Delete a story and its chapters (cascade expected in DB)
291
+ // ─────────────────────────────────────────────
292
+ router.delete('/admin/stories/:id', adminOnly, async (req, res) => {
293
+ try {
294
+ const { id } = req.params;
295
+ const { error } = await supabase.from('storylines').delete().eq('id', id);
296
+ if (error) throw error;
297
+ res.json({ success: true, message: `Story ${id} deleted.` });
298
+ } catch (err) {
299
+ res.status(500).json({ success: false, error: err.message });
300
+ }
301
+ });
302
+
303
+ // ─────────────────────────────────────────────
304
+ // ADMIN: POST /storyline/admin/chapters
305
+ // Add a chapter (with characters) to a story
306
+ // ─────────────────────────────────────────────
307
+ router.post('/admin/chapters', adminOnly, async (req, res) => {
308
+ try {
309
+ const { story_id, title, order, context, characters = [] } = req.body;
310
+ if (!story_id || !title) return res.status(400).json({ success: false, error: "story_id and title required." });
311
+
312
+ const { data: chapter, error: chErr } = await supabase
313
+ .from('storyline_chapters')
314
+ .insert({ story_id, title, order: order ?? 0, context })
315
+ .select()
316
+ .single();
317
+
318
+ if (chErr) throw chErr;
319
+
320
+ // Insert characters if provided
321
+ if (characters.length > 0) {
322
+ const charRows = characters.map(c => ({ ...c, chapter_id: chapter.id, story_id }));
323
+ const { error: charErr } = await supabase.from('storyline_characters').insert(charRows);
324
+ if (charErr) throw charErr;
325
+ }
326
+
327
+ res.json({ success: true, data: chapter });
328
+ } catch (err) {
329
+ res.status(500).json({ success: false, error: err.message });
330
+ }
331
+ });
332
+
333
+ // ─────────────────────────────────────────────
334
+ // ADMIN: DELETE /storyline/admin/chapters/:id
335
+ // ─────────────────────────────────────────────
336
+ router.delete('/admin/chapters/:id', adminOnly, async (req, res) => {
337
+ try {
338
+ const { id } = req.params;
339
+ const { error } = await supabase.from('storyline_chapters').delete().eq('id', id);
340
+ if (error) throw error;
341
+ res.json({ success: true, message: `Chapter ${id} deleted.` });
342
+ } catch (err) {
343
+ res.status(500).json({ success: false, error: err.message });
344
+ }
345
+ });
346
+
347
+ export default router;