Spaces:
Sleeping
Sleeping
| // server.js | |
| // ESM Node (package.json should contain "type": "module") | |
| // Install: npm install express body-parser cors @google/genai | |
| // Run: GEMINI_API_KEY=your_key node server.js | |
| import express from "express"; | |
| import bodyParser from "body-parser"; | |
| import cors from "cors"; | |
| import { GoogleGenAI } from "@google/genai"; | |
| const PORT = process.env.PORT ? Number(process.env.PORT) : 7860; | |
| if (!process.env.GEMINI_API_KEY) { | |
| console.error("GEMINI_API_KEY environment variable is required."); | |
| process.exit(1); | |
| } | |
| const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); | |
| const app = express(); | |
| app.use(cors()); | |
| app.use(bodyParser.json({ limit: "2mb" })); | |
| const projects = new Map(); | |
| const simpleId = (prefix = "") => prefix + Math.random().toString(36).slice(2, 9); | |
| /** | |
| * STRICT VOCABULARY - Matched to Plugin | |
| */ | |
| const VALID_ACTIONS = [ | |
| "create_part", | |
| "create_model", | |
| "create_meshpart", | |
| "create_script", | |
| "attach_script", | |
| "edit_script", | |
| "set_property", | |
| "move_to", | |
| "rotate_to", | |
| "set_parent", | |
| "delete", | |
| "weld", | |
| "list_workspace", | |
| "finish" | |
| ]; | |
| const ACTION_SYNONYMS = { | |
| "create_complex_mesh": "create_meshpart", | |
| "load_mesh": "create_meshpart", | |
| "move": "move_to", | |
| "position": "move_to", | |
| "rotate": "rotate_to", | |
| "set_rotation": "rotate_to", | |
| "add_script": "create_script", | |
| "insert_script": "create_script", | |
| "modify_script": "edit_script", | |
| "append_script": "attach_script", | |
| "group": "create_model", | |
| "parent": "set_parent", | |
| "set_parent_to": "set_parent", | |
| "join": "weld", | |
| "union": "create_model", | |
| "create_union": "create_model", | |
| "create_rig": "create_model", | |
| "add_bone": "create_part" | |
| }; | |
| function validateAndPrepareTasks(arr) { | |
| if (!Array.isArray(arr)) throw new Error("Model output must be a JSON array of tasks."); | |
| return arr.map((t) => { | |
| if (!t || typeof t !== "object") throw new Error("Each task must be an object."); | |
| if (!t.action) throw new Error("Each task must have an action field."); | |
| let actionRaw = String(t.action).trim(); | |
| const actionLower = actionRaw.toLowerCase(); | |
| let canonicalAction = null; | |
| if (VALID_ACTIONS.includes(actionRaw)) canonicalAction = actionRaw; | |
| if (!canonicalAction) { | |
| canonicalAction = VALID_ACTIONS.find(a => a.toLowerCase() === actionLower) || null; | |
| } | |
| if (!canonicalAction && ACTION_SYNONYMS[actionLower]) { | |
| canonicalAction = ACTION_SYNONYMS[actionLower]; | |
| } | |
| if (!canonicalAction) { | |
| throw new Error(`Invalid action '${actionRaw}'. Supported: ${VALID_ACTIONS.join(", ")}`); | |
| } | |
| const id = t.id || simpleId("cmd_"); | |
| const params = t.params && typeof t.params === "object" ? t.params : {}; | |
| return { id, action: canonicalAction, originalAction: actionRaw, params, target_ref: t.target_ref || null }; | |
| }); | |
| } | |
| async function generateTasks(project) { | |
| const historySample = (project.history || []).slice(-20).map(h => ({ | |
| commandId: h.commandId, | |
| status: h.status, | |
| message: h.message ? (typeof h.message === "string" ? (h.message.length > 120 ? h.message.slice(0, 117) + "..." : h.message) : null) : null, | |
| originalAction: h.originalAction || null, | |
| target_ref: h.target_ref || null, | |
| })); | |
| const refsKeys = Object.keys(project._refs || {}).slice(0, 200); | |
| const stateSummary = { | |
| projectId: project.id, | |
| refsSummary: refsKeys, | |
| recentHistory: historySample | |
| }; | |
| const systemPrompt = ` | |
| You are a precise Roblox Studio Assistant. | |
| Allowed actions: ${VALID_ACTIONS.join(", ")}. | |
| RULES: | |
| 1. **References:** When creating an object, ALWAYS assign a unique "target_ref" (e.g., "ref_wall_1"). | |
| 2. **Chaining:** When moving/modifying later, use "target_ref": "ref_wall_1". | |
| 3. **Hierarchy:** To put a Part inside a Model: create_model (ref_grp) -> create_part (ref_part) -> set_parent (target_ref: ref_part, params: { parent_ref: ref_grp }). | |
| 4. **Math:** Use numbers. Position {x,y,z}. Rotation {x,y,z} (degrees). | |
| 5. **Output:** Return ONLY a JSON Array. | |
| EXISTING STATE: | |
| ${JSON.stringify(stateSummary, null, 2)} | |
| User Request: | |
| ${JSON.stringify({ description: project.description })} | |
| `; | |
| // Using 1.5-pro as the closest valid "Pro" model. | |
| // If you strictly need "gemini-2.5-pro", allow the string, but note it might 404 if not enabled on your account. | |
| const resp = await ai.models.generateContent({ | |
| model: "gemini-2.5-pro", | |
| contents: systemPrompt, | |
| generationConfig: { responseMimeType: "application/json" } | |
| }); | |
| // === FIX: Robust Response Text Extraction === | |
| let text = ""; | |
| if (resp && typeof resp.text === 'string') { | |
| // SDK returned text property directly | |
| text = resp.text; | |
| } else if (resp && typeof resp.text === 'function') { | |
| // SDK returned text method | |
| text = resp.text(); | |
| } else if (resp?.response?.text) { | |
| // Nested response object | |
| text = typeof resp.response.text === 'function' ? resp.response.text() : resp.response.text; | |
| } else { | |
| // Fallback for raw data structure | |
| text = resp?.data?.candidates?.[0]?.content?.parts?.[0]?.text || ""; | |
| } | |
| // ============================================ | |
| let arr; | |
| try { | |
| // Sanitize markdown if still present | |
| const cleanText = text.replace(/```json/g, "").replace(/```/g, "").trim(); | |
| arr = JSON.parse(cleanText); | |
| } catch (err) { | |
| // Fallback extraction | |
| const start = text.indexOf("["); | |
| const end = text.lastIndexOf("]"); | |
| if (start >= 0 && end > start) { | |
| arr = JSON.parse(text.slice(start, end + 1)); | |
| } else { | |
| console.error("AI Output was:", text); | |
| throw new Error("Could not parse JSON from model output."); | |
| } | |
| } | |
| return validateAndPrepareTasks(arr); | |
| } | |
| // --- ENDPOINTS --- | |
| app.post("/projects", async (req, res) => { | |
| try { | |
| const projectId = req.body.projectId || simpleId("proj_"); | |
| const project = { | |
| id: projectId, | |
| description: req.body.description || "New Project", | |
| status: "queued", | |
| tasks: [], | |
| commandQueue: [], | |
| pendingResults: new Map(), | |
| running: false, | |
| history: [], | |
| _refs: {} | |
| }; | |
| projects.set(projectId, project); | |
| runProject(project).catch((err) => { | |
| project.status = "error"; | |
| console.error("runProject error:", err); | |
| }); | |
| return res.status(202).json({ projectId, status: "accepted" }); | |
| } catch (err) { | |
| return res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.post("/projects/:projectId/prompt", async (req, res) => { | |
| const project = projects.get(req.params.projectId); | |
| if (!project) return res.status(404).json({ error: "project not found" }); | |
| const prompt = req.body.prompt; | |
| project.description += `\nUser Update: ${prompt}`; | |
| try { | |
| const newTasks = await generateTasks({ ...project, description: prompt }); | |
| project.tasks.push(...newTasks); | |
| project.commandQueue.push(...newTasks); | |
| return res.json({ appended: newTasks.length, tasks: newTasks }); | |
| } catch (err) { | |
| return res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.get("/projects/:projectId/next", (req, res) => { | |
| const project = projects.get(req.params.projectId); | |
| if (!project) return res.status(404).json({ error: "project not found" }); | |
| if (project.commandQueue.length > 0) { | |
| return res.json(project.commandQueue.shift()); | |
| } | |
| return res.status(204).end(); | |
| }); | |
| app.post("/projects/:projectId/result", (req, res) => { | |
| const project = projects.get(req.params.projectId); | |
| if (!project) return res.status(404).json({ error: "project not found" }); | |
| const { commandId, status, message, extra, target_ref } = req.body; | |
| // History recording | |
| const entry = { | |
| commandId, | |
| status, | |
| message: message || null, | |
| extra: extra || null, | |
| target_ref: target_ref || null, | |
| ts: Date.now() | |
| }; | |
| project.history = project.history || []; | |
| project.history.push(entry); | |
| if (target_ref && status === "ok") { | |
| project._refs = project._refs || {}; | |
| project._refs[target_ref] = { commandId, ts: Date.now() }; | |
| } | |
| const waiter = project.pendingResults.get(commandId); | |
| if (waiter) { | |
| project.pendingResults.delete(commandId); | |
| waiter.resolve({ status, message, target_ref }); | |
| return res.json({ ok: true }); | |
| } | |
| return res.json({ ok: true }); | |
| }); | |
| app.get("/status/:projectId", (req, res) => { | |
| const project = projects.get(req.params.projectId); | |
| if (!project) return res.status(404).json({ error: "project not found" }); | |
| return res.json({ | |
| id: project.id, | |
| status: project.status, | |
| queued: project.commandQueue.length, | |
| refs: project._refs || {} | |
| }); | |
| }); | |
| async function runProject(project) { | |
| if (project.running) return; | |
| project.running = true; | |
| project.status = "running"; | |
| try { | |
| if (!project.tasks || project.tasks.length === 0) { | |
| project.tasks = await generateTasks(project); | |
| } | |
| project.commandQueue.push(...project.tasks); | |
| // Blocking wait logic | |
| for (const task of project.tasks) { | |
| const result = await new Promise((resolve, reject) => { | |
| const timeoutMs = 3 * 60 * 1000; | |
| let timedOut = false; | |
| const timeout = setTimeout(() => { | |
| timedOut = true; | |
| project.pendingResults.delete(task.id); | |
| reject(new Error("Command timed out: " + task.id)); | |
| }, timeoutMs); | |
| project.pendingResults.set(task.id, { | |
| resolve: (payload) => { | |
| if (timedOut) return; | |
| clearTimeout(timeout); | |
| resolve(payload); | |
| }, | |
| reject: (err) => { | |
| if (timedOut) return; | |
| clearTimeout(timeout); | |
| reject(err); | |
| } | |
| }); | |
| }); | |
| if (result.status !== "ok") { | |
| project.status = "failed"; | |
| throw new Error(`Command ${task.id} failed: ${result.message}`); | |
| } | |
| } | |
| project.status = "complete"; | |
| } catch (err) { | |
| console.error("Project run error:", err); | |
| if (project.status !== "failed") project.status = "error"; | |
| } finally { | |
| project.running = false; | |
| } | |
| } | |
| app.listen(PORT, () => { | |
| console.log(`BloxBuddy backend listening on port ${PORT}`); | |
| }); |