import express from 'express'; import cors from 'cors'; import { createServer } from 'node:http'; import { Server } from 'socket.io'; import Database from 'better-sqlite3'; import { createHash, pbkdf2Sync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { mkdirSync } from 'node:fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PORT = Number(process.env.PORT || 7860); const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'ai-studio.sqlite'); const PUBLIC_DIR = path.join(__dirname, 'public'); const NGROK_HEADERS = { 'ngrok-skip-browser-warning': 'true', 'content-type': 'application/json' }; mkdirSync(path.dirname(DB_PATH), { recursive: true }); const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, token TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE, hf_token TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS workers ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, worker_token_hash TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'offline', metrics TEXT NOT NULL DEFAULT '{}', last_seen TEXT, updated_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS jobs ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, worker_id TEXT NOT NULL, model TEXT NOT NULL, dataset TEXT NOT NULL, task TEXT NOT NULL, output_repo TEXT, push_to_hf INTEGER NOT NULL DEFAULT 0, params TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'queued', progress REAL NOT NULL DEFAULT 0, error TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (worker_id) REFERENCES workers(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS job_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT NOT NULL, owner_id TEXT NOT NULL, level TEXT NOT NULL DEFAULT 'info', message TEXT NOT NULL, progress REAL, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE, FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_workers_owner ON workers(owner_id); CREATE INDEX IF NOT EXISTS idx_jobs_owner ON jobs(owner_id); CREATE INDEX IF NOT EXISTS idx_jobs_worker ON jobs(worker_id); CREATE INDEX IF NOT EXISTS idx_job_logs_job ON job_logs(job_id); `); const app = express(); const httpServer = createServer(app); const io = new Server(httpServer, { cors: { origin: true, credentials: false } }); app.use(cors()); app.use(express.json({ limit: '5mb' })); app.use(express.static(PUBLIC_DIR)); function now() { return new Date().toISOString(); } function sha256(value) { return createHash('sha256').update(String(value)).digest('hex'); } function randomToken(prefix) { return `${prefix}_${randomBytes(24).toString('hex')}`; } function hashPassword(password) { const salt = randomBytes(32); const hash = pbkdf2Sync(password, salt, 310_000, 64, 'sha256'); return `${salt.toString('hex')}:${hash.toString('hex')}`; } function verifyPassword(password, storedHash) { try { const [saltHex, hashHex] = storedHash.split(':'); const salt = Buffer.from(saltHex, 'hex'); const hash = pbkdf2Sync(password, salt, 310_000, 64, 'sha256'); return timingSafeEqual(hash, Buffer.from(hashHex, 'hex')); } catch { return false; } } function parseJson(value, fallback = {}) { if (!value) return fallback; try { return JSON.parse(value); } catch { return fallback; } } function intParam(value, fallback, min, max) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.min(max, Math.max(min, Math.round(number))); } function floatParam(value, fallback, min, max) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.min(max, Math.max(min, number)); } function normalizeUrl(url) { return String(url || '').trim().replace(/\/+$/, ''); } function sanitizeTrainingParams(input = {}) { return { epochs: intParam(input.epochs, 3, 1, 100), batch_size: intParam(input.batch_size ?? input.batchSize, 2, 1, 128), learning_rate: floatParam(input.learning_rate ?? input.learningRate, 0.0002, 0.000001, 0.01), max_seq_length: intParam(input.max_seq_length ?? input.maxSeqLength, 512, 64, 8192), lora_rank: intParam(input.lora_rank ?? input.loraRank, 16, 1, 256), lora_alpha: intParam(input.lora_alpha ?? input.loraAlpha, 32, 1, 512), gradient_accumulation_steps: intParam(input.gradient_accumulation_steps ?? input.gradientAccumulationSteps, 1, 1, 256), warmup_ratio: floatParam(input.warmup_ratio ?? input.warmupRatio, 0.03, 0, 0.5), weight_decay: floatParam(input.weight_decay ?? input.weightDecay, 0.0, 0, 1), logging_steps: intParam(input.logging_steps ?? input.loggingSteps, 10, 1, 1000) }; } function getWorkers(ownerId) { return db .prepare(` SELECT id, owner_id AS ownerId, name, url, status, metrics, last_seen AS lastSeen, created_at AS createdAt FROM workers WHERE owner_id = ? ORDER BY last_seen DESC, created_at DESC `) .all(ownerId) .map((worker) => ({ ...worker, metrics: parseJson(worker.metrics, {}) })); } function getWorker(ownerId, workerId) { return db .prepare(` SELECT id, owner_id AS ownerId, name, url, status, metrics, last_seen AS lastSeen, created_at AS createdAt FROM workers WHERE id = ? AND owner_id = ? `) .get(workerId, ownerId); } function getJobs(ownerId) { return db .prepare(` SELECT j.id, j.owner_id AS ownerId, j.worker_id AS workerId, w.name AS workerName, j.model, j.dataset, j.task, j.output_repo AS outputRepo, j.push_to_hf AS pushToHf, j.params, j.status, j.progress, j.error, j.created_at AS createdAt, j.updated_at AS updatedAt FROM jobs j LEFT JOIN workers w ON w.id = j.worker_id WHERE j.owner_id = ? ORDER BY j.created_at DESC `) .all(ownerId) .map((job) => ({ ...job, params: parseJson(job.params, {}), pushToHf: Boolean(job.pushToHf) })); } function getJob(ownerId, jobId) { return db .prepare(` SELECT j.id, j.owner_id AS ownerId, j.worker_id AS workerId, w.name AS workerName, j.model, j.dataset, j.task, j.output_repo AS outputRepo, j.push_to_hf AS pushToHf, j.params, j.status, j.progress, j.error, j.created_at AS createdAt, j.updated_at AS updatedAt FROM jobs j LEFT JOIN workers w ON w.id = j.worker_id WHERE j.id = ? AND j.owner_id = ? `) .get(jobId, ownerId); } function getJobLogs(ownerId, jobId) { return db .prepare(` SELECT id, job_id AS jobId, owner_id AS ownerId, level, message, progress, created_at AS createdAt FROM job_logs WHERE job_id = ? AND owner_id = ? ORDER BY id ASC LIMIT 2000 `) .all(jobId, ownerId); } function authRequired(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7).trim() : header.trim(); if (!token) { return res.status(401).json({ error: 'Token utilisateur requis.' }); } const user = db .prepare('SELECT * FROM users WHERE token_hash = ?') .get(sha256(token)); if (!user) { return res.status(401).json({ error: 'Token utilisateur invalide.' }); } req.user = user; return next(); } function workerAuthRequired(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7).trim() : header.trim(); if (!token) { return res.status(401).json({ error: 'Token requis.' }); } // Essayer d'abord avec le worker token let worker = db .prepare('SELECT * FROM workers WHERE worker_token_hash = ?') .get(sha256(token)); if (!worker) { // Essayer avec le token utilisateur const user = db .prepare('SELECT * FROM users WHERE token_hash = ?') .get(sha256(token)); if (!user) { return res.status(401).json({ error: 'Token invalide.' }); } // Trouver le worker le plus récent de cet utilisateur worker = db .prepare('SELECT * FROM workers WHERE owner_id = ? ORDER BY updated_at DESC LIMIT 1') .get(user.id); if (!worker) { return res.status(401).json({ error: 'Aucun worker associé à cet utilisateur.' }); } } req.worker = worker; return next(); } async function workerRequest(worker, method, route, body) { const url = `${normalizeUrl(worker.url)}${route.startsWith('/') ? route : `/${route}`}`; const response = await fetch(url, { method, headers: NGROK_HEADERS, body: body === undefined ? undefined : JSON.stringify(body) }); const text = await response.text(); let data = {}; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text.slice(0, 1000) }; } if (!response.ok) { throw new Error(`Erreur worker ${response.status}: ${response.statusText} - ${text.slice(0, 500)}`); } return data; } function broadcastUser(userId, event, payload) { io.to(`user:${userId}`).emit(event, payload); } app.get('/api/health', (req, res) => { res.json({ ok: true, service: 'ai-studio-server', time: now() }); }); app.post('/api/auth/register', async (req, res) => { try { const username = String(req.body.username || '').trim(); const password = String(req.body.password || ''); if (username.length < 3) { return res.status(400).json({ error: 'Le nom d’utilisateur doit contenir au moins 3 caractères.' }); } if (password.length < 6) { return res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères.' }); } const id = randomUUID(); const token = randomToken('user'); const passwordHash = hashPassword(password); db.prepare(` INSERT INTO users (id, username, password_hash, token, token_hash) VALUES (?, ?, ?, ?, ?) `).run(id, username, passwordHash, token, sha256(token)); return res.json({ token, user: { id, username } }); } catch (error) { if (String(error.code).includes('SQLITE_CONSTRAINT')) { return res.status(409).json({ error: 'Ce nom d’utilisateur existe déjà.' }); } console.error(error); return res.status(500).json({ error: 'Erreur serveur lors de l’inscription.' }); } }); app.post('/api/auth/login', (req, res) => { const username = String(req.body.username || '').trim(); const password = String(req.body.password || ''); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user || !verifyPassword(password, user.password_hash)) { return res.status(401).json({ error: 'Nom d’utilisateur ou mot de passe invalide.' }); } return res.json({ token: user.token, user: { id: user.id, username: user.username } }); }); app.get('/api/me', authRequired, (req, res) => { res.json({ user: { id: req.user.id, username: req.user.username }, userToken: req.user.token, hfTokenConfigured: Boolean(req.user.hf_token) }); }); app.post('/api/hf/token', authRequired, (req, res) => { const hfToken = String(req.body.token || '').trim(); if (!hfToken) { db.prepare('UPDATE users SET hf_token = NULL WHERE id = ?').run(req.user.id); return res.json({ ok: true, hfTokenConfigured: false }); } db.prepare('UPDATE users SET hf_token = ? WHERE id = ?').run(hfToken, req.user.id); return res.json({ ok: true, hfTokenConfigured: true }); }); app.get('/api/hf/search', authRequired, async (req, res) => { try { const type = req.query.type === 'dataset' ? 'datasets' : 'models'; const query = String(req.query.q || req.query.query || '').trim(); const limit = Math.min(intParam(req.query.limit, 20, 1, 100), 100); const url = new URL(`https://huggingface.co/api/${type}`); if (query) url.searchParams.set('search', query); url.searchParams.set('limit', String(limit)); const headers = {}; if (req.user.hf_token) { headers.Authorization = `Bearer ${req.user.hf_token}`; } const response = await fetch(url, { headers }); const data = await response.json(); if (!response.ok) { return res.status(response.status).json({ error: 'Erreur Hugging Face API.', details: data }); } return res.json({ type, items: data }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Erreur lors de la recherche Hugging Face.' }); } }); app.get('/api/workers', authRequired, (req, res) => { res.json({ workers: getWorkers(req.user.id) }); }); app.post('/api/workers', authRequired, (req, res) => { const name = String(req.body.name || '').trim(); const url = normalizeUrl(req.body.url || req.body.ngrokUrl || ''); if (!name) { return res.status(400).json({ error: 'Le nom de la machine est requis.' }); } if (!url) { return res.status(400).json({ error: 'L’URL ngrok de la machine est requise.' }); } const id = randomUUID(); const workerToken = randomToken('worker'); db.prepare(` INSERT INTO workers (id, owner_id, name, url, worker_token_hash, status) VALUES (?, ?, ?, ?, ?, 'offline') `).run(id, req.user.id, name, url, sha256(workerToken)); const worker = getWorker(req.user.id, id); broadcastUser(req.user.id, 'workers:update', getWorkers(req.user.id)); return res.status(201).json({ worker, workerToken }); }); app.get('/api/workers/:id', authRequired, (req, res) => { const worker = getWorker(req.user.id, req.params.id); if (!worker) { return res.status(404).json({ error: 'Machine introuvable.' }); } res.json({ worker }); }); app.post('/api/workers/register', (req, res) => { const userToken = String(req.body.userToken || ''); const workerName = String(req.body.workerName || '').trim(); const ngrokUrl = normalizeUrl(req.body.ngrokUrl || req.body.workerUrl || ''); const status = req.body.status === 'busy' ? 'busy' : 'online'; const metrics = req.body.metrics || {}; const user = db .prepare('SELECT * FROM users WHERE token_hash = ?') .get(sha256(userToken)); if (!user) { return res.status(401).json({ error: 'Token utilisateur invalide.' }); } let worker = db .prepare('SELECT * FROM workers WHERE owner_id = ? AND name = ?') .get(user.id, workerName); if (!worker) { const id = randomUUID(); db.prepare(` INSERT INTO workers (id, owner_id, name, url, worker_token_hash, status) VALUES (?, ?, ?, ?, ?, ?) `).run(id, user.id, workerName, ngrokUrl, sha256(randomToken('worker')), status); worker = getWorker(user.id, id); } else { const updates = ['status = ?', 'metrics = ?', 'last_seen = ?', 'updated_at = ?']; const params = [status, JSON.stringify(metrics), now(), now()]; if (ngrokUrl) { updates.push('url = ?'); params.push(ngrokUrl); } params.push(worker.id); db.prepare(`UPDATE workers SET ${updates.join(', ')} WHERE id = ?`).run(...params); } const updatedWorker = getWorker(user.id, worker.id); broadcastUser(user.id, 'workers:update', getWorkers(user.id)); return res.json({ ok: true, worker: updatedWorker }); }); app.get('/api/workers/:id/health', authRequired, async (req, res) => { const worker = getWorker(req.user.id, req.params.id); if (!worker) { return res.status(404).json({ error: 'Machine introuvable.' }); } try { const remote = await workerRequest(worker, 'GET', '/health'); return res.json({ ok: true, remote }); } catch (error) { db.prepare('UPDATE workers SET status = ?, last_seen = ? WHERE id = ?').run('offline', now(), worker.id); broadcastUser(req.user.id, 'workers:update', getWorkers(req.user.id)); return res.status(502).json({ ok: false, error: error.message }); } }); app.get('/api/workers/:id/metrics', authRequired, async (req, res) => { const worker = getWorker(req.user.id, req.params.id); if (!worker) { return res.status(404).json({ error: 'Machine introuvable.' }); } try { const remote = await workerRequest(worker, 'GET', '/metrics'); db.prepare('UPDATE workers SET metrics = ?, last_seen = ? WHERE id = ?').run( JSON.stringify(remote), now(), worker.id ); broadcastUser(req.user.id, 'metrics:update', { workerId: worker.id, metrics: remote }); return res.json({ ok: true, metrics: remote }); } catch (error) { db.prepare('UPDATE workers SET status = ?, last_seen = ? WHERE id = ?').run('offline', now(), worker.id); broadcastUser(req.user.id, 'workers:update', getWorkers(req.user.id)); return res.status(502).json({ ok: false, error: error.message }); } }); app.delete('/api/workers/:id', authRequired, (req, res) => { const worker = getWorker(req.user.id, req.params.id); if (!worker) { return res.status(404).json({ error: 'Machine introuvable.' }); } const activeJob = db .prepare('SELECT id FROM jobs WHERE worker_id = ? AND status IN (?, ?)') .get(worker.id, 'queued', 'running'); if (activeJob) { return res.status(409).json({ error: 'Impossible de supprimer une machine qui exécute un job.' }); } db.prepare('DELETE FROM workers WHERE id = ? AND owner_id = ?').run(worker.id, req.user.id); broadcastUser(req.user.id, 'workers:update', getWorkers(req.user.id)); return res.json({ ok: true }); }); app.get('/api/jobs', authRequired, (req, res) => { res.json({ jobs: getJobs(req.user.id) }); }); app.get('/api/jobs/:id', authRequired, (req, res) => { const job = getJob(req.user.id, req.params.id); if (!job) { return res.status(404).json({ error: 'Job introuvable.' }); } res.json({ job }); }); app.get('/api/jobs/:id/logs', authRequired, (req, res) => { const job = getJob(req.user.id, req.params.id); if (!job) { return res.status(404).json({ error: 'Job introuvable.' }); } res.json({ logs: getJobLogs(req.user.id, req.params.id) }); }); app.post('/api/jobs', authRequired, async (req, res) => { try { const workerId = String(req.body.workerId || ''); const model = String(req.body.model || '').trim(); const dataset = String(req.body.dataset || '').trim(); const task = String(req.body.task || 'text-generation'); const outputRepo = String(req.body.outputRepo || '').trim(); const pushToHf = Boolean(req.body.pushToHf); const params = sanitizeTrainingParams(req.body.params || {}); const allowedTasks = ['text-generation', 'instruction-tuning', 'text-classification']; if (!allowedTasks.includes(task)) { return res.status(400).json({ error: 'Tâche d’entraînement invalide.' }); } if (!model) { return res.status(400).json({ error: 'Le modèle est requis.' }); } if (!dataset) { return res.status(400).json({ error: 'Le dataset est requis.' }); } const worker = getWorker(req.user.id, workerId); if (!worker) { return res.status(404).json({ error: 'Machine introuvable.' }); } if (worker.status === 'offline') { return res.status(400).json({ error: 'La machine sélectionnée est offline.' }); } const activeJob = db .prepare('SELECT id FROM jobs WHERE worker_id = ? AND status IN (?, ?)') .get(worker.id, 'queued', 'running'); if (activeJob) { return res.status(409).json({ error: 'Cette machine exécute déjà un job. Les GPU ne sont pas partagés.' }); } const hfTokenRow = db.prepare('SELECT hf_token AS hfToken FROM users WHERE id = ?').get(req.user.id); const jobId = randomUUID(); const insert = db.prepare(` INSERT INTO jobs ( id, owner_id, worker_id, model, dataset, task, output_repo, push_to_hf, params, status, progress ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0) `); insert.run( jobId, req.user.id, worker.id, model, dataset, task, outputRepo || null, pushToHf ? 1 : 0, JSON.stringify(params) ); const payload = { jobId, model, dataset, task, outputRepo: outputRepo || null, pushToHf, params, hfToken: hfTokenRow.hfToken || '' }; try { await workerRequest(worker, 'POST', '/start-job', payload); } catch (error) { db.prepare(` UPDATE jobs SET status = 'failed', progress = 0, error = ?, updated_at = ? WHERE id = ? `).run(error.message, now(), jobId); broadcastUser(req.user.id, 'jobs:update', getJobs(req.user.id)); broadcastUser(req.user.id, 'job:log', { jobId, level: 'error', message: `Échec de l’envoi du job au worker: ${error.message}`, progress: 0 }); return res.status(502).json({ ok: false, error: error.message, job: getJob(req.user.id, jobId) }); } db.prepare(` UPDATE jobs SET status = 'running', updated_at = ? WHERE id = ? `).run(now(), jobId); db.prepare('UPDATE workers SET status = ?, updated_at = ? WHERE id = ?') .run('busy', now(), worker.id); const job = getJob(req.user.id, jobId); broadcastUser(req.user.id, 'jobs:update', getJobs(req.user.id)); broadcastUser(req.user.id, 'workers:update', getWorkers(req.user.id)); broadcastUser(req.user.id, 'job:log', { jobId, level: 'info', message: `Job envoyé à la machine ${worker.name}.`, progress: 0 }); return res.status(201).json({ ok: true, job }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Erreur lors de la création du job.' }); } }); app.post('/api/jobs/:id/cancel', authRequired, async (req, res) => { const job = getJob(req.user.id, req.params.id); if (!job) { return res.status(404).json({ error: 'Job introuvable.' }); } const terminalStatuses = ['completed', 'failed', 'cancelled']; if (terminalStatuses.includes(job.status)) { return res.status(400).json({ error: 'Ce job est déjà terminé.' }); } const worker = getWorker(req.user.id, job.workerId); db.prepare(` UPDATE jobs SET status = 'cancelling', updated_at = ? WHERE id = ? `).run(now(), job.id); broadcastUser(req.user.id, 'jobs:update', getJobs(req.user.id)); if (worker) { try { await workerRequest(worker, 'POST', '/stop-job', { jobId: job.id }); } catch (error) { db.prepare('INSERT INTO job_logs (job_id, owner_id, level, message) VALUES (?, ?, ?, ?)') .run(job.id, req.user.id, 'error', `Erreur pendant la demande d’arrêt: ${error.message}`); } } return res.json({ ok: true, job: getJob(req.user.id, job.id) }); }); app.post('/api/jobs/:jobId/logs', workerAuthRequired, (req, res) => { const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(req.params.jobId); if (!job) { return res.status(404).json({ error: 'Job introuvable.' }); } if (job.owner_id !== req.worker.owner_id) { return res.status(403).json({ error: 'Ce worker n’est pas autorisé à écrire dans ce job.' }); } const level = String(req.body.level || 'info'); const message = String(req.body.message || ''); const progress = req.body.progress === undefined ? null : floatParam(req.body.progress, 0, 0, 100); if (!message) { return res.status(400).json({ error: 'Message de log requis.' }); } db.prepare(` INSERT INTO job_logs (job_id, owner_id, level, message, progress) VALUES (?, ?, ?, ?, ?) `).run(job.id, job.owner_id, level, message, progress); if (progress !== null) { db.prepare('UPDATE jobs SET progress = ?, updated_at = ? WHERE id = ?').run(progress, now(), job.id); } broadcastUser(job.owner_id, 'job:log', { jobId: job.id, level, message, progress }); return res.json({ ok: true }); }); app.post('/api/jobs/:jobId/status', workerAuthRequired, (req, res) => { const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(req.params.jobId); if (!job) { return res.status(404).json({ error: 'Job introuvable.' }); } if (job.owner_id !== req.worker.owner_id) { return res.status(403).json({ error: 'Ce worker n’est pas autorisé à modifier ce job.' }); } const allowedStatuses = ['queued', 'running', 'completed', 'failed', 'cancelled', 'cancelling']; const status = String(req.body.status || job.status); if (!allowedStatuses.includes(status)) { return res.status(400).json({ error: 'Statut invalide.' }); } const progress = req.body.progress === undefined ? job.progress : floatParam(req.body.progress, 0, 0, 100); const error = req.body.error ? String(req.body.error) : null; db.prepare(` UPDATE jobs SET status = ?, progress = ?, error = COALESCE(?, error), updated_at = ? WHERE id = ? `).run(status, progress, error, now(), job.id); if (['completed', 'failed', 'cancelled'].includes(status)) { db.prepare('UPDATE workers SET status = ?, updated_at = ? WHERE id = ?').run('online', now(), job.worker_id); } const updatedJob = getJob(job.owner_id, job.id); broadcastUser(job.owner_id, 'jobs:update', getJobs(job.owner_id)); broadcastUser(job.owner_id, 'workers:update', getWorkers(job.owner_id)); return res.json({ ok: true, job: updatedJob }); }); app.get('/api/system/info', (req, res) => { res.json({ ok: true, nodeVersion: process.version, socketIoEnabled: true, ngrokHeader: 'ngrok-skip-browser-warning' }); }); io.on('connection', (socket) => { const token = socket.handshake.auth?.token; if (!token) { socket.emit('error', { message: 'Token utilisateur requis.' }); socket.disconnect(true); return; } const user = db.prepare('SELECT id, username, token FROM users WHERE token_hash = ?').get(sha256(token)); if (!user) { socket.emit('error', { message: 'Token utilisateur invalide.' }); socket.disconnect(true); return; } socket.data.user = user; socket.join(`user:${user.id}`); socket.emit('me', { user: { id: user.id, username: user.username }, userToken: user.token }); socket.emit('workers:update', getWorkers(user.id)); socket.emit('jobs:update', getJobs(user.id)); }); app.get('*', (req, res) => { res.sendFile(path.join(PUBLIC_DIR, 'index.html')); }); app.use((error, req, res, next) => { console.error(error); return res.status(500).json({ error: 'Erreur serveur interne.' }); }); httpServer.listen(PORT, () => { console.log(`AI Studio serveur démarré sur le port ${PORT}`); console.log(`Base de données: ${DB_PATH}`); });