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, type: DataTypes.TEXT,
allowNull: false, 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: { imageDataUrl: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false, allowNull: true,
}, },
imageMimeType: { imageMimeType: {
type: DataTypes.STRING(64), type: DataTypes.STRING(64),
@@ -62,12 +63,6 @@ export const Job = sequelize.define('Job', {
bboxY: { type: DataTypes.FLOAT, allowNull: true }, bboxY: { type: DataTypes.FLOAT, allowNull: true },
bboxWidth: { type: DataTypes.FLOAT, allowNull: true }, bboxWidth: { type: DataTypes.FLOAT, allowNull: true },
bboxHeight: { 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', tableName: 'jobs',
timestamps: true, timestamps: true,
@@ -76,6 +71,5 @@ export const Job = sequelize.define('Job', {
export async function initDb() { export async function initDb() {
const { mkdirSync } = await import('fs'); const { mkdirSync } = await import('fs');
mkdirSync(join(__dirname, '../data'), { recursive: true }); mkdirSync(join(__dirname, '../data'), { recursive: true });
// alter: true adds new columns to an existing table without dropping data
await sequelize.sync({ alter: true }); await sequelize.sync({ alter: true });
} }

View File

@@ -6,7 +6,7 @@
<title>VLM Server</title> <title>VLM Server</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -74,9 +74,17 @@ function broadcastQueueStats() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // 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 = {}) { async function setStatus(job, status, extra = {}) {
await job.update({ 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. */ /** 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 // Main runner — two-pass: detect bbox → crop → analyse cropped image
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -245,7 +261,7 @@ async function runJob(jobId) {
bboxWidth: bboxResult.bbox.width, bboxWidth: bboxResult.bbox.width,
bboxHeight: bboxResult.bbox.height, 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 ---- // ---- Crop the image around the detected subject ----
const isFullImage = const isFullImage =
@@ -263,10 +279,6 @@ async function runJob(jobId) {
); );
imageForAnalysis = croppedDataUrl; imageForAnalysis = croppedDataUrl;
// Optionally store the cropped image for UI preview
await job.update({ croppedImageDataUrl: croppedDataUrl });
broadcast({ type: 'job_update', job: job.toJSON() });
if (cropPixels) { if (cropPixels) {
console.log( console.log(
`Cropped to ${cropPixels.width}×${cropPixels.height} ` + `Cropped to ${cropPixels.width}×${cropPixels.height} ` +
@@ -291,12 +303,18 @@ async function runJob(jobId) {
maxTokens: 1024, maxTokens: 1024,
}); });
// Scrub image before marking done — don't persist photos
await clearImageData(job);
await setStatus(job, 'done', { await setStatus(job, 'done', {
result: text, result: text,
inputTokens: usage?.promptTokens ?? null, inputTokens: usage?.promptTokens ?? null,
outputTokens: usage?.completionTokens ?? null, outputTokens: usage?.completionTokens ?? null,
}); });
} catch (err) { } catch (err) {
// Scrub image even on failure
await clearImageData(job).catch(() => {});
const detail = err?.message const detail = err?.message
? `${err.name ?? 'Error'}: ${err.message}${err.cause ? `\nCause: ${JSON.stringify(err.cause, null, 2)}` : ''}` ? `${err.name ?? 'Error'}: ${err.message}${err.cause ? `\nCause: ${JSON.stringify(err.cause, null, 2)}` : ''}`
: String(err); : 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 { registerClient } from '../ws/broadcast.js';
import { findModel, DEFAULT_MODEL_ID } from '../models.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 * @param {import('fastify').FastifyInstance} fastify
*/ */
@@ -16,7 +26,7 @@ export async function jobRoutes(fastify) {
Job.findAll({ order: [['createdAt', 'DESC']], limit: 100 }) Job.findAll({ order: [['createdAt', 'DESC']], limit: 100 })
.then((jobs) => { .then((jobs) => {
if (socket.readyState === 1) { 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(() => {}); .catch(() => {});
@@ -56,7 +66,7 @@ export async function jobRoutes(fastify) {
}); });
enqueueJob(job.id); 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) => { fastify.get('/api/jobs', async (_req, reply) => {
const jobs = await Job.findAll({ order: [['createdAt', 'DESC']], limit: 100 }); 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) => { fastify.get('/api/jobs/:id', async (req, reply) => {
const job = await Job.findByPk(req.params.id); const job = await Job.findByPk(req.params.id);
if (!job) return reply.status(404).send({ error: 'Not found' }); 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 }) { export function ImageDrop({ file, onFile }) {
const inputRef = useRef(null); const inputRef = useRef(null);
const [dragging, setDragging] = useState(false); 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) => { const handleDrop = useCallback((e) => {
e.preventDefault(); e.preventDefault();
@@ -11,8 +23,6 @@ export function ImageDrop({ file, onFile }) {
if (dropped?.type.startsWith('image/')) onFile(dropped); if (dropped?.type.startsWith('image/')) onFile(dropped);
}, [onFile]); }, [onFile]);
const preview = file ? URL.createObjectURL(file) : null;
return ( return (
<div <div
className={`drop-zone ${dragging ? 'drag-over' : ''} ${file ? 'has-file' : ''}`} 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()} onKeyDown={(e) => e.key === 'Enter' && inputRef.current?.click()}
aria-label="Drop image or click to select" 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 <input
ref={inputRef} ref={inputRef}
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment"
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={(e) => { onChange={(e) => {
const f = e.target.files?.[0]; const f = e.target.files?.[0];

View File

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

View File

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