Spaces:
Running
Running
File size: 12,147 Bytes
c9e39f3 ad2fb72 c9e39f3 dbaf080 0df020e eb92bc7 c9e39f3 b48d4b5 81f1b70 67f0412 b48d4b5 c9e39f3 67f0412 81f1b70 ad2fb72 c9e39f3 b48d4b5 c9e39f3 b48d4b5 e34a847 c9e39f3 dbaf080 b48d4b5 eb92bc7 58b6cf1 eb92bc7 58b6cf1 eb92bc7 58b6cf1 eb92bc7 843987c dbaf080 0df020e eb92bc7 dbaf080 eb92bc7 dbaf080 843987c c9e39f3 dbaf080 eb92bc7 c9e39f3 843987c aba5d22 dbaf080 eb92bc7 c9e39f3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 | import { AutoTokenizer, CLIPTextModelWithProjection, env } from '@huggingface/transformers';
import { cachedFile, DATASET, TOKENIZER, TEXT_MODEL, TEXT_MODEL_REVISION, DIMENSIONS } from './assets.js';
import { normalizeDatabase, rankImages } from './search.js';
import { loadCatalogue, searchCatalogue, tryLoadCatalogue } from './catalogue.js';
import { searchLikeAvatar } from './product-search.js';
import { loadRecentAvatarInteriors, searchAvatarInteriors, RECENT_LIMIT } from './avatar-interiors.js';
env.allowLocalModels = false;
env.backends.onnx.wasm.numThreads = 1;
let instance;
let pending;
let running = false;
const send = message => self.postMessage(message);
/**
* Interior-photo search. The text tower is bep40/clip-vit-b32-multilingual-vi-text:
* a CLIP-B/32 text config with mean pooling and the 768->512 projection baked in,
* so Vietnamese queries land in the same 512-d space as the image matrix
* (projection_dim: 512 in config.json, confirmed on the Hub).
*
* The tokenizer comes from the upstream sentence-transformers repo, NOT the fork:
* the fork's tokenizer_config.json is a BERT WordPiece config ([CLS]/[SEP],
* model_max_length 512) that does not match its own CLIP config
* (max_position_embeddings 77). The fork supplies only the ONNX graph.
*/
function initialize() {
return instance ??= (async () => {
send({ status: 'loading', label: 'Đang tải mô hình tìm kiếm' });
const progress_callback = event => {
if (event.status === 'progress' && event.file?.endsWith('.onnx')) {
send({ status: 'loading', label: 'Đang tải mô hình tìm kiếm', progress: event.progress });
}
};
const [tokenizer, model, metadata, buffer] = await Promise.all([
AutoTokenizer.from_pretrained(TOKENIZER),
// onnx/ contains exactly one graph, text_model.onnx (130.6 MB), and no
// *_quantized variant -- verified on the Hub. Any dtype other than 'fp32'
// would ask for a file that does not exist, so pin 'fp32' and device 'wasm'.
//
// `revision` is load-bearing, not a reproducibility nicety: without it the
// graph is fetched and cached under the unversioned /resolve/main/ URL, so
// browsers that cached the broken 68dc949 export keep failing with
// [ShapeInferenceError] Inferred=1 Declared=768 on every reload. See
// TEXT_MODEL_REVISION in assets.js.
CLIPTextModelWithProjection.from_pretrained(
TEXT_MODEL,
{ dtype: 'fp32', device: 'wasm', revision: TEXT_MODEL_REVISION, progress_callback },
),
cachedFile(DATASET + 'image-embeddings.json').then(buffer => JSON.parse(new TextDecoder().decode(buffer))),
cachedFile(DATASET + 'image-embeddings_25k-512-32bit.bin'),
]);
const embeddings = new Float32Array(buffer);
if (embeddings.length !== metadata.length * DIMENSIONS) {
throw new Error('The image database and embeddings do not match.');
}
normalizeDatabase(embeddings, DIMENSIONS);
send({ status: 'loading', label: 'Đang khởi động tìm kiếm', progress: 100 });
await model(tokenizer('a photograph', { padding: true, truncation: true }));
send({ status: 'ready', count: metadata.length });
return { tokenizer, model, metadata, embeddings };
})().catch(error => { instance = null; throw error; });
}
async function drain() {
if (running) return;
running = true;
try {
const { tokenizer, model, metadata, embeddings } = await initialize();
while (pending) {
const request = pending;
pending = null;
const started = performance.now();
try {
const { text_embeds } = await model(tokenizer(request.text, { padding: true, truncation: true }));
// A newer query has already arrived: don't animate an obsolete result.
if (pending) continue;
// Keep more interiors than the field shows: merge.js then interleaves
// the product hits into this list, so a top-60 request that yields 12
// products still fills a 96-tile field instead of stopping at 60.
const output = rankImages(text_embeds.data, embeddings, DIMENSIONS, 96)
.map(({ index, score }) => ({ ...metadata[index], score }));
send({ status: 'complete', id: request.id, text: request.text, output, elapsed: performance.now() - started });
} catch (error) {
send({ status: 'error', id: request.id, message: error.message });
}
}
} catch (error) {
send({ status: 'error', id: pending?.id, message: error.message });
pending = null;
} finally { running = false; }
}
/**
* Rank Malloca products against the live catalogue that vai-avatar writes.
*
* This is deliberately lexical rather than a baked CLIP matrix. A matrix built
* from a snapshot can only ever know the products that existed when it was
* built, so "Thêm SP" on vai-avatar would stay invisible until someone re-ran a
* 6,372-image embed. Reading the same JSON files vai-avatar writes means a new
* product is searchable on the next query, and a deleted one disappears on its
* own -- no rebuild, no staleness window.
*
* The catalogue is kept warm so a keystroke does not refetch; `refresh-catalogue`
* forces a re-read, and the periodic poll re-reads it without a query.
*/
let catalogue = null;
function catalogueItems() {
return catalogue ?? loadCatalogue().then(next => { catalogue = next; return next; });
}
/** Re-read both source files. Shared by the poll loop and the manual refresh. */
async function refreshCatalogue(force = false) {
const next = force ? await loadCatalogue() : await tryLoadCatalogue();
if (next) catalogue = next;
return next;
}
/* ── Interiors published on vai-avatar ───────────────────────────────────── */
/**
* The newest interior photographs from vai-avatar, kept warm.
*
* Warm for the same reason the catalogue is: a poll tick must not make a visitor
* wait on a Hub download. `avatarInteriorSignature` is the ids, so a tick that
* finds nothing new costs one fetch and no UI work -- which matters because this
* is the only thing that tells the main thread a new image exists at all.
*/
let avatarInteriors = null;
let avatarInteriorSignature = '';
let recentProductSignature = '';
async function avatarInteriorItems() {
if (avatarInteriors) return avatarInteriors;
avatarInteriors = await loadRecentAvatarInteriors(RECENT_LIMIT);
avatarInteriorSignature = avatarInteriors.map(item => item.avatarImageId).join(',');
return avatarInteriors;
}
/**
* Re-read vai-avatar's interiors and report whether anything changed.
*
* `changed` is computed here rather than in App.jsx so the comparison is made
* against the same value the worker already holds, instead of App keeping its
* own copy that could drift from what was actually sent.
*/
async function refreshAvatarInteriors() {
const items = await loadRecentAvatarInteriors(RECENT_LIMIT);
const next = items.map(item => item.avatarImageId).join(',');
// The newest ADDED PRODUCTS ride the same message as the newest interiors, so
// "mới" means the same thing for both kinds of photograph and App.jsx has one
// state update to make. Without this, a product pasted on vai-avatar (e.g.
// "Thêm SP" -> products_url_added.json) would stay invisible until someone
// typed its exact sku -- the mirror of the interiors feature it is meant to
// sit beside.
const { items: catItems } = await catalogueItems().catch(() => ({ items: null }));
const recentProducts = catItems ? recentProductsOf(catItems) : [];
const rpKey = recentProducts.map(p => p.sku + '@' + (p.addedAt || '')).join(',');
// `changed` is composite: a poll that only spins up a new product (or only
// a new interior) still counts as a change worth re-painting. A poll with
// neither touches nothing, which is what stops the field rebuilding every 30s.
const changed = next !== avatarInteriorSignature || rpKey !== recentProductSignature;
avatarInteriors = items;
avatarInteriorSignature = next;
recentProductSignature = rpKey;
send({ status: 'avatar-updated', items, signature: next, changed, count: items.length, recentProducts });
}
/**
* The newest added products, ready to ring like the newest interiors.
*
* `addedAt` comes from `_added_at` preserved by normalizeProduct (catalogue.js).
* Products with no timestamp are not "recent"; the newest RECENT_PRODUCT_LIMIT
* of those that have one are returned, newest first.
*/
const RECENT_PRODUCT_LIMIT = 12;
function recentProductsOf(items) {
if (!items?.size) return [];
const withTime = [];
for (const product of items.values()) {
if (!product?.addedAt) continue;
if (!Array.isArray(product.images) || !product.images.some(src => typeof src === 'string' && /^(https?:\/\/|data:image\/)/i.test(src))) continue;
withTime.push({ ...product, score: 1 });
}
withTime.sort((a, b) => String(b.addedAt || '').localeCompare(String(a.addedAt || '')));
return withTime.slice(0, RECENT_PRODUCT_LIMIT);
}
async function searchProducts(text, id, limit) {
const started = performance.now();
try {
send({ status: 'products-loading' });
const { items, signature } = await catalogueItems();
// Ranked with vai-avatar's own scorer (see product-search.js) so a query
// typed here finds the same product, in the same order, as the same query
// typed in vai-avatar's panel.
//
// Both scorers run and both sets are returned. `output` drives the field;
// `legacy` is kept so the ranking change can be measured rather than
// assumed -- it costs one extra pass over a few hundred products, and
// searchCatalogue was the only ranking this Space had until now.
const output = searchLikeAvatar(text, items, limit);
const legacy = searchCatalogue(text, items, limit);
// vai-avatar's newest interiors, matched against the SAME query. Carried on
// the same message as the products so App.jsx needs one state update and the
// three kinds of result can never arrive out of step with each other -- the
// field would otherwise briefly show a new photograph beside results for a
// query that did not produce it.
const interiors = searchAvatarInteriors(text, await avatarInteriorItems());
send({
status: 'products-complete', id, text, output, legacy, interiors,
signature, count: items.size, elapsed: performance.now() - started,
});
} catch (error) {
send({ status: 'products-error', id, message: error.message });
}
}
self.addEventListener('message', ({ data }) => {
if (data.type === 'init') {
initialize().catch(error => send({ status: 'error', message: error.message }));
// Warm the catalogue in the background so the first product query does not
// wait on two dataset downloads. A failure here is non-fatal: it surfaces
// on the first search, where it can be reported properly.
catalogueItems().catch(() => {});
// Same treatment for vai-avatar's interiors, so the "mới" panel can paint on
// first load instead of after a Hub round trip. Non-fatal for the same
// reason: the field works perfectly well without it.
refreshAvatarInteriors().catch(() => {});
} else if (data.type === 'search') {
pending = { id: data.id, text: data.text };
drain();
} else if (data.type === 'search-products') {
searchProducts(data.text, data.id, data.limit ?? 48);
} else if (data.type === 'refresh-catalogue') {
refreshCatalogue(data.force === true)
.then(next => next && send({ status: 'catalogue-updated', signature: next.signature, count: next.items.size, syncedAt: next.syncedAt }))
.catch(error => send({ status: 'catalogue-error', message: error.message }));
// The interiors re-read rides the same tick as the catalogue, so one poll
// covers both live sources. Two independent intervals would drift apart and
// the "mới" panel would show a stale set for part of every cycle.
refreshAvatarInteriors().catch(() => {});
} else if (data.type === 'clear') {
pending = null;
}
});
|