feat: replace transformers.js with direct ONNX WASM for Bun compatibility

- Remove @huggingface/transformers dependency (539MB native binaries)
- Add direct ONNX Runtime Web embedding engine
- Bundle all-MiniLM-L6-v2-q8 model (24MB, no runtime downloads)
- Works with Node.js, Bun, and bun build --compile
- Air-gap compatible: fully self-contained, no internet required

New WASM embedding components:
- WASMEmbeddingEngine: Main integration class
- WordPieceTokenizer: Pure TypeScript tokenizer
- EmbeddingPostProcessor: Mean pooling + L2 normalization
- ONNXInferenceEngine: Direct ONNX Runtime Web wrapper
- AssetLoader: Model file loading

Tests added:
- 11 WASM embedding integration tests
- 8 Bun compatibility tests

New npm scripts:
- test:wasm - Run WASM embedding tests
- test:bun - Run tests with Bun
- test:bun:compile - Build and run compiled binary
This commit is contained in:
David Snelling 2025-12-17 17:42:37 -08:00
parent c1deb7a623
commit 1f59aa2013
21 changed files with 34431 additions and 3459 deletions

175
scripts/download-model.cjs Normal file
View file

@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* Download Model Assets
*
* Downloads the all-MiniLM-L6-v2 Q8 model from Hugging Face.
* Run: node scripts/download-model.cjs
*/
const fs = require('node:fs')
const path = require('node:path')
const https = require('node:https')
const MODEL_DIR = path.join(__dirname, '..', 'assets', 'models', 'all-MiniLM-L6-v2-q8')
const BASE_URL = 'https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/onnx'
const FILES = [
{
name: 'model_quantized.onnx',
url: `${BASE_URL}/model_quantized.onnx`,
dest: 'model.onnx',
},
{
name: 'tokenizer.json',
url: 'https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/tokenizer.json',
dest: 'tokenizer.json',
},
{
name: 'config.json',
url: 'https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/config.json',
dest: 'config.json',
},
{
name: 'vocab.txt',
url: 'https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/vocab.txt',
dest: 'vocab.txt',
},
]
/**
* Follow redirects and download file
*/
function downloadFile(url, destPath, maxRedirects = 5) {
return new Promise((resolve, reject) => {
if (maxRedirects === 0) {
reject(new Error('Too many redirects'))
return
}
const doRequest = (reqUrl) => {
const parsedUrl = new URL(reqUrl)
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
headers: {
'User-Agent': 'Brainy-Model-Downloader/1.0',
},
}
https.get(options, (response) => {
// Handle redirects
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
response.resume() // Consume response data to free memory
const redirectUrl = response.headers.location.startsWith('http')
? response.headers.location
: new URL(response.headers.location, reqUrl).toString()
console.log(` ↳ Redirecting to: ${redirectUrl.slice(0, 80)}...`)
downloadFile(redirectUrl, destPath, maxRedirects - 1)
.then(resolve)
.catch(reject)
return
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`))
return
}
const fileStream = fs.createWriteStream(destPath)
let downloadedBytes = 0
const totalBytes = parseInt(response.headers['content-length'] || '0', 10)
response.on('data', (chunk) => {
downloadedBytes += chunk.length
if (totalBytes > 0) {
const percent = Math.round((downloadedBytes / totalBytes) * 100)
process.stdout.write(`\r Progress: ${percent}% (${Math.round(downloadedBytes / 1024 / 1024)}MB)`)
}
})
response.pipe(fileStream)
fileStream.on('finish', () => {
fileStream.close()
console.log(`\n ✅ Downloaded: ${path.basename(destPath)} (${Math.round(downloadedBytes / 1024 / 1024)}MB)`)
resolve()
})
fileStream.on('error', (err) => {
fs.unlink(destPath, () => {}) // Delete partial file
reject(err)
})
}).on('error', reject)
}
doRequest(url)
})
}
/**
* Convert vocab.txt to vocab.json
*/
function convertVocabToJson(vocabTxtPath, vocabJsonPath) {
console.log('📝 Converting vocab.txt to vocab.json...')
const content = fs.readFileSync(vocabTxtPath, 'utf-8')
const lines = content.split('\n').filter(line => line.trim())
const vocab = {}
for (let i = 0; i < lines.length; i++) {
vocab[lines[i]] = i
}
fs.writeFileSync(vocabJsonPath, JSON.stringify(vocab))
console.log(` ✅ Created vocab.json with ${Object.keys(vocab).length} tokens`)
// Remove vocab.txt since we have vocab.json
fs.unlinkSync(vocabTxtPath)
}
async function main() {
console.log('🔽 Downloading all-MiniLM-L6-v2 Q8 model assets...\n')
// Create model directory
fs.mkdirSync(MODEL_DIR, { recursive: true })
console.log(`📁 Model directory: ${MODEL_DIR}\n`)
// Download each file
for (const file of FILES) {
const destPath = path.join(MODEL_DIR, file.dest)
// Check if already exists
if (fs.existsSync(destPath)) {
const stats = fs.statSync(destPath)
if (stats.size > 0) {
console.log(`⏭️ Skipping ${file.name} (already exists)`)
continue
}
}
console.log(`📥 Downloading ${file.name}...`)
try {
await downloadFile(file.url, destPath)
} catch (error) {
console.error(` ❌ Failed to download ${file.name}: ${error.message}`)
process.exit(1)
}
}
// Convert vocab.txt to vocab.json
const vocabTxtPath = path.join(MODEL_DIR, 'vocab.txt')
const vocabJsonPath = path.join(MODEL_DIR, 'vocab.json')
if (fs.existsSync(vocabTxtPath) && !fs.existsSync(vocabJsonPath)) {
convertVocabToJson(vocabTxtPath, vocabJsonPath)
}
console.log('\n✅ All model assets downloaded successfully!')
console.log('\nModel files:')
const files = fs.readdirSync(MODEL_DIR)
for (const file of files) {
const stats = fs.statSync(path.join(MODEL_DIR, file))
const sizeMB = (stats.size / 1024 / 1024).toFixed(2)
console.log(` - ${file}: ${sizeMB}MB`)
}
}
main().catch(console.error)