Pepguy commited on
Commit
b06a41a
·
verified ·
1 Parent(s): 1c8cb1b

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +384 -142
app.js CHANGED
@@ -1,165 +1,407 @@
1
- import { serve } from "bun";
2
- import { Expo } from "expo-server-sdk";
3
-
4
- const expo = new Expo();
5
- const events = [];
6
-
7
- const html = `
8
- <!DOCTYPE html><html><head><title>Timed Event Scheduler</title></head><body>
9
- <h1>Schedule Timed Event</h1>
10
- <form onsubmit="submitEvent(event)">
11
- <label>ID: <input type="text" id="id" required /></label><br />
12
- <label>Delay (sec): <input type="number" id="delay" required /></label><br />
13
- <label>Message: <input type="text" id="message" /></label><br />
14
- <label>Event Type:
15
- <select id="type">
16
- <option value="timeout">Timeout</option>
17
- <option value="interval">Interval</option>
18
- <option value="webhook">Webhook</option>
19
- </select>
20
- </label><br />
21
- <label>Repeat Every (sec): <input type="number" id="repeat" /></label><br />
22
- <label>Expo Token(s) (comma-separated): <input type="text" id="tokens" /></label><br />
23
- <label>Webhook URL: <input type="text" id="webhookUrl" /></label><br />
24
- <label>Webhook Payload (JSON): <textarea id="payload"></textarea></label><br />
25
- <button type="submit">Submit</button>
26
- </form>
27
- <script>
28
- async function submitEvent(e) {
29
- e.preventDefault();
30
- const id = document.getElementById('id').value;
31
- const delay = parseInt(document.getElementById('delay').value);
32
- const message = document.getElementById('message').value;
33
- const type = document.getElementById('type').value;
34
- const repeat = parseInt(document.getElementById('repeat').value || 0);
35
- const tokens = document.getElementById('tokens').value.split(',').map(t => t.trim()).filter(Boolean);
36
- const webhookUrl = document.getElementById('webhookUrl').value;
37
- const payloadText = document.getElementById('payload').value;
38
- let payload = {};
39
- try {
40
- if (payloadText) payload = JSON.parse(payloadText);
41
- } catch {
42
- alert('Invalid JSON payload');
43
- return;
44
- }
 
 
 
 
 
 
 
 
 
 
 
45
 
46
- const res = await fetch('/events', {
47
- method: 'POST',
48
- headers: { 'Content-Type': 'application/json' },
49
- body: JSON.stringify({ id, delay, message, type, repeat, expoTokens: tokens, webhookUrl, payload })
 
 
 
 
 
 
 
 
50
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
- alert(await res.text());
 
 
 
 
 
 
53
  }
54
- </script>
55
- </body></html>
 
 
 
 
 
 
 
 
 
 
 
 
56
  `;
57
 
58
- serve({
59
- port: 7860,
60
- fetch: async (req) => {
61
- const url = new URL(req.url);
 
 
62
 
63
- if (req.method === "GET" && url.pathname === "/") {
64
- return new Response(html, { headers: { "Content-Type": "text/html" } });
 
 
 
 
 
 
 
 
 
 
65
  }
 
66
 
67
- // POST /events - Create Event
68
- if (req.method === "POST" && url.pathname === "/events") {
69
- try {
70
- const body = await req.json();
71
- const { id, delay, message, type, repeat, expoTokens, webhookUrl, payload } = body;
72
-
73
- if (!id || !delay || !type) {
74
- return new Response("Missing 'id', 'delay', or 'type'", { status: 400 });
75
- }
76
-
77
- if (events.find(e => e.id === id)) {
78
- return new Response("Event ID already exists", { status: 409 });
79
- }
80
-
81
- events.push({
82
- id,
83
- timestamp: Date.now() + delay * 1000,
84
- type,
85
- repeat: type === "interval" && repeat ? repeat * 1000 : undefined,
86
- data: {
87
- message,
88
- expoTokens: Array.isArray(expoTokens) ? expoTokens : [],
89
- webhookUrl,
90
- payload
91
- }
92
- });
93
 
94
- return new Response("Event scheduled", { status: 200 });
95
- } catch (err) {
96
- return new Response("Invalid JSON", { status: 400 });
97
- }
 
 
 
 
 
98
  }
 
 
99
 
100
- // DELETE /events/:id - Remove Event
101
- if (req.method === "DELETE" && url.pathname.startsWith("/events/")) {
102
- const id = url.pathname.split("/")[2];
103
- const index = events.findIndex(e => e.id === id);
104
- if (index === -1) return new Response("Event not found", { status: 404 });
105
- events.splice(index, 1);
106
- return new Response("Event deleted", { status: 200 });
107
- }
 
 
 
 
 
108
 
109
- return new Response("Not Found", { status: 404 });
 
 
 
 
 
 
 
 
 
110
  }
111
  });
