const { useState, useMemo, useEffect, useRef } = React; function App() { const [tab, setTab] = useState('research'); // search rows (manual keyword / competitor search) const [rows, setRows] = useState([ { id: 1, query: '', competitorId: null, count: 20, filters: { minLikes: '', minDailyLikes: '', orderBy: '', bsPlatform: '', bsMinViews: '', bsMinLikes: '', bsMinEngagement: '', bsSort: '', foreplayUrl: '', brandsearchUrl: '' }, platforms: { foreplay: true, adspy: false, brandsearch: true, metaads: false } }, ]); const [competitors, setCompetitors] = useState([]); const [directions, setDirections] = useState([]); const directionsRef = useRef([]); // pipeline stages const [researchAds, setResearchAds] = useState([]); const [filteredAds, setFilteredAds] = useState([]); const [remodeledAds, setRemodeledAds] = useState([]); const [autoRemodelAds, setAutoRemodelAds] = useState([]); const [remodelScripts, setRemodelScripts] = useState({}); // { [adId]: [{tag, body}] | 'pending' | 'error' } const remodelPollRef = useRef(null); const [convertedAds, setConvertedAds] = useState([]); const [autoresearchActive, setAutoresearchActive] = useState(false); // true if last Research view should show winners-only badge const [selResearch, setSelResearch] = useState(new Set()); const [selFilter, setSelFilter] = useState(new Set()); const [selRemodel, setSelRemodel] = useState(new Set()); // autoresearch controls const [arSelected, setArSelected] = useState(new Set()); const [arTarget, setArTarget] = useState('1'); const [arNumDirections, setArNumDirections] = useState('5'); // cap on directions when no explicit picks const [arPickedDirections, setArPickedDirections] = useState(new Set()); // empty = take first arNumDirections const [arRunning, setArRunning] = useState(false); const [arProgress, setArProgress] = useState(null); // { totalScored, winners, directionsSatisfied, directionsTotal, crawlState } const [arSources, setArSources] = useState({ foreplay: true, adspy: false, brandsearch: true, metaads: false }); // Platform-level credit balances surfaced in the top bar. Foreplay is live; // the other three are visual placeholders until their backends land. const [platformCredits, setPlatformCredits] = useState({ foreplay: null, adspy: null, brandsearch: null, metaads: null, }); const arPollRef = useRef(null); // ---- Auto-Remodel state ----------------------------------------------- // The orchestrator's directions picker is shared with manual autoresearch // — both pull from the same non-competitor pool. We keep it in App so the // panel + the autoresearch panel can stay independent components without // losing user picks when one re-renders. const [arOrchPickedDirections, setArOrchPickedDirections] = useState(new Set()); const toggleArOrchPickedDirection = (id) => setArOrchPickedDirections(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const clearArOrchPickedDirections = () => setArOrchPickedDirections(new Set()); const [arRunId, setArRunId] = useState(null); const [arStage, setArStage] = useState(null); const [arLastEvent, setArLastEvent] = useState(null); const [arPickedIds, setArPickedIds] = useState([]); // Refs that mirror the orchestrator state — the SSE callback below is // installed once via useEffect([]), so any state value referenced inside // its closure is captured at mount-time and goes stale. Refs let the // handler read the *current* value when an event arrives. const arRunIdRef = useRef(null); const arStageRef = useRef(null); const arPickedIdsRef = useRef([]); const arTargetAdsRef = useRef(0); const arAcceptedAdIdsRef = useRef(new Set()); useEffect(() => { arRunIdRef.current = arRunId; }, [arRunId]); useEffect(() => { arStageRef.current = arStage; }, [arStage]); useEffect(() => { arPickedIdsRef.current = arPickedIds; }, [arPickedIds]); useEffect(() => { arTargetAdsRef.current = arTargetAds; }, [arTargetAds]); // Live progress counters — fed by SSE so the panel meta line + progress // bar tick exactly like the autoresearch panel does. ``arNVariants`` is // captured at Run time so we know the variants_total denominator. const [arDirectionsDone, setArDirectionsDone] = useState(0); const [arDirectionsTotal, setArDirectionsTotal] = useState(0); const [arAdsFound, setArAdsFound] = useState(0); const [arTargetAds, setArTargetAds] = useState(0); const [arVariantsDone, setArVariantsDone] = useState(0); const [arNVariants, setArNVariants] = useState(1); const [arVariantsTotalOverride, setArVariantsTotalOverride] = useState(null); const arRunningOrch = !!arRunId && arStage !== 'done' && arStage !== 'failed'; // Platform toggles for the Auto-Remodel panel. Kept separate from // ``arSources`` (which the manual autoresearch panel owns) so the user // can configure each panel independently. const [arOrchSources, setArOrchSources] = useState({ foreplay: true, adspy: false, brandsearch: true, metaads: false, }); const toggleArOrchSource = (key) => setArOrchSources(s => ({ ...s, [key]: !s[key] })); const selectAllArOrchSources = () => { const LIVE = new Set(['foreplay', 'adspy', 'brandsearch']); setArOrchSources(s => { const next = { ...s }; const anyLiveOff = [...LIVE].some(k => !s[k]); [...LIVE].forEach(k => { next[k] = anyLiveOff ? true : false; }); return next; }); }; const toggleArPickedDirection = (id) => setArPickedDirections(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const clearArPickedDirections = () => setArPickedDirections(new Set()); const toggleArSource = (key) => setArSources(s => ({ ...s, [key]: !s[key] })); const selectAllArSources = () => { const LIVE = new Set(['foreplay', 'adspy', 'brandsearch']); setArSources(s => { const next = { ...s }; const anyLiveOff = [...LIVE].some(k => !s[k]); [...LIVE].forEach(k => { next[k] = anyLiveOff ? true : false; }); return next; }); }; const [tweaks, setTweaks] = useState({ theme: 'light', density: 'comfortable', cardStyle: 'standard', accent: 'teal' }); const [tweaksOpen, setTweaksOpen] = useState(false); const [toast, setToast] = useState(null); const [searching, setSearching] = useState(false); const [filterBusy, setFilterBusy] = useState(false); const [remodelBusy, setRemodelBusy] = useState(false); const [expandedAd, setExpandedAd] = useState(null); const AUTO_REMODEL_RESTORE_LIMIT_KEY = 'resilia.autoRemodel.lastTargetAds'; // edit-mode host protocol useEffect(() => { const onMsg = (e) => { if (e.data?.type === '__activate_edit_mode') setTweaksOpen(true); if (e.data?.type === '__deactivate_edit_mode') setTweaksOpen(false); }; window.addEventListener('message', onMsg); window.parent.postMessage({ type: '__edit_mode_available' }, '*'); return () => window.removeEventListener('message', onMsg); }, []); useEffect(() => { document.documentElement.setAttribute('data-theme', tweaks.theme); }, [tweaks.theme]); useEffect(() => { document.documentElement.setAttribute('data-accent', tweaks.accent); }, [tweaks.accent]); useEffect(() => { directionsRef.current = directions; }, [directions]); // Load competitors + directions + any cached session on mount useEffect(() => { (async () => { let loadedDirections = []; try { const [comps, dirs] = await Promise.all([ RESILIA_API.listCompetitors(), RESILIA_API.listDirections(), ]); loadedDirections = dirs || []; directionsRef.current = loadedDirections; setCompetitors(comps); setDirections(loadedDirections); } catch (e) { showToast('Failed to load directions: ' + e.message, 'Error'); } // Prime the Foreplay credit badge in the top bar. Other platforms stay // null until their backends land and wire a matching summary field. try { const s = await RESILIA_API.summary(); if (s && typeof s.credits_remaining === 'number') { setPlatformCredits(pc => ({ ...pc, foreplay: s.credits_remaining })); } } catch (e) { console.warn('initial summary fetch failed', e); } // Restore last search from Postgres cache try { const cached = await RESILIA_API.getCache('last_search_results'); const cachedAds = cached && cached.payload && Array.isArray(cached.payload.ads) ? cached.payload.ads : []; if (cachedAds.length) { const ads = cachedAds.map(a => ({ ...RESILIA_ADAPT.adaptSearchAd(a, 'foreplay'), source: 'search' })); setResearchAds(prev => [...prev.filter(a => a.source === 'autoresearch'), ...ads]); } } catch (e) { console.warn('restore search cache failed', e); } // Restore last autoresearch winners try { const perDir = parseInt(arTarget, 10) || 1; const winners = await RESILIA_API.autoresearchWinners(200, perDir); if (Array.isArray(winners) && winners.length) { const dirMap = directionMetaMap(loadedDirections); const ads = []; const seen = new Set(); for (const row of winners) { if (seen.has(row.ad_id)) continue; seen.add(row.ad_id); const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row); attachDirectionMeta(adapted, dirMap); ads.push({ ...adapted, source: 'autoresearch' }); } setResearchAds(prev => [...prev.filter(a => a.source !== 'autoresearch'), ...ads]); setAutoresearchActive(true); } } catch (e) { console.warn('restore autoresearch cache failed', e); } try { const auto = await RESILIA_API.getAutoRemodelState(); let restoredAutoRemodel = false; if (auto && auto.status === 'ok' && auto.state) { syncAutoRemodelState(auto.state); if (Array.isArray(auto.state.picked_ad_ids) && auto.state.picked_ad_ids.length) { const liveLimit = Math.max( 1, Math.min(20, parseInt(auto.state.target_ads ?? auto.state.count ?? auto.state.picked_ad_ids.length, 10) || 5), ); const pickedIds = auto.state.picked_ad_ids.slice(0, liveLimit); const ads = await hydratePickedAds(pickedIds, loadedDirections); if (ads.length) { setAutoRemodelAds(ads); setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); pollRemodelJobs(pickedIds); setTab('autoRemodel'); restoredAutoRemodel = true; } } } if (!restoredAutoRemodel) { const jobs = await RESILIA_API.listRemodelJobs({}); const autoJobs = Array.isArray(jobs) ? jobs.filter(j => j.source === 'auto_remodel' && j.ad_id) : []; const restoredIds = []; const seenAutoAds = new Set(); const readyAutoJobs = autoJobs.filter(j => j.status === 'done' && j.response_text); const pendingAutoJobs = autoJobs.filter(j => !(j.status === 'done' && j.response_text)); [ ...readyAutoJobs.slice().sort((a, b) => Number(b.id || 0) - Number(a.id || 0)), ...pendingAutoJobs.slice().sort((a, b) => Number(b.id || 0) - Number(a.id || 0)), ] .forEach(j => { if (seenAutoAds.has(j.ad_id)) return; seenAutoAds.add(j.ad_id); restoredIds.push(j.ad_id); }); if (restoredIds.length) { const restoreLimit = Math.max( 1, Math.min(20, parseInt(localStorage.getItem(AUTO_REMODEL_RESTORE_LIMIT_KEY), 10) || 5), ); const ids = restoredIds.slice(0, restoreLimit); let ads = await hydratePickedAds(ids, loadedDirections); const hydratedIds = new Set(ads.map(a => a.id)); const fallbackAds = ids .filter(id => !hydratedIds.has(id)) .map(id => { const j = autoJobs.find(job => job.ad_id === id) || {}; return { id, brand: j.ad_id || id, platform: 'foreplay', copy: '', score: null, reasoning: null, daysRunning: 0, directionLabel: 'Auto-Remodel', directionSearchLabel: 'Auto-Remodel', transcript: [], transcriptRaw: null, source: 'auto_remodel', }; }); ads = [...ads, ...fallbackAds]; setAutoRemodelAds(ads); setArPickedIds(ids); arPickedIdsRef.current = ids; setArStage('done'); setArTargetAds(restoreLimit); const idSet = new Set(ids); const restoredJobsForIds = autoJobs.filter(j => idSet.has(j.ad_id)); const restoredDone = restoredJobsForIds.filter(j => j.status === 'done' || j.status === 'failed').length; setArVariantsDone(restoredDone); setArVariantsTotalOverride(restoredJobsForIds.length || null); setArLastEvent({ stage: 'done', picked_ad_ids: ids, picked_count: ids.length, target_ads: restoreLimit }); setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); pollRemodelJobs(ids); setTab('autoRemodel'); } } } catch (e) { console.warn('restore auto-remodel state failed', e); } })(); return () => { if (arPollRef.current) clearInterval(arPollRef.current); if (remodelPollRef.current) clearInterval(remodelPollRef.current); }; }, []); const showToast = (msg, badge) => { setToast({ msg, badge }); setTimeout(() => setToast(null), 3200); }; const directionMetaMap = (items) => Object.fromEntries((items || []).map(d => { const keywords = Array.isArray(d.keywords) ? d.keywords.filter(Boolean) : []; const label = d.description || d.display_name || d.brand_name || d.id; const searchLabel = keywords.length ? keywords.slice(0, 4).join(', ') : (d.brand_name || d.display_name || label || d.id); return [d.id, { label, searchLabel }]; })); const attachDirectionMeta = (ad, metaMap) => { const meta = metaMap[ad.directionId]; ad.directionLabel = meta?.label || ad.directionId || null; ad.directionSearchLabel = meta?.searchLabel || ad.directionLabel || null; return ad; }; const syncAutoRemodelState = (state) => { if (!state) return; setArRunId(state.run_id || null); setArStage(state.stage || null); setArLastEvent({ ...state, run_id: state.run_id, stage: state.stage }); if (typeof state.directions_done === 'number') setArDirectionsDone(state.directions_done); if (typeof state.directions_total === 'number') setArDirectionsTotal(state.directions_total); if (typeof state.ads_found === 'number') setArAdsFound(state.ads_found); if (typeof state.target_ads === 'number') setArTargetAds(state.target_ads); if (typeof state.n_variants === 'number') setArNVariants(state.n_variants); if (Array.isArray(state.picked_ad_ids)) setArPickedIds(state.picked_ad_ids); }; const searchFiltersPayload = (filters = {}) => { const out = {}; const minLikes = parseInt(filters.minLikes, 10); const minDailyLikes = parseInt(filters.minDailyLikes, 10); const bsMinViews = parseInt(filters.bsMinViews, 10); const bsMinLikes = parseInt(filters.bsMinLikes, 10); const bsMinEngagement = parseFloat(filters.bsMinEngagement); if (Number.isFinite(minLikes) && minLikes > 0) out.adspy_min_likes = minLikes; if (Number.isFinite(minDailyLikes) && minDailyLikes > 0) out.adspy_min_daily_likes = minDailyLikes; if (filters.orderBy) out.adspy_order = filters.orderBy; if (filters.bsPlatform) out.brandsearch_platforms = [filters.bsPlatform]; if (Number.isFinite(bsMinViews) && bsMinViews > 0) out.brandsearch_min_views = bsMinViews; if (Number.isFinite(bsMinLikes) && bsMinLikes > 0) out.brandsearch_min_likes = bsMinLikes; if (Number.isFinite(bsMinEngagement) && bsMinEngagement > 0) out.brandsearch_min_engagement_rate = bsMinEngagement; if (filters.bsSort) out.brandsearch_sort = filters.bsSort; return Object.keys(out).length ? out : null; }; const searchSourceUrlsPayload = (filters = {}) => { const out = {}; const foreplayUrl = (filters.foreplayUrl || '').trim(); const brandsearchUrl = (filters.brandsearchUrl || '').trim(); if (foreplayUrl) out.foreplay = foreplayUrl; if (brandsearchUrl) out.brandsearch = brandsearchUrl; return Object.keys(out).length ? out : null; }; const expandAd = (ad) => { setExpandedAd(ad); if (!ad || ad.transcriptRaw || ad.platform !== 'brandsearch') return; RESILIA_API.fetchTranscript(ad.id).then(detail => { if (!detail || !detail.transcript) return; const patch = (item) => { if (item.id !== ad.id) return item; const adapted = RESILIA_ADAPT.adaptSearchAd({ ...(item.raw || {}), ad_id: item.id, brand: item.brand, platform: item.publisherPlatform, source_platform: item.platform, score: item.score, reasoning: item.reasoning, running_days: item.daysRunning, creative_url: item.creativeUrl, thumbnail_url: item.thumbnailUrl, transcript: detail.transcript, raw: { ...(item.raw || {}), ...(detail.raw || {}) }, metrics: item.raw?.metrics || detail.raw?.metrics || {}, }, item.platform); adapted.directionId = item.directionId; adapted.directionLabel = item.directionLabel; adapted.directionSearchLabel = item.directionSearchLabel; return { ...item, ...adapted, source: item.source }; }; setResearchAds(prev => prev.map(patch)); setFilteredAds(prev => prev.map(patch)); setRemodeledAds(prev => prev.map(patch)); setAutoRemodelAds(prev => prev.map(patch)); setExpandedAd(prev => prev && prev.id === ad.id ? patch(prev) : prev); }).catch(e => console.warn('brandsearch transcript hydration failed', e)); }; // ---- Manual search ------------------------------------------------------ const runSearch = async () => { setSearching(true); try { const results = []; for (const row of rows) { const sourceUrls = searchSourceUrlsPayload(row.filters); if (!row.query && !row.competitorId && !sourceUrls) continue; const platforms = Object.keys(row.platforms).filter(k => row.platforms[k]); const payload = { query: row.query, competitorId: row.competitorId, count: Math.min(row.count, 100), platforms, filters: searchFiltersPayload(row.filters), sourceUrls, }; try { const resp = await RESILIA_API.search(payload); const ads = (resp.ads || []).map(a => ({ ...RESILIA_ADAPT.adaptSearchAd(a, 'foreplay'), source: 'search' })); results.push(...ads); if (resp && typeof resp.credits_remaining === 'number') { setPlatformCredits(pc => ({ ...pc, foreplay: resp.credits_remaining })); } if (resp && Array.isArray(resp.warnings) && resp.warnings.length) { const labels = resp.warnings.map(w => w.source).filter(Boolean).join(', '); showToast(`Search completed with ${labels || resp.warnings.length} warning(s).`, 'Warn'); } } catch (e) { showToast(`Search failed for ${row.query || row.competitorId}: ${e.message}`, 'Error'); } } // dedupe by ad id across rows const seen = new Set(); const deduped = []; for (const a of results) { if (seen.has(a.id)) continue; seen.add(a.id); deduped.push(a); } // Keep existing autoresearch ads; only replace the search slice. setResearchAds(prev => [...prev.filter(a => a.source === 'autoresearch'), ...deduped]); if (deduped.length === 0) { showToast('No ads returned. Check your query or competitor.', 'Search'); } else { showToast(`${deduped.length} ads fetched (scored).`, 'Search'); } } finally { setSearching(false); } }; // ---- Autoresearch ------------------------------------------------------- const pollWinners = async () => { try { // Checkpoint-aware: only returns winners evaluated since the last // /api/autoresearch/checkpoint call, so previous runs never leak in. const perDir = parseInt(arTarget, 10) || 1; const rows = await RESILIA_API.autoresearchWinners(500, perDir); const dirMap = directionMetaMap(directions); const seen = new Set(); const ads = []; for (const row of rows) { if (seen.has(row.ad_id)) continue; seen.add(row.ad_id); const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row); attachDirectionMeta(adapted, dirMap); ads.push({ ...adapted, source: 'autoresearch' }); } // Replace only the autoresearch slice; search-source ads stay put. setResearchAds(prev => [...prev.filter(a => a.source !== 'autoresearch'), ...ads]); const summary = await RESILIA_API.summary(); if (summary) { const runTotal = summary.run_directions_total || 0; setArProgress({ totalScored: summary.total_scored, winners: ads.length, totalWinners: summary.winners, // Prefer the per-run counter while a crawl is active so the bar // tracks "directions finished this run", not lifetime DB state. directionsSatisfied: runTotal > 0 ? summary.run_directions_done : summary.directions_satisfied, directionsTotal: runTotal > 0 ? runTotal : summary.directions_total, crawlState: summary.crawl_state, creditsRemaining: summary.credits_remaining, }); if (typeof summary.credits_remaining === 'number') { setPlatformCredits(pc => ({ ...pc, foreplay: summary.credits_remaining })); } } if (summary && summary.crawl_state === 'idle') { setArRunning(false); if (arPollRef.current) { clearInterval(arPollRef.current); arPollRef.current = null; } showToast(`Autoresearch complete. ${ads.length} winners.`, 'Autoresearch'); } } catch (e) { console.warn('poll winners failed', e); } }; const runAutoresearch = async () => { const randomPool = arSources.brandsearch ? directions : directions.filter(d => d.type !== 'competitor'); if (randomPool.length === 0) { showToast('No directions configured for the selected sources.', 'Autoresearch'); return; } // Direction selection: explicit checkbox picks win; otherwise randomly // sample arNumDirections (default 5) from the non-competitor pool. // Random instead of slice-from-top so repeat runs cover different ground. let ids; if (arPickedDirections.size > 0) { ids = directions.filter(d => arPickedDirections.has(d.id)).map(d => d.id); } else { const cap = Math.max(1, Math.min(randomPool.length, parseInt(arNumDirections, 10) || 5)); const shuffled = randomPool.slice(); for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } ids = shuffled.slice(0, cap).map(d => d.id); } if (ids.length === 0) { showToast('No directions resolved — check the picker or the cap.', 'Autoresearch'); return; } const sources = Object.keys(arSources).filter(k => arSources[k]); if (sources.length === 0) { showToast('Pick at least one source (Foreplay, AdSpy, …).', 'Autoresearch'); return; } setArSelected(new Set(ids)); setAutoresearchActive(true); setArProgress({ crawlState: 'starting', winners: 0, totalScored: 0, directionsSatisfied: 0, directionsTotal: ids.length * sources.length }); // Drop previous autoresearch results from the UI so stale cards don't // linger before the first poll returns. setResearchAds(prev => prev.filter(a => a.source !== 'autoresearch')); try { // Mark a fresh checkpoint on the backend — winners from prior runs // won't show up in /api/autoresearch/winners anymore. await RESILIA_API.setAutoresearchCheckpoint(); await RESILIA_API.startAutoresearch({ directionIds: ids, perDirectionTarget: arTarget ? parseInt(arTarget, 10) : null, platforms: sources, }); setArRunning(true); showToast(`Autoresearch started for ${ids.length} direction(s) across ${sources.length} source(s).`, 'Autoresearch'); if (arPollRef.current) clearInterval(arPollRef.current); arPollRef.current = setInterval(pollWinners, 4000); pollWinners(); } catch (e) { showToast('Autoresearch failed to start: ' + e.message, 'Error'); } }; const stopAutoresearch = async () => { try { await RESILIA_API.stopAutoresearch(); setArRunning(false); if (arPollRef.current) { clearInterval(arPollRef.current); arPollRef.current = null; } showToast('Autoresearch stopping…', 'Autoresearch'); } catch (e) { showToast('Failed to stop: ' + e.message, 'Error'); } }; // ---- Auto-Remodel orchestrator ---------------------------------------- // Once the orchestrator picks ads, hydrate the swipe rows for them and // push them through the existing Research → Filter → Remodel views as the // pipeline transitions stages. This reuses the same panels the manual // flow uses (no duplicate UI to maintain). const hydratePickedAds = async (adIds, directionSource = directionsRef.current) => { if (!adIds || adIds.length === 0) return []; const dirMap = directionMetaMap(directionSource); const getSwipeWithRetry = async (id) => { for (let attempt = 0; attempt < 6; attempt += 1) { const swipe = await RESILIA_API.getSwipe(id); if (swipe) return swipe; await new Promise(resolve => setTimeout(resolve, 500)); } return null; }; const rows = await Promise.all(adIds.map(async (id) => { try { const swipe = await getSwipeWithRetry(id); if (!swipe) return null; let row = swipe; if (!row.transcript) { const detail = await RESILIA_API.fetchTranscript(id); if (detail && detail.transcript) { row = { ...row, transcript: detail.transcript, raw: { ...(row.raw || {}), ...(detail.raw || {}) } }; } } // ``adaptEvaluatedAd`` expects evaluated_ads-shaped rows; swipe rows // have the same column names for everything we need (ad_id, brand, // platform, transcript, score, reasoning, running_days, raw, …). const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row); attachDirectionMeta(adapted, dirMap); return { ...adapted, source: 'auto_remodel' }; } catch (e) { console.warn('hydratePickedAds failed for', id, e); return null; } })); return rows.filter(Boolean); }; useEffect(() => { const ids = arPickedIds || []; if (!ids.length || !['picked', 'filtering', 'filter_done', 'remodeling', 'done'].includes(arStage)) return; let cancelled = false; (async () => { const ads = await hydratePickedAds(ids); if (cancelled || ads.length === 0) return; setAutoRemodelAds(prev => { const seen = new Set(prev.map(a => a.id)); const missing = ads.filter(a => !seen.has(a.id)); return missing.length ? [...prev, ...missing] : prev; }); setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); pollRemodelJobs(ids); setTab('autoRemodel'); })(); return () => { cancelled = true; }; // arPickedIds is an array from SSE/state restore; use its contents as the trigger. // eslint-disable-next-line react-hooks/exhaustive-deps }, [arStage, arPickedIds.join('|')]); // SSE subscription. The same channel powers the existing autoresearch // panel's live progress; we layer Auto-Remodel on top with no new // transport. Returned cleanup closes the EventSource on unmount / HMR. useEffect(() => { const close = RESILIA_API.subscribe((ev) => { if (!ev || !ev.type) return; if (ev.type === 'auto_remodel_event') { // Late subscribers latch onto whichever run is broadcasting — keep // the first run_id we see for this session unless dismissed. setArRunId(prev => prev || ev.run_id); if (arRunIdRef.current && ev.run_id && ev.run_id !== arRunIdRef.current) return; setArStage(ev.stage || null); setArLastEvent(ev); // Crawl-side progress counters from the orchestrator. if (typeof ev.directions_done === 'number') setArDirectionsDone(ev.directions_done); if (typeof ev.directions_total === 'number') setArDirectionsTotal(ev.directions_total); if (typeof ev.ads_found === 'number') { const target = arTargetAdsRef.current || ev.target_ads || 0; setArAdsFound(target > 0 ? Math.min(ev.ads_found, target) : ev.ads_found); } if (typeof ev.target_ads === 'number') setArTargetAds(ev.target_ads); if (Array.isArray(ev.picked_ad_ids) && ev.picked_ad_ids.length) { const target = ev.target_ads || arTargetAdsRef.current || ev.picked_ad_ids.length; const cappedIds = ev.picked_ad_ids.slice(0, Math.max(1, Math.min(20, target))); arPickedIdsRef.current = cappedIds; setArPickedIds(cappedIds); } // Pipeline now skips the Filter step entirely (Sonnet pre-approves // every pick at score ≥ 7.5). Stages we react to: // * picked (with non-empty picks) // → hydrate ads, switch to Remodel tab, push to // remodeledAds with pending variant slots, start // polling. // * picked (empty picks) // → orchestrator's fresh-run filter found nothing // new. Stay on the current tab; toast explains. // * remodeling → no-op (just a status update). // * done/failed → final poll + toast. (async () => { if (ev.stage === 'picked' && Array.isArray(ev.picked_ad_ids)) { if (ev.picked_ad_ids.length === 0) { // Critical: never switch tabs on empty picks. The user clicked // Run expecting ads — switching to a blank Remodel view feels // like the app broke. Stay put; the orchestrator will fire // ``done`` next anyway. showToast( 'Auto-Remodel: no fresh ads found this run. Try different directions or wait for new winners to surface.', 'Auto-Remodel', ); return; } const ads = await hydratePickedAds(ev.picked_ad_ids); setAutoRemodelAds(ads); setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { next[a.id] = 'pending'; }); return next; }); if (remodelPollRef.current) clearInterval(remodelPollRef.current); const adIds = ads.map(a => a.id); remodelPollRef.current = setInterval(() => pollRemodelJobs(adIds), 3000); pollRemodelJobs(adIds); // Now that the picks for THIS run are known, jump to the // Remodel tab so the user lands on the variants as they stream // in. Doing it here (instead of on Run click) means the user // sees their starting tab during the crawl wait. setTab('autoRemodel'); showToast( `Auto-Remodel: ${ads.length} ad${ads.length === 1 ? '' : 's'} picked — generating variants.`, 'Auto-Remodel', ); } else if (ev.stage === 'done') { // Suppress the redundant "done" toast when we already toasted // about an empty pick — same run, same news. if ((ev.picked ?? 0) > 0) { showToast( `Auto-Remodel done — ${ev.completed ?? 0}/${ev.jobs_created ?? 0} jobs ok`, ev.failed ? 'Warn' : 'Auto-Remodel', ); } if (arPickedIdsRef.current.length) pollRemodelJobs(arPickedIdsRef.current); } else if (ev.stage === 'failed') { showToast(`Auto-Remodel failed: ${ev.error || 'unknown'}`, 'Error'); } })(); } else if (ev.type === 'remodel_completed' || ev.type === 'remodel_failed' || ev.type === 'remodel_edited') { // Variant-level updates: refresh just the affected ad's scripts via // the existing poll helper (handles the parsing + status logic). if (ev.ad_id && arPickedIdsRef.current.includes(ev.ad_id)) { pollRemodelJobs([ev.ad_id]); // Each remodel_completed/_failed represents one variant finishing // (in any state) — tick the panel's variant counter. if (ev.type === 'remodel_completed' || ev.type === 'remodel_failed') { setArVariantsDone(n => n + 1); } } } else if (ev.type === 'remodel_promoted') { if (ev.ad_id && arPickedIdsRef.current.includes(ev.ad_id)) { pollRemodelJobs([ev.ad_id]); } showToast(`Promoted to Video Queue (job ${ev.job_id})`, 'Promote'); } else if (ev.type === 'evaluated_deleted') { showToast(`Bulk delete — ${ev.count} ad(s) removed`, 'Delete'); } else if (ev.type === 'ad_scored' && ev.passes === true) { // The crawler fires ``ad_scored`` for every ad it evaluates — when // ``passes`` is true the ad just qualified. Stream qualifying ads // into the Research grid live so the user sees winners landing as // the orchestrator finds them, instead of waiting for the entire // crawl to finish before anything appears. Gated to active // Auto-Remodel runs so we don't conflict with the autoresearch // panel's own polling-based flow. const stageNow = arStageRef.current; const active = !!arRunIdRef.current && stageNow !== 'done' && stageNow !== 'failed'; if (!active) return; const target = arTargetAdsRef.current || 5; if (arAcceptedAdIdsRef.current.has(ev.ad_id)) return; if (arAcceptedAdIdsRef.current.size >= target) return; arAcceptedAdIdsRef.current.add(ev.ad_id); setArAdsFound(Math.min(arAcceptedAdIdsRef.current.size, target)); const dirMap = directionMetaMap(directionsRef.current); const synth = { ad_id: ev.ad_id, direction_id: ev.direction_id, score: ev.score, passes: true, reasoning: ev.reasoning, transcript: ev.transcript_preview, brand: ev.brand, platform: ev.platform, source_platform: ev.source_platform, running_days: ev.running_days, creative_url: ev.creative_url, thumbnail_url: ev.thumbnail_url, link_url: ev.link_url, metrics: ev.metrics || {}, raw: { source_platform: ev.source_platform, metrics: ev.metrics || {} }, }; const adapted = RESILIA_ADAPT.adaptEvaluatedAd(synth); attachDirectionMeta(adapted, dirMap); const ad = { ...adapted, source: 'autoresearch' }; RESILIA_API.fetchTranscript(ev.ad_id).then(detail => { if (!detail || !detail.transcript) return; const patchTranscript = (item) => { if (item.id !== ev.ad_id) return item; const patched = RESILIA_ADAPT.adaptEvaluatedAd({ ...synth, transcript: detail.transcript, raw: { ...(synth.raw || {}), ...(detail.raw || {}) }, }); patched.directionLabel = item.directionLabel; patched.directionSearchLabel = item.directionSearchLabel; return { ...item, ...patched, source: item.source }; }; setResearchAds(prev => prev.map(patchTranscript)); }).catch(e => console.warn('auto-remodel transcript hydration failed', e)); // Push only to the Research grid. The Auto-Remodel panel shows // progress; rendering cards there as well duplicates the ads below. setResearchAds(prev => { if (prev.some(a => a.id === ad.id)) return prev; const current = prev.filter(a => a.source === 'autoresearch'); if (current.length >= target) return prev; return [...prev, ad]; }); setAutoresearchActive(true); } }); return close; // We intentionally don't include arRunId/arPickedIds in deps — re-creating // the SSE connection on every state change would thrash the server. The // closure reads the latest values via the setState callbacks. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const startAutoRemodel = async ({ mode, directionId, directionIds, count, nVariants, platforms }) => { try { const out = await RESILIA_API.runAutoRemodel({ mode, directionId, directionIds, count, nVariants, platforms }); if (out.status === 'already_running') { showToast('Auto-Remodel is already running.', 'Auto-Remodel'); return; } // Fresh run — clear stale state. Stay on whatever tab the user is on; // the SSE handler will switch to Remodel once the orchestrator emits // the ``picked`` event (i.e. after the crawl finishes and the picks // for THIS run are known). Stop the manual-flow remodel poller too, // otherwise it could overwrite the orchestrator's state. if (remodelPollRef.current) { clearInterval(remodelPollRef.current); remodelPollRef.current = null; } setArRunId(out.run_id); setArStage('queued'); setArLastEvent(null); setArPickedIds([]); arPickedIdsRef.current = []; arAcceptedAdIdsRef.current = new Set(); setArDirectionsDone(0); setArDirectionsTotal(0); setArAdsFound(0); setArTargetAds(Math.max(1, Math.min(20, parseInt(count, 10) || 5))); localStorage.setItem( AUTO_REMODEL_RESTORE_LIMIT_KEY, String(Math.max(1, Math.min(20, parseInt(count, 10) || 5))), ); setArVariantsDone(0); setArNVariants(Math.max(1, parseInt(nVariants, 10) || 1)); setArVariantsTotalOverride(null); setAutoRemodelAds([]); setRemodelScripts(prev => { const next = { ...prev }; autoRemodelAds.forEach(ad => { delete next[ad.id]; }); arPickedIds.forEach(id => { delete next[id]; }); return next; }); setTab('autoRemodel'); // Clear the autoresearch slice so qualifying ads from a prior run // don't linger behind the new ones streaming in. setResearchAds(prev => prev.filter(a => a.source !== 'autoresearch')); const snapshot = await RESILIA_API.getAutoRemodelState(out.run_id); if (snapshot && snapshot.status === 'ok' && snapshot.state) { syncAutoRemodelState(snapshot.state); } showToast(`Auto-Remodel started (${out.run_id}) — staying here until picks land.`, 'Auto-Remodel'); } catch (e) { showToast('Failed to start Auto-Remodel: ' + e.message, 'Error'); } }; const regenerateRemodelJob = async (jobId) => { try { const out = await RESILIA_API.retryRemodelJob(jobId); const adId = Object.keys(remodelScripts).find(id => { const e = remodelScripts[id]; return e && Array.isArray(e.variants) && e.variants.some(v => v.jobId === jobId); }); if (adId) { setRemodelScripts(prev => ({ ...prev, [adId]: { status: 'pending', variants: [] } })); if (remodelPollRef.current) clearInterval(remodelPollRef.current); remodelPollRef.current = setInterval(() => pollRemodelJobs([adId]), 3000); pollRemodelJobs([adId]); } showToast(`Regeneration started (${out.run_id || jobId}).`, 'Remodel'); } catch (e) { showToast('Regenerate failed: ' + e.message, 'Error'); } }; const discardRemodelJob = async (jobId) => { if (!confirm('Discard this remodel variant?')) return; try { await RESILIA_API.deleteRemodelJob(jobId); setRemodelScripts(prev => { const next = { ...prev }; for (const adId of Object.keys(next)) { const entry = next[adId]; if (!entry || !Array.isArray(entry.variants)) continue; const variants = entry.variants.filter(v => v.jobId !== jobId); next[adId] = { ...entry, variants, status: variants.length ? entry.status : 'error' }; } return next; }); showToast('Variant discarded.', 'Remodel'); } catch (e) { showToast('Discard failed: ' + e.message, 'Error'); } }; const promoteRemodelJob = async (jobId) => { try { const out = await RESILIA_API.promoteRemodelJob(jobId); const job = out.job || out; const ad = remodeledAds.find(a => a.id === job.ad_id) || autoRemodelAds.find(a => a.id === job.ad_id); if (ad) { setConvertedAds(prev => prev.some(a => a.id === ad.id) ? prev : [...prev, ad]); } if (job.ad_id) pollRemodelJobs([job.ad_id]); showToast(`Promoted to Video Queue (job ${job.id || jobId}).`, 'Promote'); return job; } catch (e) { showToast('Promote failed: ' + e.message, 'Error'); return null; } }; const promoteAllAutoRemodelJobs = async () => { const jobIds = []; autoRemodelAds.forEach(ad => { const entry = remodelScripts[ad.id]; if (!entry || !Array.isArray(entry.variants)) return; entry.variants.forEach(v => { if (v.jobId) jobIds.push(v.jobId); }); }); if (!jobIds.length) { showToast('No Auto-Remodel scripts are ready.', 'Promote'); return; } try { const promoted = []; for (const jobId of jobIds) { const out = await RESILIA_API.promoteRemodelJob(jobId); const job = out.job || out; if (job) promoted.push(job); } const promotedAdIds = new Set(promoted.map(j => j.ad_id).filter(Boolean)); setConvertedAds(prev => { const seen = new Set(prev.map(a => a.id)); const next = [...prev]; autoRemodelAds.forEach(ad => { if (promotedAdIds.has(ad.id) && !seen.has(ad.id)) next.push(ad); }); return next; }); if (promotedAdIds.size) pollRemodelJobs([...promotedAdIds]); showToast(`${promoted.length} Auto-Remodel script${promoted.length === 1 ? '' : 's'} promoted to Video Queue.`, 'Promote'); } catch (e) { showToast('Promote all failed: ' + e.message, 'Error'); } }; // ---- Research-tab bulk delete ----------------------------------------- const bulkDeleteResearch = async () => { const ids = [...selResearch]; if (!ids.length) return; if (!confirm(`Delete ${ids.length} evaluated ad row(s)?\n\nQualified winners and swipe file are NOT touched.`)) return; try { const out = await RESILIA_API.bulkDeleteEvaluated(ids); setResearchAds(prev => prev.filter(a => !selResearch.has(a.id))); setSelResearch(new Set()); showToast(`${out.removed} ad(s) removed.`, 'Delete'); } catch (e) { showToast('Bulk delete failed: ' + e.message, 'Error'); } }; // ---- Pipeline transitions ---------------------------------------------- const toggleSel = (setter) => (id) => { setter(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); }; const ensureAdsInSwipe = async (picked) => { // Filter/Remodel backends read transcripts from swipe_file, so ads // coming straight from search need to be persisted first. Autoresearch // ads are already in swipe_file via the promote-winners flow. for (const ad of picked) { try { await RESILIA_API.addToSwipe({ adId: ad.id, raw: ad.raw || {}, directionId: ad.directionId || null, }); } catch (e) { console.warn('addToSwipe failed for', ad.id, e); } } }; const passToFilter = async () => { const picked = researchAds.filter(a => selResearch.has(a.id)); if (picked.length === 0) return; setFilterBusy(true); try { await ensureAdsInSwipe(picked); await RESILIA_API.runFilter({ adIds: picked.map(a => a.id) }); // Optimistically seed the Filter tab with the picked ads; real // verdicts arrive async — UI can refresh via SSE in a follow-up. setFilteredAds(picked); setSelResearch(new Set()); setTab('filter'); showToast(`Filter started on ${picked.length} ads.`, 'Filter'); } catch (e) { showToast('Filter failed: ' + e.message, 'Error'); } finally { setFilterBusy(false); } }; const passWithoutFilter = async () => { const picked = researchAds.filter(a => selResearch.has(a.id)); if (picked.length === 0) return; try { await ensureAdsInSwipe(picked); await startRemodelAndPoll(picked); setRemodeledAds(picked); setSelResearch(new Set()); setTab('remodel'); showToast(`${picked.length} ads passed without filtering. Remodeling…`, 'Bypass'); } catch (e) { showToast('Pass failed: ' + e.message, 'Error'); } }; // Extract just the REMODELED SCRIPT prose from the remodeler output — skip // the analysis header and any surrounding metadata. Each paragraph becomes // one untagged line so the UI renders it as a readable transcript rather // than a HOOK/PROBLEM/SOLUTION breakdown. const parseRemodelScript = (text) => { if (!text) return []; const scriptMatch = text.match(/###\s*REMODELED\s+SCRIPT\s*\n([\s\S]*?)(?=\n###\s|$)/i); const body = scriptMatch ? scriptMatch[1].trim() : text.trim(); const paras = body.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean); return paras.map(p => ({ tag: '', body: p.replace(/^\*\*[^*]+\*\*\s*/, '').replace(/\n+/g, ' '), })); }; // ``editRemodelScript`` now takes a job id directly (RemodelView passes // the jobId of the specific variant being edited). Each ad can have // multiple variants in flight, so an adId-only lookup would be ambiguous. const editRemodelScript = async (jobId, newText) => { if (!jobId) { showToast('No remodel job to edit yet — wait for it to finish.', 'Remodel'); return; } try { await RESILIA_API.updateRemodelScript({ jobId, responseText: newText }); // Refresh by re-polling the affected ad — keeps the variants array in // sync with the DB without a bespoke patch path. const adId = Object.keys(remodelScripts).find(id => { const e = remodelScripts[id]; return e && Array.isArray(e.variants) && e.variants.some(v => v.jobId === jobId); }); if (adId) pollRemodelJobs([adId]); showToast('Edit saved.', 'Remodel'); } catch (e) { showToast('Save failed: ' + e.message, 'Error'); throw e; } }; // Each ad can spawn N variants (one Sonnet call per variant). We track // them as ``{ status, variants: [{ jobId, variantIdx, raw, lines }] }`` // so the Remodel view can render every finished variant as its own // editable card. Polling stops only when every variant for every ad has // reached done/failed. const pollRemodelJobs = async (adIds) => { try { const perAd = await Promise.all( adIds.map(id => RESILIA_API.listRemodelJobs({ adId: id })), ); setRemodelScripts(prev => { const next = { ...prev }; let stillPending = 0; adIds.forEach((id, i) => { const jobs = perAd[i] || []; const done = jobs.filter(j => j.status === 'done' && j.response_text); const failed = jobs.filter(j => j.status === 'failed'); const inFlight = jobs.length - done.length - failed.length; if (done.length > 0) { next[id] = { status: 'ready', variants: done .slice() .sort((a, b) => (a.variant_idx || 0) - (b.variant_idx || 0)) .map(j => ({ jobId: j.id, variantIdx: j.variant_idx || 0, raw: j.response_text, lines: parseRemodelScript(j.response_text), promotedAt: j.promoted_at || null, })), }; if (inFlight > 0) stillPending++; } else if (failed.length === jobs.length && jobs.length > 0) { next[id] = { status: 'error', variants: [] }; } else { next[id] = { status: 'pending', variants: [] }; stillPending++; } }); if (stillPending === 0 && remodelPollRef.current) { clearInterval(remodelPollRef.current); remodelPollRef.current = null; } return next; }); } catch (e) { console.warn('poll remodel jobs failed', e); } }; const startRemodelAndPoll = async (picked) => { const adIds = picked.map(a => a.id); setRemodelScripts(prev => { const next = { ...prev }; adIds.forEach(id => { next[id] = 'pending'; }); return next; }); await RESILIA_API.runRemodel({ adIds, nVariants: 1 }); if (remodelPollRef.current) clearInterval(remodelPollRef.current); remodelPollRef.current = setInterval(() => pollRemodelJobs(adIds), 3000); pollRemodelJobs(adIds); }; const runRemodel = async () => { const picked = filteredAds.filter(a => selFilter.has(a.id)); if (picked.length === 0) return; setRemodelBusy(true); try { await ensureAdsInSwipe(picked); await startRemodelAndPoll(picked); setRemodeledAds(picked); setSelFilter(new Set()); setTab('remodel'); showToast(`Remodel started on ${picked.length} ads (1 variant each).`, 'Remodel'); } catch (e) { showToast('Remodel failed: ' + e.message, 'Error'); } finally { setRemodelBusy(false); } }; const convert = () => { const picked = remodeledAds.filter(a => selRemodel.has(a.id)); setConvertedAds(picked); setSelRemodel(new Set()); setTab('video'); showToast(`${picked.length} ads queued for video conversion.`, 'Video'); }; const counts = { research: researchAds.length, autoRemodel: autoRemodelAds.length || arPickedIds.length, filter: filteredAds.length, remodel: remodeledAds.length, video: convertedAds.length, }; // group by platform const groupByPlatform = (ads) => { const g = { foreplay: [], adspy: [], brandsearch: [], metaads: [] }; ads.forEach(a => g[a.platform]?.push(a)); return g; }; return (
{tab === 'research' && ( d.type !== 'competitor')} arPickedDirections={arPickedDirections} onToggleArPickedDirection={toggleArPickedDirection} onClearArPickedDirections={clearArPickedDirections} onRunAutoresearch={runAutoresearch} onStopAutoresearch={stopAutoresearch} /> )} {tab === 'filter' && ( )} {tab === 'autoRemodel' && ( )} {tab === 'remodel' && ( )} {tab === 'video' && ( )}
{tab === 'research' && ( setSelResearch(new Set())} onSelectAll={() => setSelResearch(new Set(researchAds.map(a => a.id)))} /> )} {tab === 'filter' && ( setSelFilter(new Set())} onSelectAll={() => setSelFilter(new Set(filteredAds.map(a => a.id)))} /> )} {tab === 'remodel' && ( setSelRemodel(new Set())} onSelectAll={() => setSelRemodel(new Set(remodeledAds.map(a => a.id)))} /> )} setExpandedAd(null)} /> {toast && (
{toast.badge} {toast.msg}
)} {!tweaksOpen && ( )} {tweaksOpen && ( { setTweaks(next); window.parent.postMessage({ type: '__edit_mode_set_keys', edits: next }, '*'); }} onClose={() => setTweaksOpen(false)} /> )}
); } function ResearchView({ rows, setRows, competitors, ads, searching, sel, onToggle, onSearch, onExpandAd, density, minimal, groupByPlatform, arRunning, arProgress, arSources, onToggleArSource, onSelectAllArSources, arTarget, setArTarget, onRunAutoresearch, onStopAutoresearch, arNumDirections, setArNumDirections, directions, arPickedDirections, onToggleArPickedDirection, onClearArPickedDirections, }) { const updateRow = (id, patch) => setRows(rows.map(r => r.id === id ? patch : r)); const removeRow = (id) => setRows(rows.filter(r => r.id !== id)); const addRow = () => setRows([...rows, { id: Date.now(), query: '', competitorId: null, count: 20, filters: { minLikes: '', minDailyLikes: '', orderBy: '', bsPlatform: '', bsMinViews: '', bsMinLikes: '', bsMinEngagement: '', bsSort: '', foreplayUrl: '', brandsearchUrl: '' }, platforms: { foreplay: true, adspy: false, brandsearch: true, metaads: false }, }]); const searchAds = ads.filter(a => a.source !== 'autoresearch'); const autoAds = ads.filter(a => a.source === 'autoresearch'); const searchGrouped = groupByPlatform(searchAds); const autoGrouped = groupByPlatform(autoAds); const searchPlatforms = Object.keys(searchGrouped).filter(p => searchGrouped[p].length > 0); const autoPlatforms = Object.keys(autoGrouped).filter(p => autoGrouped[p].length > 0); return ( <>
Search Multi-row keyword or competitor-scoped search · Foreplay + AdSpy + BrandSearch live
{rows.map(r => ( updateRow(r.id, patch)} onRemove={() => removeRow(r.id)} canRemove={rows.length > 1} /> ))}
{searchPlatforms.length > 0 && searchPlatforms.map(p => ( ))} {autoPlatforms.length > 0 && autoPlatforms.map(p => ( ))} {searchPlatforms.length === 0 && autoPlatforms.length === 0 && (
No results yet.

Run a search above or pick autoresearch directions and press Run Autoresearch.

)} ); } function PipelineView({ title, subtitle, ads, sel, onToggle, onExpandAd, density, minimal, groupByPlatform, emptyTitle, emptyBody }) { const grouped = groupByPlatform(ads); const activePlatforms = Object.keys(grouped).filter(p => grouped[p].length > 0); return ( <>
{title} {subtitle}
{activePlatforms.length === 0 ? (
{emptyTitle}

{emptyBody}

) : ( activePlatforms.map(p => ( )) )} ); } function AutoRemodelWorkspace({ ads, scripts, directionOptions, running, stage, lastEvent, adsFound, targetAds, variantsDone, variantsTotal, sources, onToggleSource, onSelectAllSources, onRun, onEditScript, onRegenerate, onDiscard, onPromote, onPromoteAll, }) { const readyJobs = ads.flatMap(ad => { const entry = scripts[ad.id]; if (!entry || !Array.isArray(entry.variants)) return []; return entry.variants.filter(v => v.jobId).map(v => v.jobId); }); return ( <>
Auto-Remodel Autonomous search, scoring, filter audit, and script generation.
); } function AutoRemodelResultsView({ ads, scripts, readyJobs, running, stage, onEditScript, onRegenerate, onDiscard, onPromote, onPromoteAll, }) { const canPromoteAll = readyJobs.length > 0; const sortedAds = ads.slice().sort((a, b) => { const entryA = scripts[a.id]; const entryB = scripts[b.id]; const readyA = entryA && Array.isArray(entryA.variants) && entryA.variants.length > 0; const readyB = entryB && Array.isArray(entryB.variants) && entryB.variants.length > 0; if (readyA !== readyB) return readyA ? -1 : 1; const hasTranscriptA = !!(a.transcriptRaw || (Array.isArray(a.transcript) && a.transcript.length)); const hasTranscriptB = !!(b.transcriptRaw || (Array.isArray(b.transcript) && b.transcript.length)); if (hasTranscriptA !== hasTranscriptB) return hasTranscriptA ? -1 : 1; return 0; }); return (
Auto-Remodel Results {ads.length} ad{ads.length === 1 ? '' : 's'}
{ads.length === 0 ? (
No Auto-Remodel results yet

{running ? 'The run is working. Results will appear here as soon as top ads are picked.' : stage === 'failed' ? 'The last run failed.' : 'Configure Auto-Remodel above and run it.'}

) : (
{sortedAds.map(ad => ( ))}
)}
); } function AutoRemodelResultCard({ ad, entry, onEditScript, onRegenerate, onDiscard, onPromote }) { const variants = entry && Array.isArray(entry.variants) ? entry.variants : []; const status = entry && entry.status ? entry.status : (entry === 'error' ? 'error' : 'pending'); const transcriptText = ad.transcriptRaw || (Array.isArray(ad.transcript) ? ad.transcript.map(seg => seg.body || seg.t || '').filter(Boolean).join('\n') : (ad.transcript || '')); const directionWords = ad.directionSearchLabel || ad.directionLabel; const statusLabel = variants.length > 0 ? `${variants.length} remodeled script${variants.length === 1 ? '' : 's'}` : status === 'error' ? 'Generation failed' : 'Generating scripts'; return (
{ad.brand || ad.id} {ad.id}
0 ? 'ready' : 'pending'}`}>{statusLabel} {typeof ad.score === 'number' && {ad.score.toFixed(1)}}
{ad.daysRunning ? {ad.daysRunning}d running : null} {directionWords ? ( {directionWords} ) : null} {ad.platform ? {ad.platform} : null}
{(transcriptText || ad.reasoning) && (
Original transcript, Sonnet score, and reasoning {typeof ad.score === 'number' &&

Sonnet score: {ad.score.toFixed(1)}

} {ad.reasoning &&

{ad.reasoning}

} {transcriptText &&
{transcriptText}
}
)}
{status === 'pending' && variants.length === 0 && (
Generating scripts

Claude Sonnet is still working on this ad.

)} {status === 'error' && variants.length === 0 && (
Generation failed

Regenerate from the job action when a failed variant is available.

)} {variants.map(v => ( onEditScript(v.jobId, text)} onRegenerate={() => onRegenerate(v.jobId)} onDiscard={() => onDiscard(v.jobId)} onPromote={() => onPromote(v.jobId)} /> ))}
); } function AutoRemodelMediaPreview({ ad, transcriptText }) { const [playing, setPlaying] = React.useState(false); const p = PLATFORMS[ad.platform] || PLATFORMS.foreplay; const metricChips = RESILIA_ADAPT.metricChips(ad); const hasVideo = !!ad.creativeUrl; const hasThumbnail = !!ad.thumbnailUrl; const showVideoPreview = hasVideo && !hasThumbnail && !playing; const scoreText = typeof ad.score === 'number' ? (ad.score > 10 ? Math.round(ad.score) : ad.score.toFixed(1)) : null; const sourceCopy = ad.copy || (transcriptText ? transcriptText.slice(0, 220) : ''); const directionWords = ad.directionSearchLabel || ad.directionLabel; return (
{playing && hasVideo ? (
Original ad {hasVideo ? video : thumbnail}
{directionWords && (
{directionWords}
)} {sourceCopy &&

{sourceCopy}

}
{scoreText !== null && {scoreText}} {metricChips.map(({ label, value, title, className }) => ( {label}{value} ))} {ad.daysRunning ? {ad.daysRunning}d running : null} {ad.directionLabel && ad.directionLabel !== directionWords ? {ad.directionLabel} : null}
); } function AutoRemodelScriptEditor({ variant, onSave, onRegenerate, onDiscard, onPromote }) { const [draft, setDraft] = React.useState(variant.raw || ''); const [expanded, setExpanded] = React.useState(false); const [editing, setEditing] = React.useState(false); const [saving, setSaving] = React.useState(false); React.useEffect(() => { setDraft(variant.raw || ''); }, [variant.raw, variant.jobId]); const startEdit = () => { setExpanded(true); setEditing(true); }; const save = async () => { setSaving(true); try { await onSave(draft); setEditing(false); } finally { setSaving(false); } }; const cancelEdit = () => { setDraft(variant.raw || ''); setEditing(false); }; return (
Remodeled {variant.promotedAt && Promoted {String(variant.promotedAt).slice(0, 16)}}
{!editing && ( )} {!editing && ( )} {!editing && ( )} {!editing && ( )} {editing && ( <> )}
{expanded && (editing ? (