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 ; } 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
; }