VSEARCH / src /App.jsx
bep40's picture
load timeList (newest-first interiors) and pass it to PhotoViewer for date-based navigation
bcd06d8 verified
Raw History Blame Contribute Delete
18.9 kB
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ImageField from './ImageField.jsx';
import PhotoViewer from './PhotoViewer.jsx';
import { mergeResults } from './merge.js';
import { searchAvatarInteriors, loadRecentAvatarInteriors } from './avatar-interiors.js';
/**
* How often to re-read the product catalogue so add/edit/delete on vai-avatar
* show up without a reload.
*
* Short enough to feel like it is actually synchronised. The catalogue is read
* straight from the datasets vai-avatar writes (see catalogue.js), so this
* interval IS the end-to-end delay between pressing "Lưu" there and the change
* showing up here. The previous 90s sat just inside the window where a tab
* stops being believed, which is why edits seemed not to land at all.
*
* Each tick re-reads two small JSON files, and a content hash decides whether
* anything re-ranks, so a tick that finds no change costs one fetch and no UI
* work. 30s keeps the visible delay below the point where anyone reloads to
* "fix" it.
*/
const POLL_MS = 30_000;
function CloseIcon() {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m7 7 10 10M7 17 17 7" /></svg>;
}
export default function App() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [status, setStatus] = useState({ phase: 'idle', label: '' });
const [busy, setBusy] = useState(false);
const [photo, setPhoto] = useState(null);
// Placeholder until the worker reports the real collection size.
const [count, setCount] = useState(1222);
// Malloca product results, ranked against the vai-avatar catalogue.
const [products, setProducts] = useState([]);
const [productStatus, setProductStatus] = useState({ loading: false, error: '' });
const [catalogue, setCatalogue] = useState({ count: 0 });
// The newest interior photographs published on vai-avatar, already matched
// against the current query by the worker (see avatar-interiors.js). Kept as
// its own state so a new photograph appearing upstream reaches the field on
// the next poll even when nothing has been typed.
const [avatarInteriors, setAvatarInteriors] = useState([]);
// The newest ADDED PRODUCTS (via vai-avatar "Thêm SP"). Kept as its own state
// so a product pasted on vai-avatar shows up in the field -- ringed and
// enlarged like the newest interiors -- on the next poll, with no query.
const [recentProducts, setRecentProducts] = useState([]);
// The unfiltered newest-first set, held so clearing the query can restore it.
// Without this the field would keep showing only the photographs that happened
// to match the last search, and a new upload would not appear until it was
// typed at.
const avatarPool = useRef([]);
const worker = useRef(null);
const input = useRef(null);
const requestId = useRef(0);
const productId = useRef(0);
const initialized = useRef(false);
const waiter = useRef(null);
// Read by the worker handler and the poll loop. Refs, not state, so those
// effects never have to be re-subscribed -- re-subscribing would tear down
// and recreate the worker on every keystroke.
const queryRef = useRef('');
const productsRef = useRef([]);
const signature = useRef('');
/**
* The newest-first interior list, used by the fullscreen viewer for time-based
* left/right navigation. Kept in a ref (not state) so the render of the
* viewer never re-renders when the pool updates -- only the navigation
* behaviour changes.
*/
const timeList = useRef([]);
useEffect(() => {
const instance = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
worker.current = instance;
instance.onmessage = ({ data }) => {
if (data.status === 'loading') setStatus({ phase: 'loading', label: data.label, progress: data.progress });
if (data.status === 'ready') { initialized.current = true; setStatus({ phase: 'ready', label: '' }); setCount(data.count); }
if (data.status === 'complete' && data.id === requestId.current) {
setResults(data.output); setBusy(false);
setStatus({ phase: 'ready', label: '', elapsed: data.elapsed });
waiter.current?.resolve({ query: data.text, matches: data.output.slice(0, 12).map(({ id, score }) => ({ id, score })), elapsedMs: Math.round(data.elapsed) });
waiter.current = null;
}
// Product search: independent of the interior index, same query.
if (data.status === 'products-loading') setProductStatus(current => ({ ...current, loading: true }));
if (data.status === 'products-complete' && data.id === productId.current) {
setProducts(data.output); setProductStatus({ loading: false, error: '' });
// The matched interiors ride the same message, so one setState pair
// updates both halves of the field atomically -- no frame where a new
// photograph sits next to results for a query that did not produce it.
if (Array.isArray(data.interiors)) setAvatarInteriors(data.interiors);
if (typeof data.signature === 'string') signature.current = data.signature;
if (data.count) setCatalogue({ count: data.count });
}
// vai-avatar published new interiors. `changed` is false on the vast
// majority of ticks, and skipping the setState then is what stops the
// field being torn down and rebuilt every 30s for no visible reason.
if (data.status === 'avatar-updated') {
if (data.changed) {
// With no query, every new photograph is shown. That is the whole
// point of the feature: a photograph added on vai-avatar has to be
// visible here without anyone having to search for it. With a query,
// the list is filtered to the ones that answer it, so the "new" row at
// the top of the field is always relevant to what was actually typed.
const text = queryRef.current.trim();
avatarPool.current = data.items ?? [];
setAvatarInteriors(text ? searchAvatarInteriors(text, avatarPool.current) : avatarPool.current);
// The newest added products, ringed on the same field as the newest
// interiors. `data.recentProducts` is already newest-first and
// image-bearing (the worker filters), so it drops straight in.
if (Array.isArray(data.recentProducts)) setRecentProducts(data.recentProducts);
// Ask for a re-rank so the products arrive from the same snapshot.
if (text) worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 48 });
}
}
if (data.status === 'products-error' && data.id === productId.current) {
setProducts([]); setProductStatus({ loading: false, error: data.message });
}
// The catalogue changed on vai-avatar while this tab was open: re-rank the
// current query so a newly added, edited or removed product appears (or
// disappears) without the user touching anything. Driven by a content
// hash, not a timestamp, so an edit that leaves the SKU set unchanged is
// still noticed.
if (data.status === 'catalogue-updated') {
const changed = data.signature !== signature.current;
signature.current = data.signature;
setCatalogue({ count: data.count });
if (changed) {
const text = queryRef.current.trim();
if (text) worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 48 });
}
}
// Only surface a refresh failure if the panel has nothing to show: a
// stale-but-working catalogue beats an error over a transient blip.
if (data.status === 'catalogue-error') {
setProductStatus(current => (current.error || productsRef.current.length
? current
: { loading: false, error: `Không đồng bộ được danh mục sản phẩm: ${data.message}` }));
}
if (data.status === 'error' && (data.id == null || data.id === requestId.current)) {
setStatus({ phase: 'error', label: data.message }); setBusy(false);
waiter.current?.reject(new Error(data.message)); waiter.current = null;
}
};
instance.onerror = event => {
setStatus({ phase: 'error', label: event.message || 'Search could not start. Please reload and try again.' });
setBusy(false);
waiter.current?.reject(new Error('Search worker failed.')); waiter.current = null;
};
/**
* Start loading the model as soon as the worker exists.
*
* This used to wait 900ms "so the gallery renders first". But the model is a
* single 130 MB ONNX graph, and that wait was pure dead time added to the
* one thing a visitor is actually waiting on: the first search. The gallery
* was never blocked by it either -- ImageField lays out and paints on its
* own, and the worker runs off the main thread, so nothing this delay
* protected is actually blocked.
*
* Removing it is what makes the 130 MB download overlap the first paint
* instead of starting a second of dead time after it.
*
* `init` is still sent rather than run at module scope so the message
* handler below is installed first: the worker posts `loading` immediately,
* and a reply that arrived with no listener would drop the progress readout.
*/
instance.postMessage({ type: 'init' });
return () => { instance.terminate(); };
}, []);
// Load the time-ordered interior list once, so the viewer can page by date.
useEffect(() => {
let cancelled = false;
loadRecentAvatarInteriors(400).then(list => {
if (!cancelled) {
timeList.current = list;
if (!avatarPool.current.length && list.length) {
avatarPool.current = list;
}
}
}).catch(console.error);
return () => { cancelled = true; };
}, []);
useEffect(() => { productsRef.current = products; }, [products]);
// Poll the catalogue so add / edit / delete on vai-avatar propagate here.
useEffect(() => {
const tick = () => worker.current?.postMessage({ type: 'refresh-catalogue' });
const timer = setInterval(tick, POLL_MS);
// Also re-read when the tab comes back: a laptop waking from sleep has just
// missed whatever happened while it was away, and the cheapest catch-up is
// a single re-read.
const onVisible = () => { if (document.visibilityState === 'visible') tick(); };
document.addEventListener('visibilitychange', onVisible);
return () => { clearInterval(timer); document.removeEventListener('visibilitychange', onVisible); };
}, []);
const changeQuery = useCallback((value) => {
waiter.current?.reject(new Error('Search superseded by a newer query.')); waiter.current = null;
// Close the open photo only when the query ACTUALLY changed.
//
// This used to be unconditional on every onChange, so someone reading a
// product at full screen and typing another word had their viewer slammed
// shut mid-lookup. For the interior field that is arguably right — you are
// editing the query that produced it. For the catalogue it defeats the
// feature: the shared viewer is the reason to click a product at all, so
// anything that closes it on a keystroke undermines merging the galleries.
//
// Same-value calls are still reached (the retry button and Enter both call
// changeQuery(query)), so results are re-ranked either way; only the
// dismissal is now conditional.
if (value.trim() !== queryRef.current.trim()) setPhoto(null);
setQuery(value);
queryRef.current = value;
const id = ++requestId.current;
const text = value.trim();
if (!text) {
worker.current?.postMessage({ type: 'clear' });
setResults([]); setBusy(false);
setProducts([]); setProductStatus({ loading: false, error: '' });
// Clearing the query restores the unfiltered newest-first set, so the
// photographs just added on vai-avatar are visible again rather than the
// field being left showing only the tail of the previous search.
setAvatarInteriors(avatarPool.current);
return;
}
setBusy(true);
setProductStatus({ loading: true, error: '' });
// Filtered locally first so the featured row does not blank out on every
// keystroke while the worker's own answer is still in flight.
setAvatarInteriors(searchAvatarInteriors(text, avatarPool.current));
// The worker coalesces queued requests; typing needs no artificial debounce.
worker.current?.postMessage({ type: 'search', text, id });
// Products share the query but are ranked against their own catalogue.
worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 48 });
}, []);
useEffect(() => {
const keydown = event => {
if (photo || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === '/' && event.target.tagName !== 'INPUT') { event.preventDefault(); input.current?.focus(); }
if (event.key === 'Escape') { changeQuery(''); input.current?.blur(); }
};
window.addEventListener('keydown', keydown);
return () => window.removeEventListener('keydown', keydown);
}, [photo, changeQuery]);
useEffect(() => {
const context = document.modelContext;
if (!context?.registerTool) return;
const lifecycle = new AbortController();
const tool = {
name: 'search_images', title: 'Tìm ảnh nội thất',
description: 'Tìm ảnh nội thất theo ý nghĩa và sắp xếp lại trường ảnh đang hiển thị. Truy vấn rỗng sẽ khôi phục bộ sưu tập.',
inputSchema: { type: 'object', properties: { query: { type: 'string', maxLength: 300 } }, required: ['query'], additionalProperties: false },
annotations: { readOnlyHint: false, untrustedContentHint: false },
execute: async ({ query: text }) => {
if (typeof text !== 'string' || text.length > 300) throw new Error('Query must be a string of at most 300 characters.');
changeQuery(text);
if (!text.trim()) { await new Promise(resolve => requestAnimationFrame(resolve)); return { query: '', reset: true }; }
return new Promise((resolve, reject) => { waiter.current = { resolve, reject }; });
},
};
try { Promise.resolve(context.registerTool(tool, { signal: lifecycle.signal })).catch(console.warn); } catch { /* Unsupported experimental API. */ }
return () => lifecycle.abort();
}, [changeQuery]);
const loading = status.phase === 'loading';
const error = status.phase === 'error';
/**
* One field, not two lists.
*
* Interior photographs and Malloca products used to be rendered separately:
* photographs as the animated plane, products as a strip across the top of
* it. They are now interleaved into a single ranking, so a product occupies
* the same plane as the interiors and opens in the same viewer.
*
* The merge runs on every render because `products` arrives from its own
* request: the catalogue is read straight from the datasets vai-avatar writes,
* so a product added there appears here within one poll (30s) with no rebuild
* and no re-embedding.
*/
const field = useMemo(
// `query` is a dependency because mergeResults needs it to decide which
// product is the decisive winner, and that decision changes as the query
// changes. Without it, a tile enlarged for the previous query's exact answer
// would keep its size under the new one.
() => mergeResults(results, products, { avatarInteriors, recentProducts, query }),
[results, products, avatarInteriors, recentProducts, query],
);
/**
* Catalogue trouble is still reported, but only when it leaves the field
* empty. The panel that used to carry this message is gone, and an error
* sitting over the photographs would be worse than the missing products.
*/
const catalogueError = !productStatus.error || products.length ? '' : productStatus.error;
return <main className="app relative isolate h-dvh w-full overflow-hidden">
<div className="brand-backdrop" aria-hidden="true" />
<ImageField
results={field}
paused={!!photo}
query={query}
products={products}
onSelect={(image, origin) => setPhoto({ ...image, origin })}
/>
<div className="vignette" aria-hidden="true" />
<div className="grain" aria-hidden="true" />
<section className="search-center" aria-label="Tìm kiếm ảnh ngữ nghĩa">
<form className="search-pill" role="search" onSubmit={event => { event.preventDefault(); changeQuery(query); }}>
<input ref={input} type="text" value={query} placeholder="Tìm ảnh nội thất…" aria-label="Tìm ảnh nội thất theo ý nghĩa" aria-describedby="search-status" autoComplete="off" spellCheck="false" maxLength={300} onChange={event => changeQuery(event.target.value)} />
{query && <button type="button" className="clear-button" aria-label="Xoá tìm kiếm" onClick={() => { changeQuery(''); input.current?.focus(); }}><CloseIcon /></button>}
<div className={`search-progress ${loading || busy ? 'visible' : ''}`} aria-hidden="true"><span style={{ width: loading && status.progress ? `${status.progress}%` : '35%' }} /></div>
</form>
<div id="search-status" className={`search-status ${error ? 'has-error' : ''}`} role="status" aria-live="polite">
{!error && catalogueError ? <><span className="has-error-inline">{catalogueError}</span></> : null}
{error ? <><span>Không tải được bộ tìm kiếm.</span><button onClick={() => { setStatus({ phase: 'loading', label: 'Retrying' }); worker.current?.postMessage({ type: 'init' }); if (query.trim()) changeQuery(query); }}>Thử lại</button><span className="error-detail">{status.label}</span></> : loading ? <><span className="status-dot" />{status.label}{status.progress != null ? ` · ${Math.round(status.progress)}%` : ''}</> : busy ? <span className="sr-only">Searching</span> : null}
</div>
</section>
<footer className="footer pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-between">
<a className="pointer-events-auto" href="https://github.com/huggingface/transformers.js-examples/tree/main/semantic-image-search-web" target="_blank" rel="noreferrer">Transformers.js <span className="version">4.3.0</span></a>
<span>{catalogue.count ? `${catalogue.count.toLocaleString()} sản phẩm` : 'sản phẩm'}<span className="footer-divider">/</span> {count.toLocaleString()} ảnh</span>
</footer>
{photo && <PhotoViewer photo={photo} timeList={timeList.current} onClose={() => setPhoto(null)} />}
</main>;
}