AI-Studio / server.js
NathMen12's picture
Update server.js
e45a982 verified
Raw
History Blame Contribute Delete
27.5 kB
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}`);
});