Update lock files and cleanup

This commit is contained in:
khalid@traclabs.com
2026-05-14 09:00:04 -05:00
parent 62e5e309ad
commit 4564fb1b56
8 changed files with 1310 additions and 708 deletions

View File

@@ -20,10 +20,11 @@ export const Job = sequelize.define('Job', {
type: DataTypes.TEXT,
allowNull: false,
},
// Base64 data URL stored for replay; in production use object storage
// Transient — holds the image only while the job is queued/running.
// Nulled out once processing finishes so we don't persist user photos.
imageDataUrl: {
type: DataTypes.TEXT,
allowNull: false,
allowNull: true,
},
imageMimeType: {
type: DataTypes.STRING(64),
@@ -62,12 +63,6 @@ export const Job = sequelize.define('Job', {
bboxY: { type: DataTypes.FLOAT, allowNull: true },
bboxWidth: { type: DataTypes.FLOAT, allowNull: true },
bboxHeight: { type: DataTypes.FLOAT, allowNull: true },
// Cropped image data URL (stored for UI preview / debugging)
croppedImageDataUrl: {
type: DataTypes.TEXT,
allowNull: true,
},
}, {
tableName: 'jobs',
timestamps: true,
@@ -76,6 +71,5 @@ export const Job = sequelize.define('Job', {
export async function initDb() {
const { mkdirSync } = await import('fs');
mkdirSync(join(__dirname, '../data'), { recursive: true });
// alter: true adds new columns to an existing table without dropping data
await sequelize.sync({ alter: true });
}

View File

@@ -6,7 +6,7 @@
<title>VLM Server</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Aldrich:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Aldrich&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

View File

@@ -74,9 +74,17 @@ function broadcastQueueStats() {
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Strip image data before sending job info to clients. */
function sanitizeJob(job) {
const json = job.toJSON();
delete json.imageDataUrl;
return json;
}
async function setStatus(job, status, extra = {}) {
await job.update({ status, ...extra });
broadcast({ type: 'job_update', job: job.toJSON() });
broadcast({ type: 'job_update', job: sanitizeJob(job) });
}
/** Padding factor applied to each side of the detected bounding box. */
@@ -211,6 +219,14 @@ async function cropImage(imageDataUrl, bbox, padding = BBOX_PADDING) {
};
}
/**
* Scrub image data from the job row so we don't persist user photos.
* Called in both the success and error paths.
*/
async function clearImageData(job) {
await job.update({ imageDataUrl: null });
}
// ---------------------------------------------------------------------------
// Main runner — two-pass: detect bbox → crop → analyse cropped image
// ---------------------------------------------------------------------------
@@ -245,7 +261,7 @@ async function runJob(jobId) {
bboxWidth: bboxResult.bbox.width,
bboxHeight: bboxResult.bbox.height,
});
broadcast({ type: 'job_update', job: job.toJSON() });
broadcast({ type: 'job_update', job: sanitizeJob(job) });
// ---- Crop the image around the detected subject ----
const isFullImage =
@@ -263,10 +279,6 @@ async function runJob(jobId) {
);
imageForAnalysis = croppedDataUrl;
// Optionally store the cropped image for UI preview
await job.update({ croppedImageDataUrl: croppedDataUrl });
broadcast({ type: 'job_update', job: job.toJSON() });
if (cropPixels) {
console.log(
`Cropped to ${cropPixels.width}×${cropPixels.height} ` +
@@ -291,12 +303,18 @@ async function runJob(jobId) {
maxTokens: 1024,
});
// Scrub image before marking done — don't persist photos
await clearImageData(job);
await setStatus(job, 'done', {
result: text,
inputTokens: usage?.promptTokens ?? null,
outputTokens: usage?.completionTokens ?? null,
});
} catch (err) {
// Scrub image even on failure
await clearImageData(job).catch(() => {});
const detail = err?.message
? `${err.name ?? 'Error'}: ${err.message}${err.cause ? `\nCause: ${JSON.stringify(err.cause, null, 2)}` : ''}`
: String(err);

1893
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,16 @@ import { enqueueJob } from '../jobs/queue.js';
import { registerClient } from '../ws/broadcast.js';
import { findModel, DEFAULT_MODEL_ID } from '../models.js';
/**
* Strip image data from a job object before sending it to clients.
* Images are only needed server-side during processing.
*/
function sanitizeJob(job) {
const json = job.toJSON();
delete json.imageDataUrl;
return json;
}
/**
* @param {import('fastify').FastifyInstance} fastify
*/
@@ -16,7 +26,7 @@ export async function jobRoutes(fastify) {
Job.findAll({ order: [['createdAt', 'DESC']], limit: 100 })
.then((jobs) => {
if (socket.readyState === 1) {
socket.send(JSON.stringify({ type: 'init', jobs: jobs.map((j) => j.toJSON()) }));
socket.send(JSON.stringify({ type: 'init', jobs: jobs.map(sanitizeJob) }));
}
})
.catch(() => {});
@@ -56,7 +66,7 @@ export async function jobRoutes(fastify) {
});
enqueueJob(job.id);
return reply.status(201).send(job.toJSON());
return reply.status(201).send(sanitizeJob(job));
});
// ------------------------------------------------------------------
@@ -64,7 +74,7 @@ export async function jobRoutes(fastify) {
// ------------------------------------------------------------------
fastify.get('/api/jobs', async (_req, reply) => {
const jobs = await Job.findAll({ order: [['createdAt', 'DESC']], limit: 100 });
return reply.send(jobs.map((j) => j.toJSON()));
return reply.send(jobs.map(sanitizeJob));
});
// ------------------------------------------------------------------
@@ -73,6 +83,6 @@ export async function jobRoutes(fastify) {
fastify.get('/api/jobs/:id', async (req, reply) => {
const job = await Job.findByPk(req.params.id);
if (!job) return reply.status(404).send({ error: 'Not found' });
return reply.send(job.toJSON());
return reply.send(sanitizeJob(job));
});
}

View File

@@ -1,8 +1,20 @@
import { useRef, useState, useCallback } from 'react';
import { useRef, useState, useCallback, useEffect } from 'react';
export function ImageDrop({ file, onFile }) {
const inputRef = useRef(null);
const [dragging, setDragging] = useState(false);
const [preview, setPreview] = useState(null);
// Create / revoke object URLs properly to avoid memory leaks
useEffect(() => {
if (!file) {
setPreview(null);
return;
}
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file]);
const handleDrop = useCallback((e) => {
e.preventDefault();
@@ -11,8 +23,6 @@ export function ImageDrop({ file, onFile }) {
if (dropped?.type.startsWith('image/')) onFile(dropped);
}, [onFile]);
const preview = file ? URL.createObjectURL(file) : null;
return (
<div
className={`drop-zone ${dragging ? 'drag-over' : ''} ${file ? 'has-file' : ''}`}
@@ -25,12 +35,11 @@ export function ImageDrop({ file, onFile }) {
onKeyDown={(e) => e.key === 'Enter' && inputRef.current?.click()}
aria-label="Drop image or click to select"
>
{/* Hidden file input — `capture` triggers camera on mobile */}
{/* Hidden file input — no capture attr so mobile shows both camera + gallery */}
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
style={{ display: 'none' }}
onChange={(e) => {
const f = e.target.files?.[0];

View File

@@ -7,9 +7,13 @@ import { useEffect, useRef, useCallback } from 'react';
export function useJobSocket(onMessage) {
const wsRef = useRef(null);
const onMessageRef = useRef(onMessage);
const mountedRef = useRef(true);
onMessageRef.current = onMessage;
const connect = useCallback(() => {
// Don't reconnect if unmounted
if (!mountedRef.current) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
wsRef.current = ws;
@@ -23,15 +27,21 @@ export function useJobSocket(onMessage) {
};
ws.onclose = () => {
// Auto-reconnect after 2 s
// Only auto-reconnect if the component is still mounted
if (mountedRef.current) {
setTimeout(connect, 2000);
}
};
return ws;
}, []);
useEffect(() => {
mountedRef.current = true;
const ws = connect();
return () => ws.close();
return () => {
mountedRef.current = false;
ws?.close();
};
}, [connect]);
}

View File

@@ -1,6 +1,6 @@
/* ============================================================
VLM Server — Industrial dark theme
Fonts: Aldrich (display) + JetBrains Mono (data/code)
Fonts: Aldrich (display, 400 only) + JetBrains Mono (data/code)
============================================================ */
:root {
@@ -93,8 +93,8 @@ body {
.app-title {
font-size: 1.35rem;
font-weight: 800;
letter-spacing: -0.02em;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text);
display: flex;
align-items: center;
@@ -197,7 +197,7 @@ body {
.drop-placeholder span {
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.drop-placeholder small {
@@ -262,8 +262,8 @@ body {
border-radius: var(--radius);
font-family: var(--font-ui);
font-size: 0.88rem;
font-weight: 700;
letter-spacing: 0.03em;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 12px 20px;
cursor: pointer;
white-space: nowrap;
@@ -332,7 +332,6 @@ body {
.jobs-title {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
@@ -475,17 +474,6 @@ body {
white-space: pre-wrap;
}
.job-error {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--red);
background: rgba(217, 79, 79, 0.06);
border-left: 3px solid var(--red);
padding: 10px 14px;
border-radius: 0 var(--radius) var(--radius) 0;
white-space: pre-wrap;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 600px) {
.app-main {
@@ -566,7 +554,6 @@ body {
.model-label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
@@ -609,7 +596,6 @@ body {
.model-select optgroup {
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
}