import express from 'express'; import bodyParser from 'body-parser'; import cors from 'cors'; import { StateManager, initDB } from './stateManager.js'; import { AIEngine } from './aiEngine.js'; import fs from 'fs'; import admin from 'firebase-admin'; import crypto from "crypto"; // --- FIREBASE SETUP --- let db = null; let firestore = null; let storage = null; try { if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) { const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_JSON); // Specific bucket as requested const bucketName = `shago-web.firebasestorage.app`; if (admin.apps.length === 0) { admin.initializeApp({ credential: admin.credential.cert(serviceAccount), databaseURL: "https://shago-web-default-rtdb.firebaseio.com", storageBucket: bucketName }); } db = admin.database(); firestore = admin.firestore(); storage = admin.storage(); initDB(db); console.log("🔥 Firebase Connected (RTDB, Firestore, Storage) & StateManager Linked"); } else { console.warn("⚠️ Memory-Only mode."); } } catch (e) { console.error("Firebase Init Error:", e); } const app = express(); const PORT = process.env.PORT || 7860; // --- /* app.use((req, res, next) => { console.log('[REQ]', req.method, req.originalUrl); console.log('[HEADERS]', req.headers); console.log('[BODY]', req.body ?? '(unparseable or empty)'); next(); }); */ // --- // Load prompts safely let sysPrompts = {}; try { sysPrompts = JSON.parse(fs.readFileSync('./prompts.json', 'utf8')); } catch (e) { console.error("Failed to load prompts.json:", e); } app.use(cors()); app.use(bodyParser.json({ limit: '50mb' })); // --- CREDIT CONFIGURATION --- // Minimums required to start a request const MIN_BASIC_REQUIRED = 50; const MIN_DIAMOND_REQUIRED = 50; // Fixed cost for images (Basic credits) const IMAGE_COST_BASIC = 1000; /** * Checks if user has enough credits of a specific type. * @param {string} userId * @param {'basic'|'diamond'} type */ async function checkMinimumCredits(userId, type = 'basic') { if (!db) return; // Path: users/{uid}/credits/basic OR users/{uid}/credits/diamond const snap = await db.ref(`users/${userId}/credits/${type}`).once('value'); const credits = snap.val() || 0; const required = type === 'diamond' ? MIN_DIAMOND_REQUIRED : MIN_BASIC_REQUIRED; if (credits < required) { res.status(500).json({ insufficient: true, error:`Insufficient ${type} credits. You have ${credits}, need minimum ${required} to proceed.` }); // throw new Error(`Insufficient ${type} credits. You have ${credits}, need minimum ${required} to proceed.`); } } /** * Deducts exact tokens from the user's specific wallet. * @param {string} userId * @param {number} amount * @param {'basic'|'diamond'} type */ async function deductUserCredits(userId, amount, type = 'basic') { if (!db || !amount || amount <= 0) return; try { const ref = db.ref(`users/${userId}/credits/${type}`); await ref.transaction((current_credits) => { const current = current_credits || 0; return Math.max(0, current - amount); }); console.log(`[Credits] Deducted ${amount} ${type} credits from User ${userId}`); } catch (err) { console.error(`[Credits] Failed to deduct ${amount} ${type} from ${userId}:`, err); } } // --- MIDDLEWARE --- const validateRequest = (req, res, next) => { if (req.path.includes('/admin/cleanup')) return next(); const { userId, projectId } = req.body; // Ensure userId exists for credit checks if (!userId && (req.path.includes('/onboarding') || req.path.includes('/new') || req.path.includes('/project'))) { return res.status(400).json({ error: "Missing userId" }); } next(); }; function extractWorkerPrompt(text) { const match = text.match(/WORKER_PROMPT:\s*(.*)/s); return match ? match[1].trim() : null; } function formatContext({ hierarchyContext, scriptContext, logContext }) { let out = ""; if (scriptContext) out += `\n[TARGET SCRIPT]: ${scriptContext.targetName}\n[SOURCE PREVIEW]: ${scriptContext.scriptSource?.substring(0, 1000)}...`; if (logContext) out += `\n[LAST LOGS]: ${logContext.logs}`; return out; } function extractPMQuestion(text) { const match = text.match(/\[ASK_PM:\s*(.*?)\]/s); return match ? match[1].trim() : null; } function extractImagePrompt(text) { const match = text.match(/\[GENERATE_IMAGE:\s*(.*?)\]/s); return match ? match[1].trim() : null; } // --- ADMIN ENDPOINTS --- app.get('/admin/cleanup', async (req, res) => { try { const removed = StateManager.cleanupMemory(); res.json({ success: true, removedCount: removed }); } catch (err) { res.status(500).json({ error: "Cleanup failed" }); } }); // --- ONBOARDING ENDPOINTS --- app.post('/onboarding/analyze', validateRequest, async (req, res) => { const { description, userId } = req.body; if (!description) return res.status(400).json({ error: "Description required" }); try { // Analyst uses BASIC models (Flash) await checkMinimumCredits(userId, 'basic'); console.log(`[Onboarding] Analyzing idea...`); const result = await AIEngine.generateEntryQuestions(description); const usage = result.usage?.totalTokenCount || 0; // Bill Basic if (usage > 0) await deductUserCredits(userId, usage, 'basic'); if (result.status === "REJECTED") { return res.json({ rejected: true, reason: result.reason || "Idea violates TOS." }); } res.json({ questions: result.questions }); } catch (err) { console.error(err); if (err.message.includes("Insufficient")) { return res.status(402).json({ error: err.message }); } res.status(500).json({ error: "Analysis failed" }); } }); app.post('/onboarding/create', validateRequest, async (req, res) => { const { userId, description, answers } = req.body; let basicTokens = 0; // Create flow uses Grader (Basic) + Image (Basic) try { // Pre-flight check await checkMinimumCredits(userId, 'basic'); const randomHex = (n = 6) => crypto.randomBytes(Math.ceil(n/2)).toString("hex").slice(0, n); const projectId = `proj_${Date.now()}_${randomHex(7)}`; console.log(`[Onboarding] Grading Project ${projectId}...`); // 1. Grading (Basic) const gradingResult = await AIEngine.gradeProject(description, answers); basicTokens += (gradingResult.usage?.totalTokenCount || 0); const isFailure = gradingResult.feasibility < 30 || gradingResult.rating === 'F'; let thumbnailBase64 = null; if (!isFailure) { console.log(`[Onboarding] ✅ Passed. Generating Thumbnail...`); const imagePrompt = `Game Title: ${gradingResult.title}. Core Concept: ${description}`; // 2. Image Generation (Basic) const imgResult = await AIEngine.generateImage(imagePrompt); if (imgResult) { thumbnailBase64 = imgResult.image; // Add token usage for text + fixed cost basicTokens += (imgResult.usage || 0); if (imgResult.image) basicTokens += IMAGE_COST_BASIC; } } // Upload Logic let thumbnailUrl = null; if (thumbnailBase64 && storage) { try { const base64Data = thumbnailBase64.replace(/^data:image\/\w+;base64,/, ""); const buffer = Buffer.from(base64Data, 'base64'); const bucket = storage.bucket(); const file = bucket.file(`${projectId}/thumbnail.png`); await file.save(buffer, { metadata: { contentType: 'image/png' } }); await file.makePublic(); thumbnailUrl = `https://storage.googleapis.com/${bucket.name}/${projectId}/thumbnail.png`; } catch (uploadErr) { console.error("Storage Upload Failed:", uploadErr); } } const timestamp = Date.now(); const status = isFailure ? "rejected" : "Idle"; // Save Data Logic if (!isFailure) { const memoryObject = { id: projectId, userId, title: gradingResult.title || "Untitled", description, answers, stats: gradingResult, thumbnail: thumbnailUrl, createdAt: timestamp, status, workerHistory: [], pmHistory: [], commandQueue: [], failureCount: 0 }; await StateManager.updateProject(projectId, memoryObject); } if (firestore && !isFailure) { await firestore.collection('projects').doc(projectId).set({ id: projectId, userId: userId, assets: thumbnailUrl ? [thumbnailUrl] : [], createdAt: admin.firestore.FieldValue.serverTimestamp() }); } if (db && !isFailure) { const updates = {}; updates[`projects/${projectId}/info`] = { id: projectId, userId, title: gradingResult.title || "Untitled", description, answers, stats: gradingResult, createdAt: timestamp, status }; if (thumbnailUrl) updates[`projects/${projectId}/thumbnail`] = { url: thumbnailUrl }; updates[`projects/${projectId}/state`] = { workerHistory: [], pmHistory: [], commandQueue: [], failureCount: 0 }; await db.ref().update(updates); } // Deduct Basic Credits if (basicTokens > 0) await deductUserCredits(userId, basicTokens, 'basic'); console.log("sending"); res.json({ status: 200, success: !isFailure, projectId, stats: gradingResult, title: gradingResult.title || "Untitled", thumbnail: thumbnailBase64 }); } catch (err) { console.error("Create Error:", err); if (err.message.includes("Insufficient")) { return res.status(402).json({ error: err.message }); } res.status(500).json({ error: "Creation failed" }); } }); // --- CORE ENDPOINTS --- async function runBackgroundInitialization(projectId, userId, description) { console.log(`[Background] Starting initialization for ${projectId}`); // Separate Counters let diamondUsage = 0; // For PM let basicUsage = 0; // For Worker try { const pmHistory = []; // 1. Generate GDD (PM -> Diamond) const gddPrompt = `Create a comprehensive GDD for: ${description}`; const gddResult = await AIEngine.callPM(pmHistory, gddPrompt); diamondUsage += (gddResult.usage?.totalTokenCount || 0); const gddText = gddResult.text; pmHistory.push({ role: 'user', parts: [{ text: gddPrompt }] }); pmHistory.push({ role: 'model', parts: [{ text: gddText }] }); // 2. Generate First Task (PM -> Diamond) const taskPrompt = "Based on the GDD, generate the first technical milestone.\nOutput format:\nTASK_NAME: \nWORKER_PROMPT: "; const taskResult = await AIEngine.callPM(pmHistory, taskPrompt); diamondUsage += (taskResult.usage?.totalTokenCount || 0); const taskText = taskResult.text; pmHistory.push({ role: 'user', parts: [{ text: taskPrompt }] }); pmHistory.push({ role: 'model', parts: [{ text: taskText }] }); // 3. Initialize Worker (Worker -> Basic) const initialWorkerInstruction = extractWorkerPrompt(taskText) || `Initialize structure for: ${description}`; const workerHistory = []; const initialWorkerPrompt = `CONTEXT: New Project. \nINSTRUCTION: ${initialWorkerInstruction}`; const workerResult = await AIEngine.callWorker(workerHistory, initialWorkerPrompt, []); basicUsage += (workerResult.usage?.totalTokenCount || 0); const workerText = workerResult.text; workerHistory.push({ role: 'user', parts: [{ text: initialWorkerPrompt }] }); workerHistory.push({ role: 'model', parts: [{ text: workerText }] }); // 4. Update Memory await StateManager.updateProject(projectId, { userId, pmHistory, workerHistory, gdd: gddText, failureCount: 0 }); // 5. Queue commands (Handle assets + basic costs) // We pass userId to deduct image costs (Basic) immediately inside this function await processAndQueueResponse(projectId, workerText, userId); // 6. Update Status if(db) await db.ref(`projects/${projectId}/info`).update({ status: "IDLE", lastUpdated: Date.now() }); // 7. Deduct Accumulated Credits if (diamondUsage > 0) await deductUserCredits(userId, diamondUsage, 'diamond'); if (basicUsage > 0) await deductUserCredits(userId, basicUsage, 'basic'); console.log(`[Background] Init complete. Diamond: ${diamondUsage}, Basic: ${basicUsage}`); } catch (err) { console.error(`[Background] Init Error for ${projectId}:`, err); if(db) await db.ref(`projects/${projectId}/info/status`).set("error"); } } app.post('/new/project', validateRequest, async (req, res) => { const { userId, projectId, description } = req.body; try { // Init requires BOTH types to be safe await checkMinimumCredits(userId, 'diamond'); await checkMinimumCredits(userId, 'basic'); if(db) db.ref(`projects/${projectId}/info/status`).set("initializing"); res.json({ success: true, message: "Project initialization started in background." }); runBackgroundInitialization(projectId, userId, description); } catch (err) { if (err.message.includes("Insufficient")) { return res.status(402).json({ error: err.message }); } res.status(500).json({ error: "Failed to start project" }); } }); app.post('/project/feedback', async (req, res) => { const { userId, projectId, prompt, hierarchyContext, scriptContext, logContext, taskComplete, images } = req.body; let diamondUsage = 0; let basicUsage = 0; try { const project = await StateManager.getProject(projectId); if (!project) return res.status(404).json({ error: "Project not found." }); if (project.userId !== userId) return res.status(403).json({ error: "Unauthorized" }); // Basic Check (most interaction is worker) await checkMinimumCredits(userId, 'basic'); if(db) await db.ref(`projects/${projectId}/info/status`).set("working"); if (taskComplete) { console.log(`[${projectId}] ✅ TASK COMPLETE.`); const summary = `Worker completed the previous task. Logs: ${logContext?.logs || "Clean"}. \nGenerate the NEXT task using 'WORKER_PROMPT:' format.`; // PM Call -> Diamond const pmResult = await AIEngine.callPM(project.pmHistory, summary); diamondUsage += (pmResult.usage?.totalTokenCount || 0); const pmText = pmResult.text; project.pmHistory.push({ role: 'user', parts: [{ text: summary }] }); project.pmHistory.push({ role: 'model', parts: [{ text: pmText }] }); const nextInstruction = extractWorkerPrompt(pmText); if (!nextInstruction) { await StateManager.updateProject(projectId, { pmHistory: project.pmHistory, status: "IDLE" }); if(db) await db.ref(`projects/${projectId}/info`).update({ status: "IDLE", lastUpdated: Date.now() }); // Deduct what we used if (diamondUsage > 0) await deductUserCredits(userId, diamondUsage, 'diamond'); return res.json({ success: true, message: "No further tasks. Project Idle." }); } const newWorkerHistory = []; const newPrompt = `New Objective: ${nextInstruction}`; // Worker Call -> Basic const workerResult = await AIEngine.callWorker(newWorkerHistory, newPrompt, []); basicUsage += (workerResult.usage?.totalTokenCount || 0); const workerText = workerResult.text; newWorkerHistory.push({ role: 'user', parts: [{ text: newPrompt }] }); newWorkerHistory.push({ role: 'model', parts: [{ text: workerText }] }); await StateManager.updateProject(projectId, { pmHistory: project.pmHistory, workerHistory: newWorkerHistory, failureCount: 0 }); StateManager.queueCommand(projectId, { type: "EXECUTE", payload: "warn('Starting Next Task...')" }); await processAndQueueResponse(projectId, workerText, userId); if(db) await db.ref(`projects/${projectId}/info`).update({ status: "working", lastUpdated: Date.now() }); // Final Bill if (diamondUsage > 0) await deductUserCredits(userId, diamondUsage, 'diamond'); if (basicUsage > 0) await deductUserCredits(userId, basicUsage, 'basic'); return res.json({ success: true, message: "Next Task Assigned" }); } // Logic for Failure Handling (PM Guidance) if (project.failureCount > 3) { const pmPrompt = (sysPrompts.pm_guidance_prompt || "Analyze logs: {{LOGS}}").replace('{{LOGS}}', logContext?.logs); // PM -> Diamond const pmResult = await AIEngine.callPM(project.pmHistory, pmPrompt); diamondUsage += (pmResult.usage?.totalTokenCount || 0); const pmVerdict = pmResult.text; if (pmVerdict.includes("[TERMINATE]")) { const fixInstruction = pmVerdict.replace("[TERMINATE]", "").trim(); const resetHistory = []; const resetPrompt = `[SYSTEM]: Previous worker terminated. \nNew Objective: ${fixInstruction}`; // Worker -> Basic const workerResult = await AIEngine.callWorker(resetHistory, resetPrompt, []); basicUsage += (workerResult.usage?.totalTokenCount || 0); resetHistory.push({ role: 'user', parts: [{ text: resetPrompt }] }); resetHistory.push({ role: 'model', parts: [{ text: workerResult.text }] }); await StateManager.updateProject(projectId, { workerHistory: resetHistory, failureCount: 0 }); StateManager.queueCommand(projectId, { type: "EXECUTE", payload: "print('SYSTEM: Worker Reset')" }); await processAndQueueResponse(projectId, workerResult.text, userId); if (diamondUsage > 0) await deductUserCredits(userId, diamondUsage, 'diamond'); if (basicUsage > 0) await deductUserCredits(userId, basicUsage, 'basic'); return res.json({ success: true, message: "Worker Terminated." }); } else { const injection = `[PM GUIDANCE]: ${pmVerdict} \n\nApply this fix now.`; // Worker -> Basic const workerResult = await AIEngine.callWorker(project.workerHistory, injection, []); basicUsage += (workerResult.usage?.totalTokenCount || 0); project.workerHistory.push({ role: 'user', parts: [{ text: injection }] }); project.workerHistory.push({ role: 'model', parts: [{ text: workerResult.text }] }); await StateManager.updateProject(projectId, { workerHistory: project.workerHistory, failureCount: 1 }); await processAndQueueResponse(projectId, workerResult.text, userId); if (diamondUsage > 0) await deductUserCredits(userId, diamondUsage, 'diamond'); if (basicUsage > 0) await deductUserCredits(userId, basicUsage, 'basic'); return res.json({ success: true, message: "PM Guidance Applied." }); } } // Standard Interaction (Worker Only = Basic) const fullInput = `USER: ${prompt || "Automatic Feedback"}` + formatContext({ hierarchyContext, scriptContext, logContext }); let workerResult = await AIEngine.callWorker(project.workerHistory, fullInput, images || []); basicUsage += (workerResult.usage?.totalTokenCount || 0); let responseText = workerResult.text; // PM Consultation Check const pmQuestion = extractPMQuestion(responseText); if (pmQuestion) { // PM -> Diamond const pmConsultPrompt = `[WORKER CONSULTATION]: The Worker asks: "${pmQuestion}"\nProvide a technical answer to unblock them.`; const pmResult = await AIEngine.callPM(project.pmHistory, pmConsultPrompt); diamondUsage += (pmResult.usage?.totalTokenCount || 0); const pmAnswer = pmResult.text; project.pmHistory.push({ role: 'user', parts: [{ text: pmConsultPrompt }] }); project.pmHistory.push({ role: 'model', parts: [{ text: pmAnswer }] }); const injectionMsg = `[PM RESPONSE]: ${pmAnswer}`; project.workerHistory.push({ role: 'user', parts: [{ text: fullInput }] }); project.workerHistory.push({ role: 'model', parts: [{ text: responseText }] }); // Re-call Worker -> Basic workerResult = await AIEngine.callWorker(project.workerHistory, injectionMsg, []); basicUsage += (workerResult.usage?.totalTokenCount || 0); responseText = workerResult.text; project.workerHistory.push({ role: 'user', parts: [{ text: injectionMsg }] }); project.workerHistory.push({ role: 'model', parts: [{ text: responseText }] }); } else { project.workerHistory.push({ role: 'user', parts: [{ text: fullInput }] }); project.workerHistory.push({ role: 'model', parts: [{ text: responseText }] }); } await StateManager.updateProject(projectId, { workerHistory: project.workerHistory, pmHistory: project.pmHistory, failureCount: project.failureCount }); await processAndQueueResponse(projectId, responseText, userId); if(db) await db.ref(`projects/${projectId}/info`).update({ status: "idle", lastUpdated: Date.now() }); // Final Billing if (diamondUsage > 0) await deductUserCredits(userId, diamondUsage, 'diamond'); if (basicUsage > 0) await deductUserCredits(userId, basicUsage, 'basic'); res.json({ success: true }); } catch (err) { console.error("AI Error:", err); if (err.message.includes("Insufficient")) { return res.status(402).json({ error: err.message }); } res.status(500).json({ error: "AI Failed" }); } }); app.post('/project/ping', async (req, res) => { const { projectId, userId } = req.body; if (!projectId || !userId) return res.status(400).json({ error: "Missing ID fields" }); const project = await StateManager.getProject(projectId); if (!project) return res.status(404).json({ action: "IDLE", error: "Project not found" }); if (project.userId !== userId) return res.status(403).json({ error: "Unauthorized" }); const command = await StateManager.popCommand(projectId); if (command) { if (command.payload === "CLEAR_CONSOLE") { res.json({ action: "CLEAR_LOGS" }); } else { res.json({ action: command.type, target: command.payload, code: command.type === 'EXECUTE' ? command.payload : null }); } } else { res.json({ action: "IDLE" }); } }); app.post('/human/override', validateRequest, async (req, res) => { const { projectId, instruction, pruneHistory, userId } = req.body; let basicUsage = 0; try { await checkMinimumCredits(userId, 'basic'); const project = await StateManager.getProject(projectId); const overrideMsg = `[SYSTEM OVERRIDE]: ${instruction}`; if (pruneHistory && project.workerHistory.length >= 2) { project.workerHistory.pop(); project.workerHistory.pop(); } // Worker -> Basic const workerResult = await AIEngine.callWorker(project.workerHistory, overrideMsg, []); basicUsage += (workerResult.usage?.totalTokenCount || 0); project.workerHistory.push({ role: 'user', parts: [{ text: overrideMsg }] }); project.workerHistory.push({ role: 'model', parts: [{ text: workerResult.text }] }); await StateManager.updateProject(projectId, { workerHistory: project.workerHistory }); await processAndQueueResponse(projectId, workerResult.text, userId); if (basicUsage > 0) await deductUserCredits(userId, basicUsage, 'basic'); res.json({ success: true }); } catch (err) { if (err.message.includes("Insufficient")) { return res.status(402).json({ error: err.message }); } res.status(500).json({ error: "Override Failed" }); } }); // Helper to handle Asset Parsing & Billing (Basic Credits) async function processAndQueueResponse(projectId, rawResponse, userId) { const imgPrompt = extractImagePrompt(rawResponse); if (imgPrompt) { console.log(`[${projectId}] 🎨 Generating Asset: ${imgPrompt}`); // 1. Generate Image (returns { image, usage } or null) const imgResult = await AIEngine.generateImage(imgPrompt); if (imgResult && imgResult.image) { // 2. Bill for image tokens immediately (Basic) const imgTokens = imgResult.usage?.totalTokenCount || 0; const totalCost = imgTokens; // imgTokens + IMAGE_COST_BASIC; if (userId && totalCost > 0) { await deductUserCredits(userId, totalCost, 'basic'); } // 3. Queue creation command await StateManager.queueCommand(projectId, { type: "CREATE_ASSET", payload: imgResult.image }); } } // Queue the raw text response for the frontend await StateManager.queueCommand(projectId, rawResponse); } app.listen(PORT, () => { console.log(`AI Backend Running on ${PORT}`); });