Blox_center / app.js
Pepguy's picture
Update app.js
45ae1d9 verified
// 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}`);
});