Spaces:
Running
Running
Download src/App.jsx from bep40/VSEARCH: direct link, hf CLI and curl.
- Browser
- Download file 18.9 kB
-
https://huggingface.co/spaces/bep40/VSEARCH/resolve/main/src/App.jsx
- Command line
-
hf download hf://spaces/bep40/VSEARCH/src/App.jsx
-
curl -L -o App.jsx https://huggingface.co/spaces/bep40/VSEARCH/resolve/main/src/App.jsx
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>; | |
| } | |