112
 
113
- // Event Loop
114
- setInterval(async () => {
115
- const now = Date.now();
116
- const due = events.filter(e => e.timestamp <= now);
117
-
118
- for (const e of due) {
119
- console.log(`⏰ Executing event: ${e.id} (${e.type})`);
120
-
121
- // Expo Notifications
122
- if (e.data.expoTokens?.length && e.data.message) {
123
- console.log("found a notifications events")
124
- const messages = e.data.expoTokens
125
- .filter(t => Expo.isExpoPushToken(t))
126
- .map(token => ({
127
- to: token,
128
- sound: 'default',
129
- title: '⏰ Scheduled Event',
130
- body: e.data.message,
131
- data: {}
132
- }));
133
- for (const chunk of expo.chunkPushNotifications(messages)) {
134
- try {
135
- await expo.sendPushNotificationsAsync(chunk);
136
- } catch (err) {
137
- console.error("🔥 Expo error:", err);
138
- }
139
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  }
141
 
142
- // Webhook Trigger
143
- if (e.type === "webhook" && e.data.webhookUrl) {
144
- console.log("found a webhook event")
145
- try {
146
- await fetch(e.data.webhookUrl, {
147
- method: "POST",
148
- headers: { "Content-Type": "application/json" },
149
- body: JSON.stringify(e.data.payload || {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  });
151
- console.log(`📡 Webhook fired for ${e.id}`);
152
- } catch (err) {
153
- console.error(`❌ Webhook failed for ${e.id}`, err);
 
 
 
154
  }
155
- }
156
 
157
- // Repeat or remove
158
- if (e.type === "interval" && e.repeat) {
159
- e.timestamp = now + e.repeat;
160
- } else {
161
- const i = events.findIndex(ev => ev.id === e.id);
162
- if (i !== -1) events.splice(i, 1);
163
  }
 
 
 
 
 
 
 
164
  }
165
- }, 1000);
 
 
 
 
 
1
+ // server.js
2
+ // ESM Node (package.json should contain "type": "module")
3
+ // Install: npm install express body-parser cors @google/genai
4
+ // Run: GEMINI_API_KEY=your_key node server.js
5
+
6
+ import express from "express";
7
+ import bodyParser from "body-parser";
8
+ import cors from "cors";
9
+ import { GoogleGenAI } from "@google/genai";
10
+
11
+ const PORT = process.env.PORT ? Number(process.env.PORT) : 7860;
12
+ if (!process.env.GEMINI_API_KEY) {
13
+ console.error("GEMINI_API_KEY environment variable is required.");
14
+ process.exit(1);
15
+ }
16
+
17
+ const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
18
+
19
+ const app = express();
20
+ app.use(cors());
21
+ app.use(bodyParser.json({ limit: "2mb" }));
22
+
23
+ /**
24
+ * In-memory store (prototype). For production, replace with persistent storage.
25
+ */
26
+ const projects = new Map();
27
+
28
+ const simpleId = (prefix = "") => prefix + Math.random().toString(36).slice(2, 9);
29
+
30
+ /**
31
+ * Action vocabulary — keep plugin and server in sync.
32
+ */
33
+ const VALID_ACTIONS = [
34
+ "create_part",
35
+ "create_model",
36
+ "create_meshpart",
37
+ "create_complex_mesh",
38
+ "create_union",
39
+ "create_script",
40
+ "attach_script",
41
+ "edit_script",
42
+ "set_property",
43
+ "move_to",
44
+ "rotate_to",
45
+ "set_parent",
46
+ "delete",
47
+ "apply_texture",
48
+ "weld",
49
+ "unweld",
50
+ "create_rig",
51
+ "apply_animation",
52
+ "feedback_request",
53
+ "list_workspace",
54
+ "finish"
55
+ ];
56
 
57
+ /**
58
+ * Validate/prepare model output tasks (ensure allowed actions, add ids)
59
+ */
60
+ function validateAndPrepareTasks(arr) {
61
+ if (!Array.isArray(arr)) throw new Error("Model output must be a JSON array of tasks.");
62
+ return arr.map((t) => {
63
+ if (!t || typeof t !== "object") throw new Error("Each task must be an object.");
64
+ if (!t.action) throw new Error("Each task must have an action field.");
65
+ if (!VALID_ACTIONS.includes(t.action)) throw new Error(`Invalid action from model: ${t.action}`);
66
+ const id = t.id || simpleId("cmd_");
67
+ const params = t.params && typeof t.params === "object" ? t.params : {};
68
+ return { id, action: t.action, params, target_ref: t.target_ref || null };
69
  });
70
+ }
71
+
72
+ /**
73
+ * generateTasks(project)
74
+ * Uses Gemini to convert description + project state into an ordered JSON array of tasks.
75
+ */
76
+ async function generateTasks(project) {
77
+ // Summarize project state (concise)
78
+ const historySample = (project.history || []).slice(-20).map(h => ({
79
+ commandId: h.commandId,
80
+ status: h.status,
81
+ message: h.message ? (typeof h.message === "string" ? (h.message.length > 120 ? h.message.slice(0, 117) + "..." : h.message) : null) : null,
82
+ target_ref: h.target_ref || null,
83
+ ts: h.ts
84
+ }));
85
+ const refsKeys = Object.keys(project._refs || {}).slice(0, 200);
86
+ const queuedCount = project.commandQueue ? project.commandQueue.length : 0;
87
+ const tasksCount = project.tasks ? project.tasks.length : 0;
88
+ const status = project.status || "unknown";
89
+
90
+ const stateSummary = {
91
+ projectId: project.id,
92
+ status,
93
+ queuedCount,
94
+ tasksCount,
95
+ recentHistory: historySample,
96
+ refsSummary: refsKeys
97
+ };
98
+
99
+ const systemPrompt = `
100
+ You are a precise assistant that converts a high-level Roblox project description into an ordered JSON array of low-level editor tasks. Be spatially aware: reason about coordinates (x,y,z), sizes, rotations (euler degrees as {x,y,z}), orientations, and parent/child relationships. For every object you create, assign a stable "target_ref" string (short alphanumeric) that later tasks can reference.
101
+
102
+ Return ONLY valid JSON — a single array, no commentary.
103
 
104
+ Allowed actions (use exactly these): ${VALID_ACTIONS.join(", ")}.
105
+
106
+ Task object shape:
107
+ {
108
+ "action": "<action>",
109
+ "params": { ... }, // numbers, strings, booleans, objects with numeric x,y,z, etc.
110
+ "target_ref": "<optional-ref>" // for create_* actions include this for reference
111
  }
112
+
113
+ Important rules:
114
+ - Spatial parameters use explicit numeric fields: position: { x, y, z }, size: { x, y, z }, rotation: { x, y, z } (degrees).
115
+ - When referencing an earlier-created object, use params.target_ref exactly as the ref string returned earlier.
116
+ - Do not reference Roblox-specific paths (like Workspace.Model.Part).
117
+ - Use only the allowed actions listed above.
118
+ - If you need the plugin to evaluate the scene and return properties (for feedback), use actions "feedback_request" or "list_workspace".
119
+ - End the sequence with an action "finish" when the project is complete.
120
+
121
+ EXISTING PROJECT STATE (read-only; use to decide next tasks):
122
+ ${JSON.stringify(stateSummary, null, 2)}
123
+
124
+ Now produce tasks for this project:
125
+ ${JSON.stringify({ id: project.id, description: project.description })}
126
  `;
127
 
128
+ const resp = await ai.models.generateContent({
129
+ model: "gemini-2.5-flash",
130
+ contents: systemPrompt,
131
+ temperature: 0.0,
132
+ maxOutputTokens: 2000
133
+ });
134
 
135
+ const text = resp?.text ?? (resp?.data?.[0]?.text ?? "");
136
+ let arr;
137
+ try {
138
+ arr = JSON.parse(text);
139
+ } catch (err) {
140
+ // Try to extract substring
141
+ const start = text.indexOf("[");
142
+ const end = text.lastIndexOf("]");
143
+ if (start >= 0 && end > start) {
144
+ arr = JSON.parse(text.slice(start, end + 1));
145
+ } else {
146
+ throw new Error("Could not parse JSON from model output: " + text.slice(0, 1000));
147
  }
148
+ }
149
 
150
+ return validateAndPrepareTasks(arr);
151
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
+ /**
154
+ * Create project endpoint
155
+ * Body: { description: string, projectId?: string }
156
+ */
157
+ app.post("/projects", async (req, res) => {
158
+ try {
159
+ const description = req.body.description;
160
+ if (!description || typeof description !== "string") {
161
+ return res.status(400).json({ error: "description string required in body" });
162
  }
163
+ const projectId = req.body.projectId || simpleId("proj_");
164
+ if (projects.has(projectId)) return res.status(400).json({ error: "projectId already exists" });
165
 
166
+ const project = {
167
+ id: projectId,
168
+ description,
169
+ status: "queued",
170
+ tasks: [],
171
+ commandQueue: [],
172
+ pendingResults: new Map(),
173
+ running: false,
174
+ history: [], // { commandId, status, message, extra, target_ref, ts }
175
+ _refs: {} // mapping: target_ref -> { commandId, extra, ts }
176
+ };
177
+
178
+ projects.set(projectId, project);
179
 
180
+ // Start background orchestrator (non-blocking)
181
+ runProject(project).catch((err) => {
182
+ project.status = "error";
183
+ console.error("runProject error:", err);
184
+ });
185
+
186
+ return res.status(202).json({ projectId, status: "accepted" });
187
+ } catch (err) {
188
+ console.error("projects POST error", err);
189
+ return res.status(500).json({ error: err.message });
190
  }
191
  });
192
 
193
+ /**
194
+ * Tweak / prompt endpoint — now includes full project context so the model knows what has been done.
195
+ * Body: { prompt: string }
196
+ */
197
+ app.post("/projects/:projectId/prompt", async (req, res) => {
198
+ const project = projects.get(req.params.projectId);
199
+ if (!project) return res.status(404).json({ error: "project not found" });
200
+ const prompt = req.body.prompt;
201
+ if (!prompt || typeof prompt !== "string") return res.status(400).json({ error: "prompt required" });
202
+
203
+ try {
204
+ const contextSummary = {
205
+ id: project.id,
206
+ status: project.status,
207
+ queuedCount: project.commandQueue.length,
208
+ tasksCount: project.tasks.length,
209
+ recentHistory: (project.history || []).slice(-50).map(h => ({ commandId: h.commandId, status: h.status, target_ref: h.target_ref })),
210
+ refsKeys: Object.keys(project._refs || {}).slice(0, 200)
211
+ };
212
+
213
+ const pseudoProject = {
214
+ id: project.id,
215
+ description: `${project.description}\n\n---\nContextSummary: ${JSON.stringify(contextSummary)}\n\nUserTweak: ${prompt}`
216
+ };
217
+
218
+ const newTasks = await generateTasks(pseudoProject);
219
+
220
+ // Append tasks to the project's task list
221
+ project.tasks.push(...newTasks);
222
+
223
+ return res.json({ appended: newTasks.length, tasks: newTasks });
224
+ } catch (err) {
225
+ console.error("prompt endpoint error:", err);
226
+ return res.status(500).json({ error: err.message });
227
+ }
228
+ });
229
+
230
+ /**
231
+ * Direct model endpoint for manual calls (returns Gemini text)
232
+ * Body: { prompt: string }
233
+ */
234
+ app.post("/model", async (req, res) => {
235
+ try {
236
+ const prompt = req.body.prompt;
237
+ if (!prompt || typeof prompt !== "string") return res.status(400).json({ error: "prompt required" });
238
+
239
+ const resp = await ai.models.generateContent({
240
+ model: "gemini-2.5-flash",
241
+ contents: prompt,
242
+ temperature: 0.0,
243
+ maxOutputTokens: 1500
244
+ });
245
+
246
+ return res.json({ text: resp?.text ?? "" });
247
+ } catch (err) {
248
+ console.error("/model error", err);
249
+ return res.status(500).json({ error: err.message });
250
+ }
251
+ });
252
+
253
+ /**
254
+ * Plugin polling: GET next command or 204 if none
255
+ */
256
+ app.get("/projects/:projectId/next", (req, res) => {
257
+ const project = projects.get(req.params.projectId);
258
+ if (!project) return res.status(404).json({ error: "project not found" });
259
+
260
+ if (project.commandQueue.length > 0) {
261
+ const cmd = project.commandQueue.shift();
262
+ return res.json(cmd);
263
+ }
264
+ return res.status(204).end();
265
+ });
266
+
267
+ /**
268
+ * Plugin posts execution result. This records history and resolves waiters.
269
+ * Body: { commandId, status: "ok"|"error", message?: string, extra?: any, target_ref?: string }
270
+ */
271
+ app.post("/projects/:projectId/result", (req, res) => {
272
+ const project = projects.get(req.params.projectId);
273
+ if (!project) return res.status(404).json({ error: "project not found" });
274
+
275
+ const { commandId, status, message, extra, target_ref } = req.body;
276
+ if (!commandId || !status) return res.status(400).json({ error: "commandId and status required" });
277
+
278
+ // Record in history
279
+ const entry = {
280
+ commandId,
281
+ status,
282
+ message: message || null,
283
+ extra: extra || null,
284
+ target_ref: target_ref || null,
285
+ ts: Date.now()
286
+ };
287
+ project.history = project.history || [];
288
+ project.history.push(entry);
289
+
290
+ // Map target_ref if present and OK
291
+ if (target_ref && status === "ok") {
292
+ project._refs = project._refs || {};
293
+ project._refs[target_ref] = { commandId, extra, ts: Date.now() };
294
+ }
295
+
296
+ // Resolve pending waiter if present
297
+ const waiter = project.pendingResults.get(commandId);
298
+ if (waiter) {
299
+ project.pendingResults.delete(commandId);
300
+ waiter.resolve({ status, message, extra, target_ref });
301
+ return res.json({ ok: true });
302
+ }
303
+
304
+ // Otherwise accept and return
305
+ return res.json({ ok: true, note: "recorded result (no pending waiter)" });
306
+ });
307
+
308
+ /**
309
+ * Status endpoint
310
+ */
311
+ app.get("/status/:projectId", (req, res) => {
312
+ const project = projects.get(req.params.projectId);
313
+ if (!project) return res.status(404).json({ error: "project not found" });
314
+ return res.json({
315
+ id: project.id,
316
+ status: project.status,
317
+ queuedCommands: project.commandQueue.length,
318
+ totalTasks: project.tasks.length,
319
+ refs: project._refs || {},
320
+ historyLength: (project.history || []).length
321
+ });
322
+ });
323
+
324
+ /**
325
+ * Simple list of projects (debug)
326
+ */
327
+ app.get("/projects", (req, res) => {
328
+ const list = Array.from(projects.values()).map((p) => ({
329
+ id: p.id,
330
+ description: p.description,
331
+ status: p.status,
332
+ queuedCommands: p.commandQueue.length,
333
+ tasks: p.tasks.length
334
+ }));
335
+ return res.json(list);
336
+ });
337
+
338
+ /**
339
+ * Orchestrator per project
340
+ * - If no tasks: generate from description
341
+ * - Iterate tasks, push to commandQueue, wait for plugin result, then continue
342
+ */
343
+ async function runProject(project) {
344
+ if (project.running) return;
345
+ project.running = true;
346
+ project.status = "running";
347
+
348
+ try {
349
+ // If no tasks, generate initial plan
350
+ if (!project.tasks || project.tasks.length === 0) {
351
+ project.tasks = await generateTasks(project);
352
  }
353
 
354
+ // Iterate tasks
355
+ for (const task of project.tasks) {
356
+ // push to queue
357
+ project.commandQueue.push(task);
358
+
359
+ // Wait for plugin result for this task
360
+ const result = await new Promise((resolve, reject) => {
361
+ const timeoutMs = 3 * 60 * 1000; // 3 minutes
362
+ let timedOut = false;
363
+ const timeout = setTimeout(() => {
364
+ timedOut = true;
365
+ project.pendingResults.delete(task.id);
366
+ reject(new Error("Command timed out: " + task.id));
367
+ }, timeoutMs);
368
+
369
+ project.pendingResults.set(task.id, {
370
+ resolve: (payload) => {
371
+ if (timedOut) return;
372
+ clearTimeout(timeout);
373
+ resolve(payload);
374
+ },
375
+ reject: (err) => {
376
+ if (timedOut) return;
377
+ clearTimeout(timeout);
378
+ reject(err);
379
+ }
380
  });
381
+ });
382
+
383
+ // If failure, mark and stop
384
+ if (result.status !== "ok") {
385
+ project.status = "failed";
386
+ throw new Error(`Command ${task.id} failed: ${result.message ?? "no message"}`);
387
  }
 
388
 
389
+ // If task had target_ref & plugin returned target_ref, map it
390
+ if (task.target_ref && result.target_ref) {
391
+ project._refs = project._refs || {};
392
+ project._refs[task.target_ref] = result.target_ref;
393
+ }
 
394
  }
395
+
396
+ project.status = "complete";
397
+ } catch (err) {
398
+ console.error("Project run error for", project.id, err);
399
+ if (project.status !== "failed") project.status = "error";
400
+ } finally {
401
+ project.running = false;
402
  }
403
+ }
404
+
405
+ app.listen(PORT, () => {
406
+ console.log(`BloxBuddy backend listening on port ${PORT}`);
407
+ });