// app.js — the entire HuggingFace instance. One job: embeddings. // Runs a sentence-embedding model IN this Space (transformers.js), so the // "heavy task" — loading and running the model — happens on HF's compute, // not on the phone/Termux box running the local server. Everything else // (text generation) is a lightweight SDK call the local server makes // directly to HF Inference API / Gemini — it never touches this file. import express from 'express'; import cors from 'cors'; import { pipeline } from '@xenova/transformers'; const app = express(); app.use(cors()); app.use(express.json({ limit: '2mb' })); const PORT = process.env.PORT || 7860; // 7860 is the HF Spaces convention const MODEL_ID = process.env.EMBED_MODEL_ID || 'Xenova/all-MiniLM-L6-v2'; // Loaded once, lazily, on first request — then reused. Avoids paying the // model-load cost on every /embed call, and avoids paying it at all if the // Space spins up but nothing calls it yet. let extractorPromise = null; function getExtractor() { if (!extractorPromise) { console.log(`Loading embedding model: ${MODEL_ID}`); extractorPromise = pipeline('feature-extraction', MODEL_ID); } return extractorPromise; } function meanPoolAndNormalize(output) { // transformers.js feature-extraction with pooling:'mean', normalize:true // already returns a flat, normalized vector — this just guards against // shape surprises across model families. const data = output.data ? Array.from(output.data) : output; return Array.isArray(data[0]) ? data.flat() : data; } app.get('/', (_req, res) => { res.json({ ok: true, service: 'morning-briefing-embeddings', model: MODEL_ID }); }); app.get('/health', async (_req, res) => { res.json({ ok: true, modelLoaded: extractorPromise !== null }); }); // POST /embed { text: string } -> { vector: number[], model: string } app.post('/embed', async (req, res) => { try { const { text } = req.body; if (!text || typeof text !== 'string') { return res.status(400).json({ error: 'Body must include a "text" string' }); } const extractor = await getExtractor(); const output = await extractor(text, { pooling: 'mean', normalize: true }); const vector = meanPoolAndNormalize(output); res.json({ vector, model: MODEL_ID }); } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } }); // POST /embed/batch { texts: string[] } -> { vectors: number[][], model: string } // Same model instance, run sequentially — transformers.js on CPU doesn't // gain much from manual batching here, and this keeps the file small. app.post('/embed/batch', async (req, res) => { try { const { texts } = req.body; if (!Array.isArray(texts) || texts.some(t => typeof t !== 'string')) { return res.status(400).json({ error: 'Body must include a "texts" string array' }); } const extractor = await getExtractor(); const vectors = []; for (const text of texts) { const output = await extractor(text, { pooling: 'mean', normalize: true }); vectors.push(meanPoolAndNormalize(output)); } res.json({ vectors, model: MODEL_ID }); } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } }); app.listen(PORT, () => { console.log(`Embeddings server listening on port ${PORT}`); });