Update lock files and cleanup
This commit is contained in:
12
db/models.js
12
db/models.js
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
1893
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
setTimeout(connect, 2000);
|
||||
// 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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -634,4 +620,4 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
max-width: 160px;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user