const { useState, useMemo, useEffect, useRef } = React; const LIVE_SOURCES = ['foreplay', 'adspy', 'brandsearch']; const MANUAL_SEARCH_ALL_COMPETITORS_ID = '__all_competitors__'; const AR_TARGET_MIN = 1; const AR_TARGET_MAX = window.RESILIA_LIMITS?.autoresearchPerDirectionMax || 1000; const SEARCH_ADS_PER_DIRECTION_MAX = window.RESILIA_LIMITS?.searchAdsPerDirectionMax || 10000; const SEARCH_ADS_PER_PLATFORM_MAX = window.RESILIA_LIMITS?.searchAdsPerPlatformMax || 100000; const AUTORESEARCH_PAGE_SIZE = 30; const AUTORESEARCH_FAIL_DISPLAY_LIMIT = 500; const AUTO_REMODEL_COUNT_MAX = window.RESILIA_LIMITS?.autoRemodelCountMax || 10000; const AUTO_REMODEL_PAGE_SIZE = 20; const AUTO_REMODEL_PREVIOUS_PAGE_SIZE = 10; const PIPELINE_PAGE_SIZE = 20; // Background pollers: 12s cadence matches the user's "ads come winning after // ~10s" expectation while keeping DB pool pressure low. Pollers are also // gated on document.hidden so a backgrounded tab does not hammer the API. const AR_POLL_INTERVAL_MS = 12000; const AR_ACTIVE_POLL_INTERVAL_MS = 5000; const AR_PREFILL_POLL_INTERVAL_MS = 2000; const REMODEL_POLL_INTERVAL_MS = 12000; const FILTER_POLL_INTERVAL_MS = 12000; const COMPLIANCE_POLL_INTERVAL_MS = 12000; const TAB_STORAGE_KEY = 'resilia.dashboard.activeTab'; const showAutoRemodelUI = () => window.RESILIA_UI?.showAutoRemodel === true; const normalizeDashboardTab = (nextTab, fallback = 'research') => { const resolved = DASHBOARD_TABS.has(nextTab) ? nextTab : fallback; if (resolved === 'autoRemodel' && !showAutoRemodelUI()) return 'autoSearch'; return resolved; }; const ACTIVE_SEARCH_RUNS_STORAGE_KEY = 'resilia.dashboard.activeSearchRuns'; const REMODEL_STRATEGY_MAX = 4000; const REMODEL_VARIANTS_MAX = 10; const REMODEL_METHODS = [ { id: 'updated', label: 'Updated', short: 'U', defaultTemperature: 0.5 }, { id: 'traditional', label: 'Traditional', short: 'T', defaultTemperature: 0.5 }, { id: 'hybrid', label: 'Hybrid', short: 'H', defaultTemperature: 0.5 }, ]; const REMODEL_METHOD_BY_ID = Object.fromEntries(REMODEL_METHODS.map(method => [method.id, method])); const normalizeRemodelMethod = (value) => (REMODEL_METHOD_BY_ID[value] ? value : 'updated'); const remodelMethodMeta = (value) => REMODEL_METHOD_BY_ID[normalizeRemodelMethod(value)]; const clampRemodelTemperature = (value, fallback = 0.5) => { if (value === null || value === undefined || value === '') return fallback; const n = Number(value); return Math.max(0, Math.min(1, Number.isFinite(n) ? n : fallback)); }; const formatRemodelTemperature = (value) => clampRemodelTemperature(value).toFixed(2); const remodelPlanRow = (method = 'updated', temperature = null) => { const meta = remodelMethodMeta(method); return { remodelMethod: meta.id, temperature: clampRemodelTemperature( temperature, meta.defaultTemperature, ), }; }; const remodelPlanFromVariants = (variants = []) => { const rows = (variants || []).map(v => remodelPlanRow(v.remodelMethod, v.temperature ?? 0.5)); return rows.length ? rows.slice(0, REMODEL_VARIANTS_MAX) : [remodelPlanRow()]; }; const remodelVariantLabel = (variant, fallbackIndex = 0) => { const meta = remodelMethodMeta(variant?.remodelMethod); const temp = variant?.temperature; const tempLabel = temp === null || temp === undefined ? '' : ` · temp ${formatRemodelTemperature(temp)}`; return `Variant ${fallbackIndex + 1} · ${meta.short}${tempLabel}`; }; const remodelPendingEntryForPlan = (variantPlan = [remodelPlanRow()]) => ({ status: 'pending', expectedVariants: variantPlan.length, variants: variantPlan.map((row, index) => ({ jobId: null, variantIdx: index + 1, pending: true, raw: '', lines: [{ tag: 'REMODELING', body: 'Claude Sonnet is generating this remodel output.' }], remodelMethod: normalizeRemodelMethod(row.remodelMethod), temperature: clampRemodelTemperature(row.temperature), promotedAt: null, })), }); const DASHBOARD_TABS = new Set(['staticAds', 'autoRemodel', 'autoSearch', 'research', 'filter', 'remodel', 'compliance', 'video', 'accessRequests', 'adminDirections', 'adminWinningCriteria', 'adminGlobalCrawler', 'adminCosts', 'adminEvaluationQuotas', 'adminWorkspaces', 'adminUsers']); const STATIC_ADS_POLL_INTERVAL_MS = 12000; const passThresholdForAd = (ad) => { const threshold = Number(ad?.passThreshold ?? ad?.pass_threshold ?? ad?.scoreThreshold ?? ad?.score_threshold); return Number.isFinite(threshold) ? threshold : null; }; const sourcePlatformForAd = (ad) => { const raw = ad?.raw && typeof ad.raw === 'object' ? ad.raw : {}; const platform = String( ad?.sourcePlatform || ad?.source_platform || raw.source_platform || raw.source || ad?.platform || 'foreplay', ).trim().toLowerCase(); return ['foreplay', 'adspy', 'brandsearch'].includes(platform) ? platform : 'foreplay'; }; const adMeetsPassThreshold = (ad, unknownFallback = false) => { if (typeof ad?.passes === 'boolean') return ad.passes; const score = Number(ad?.score); const threshold = passThresholdForAd(ad); if (Number.isFinite(score) && threshold !== null) return score >= threshold; return unknownFallback; }; const savedDashboardTab = () => { try { const saved = window.localStorage.getItem(TAB_STORAGE_KEY); return saved && DASHBOARD_TABS.has(saved) ? normalizeDashboardTab(saved) : null; } catch { return null; } }; const boundedPositiveIntOrNull = (value, max = AR_TARGET_MAX) => { if (value === null || value === undefined || value === '') return null; const n = Number(value); if (!Number.isFinite(n)) return null; return Math.max(AR_TARGET_MIN, Math.min(max, Math.floor(n))); }; const schedulerClockIntOrNull = (value, max) => { if (value === null || value === undefined || value === '') return null; const n = Number(value); if (!Number.isFinite(n)) return null; return Math.max(0, Math.min(max, Math.floor(n))); }; const sanitizeSchedulerClockInput = (value, max) => { if (value === '') return ''; const n = schedulerClockIntOrNull(value, max); return n === null ? '' : String(n); }; const SCHEDULER_TIMEZONE_OPTIONS = [ 'Asia/Karachi', 'UTC', 'America/New_York', 'America/Los_Angeles', 'Europe/London', 'Asia/Dubai', 'Asia/Singapore', ]; const COST_FLOW_ORDER = ['search', 'autoresearch', 'auto_remodel']; const COST_FLOW_LABELS = { search: 'Manual Search', autoresearch: 'AutoSearch', auto_remodel: 'Auto-Remodel', }; const DEDUPE_DEFAULTS = { scope_mode: 'platform_search_type', match_strategy: 'exact' }; const DEDUPE_SCOPE_LABELS = { platform_search_type: 'Both: platform + search type', platform: 'Platform only', search_type: 'Search type only', overall: 'Everywhere', }; const costMoney = (value) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4, }).format(Number(value || 0)); const compactNumber = (value) => new Intl.NumberFormat('en-US').format(Number(value || 0)); const sanitizeArTargetInput = (value, max = AR_TARGET_MAX) => { if (value === '') return ''; const n = boundedPositiveIntOrNull(value, max); return n === null ? '' : String(n); }; const resolveAutoresearchProgressMeta = (runState, summary, { looksComplete, useRunState }) => { const prefilledCount = Number(runState?.prefilled_count || runState?.result_count || 0); const remainingTarget = Number(runState?.remaining_target || 0); const runPhase = runState?.run_phase || null; const poolOnlyComplete = Boolean( runState?.shared_pool_prefill_only || runPhase === 'shared_pool_ready' || runState?.status === 'done' ); if (looksComplete || poolOnlyComplete) { return { crawlState: 'done', runPhase: runPhase || 'done', prefilledCount, remainingTarget, }; } if (runPhase) { return { crawlState: runPhase, runPhase, prefilledCount, remainingTarget }; } const crawlState = useRunState ? (runState?.crawl_status || runState?.job_status || runState?.status || 'running') : summary?.crawl_state; return { crawlState, runPhase: crawlState, prefilledCount, remainingTarget }; }; const sourceState = (selected = ['foreplay']) => { const active = new Set(Array.isArray(selected) ? selected : [selected]); return { foreplay: active.has('foreplay'), adspy: active.has('adspy'), brandsearch: active.has('brandsearch'), metaads: false, }; }; const allSourceState = () => sourceState(LIVE_SOURCES); const resultKey = (ad) => ( ad?.resultUuid || ad?.result_uuid || ad?.cardKey || [ad?._arRunId, ad?._arBatchId, ad?.searchRunId, ad?.searchBatchId, ad?.runId, ad?.id] .filter(Boolean) .join(':') ); const stableRandomRank = (value, seed = '') => { const text = `${seed}:${value || ''}`; let hash = 2166136261; for (let i = 0; i < text.length; i += 1) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); } return hash >>> 0; }; const normalizedCreativeKey = (ad) => { const raw = ad?.creativeUrl || ad?.creative_url || ad?.raw?.creative_url || ad?.raw?.creativeUrl || ad?.raw?.video_url || ad?.raw?.videoUrl || ad?.thumbnailUrl || ad?.thumbnail_url || ad?.thumb || ad?.raw?.thumbnail_url || ad?.raw?.thumbnail || ad?.raw?.thumb_url || ad?.raw?.image_url || ad?.raw?.image || ad?.url || null; if (!raw || typeof raw !== 'string') return null; try { const base = typeof window !== 'undefined' ? window.location.origin : 'https://resilia.local'; const parsed = new URL(raw, base); [...parsed.searchParams.keys()].forEach(key => { const lower = key.toLowerCase(); if (lower.startsWith('utm_') || ['fbclid', 'gclid', 'msclkid'].includes(lower)) { parsed.searchParams.delete(key); } }); parsed.hash = ''; return parsed.href.toLowerCase(); } catch { return raw.trim().toLowerCase(); } }; const transcriptTextKey = (ad) => { const explicitHash = ad?.transcriptHash || ad?.transcript_hash || ad?.raw?.transcript_hash || null; if (explicitHash) return String(explicitHash).trim().toLowerCase(); const text = ad?.transcriptRaw || ad?.full_transcription || ad?.raw?.full_transcription || ad?.raw?.transcript || ad?.raw?.transcription || (typeof ad?.transcript === 'string' ? ad.transcript : null); if (!text || typeof text !== 'string') return null; return text.replace(/\s+/g, ' ').trim().toLowerCase() || null; }; const visibleCopyKey = (ad) => { const text = ad?.copy || ad?.raw?.copy || ad?.raw?.text || ad?.raw?.body || ad?.raw?.title || null; if (!text || typeof text !== 'string') return null; const brand = ad?.brand || ad?.raw?.brand || ad?.raw?.page_name || ad?.raw?.name || ''; const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase(); return normalized ? `${String(brand).trim().toLowerCase()}:${normalized}` : null; }; const campaignInstanceKey = (ad) => { const raw = ad?.raw && typeof ad.raw === 'object' ? ad.raw : {}; const source = ad?.sourcePlatform || ad?.source_platform || raw.source_platform || raw.source || ad?.platform || ''; const brand = ad?.brand || raw.brand || raw.page_name || raw.name || ''; const publisher = ad?.publisherPlatform || ad?.publisher_platform || raw.publisher_platform || raw.platform || ''; const started = ad?.startedRunningMs || ad?.started_running_ms || raw.started_running_ms || raw.started_running || null; const link = ad?.linkUrl || ad?.link_url || raw.link_url || raw.url || ''; if (!source || !brand || !started || !link) return null; return [ String(source).trim().toLowerCase(), String(brand).trim().toLowerCase(), String(publisher).trim().toLowerCase(), String(started), String(link).trim().toLowerCase(), ].join(':'); }; const autoresearchDisplayKey = (ad) => { const campaign = campaignInstanceKey(ad); if (campaign) return `campaign:${campaign}`; const creative = normalizedCreativeKey(ad); if (creative) return `creative:${creative}`; const transcript = transcriptTextKey(ad); if (transcript) return `transcript:${transcript}`; const copy = visibleCopyKey(ad); if (copy) return `copy:${copy}`; return resultKey(ad) || ad?.id; }; const uniqueAutoresearchAds = (items) => { const seen = new Set(); const out = []; for (const ad of items || []) { const key = autoresearchDisplayKey(ad); if (seen.has(key)) continue; seen.add(key); out.push(ad); } return out; }; const uniqueSearchAds = (items) => { const seen = new Set(); const out = []; for (const ad of items || []) { const key = autoresearchDisplayKey(ad); if (seen.has(key)) continue; seen.add(key); out.push(ad); } return out; }; const autoRemodelDisplayKey = (ad) => autoresearchDisplayKey(ad); const normaliseDedupeSettings = (settings = {}) => ({ scope_mode: DEDUPE_SCOPE_LABELS[settings?.scope_mode] ? settings.scope_mode : DEDUPE_DEFAULTS.scope_mode, match_strategy: DEDUPE_DEFAULTS.match_strategy, }); const normaliseDedupePlatform = (value) => { if (Array.isArray(value)) { return [...new Set(value.map(normaliseDedupePlatform).filter(Boolean))].sort().join(','); } if (value === null || value === undefined) return null; const parts = String(value) .split(/[,/|]/) .map(part => part.trim().toLowerCase().replace(/\s+/g, '_')) .filter(Boolean); return parts.length ? [...new Set(parts)].sort().join(',') : null; }; const autoRemodelPublisherPlatform = (ad) => { const raw = ad?.raw && typeof ad.raw === 'object' ? ad.raw : {}; const source = normaliseDedupePlatform(ad?.sourcePlatform || ad?.source_platform || ad?.source || raw.source_platform || raw.source); return normaliseDedupePlatform( ad?.publisherPlatform || ad?.publisher_platform || ad?.platform || raw.publisher_platform || raw.platform, ) || source || 'unknown'; }; const autoRemodelDedupeScopePrefix = (ad, settings = DEDUPE_DEFAULTS) => { const resolved = normaliseDedupeSettings(settings); const platform = autoRemodelPublisherPlatform(ad); if (resolved.scope_mode === 'platform_search_type') return `platform_search_type:${platform}:auto_remodel`; if (resolved.scope_mode === 'platform') return `platform:${platform}`; if (resolved.scope_mode === 'search_type') return 'search_type:auto_remodel'; return 'overall'; }; const autoRemodelDedupeKeys = (ad, settings = DEDUPE_DEFAULTS) => { const resolved = normaliseDedupeSettings(settings); const scope = autoRemodelDedupeScopePrefix(ad, resolved); const creative = normalizedCreativeKey(ad); const transcript = transcriptTextKey(ad); const keys = []; if (creative) keys.push(`${scope}:url:${creative}`); if (transcript && !creative) { keys.push(`${scope}:transcript:${transcript}`); } if (!keys.length) keys.push(`display:${autoRemodelDisplayKey(ad)}`); return keys; }; const hasAutoRemodelDedupeOverlap = (keySet, ad, settings = DEDUPE_DEFAULTS) => ( autoRemodelDedupeKeys(ad, settings).some(key => keySet.has(key)) ); const addAutoRemodelDedupeKeys = (keySet, ad, settings = DEDUPE_DEFAULTS) => { autoRemodelDedupeKeys(ad, settings).forEach(key => keySet.add(key)); }; const autoRemodelDedupeKeySet = (items, settings = DEDUPE_DEFAULTS) => { const keySet = new Set(); (items || []).forEach(ad => addAutoRemodelDedupeKeys(keySet, ad, settings)); return keySet; }; const uniqueAutoRemodelAds = (items, settings = DEDUPE_DEFAULTS) => { const seen = new Set(); const out = []; for (const ad of items || []) { if (hasAutoRemodelDedupeOverlap(seen, ad, settings)) continue; addAutoRemodelDedupeKeys(seen, ad, settings); out.push(ad); } return out; }; const scriptEntryForAd = (scripts, ad) => { const key = resultKey(ad); if (key && scripts[key]) return scripts[key]; const hasInstanceKey = !!(ad?.resultUuid || ad?.result_uuid || ad?.cardKey); return hasInstanceKey ? undefined : scripts[ad?.id]; }; const scriptLinesFromText = (text) => String(text || '') .split(/\n\s*\n/) .map(p => p.trim()) .filter(Boolean) .map(body => ({ tag: '', body })); const remodelOutputWithComplianceScript = (fullOutput, replacementScript) => { const full = String(fullOutput || '').trim(); const replacement = String(replacementScript || '').trim(); if (!full) return replacement ? `### REMODELED SCRIPT\n\n${replacement}` : ''; if (!replacement) return full; const block = `### REMODELED SCRIPT\n\n${replacement}`; const scriptSection = /###\s*REMODELED\s+SCRIPT\s*\n[\s\S]*?(?=\n###\s|$)/i; if (scriptSection.test(full)) return full.replace(scriptSection, block); return `${full}\n\n${block}`; }; const capRemodelInstruction = (value) => String(value || '').slice(0, REMODEL_STRATEGY_MAX); const mergeRemodelInstructions = (pageInstruction, adInstruction) => { const parts = [ capRemodelInstruction(pageInstruction).trim(), capRemodelInstruction(adInstruction).trim(), ].filter(Boolean); return capRemodelInstruction(parts.join('\n\n')).trim(); }; const hasTranscriptContent = (ad) => !!( (ad?.transcriptRaw && String(ad.transcriptRaw).trim()) || (typeof ad?.transcript === 'string' && ad.transcript.trim()) || (Array.isArray(ad?.transcript) && ad.transcript.length > 0) ); const mergeRichAdDetails = (existing, incoming) => { if (!existing) return incoming; if (!incoming) return existing; const incomingHasTranscript = hasTranscriptContent(incoming); const existingHasTranscript = hasTranscriptContent(existing); const raw = (existing.raw || incoming.raw) ? { ...(existing.raw || {}), ...(incoming.raw || {}) } : undefined; const thumbnailUrl = incoming.thumbnailUrl || existing.thumbnailUrl || null; const creativeUrl = incoming.creativeUrl || existing.creativeUrl || null; return { ...existing, ...incoming, raw, creativeUrl, thumbnailUrl, thumb: thumbnailUrl ? (incoming.thumbnailUrl ? incoming.thumb : existing.thumb || incoming.thumb) : (incoming.thumb || existing.thumb), transcript: incomingHasTranscript || !existingHasTranscript ? incoming.transcript : existing.transcript, transcriptRaw: incomingHasTranscript || !existingHasTranscript ? incoming.transcriptRaw : existing.transcriptRaw, transcriptStatus: incoming.transcriptStatus || existing.transcriptStatus || null, transcriptSource: incoming.transcriptSource || existing.transcriptSource || null, isVoiceTranscript: typeof incoming.isVoiceTranscript === 'boolean' ? incoming.isVoiceTranscript : existing.isVoiceTranscript, fallbackCopy: incoming.fallbackCopy || existing.fallbackCopy || null, videoUrl: incoming.videoUrl || existing.videoUrl || creativeUrl, reasoning: incoming.reasoning || existing.reasoning || null, copy: incoming.copy || existing.copy || '', }; }; const toggleSourceState = (current, key) => { if (!LIVE_SOURCES.includes(key)) return current || sourceState(); const next = { ...(current || sourceState()) }; next[key] = !next[key]; next.metaads = false; return next; }; const defaultSearchFilters = () => ({ fpFormat: 'video', fpPublisherPlatforms: '', fpLanguages: 'en', fpLiveStatus: '', fpMinDays: '', fpOrder: '', minLikes: '', maxLikes: '', minDailyLikes: '', maxDailyLikes: '', orderBy: '', adspySiteType: '', adspyMediaType: 'video', adspyGender: '', adspyAgeMin: '', adspyAgeMax: '', adspyCountries: '', adspyLang: 'eng', adspyCreatedFrom: '', adspyCreatedTo: '', adspySeenFrom: '', adspySeenTo: '', adspyUsername: '', adspyUserId: '', adspyAffNetwork: '', adspyAffId: '', adspyOfferId: '', adspyTech: '', adspyButtons: '', adspyCtaMode: 'all', adspyUrl: '', adspyLandingUrl: '', bsPlatform: '', bsMinViews: '', bsMinLikes: '', bsMinEngagement: '', bsMinReach: '', bsMinSpend: '', bsMaxSpend: '', foreplayUrl: '', brandsearchUrl: '', }); const DIRECTION_PLATFORM_DEFAULT_FILTERS = { foreplay: { display_format: ['video'], languages: ['en'], running_duration_min_days: 15, live: true, order: 'longest_running', }, adspy: { mediaType: 'video' }, brandsearch: { display_format: ['video'], brandsearch_status: 'active' }, }; const cloneDirectionPlatformDefaults = (platform) => Object.fromEntries( Object.entries(DIRECTION_PLATFORM_DEFAULT_FILTERS[platform] || {}) .map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]), ); const emptyDirectionPlatformFilters = () => Object.fromEntries( LIVE_SOURCES.map(platform => [platform, cloneDirectionPlatformDefaults(platform)]), ); const DIRECTION_FILTER_FIELDS = { foreplay: [ { key: 'display_format', label: 'Format', type: 'select', array: true, options: [['', 'Any format'], ['video', 'Video'], ['image', 'Image'], ['carousel', 'Carousel']] }, { key: 'publisher_platform', label: 'Publisher', type: 'list', placeholder: 'facebook, instagram' }, { key: 'languages', label: 'Lang', type: 'list', placeholder: 'en' }, { key: 'running_duration_min_days', label: 'Min days', type: 'number', min: 0, placeholder: '14' }, { key: 'live', label: 'Status', type: 'boolean', emptyLabel: 'Any status', trueLabel: 'Live only', falseLabel: 'Inactive only' }, { key: 'order', label: 'Order', type: 'select', options: [['', 'Default'], ['longest_running', 'Longest'], ['saved_newest', 'Newest'], ['saved_oldest', 'Oldest']] }, ], adspy: [ { key: 'siteType', label: 'Network', type: 'select', options: [['', 'FB+IG'], ['facebook', 'Facebook'], ['instagram', 'Instagram']] }, { key: 'mediaType', label: 'Format', type: 'select', options: [['', 'Any format'], ['video', 'Video'], ['photo', 'Photo']] }, { key: 'gender', label: 'Gender', type: 'select', options: [['', 'Any gender'], ['female', 'Female'], ['male', 'Male']] }, { key: 'ages', label: 'Age', type: 'range', min: 18, max: 64, startPlaceholder: '18', endPlaceholder: '64' }, { key: 'totalLikes', label: 'Likes', type: 'range', min: 0, max: 100000, startPlaceholder: '0', endPlaceholder: '100000' }, { key: 'dailyLikes', label: 'Daily', type: 'range', min: 0, max: 1000, startPlaceholder: '0', endPlaceholder: '1000' }, { key: 'orderBy', label: 'Sort', type: 'select', options: [['', 'Default'], ['total_likes', 'Likes'], ['total_shares', 'Shares'], ['created_on_asc', 'Oldest']] }, { key: 'countries', label: 'Geo', type: 'list', placeholder: 'US, GB' }, { key: 'lang', label: 'Lang', type: 'text', placeholder: 'eng' }, { key: 'username', label: 'User', type: 'text', placeholder: 'username' }, { key: 'userId', label: 'Page ID', type: 'number', min: 1, placeholder: 'id' }, { key: 'affNetwork', label: 'Net', type: 'number', min: 1, placeholder: 'id' }, { key: 'affId', label: 'Aff', type: 'text', placeholder: 'aff id' }, { key: 'offerId', label: 'Offer', type: 'text', placeholder: 'offer id' }, { key: 'buttons', label: 'CTA', type: 'list', placeholder: 'Shop Now' }, { key: 'createdBetween', label: 'Created', type: 'dateRange' }, { key: 'seenBetween', label: 'Seen', type: 'dateRange' }, ], brandsearch: [ { key: 'brandsearch_platforms', label: 'Network', type: 'select', array: true, options: [['', 'All BS'], ['meta', 'Meta'], ['tiktok', 'TikTok'], ['instagram', 'Instagram']] }, { key: 'display_format', label: 'Format', type: 'select', array: true, options: [['', 'Any format'], ['video', 'Video'], ['image', 'Image']] }, { key: 'brandsearch_status', label: 'Status', type: 'select', options: [['', 'Any status'], ['active', 'Live only'], ['inactive', 'Inactive only']] }, { key: 'brandsearch_min_views', label: 'Views', type: 'number', min: 0, placeholder: '0' }, { key: 'brandsearch_min_likes', label: 'Likes', type: 'number', min: 0, placeholder: '0' }, { key: 'brandsearch_min_engagement_rate', label: 'ER%', type: 'number', min: 0, step: 0.1, placeholder: '0' }, { key: 'brandsearch_min_reach', label: 'Reach', type: 'number', min: 0, placeholder: '0' }, { key: 'brandsearch_min_spend', label: 'Min spend', type: 'number', min: 0, placeholder: '0' }, { key: 'brandsearch_max_spend', label: 'Max spend', type: 'number', min: 0, placeholder: '0' }, ], }; const asFilterList = (value) => { if (value === null || value === undefined || value === '') return []; if (Array.isArray(value)) return value.filter(item => item !== null && item !== undefined && item !== ''); if (typeof value === 'string') return value.split(',').map(item => item.trim()).filter(Boolean); return [value]; }; const listText = (value) => asFilterList(value).join(', '); const setIfMissing = (out, key, value) => { if (out[key] === undefined && value !== undefined && value !== null && value !== '') out[key] = value; }; const normalizeDirectionApiFilters = (platform, filters = {}) => { const out = { ...cloneDirectionPlatformDefaults(platform), ...(filters && typeof filters === 'object' && !Array.isArray(filters) ? filters : {}), }; if (platform === 'adspy') { setIfMissing(out, 'siteType', out.adspy_site_type); setIfMissing(out, 'mediaType', out.adspy_media_type); setIfMissing(out, 'gender', out.adspy_gender); setIfMissing(out, 'orderBy', out.adspy_order); setIfMissing(out, 'username', out.adspy_username); setIfMissing(out, 'userId', out.adspy_user_id); setIfMissing(out, 'affNetwork', out.adspy_aff_network); setIfMissing(out, 'affId', out.adspy_aff_id); setIfMissing(out, 'offerId', out.adspy_offer_id); setIfMissing(out, 'buttons', out.adspy_buttons); setIfMissing(out, 'lang', Array.isArray(out.languages) ? out.languages[0] : out.languages); if (out.ages === undefined && (out.adspy_age_min || out.adspy_age_max)) out.ages = [out.adspy_age_min, out.adspy_age_max].filter(Boolean); if (out.totalLikes === undefined && (out.adspy_min_likes || out.adspy_max_likes)) out.totalLikes = [out.adspy_min_likes, out.adspy_max_likes].filter(Boolean); if (out.dailyLikes === undefined && (out.adspy_min_daily_likes || out.adspy_max_daily_likes)) out.dailyLikes = [out.adspy_min_daily_likes, out.adspy_max_daily_likes].filter(Boolean); if (out.createdBetween === undefined && (out.adspy_created_from || out.adspy_created_to)) out.createdBetween = [out.adspy_created_from, out.adspy_created_to].filter(Boolean); if (out.seenBetween === undefined && (out.adspy_seen_from || out.adspy_seen_to)) out.seenBetween = [out.adspy_seen_from, out.adspy_seen_to].filter(Boolean); } if (platform === 'brandsearch') { setIfMissing(out, 'brandsearch_platforms', out.brandsearch_platform); } return out; }; const cleanApiFilters = (filters = {}) => Object.fromEntries( Object.entries(filters || {}).flatMap(([key, value]) => { if (value === null || value === undefined || value === '') return []; if (Array.isArray(value)) { const list = value.map(item => (typeof item === 'string' ? item.trim() : item)).filter(item => item !== null && item !== undefined && item !== ''); return list.length ? [[key, list]] : []; } if (typeof value === 'string') { const text = value.trim(); return text ? [[key, text]] : []; } return [[key, value]]; }), ); function App() { const [authState, setAuthState] = useState({ status: 'loading', user: null }); const queryTab = new URLSearchParams(window.location.search).get('tab'); const initialTab = window.location.pathname === '/admin/access-requests' ? 'accessRequests' : normalizeDashboardTab(DASHBOARD_TABS.has(queryTab) ? queryTab : (savedDashboardTab() || 'research')); const [tab, setTabState] = useState(initialTab); const setTab = (nextTab) => setTabState(normalizeDashboardTab(nextTab, tab)); // search rows (manual keyword / competitor search) const [rows, setRows] = useState([ { id: 1, query: '', competitorId: null, count: 20, filters: defaultSearchFilters(), platforms: sourceState() }, ]); const [competitors, setCompetitors] = useState([]); const [directions, setDirections] = useState([]); const directionsRef = useRef([]); const arDefaultDirectionsLoadedRef = useRef(false); // pipeline stages const [researchAds, setResearchAds] = useState([]); const [filteredAds, setFilteredAds] = useState([]); const [remodeledAds, setRemodeledAds] = useState([]); const [filterPage, setFilterPage] = useState({ loaded: 0, hasMore: false, loading: false }); const [remodelPage, setRemodelPage] = useState({ loaded: 0, hasMore: false, loading: false }); const [complianceJobs, setComplianceJobs] = useState([]); const [compliancePage, setCompliancePage] = useState({ loaded: 0, hasMore: false, loading: false }); const [complianceStrictness, setComplianceStrictness] = useState('strict'); const [complianceBusy, setComplianceBusy] = useState(false); const [complianceRunState, setComplianceRunState] = useState(null); const [autoRemodelAds, setAutoRemodelAds] = useState([]); const researchAdsRef = useRef([]); const autoRemodelAdsRef = useRef([]); const [searchCacheLoading, setSearchCacheLoading] = useState(false); const [remodelScripts, setRemodelScripts] = useState({}); // { [adId]: [{tag, body}] | 'pending' | 'error' } const [remodelPageInstruction, setRemodelPageInstruction] = useState(''); const [remodelAdInstructions, setRemodelAdInstructions] = useState({}); const [autoRemodelPageInstruction, setAutoRemodelPageInstruction] = useState(''); const [autoRemodelAdInstructions, setAutoRemodelAdInstructions] = useState({}); const remodelPollRef = useRef(null); const remodelStartPendingRef = useRef(false); const compliancePollRef = useRef(null); const [convertedAds, setConvertedAds] = useState([]); const [autoresearchActive, setAutoresearchActive] = useState(false); // true if last Research view should show winners-only badge const setInstructionForAd = (setter) => (adId, value) => { setter(prev => { const next = { ...prev }; const cleaned = capRemodelInstruction(value); if (cleaned.trim()) next[adId] = cleaned; else delete next[adId]; return next; }); }; const setManualAdInstruction = setInstructionForAd(setRemodelAdInstructions); const setAutoAdInstruction = setInstructionForAd(setAutoRemodelAdInstructions); const [selResearch, setSelResearch] = useState(new Set()); const [selFilter, setSelFilter] = useState(new Set()); const [selRemodel, setSelRemodel] = useState(new Set()); const [selAutoRemodel, setSelAutoRemodel] = useState(new Set()); const [selRemodelVariants, setSelRemodelVariants] = useState(new Set()); const [selAutoRemodelVariants, setSelAutoRemodelVariants] = useState(new Set()); const [selCompliance, setSelCompliance] = useState(new Set()); const pipelineFocusRef = useRef({ tab: null, ids: [], tick: 0 }); const [pipelineFocusTick, setPipelineFocusTick] = useState(0); // autoresearch controls const [arSelected, setArSelected] = useState(new Set()); const [arTarget, setArTarget] = useState('1'); const setBoundedArTarget = (value) => setArTarget(sanitizeArTargetInput(value)); const [arMaxAdsPerDirection, setArMaxAdsPerDirection] = useState('100'); const [arMaxAdsPerPlatform, setArMaxAdsPerPlatform] = useState(''); const setBoundedArMaxAdsPerDirection = (value) => setArMaxAdsPerDirection(sanitizeArTargetInput(value, SEARCH_ADS_PER_DIRECTION_MAX)); const setBoundedArMaxAdsPerPlatform = (value) => setArMaxAdsPerPlatform(sanitizeArTargetInput(value, SEARCH_ADS_PER_PLATFORM_MAX)); const [arNumDirections, setArNumDirections] = useState(''); const [arPickedDirections, setArPickedDirections] = useState(new Set()); const [arRunning, setArRunning] = useState(false); const [arProgress, setArProgress] = useState(null); // { totalScored, winners, directionsSatisfied, directionsTotal, crawlState } const [arSources, setArSources] = useState(sourceState()); const [arFilters, setArFilters] = useState(defaultSearchFilters()); const [autoresearchPage, setAutoresearchPage] = useState({ loaded: 0, total: null, hasMore: false, loading: false }); const autoresearchLoadedIdsRef = useRef(new Set()); const autoresearchCurrentIdsRef = useRef(new Set()); const autoresearchLoadedPlatformCountsRef = useRef({}); const autoresearchTargetRef = useRef(null); const arRunningRef = useRef(false); const autoresearchActiveRef = useRef(false); const autoresearchWinnersInFlightRef = useRef(new Map()); // Bug retention — each Autoresearch run is tagged with a monotonically // increasing batch id so the UI can show new winners on top while older // batches stay below as history. const autoresearchBatchIdRef = useRef(null); // 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); const arPollPhaseRef = useRef(null); // Monotonic token so late pollWinners responses from a prior click cannot // mark the current run complete or clear visible ads. const autoresearchRunGenerationRef = useRef(0); // ---- Static Ads state ------------------------------------------------- // Static Ads is a standalone product surface — it does not share any // pipeline state with the video-ads flow. App only holds the run snapshot // (from /api/static_ads/state), the history list, and the in-flight flag. const [saActiveRun, setSaActiveRun] = useState(null); const [saStatus, setSaStatus] = useState(null); const [saState, setSaState] = useState(null); const [saRunning, setSaRunning] = useState(false); const [saRuns, setSaRuns] = useState([]); const [saStarting, setSaStarting] = useState(false); const [saError, setSaError] = useState(null); const saPollRef = useRef(null); // /state only returns *active* runs; once a run is terminal it drops out. // We remember the current run id so the finished run (and its gallery) stays // on screen until the user starts another or deletes it. const saCurrentRunIdRef = useRef(null); // ---- Auto-Remodel state ----------------------------------------------- // The orchestrator's directions picker is shared with manual autoresearch // — both pull from the same full direction 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([]); const [arStartPending, setArStartPending] = useState(false); // 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()); const arStartPendingRef = useRef(false); // 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 [autoRemodelHydration, setAutoRemodelHydration] = useState({ total: 0, loaded: 0, loading: false }); const [autoRemodelRestoring, setAutoRemodelRestoring] = useState(false); const autoRemodelLoadedIdsRef = useRef(new Set()); const autoRemodelHydratingPageRef = useRef(false); const arNVariantsRef = useRef(1); const arRunningOrch = !!arRunId && !['done', 'failed', 'cancelled'].includes(arStage); const [searchPassesOnly, setSearchPassesOnly] = useState(true); const searchPassesOnlyRef = useRef(searchPassesOnly); useEffect(() => { arRunIdRef.current = arRunId; }, [arRunId]); useEffect(() => { arStageRef.current = arStage; }, [arStage]); useEffect(() => { arPickedIdsRef.current = arPickedIds; }, [arPickedIds]); useEffect(() => { arTargetAdsRef.current = arTargetAds; }, [arTargetAds]); useEffect(() => { researchAdsRef.current = researchAds; }, [researchAds]); useEffect(() => { autoRemodelAdsRef.current = autoRemodelAds; }, [autoRemodelAds]); useEffect(() => { arNVariantsRef.current = arNVariants; }, [arNVariants]); useEffect(() => { arRunningRef.current = arRunning; }, [arRunning]); useEffect(() => { autoresearchActiveRef.current = autoresearchActive; }, [autoresearchActive]); useEffect(() => { searchPassesOnlyRef.current = searchPassesOnly; }, [searchPassesOnly]); // 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(allSourceState()); const toggleArOrchSource = (key) => { if (!LIVE_SOURCES.includes(key)) return; setArOrchSources(s => toggleSourceState(s, key)); }; const selectAllArOrchSources = () => setArOrchSources(allSourceState()); 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 selectAllArPickedDirections = () => setArPickedDirections(new Set(directionsRef.current.map(d => d.id))); const toggleArSource = (key) => { if (!LIVE_SOURCES.includes(key)) return; setArSources(s => toggleSourceState(s, key)); }; const selectAllArSources = () => setArSources(allSourceState()); const exclusiveAdSelection = (adSetter, variantSetter) => (id) => { if (!id) return; variantSetter(new Set()); adSetter(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }; const exclusiveVariantSelection = (variantSetter, adSetter) => (jobId) => { if (!jobId) return; adSetter(new Set()); variantSetter(prev => { const next = new Set(prev); next.has(jobId) ? next.delete(jobId) : next.add(jobId); return next; }); }; const toggleRemodelAdSelection = exclusiveAdSelection(setSelRemodel, setSelRemodelVariants); const toggleAutoRemodelAdSelection = exclusiveAdSelection(setSelAutoRemodel, setSelAutoRemodelVariants); const toggleRemodelVariantSelection = exclusiveVariantSelection(setSelRemodelVariants, setSelRemodel); const toggleAutoRemodelVariantSelection = exclusiveVariantSelection(setSelAutoRemodelVariants, setSelAutoRemodel); const [tweaks, setTweaks] = useState({ theme: 'light', density: 'comfortable', cardStyle: 'standard', accent: 'teal' }); const [tweaksOpen, setTweaksOpen] = useState(false); const [toast, setToast] = useState(null); const [dedupeSettings, setDedupeSettings] = useState(DEDUPE_DEFAULTS); const dedupeSettingsRef = useRef(DEDUPE_DEFAULTS); const [dedupeSaving, setDedupeSaving] = useState(false); useEffect(() => { dedupeSettingsRef.current = dedupeSettings; }, [dedupeSettings]); const [searching, setSearching] = useState(false); const [searchingRowIds, setSearchingRowIds] = useState(new Set()); const activeSearchRunIdsRef = useRef(new Map()); const searchStopRequestedRef = useRef(false); const searchResumeStartedRef = useRef(false); // High-severity per-platform warnings from the last manual Search run // (e.g. "no AdSpy ads matched 'oregano oil' \u2014 showing top 5 instead"). // Rendered as a red banner above the search results panels. const [searchWarnings, setSearchWarnings] = useState([]); const [latestSearchRun, setLatestSearchRun] = useState(null); const [latestAutoresearchRun, setLatestAutoresearchRun] = useState(null); const [filterRunState, setFilterRunState] = useState(null); const [remodelRunState, setRemodelRunState] = useState(null); const [filterBusy, setFilterBusy] = useState(false); const [remodelBusy, setRemodelBusy] = useState(false); const [expandedAd, setExpandedAd] = useState(null); const [pendingAccessCount, setPendingAccessCount] = useState(0); const [managementCollapsed, setManagementCollapsed] = useState(false); const filterPollRef = useRef(null); // Active-poller callback handles. Stored so that visibilitychange can fire // each active poller once when the user returns to the tab (catch-up) and // so each setInterval body can read the latest closure via the ref. const arPollCallbackRef = useRef(null); const remodelPollCallbackRef = useRef(null); const filterPollCallbackRef = useRef(null); const compliancePollCallbackRef = useRef(null); const saPollCallbackRef = useRef(null); // Tracks tabs whose first page has been lazy-loaded so re-entering a tab // does not refetch on every click. Reset by manual user action / logout. const tabLoadedRef = useRef(new Set()); const searchCacheRestoredRef = useRef(false); const [phaseAComplete, setPhaseAComplete] = useState(false); const [autoRemodelHistoryLoaded, setAutoRemodelHistoryLoaded] = useState(false); const [autoRemodelHistoryLoading, setAutoRemodelHistoryLoading] = useState(false); const startVisibilityAwarePoll = (refKey, callback, intervalMs) => { const callbackRef = ( refKey === 'ar' ? arPollCallbackRef : refKey === 'remodel' ? remodelPollCallbackRef : refKey === 'filter' ? filterPollCallbackRef : refKey === 'compliance' ? compliancePollCallbackRef : refKey === 'staticAds' ? saPollCallbackRef : null ); const intervalRef = ( refKey === 'ar' ? arPollRef : refKey === 'remodel' ? remodelPollRef : refKey === 'filter' ? filterPollRef : refKey === 'compliance' ? compliancePollRef : refKey === 'staticAds' ? saPollRef : null ); if (!callbackRef || !intervalRef) return null; if (intervalRef.current) clearInterval(intervalRef.current); callbackRef.current = callback; intervalRef.current = setInterval(() => { if (typeof document !== 'undefined' && document.hidden) return; const fn = callbackRef.current; if (typeof fn === 'function') fn(); }, intervalMs); return intervalRef.current; }; const stopPoll = (refKey) => { const callbackRef = ( refKey === 'ar' ? arPollCallbackRef : refKey === 'remodel' ? remodelPollCallbackRef : refKey === 'filter' ? filterPollCallbackRef : refKey === 'compliance' ? compliancePollCallbackRef : refKey === 'staticAds' ? saPollCallbackRef : null ); const intervalRef = ( refKey === 'ar' ? arPollRef : refKey === 'remodel' ? remodelPollRef : refKey === 'filter' ? filterPollRef : refKey === 'compliance' ? compliancePollRef : refKey === 'staticAds' ? saPollRef : null ); if (intervalRef && intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } if (callbackRef) callbackRef.current = null; }; // When the tab becomes visible again, run each active poller once so the UI // catches up immediately instead of waiting up to 12s for the next tick. useEffect(() => { if (typeof document === 'undefined') return undefined; const onVisibilityChange = () => { if (document.hidden) return; try { arPollCallbackRef.current?.(); } catch (e) { console.warn('ar poll catchup failed', e); } try { remodelPollCallbackRef.current?.(); } catch (e) { console.warn('remodel poll catchup failed', e); } try { filterPollCallbackRef.current?.(); } catch (e) { console.warn('filter poll catchup failed', e); } try { compliancePollCallbackRef.current?.(); } catch (e) { console.warn('compliance poll catchup failed', e); } try { saPollCallbackRef.current?.(); } catch (e) { console.warn('static ads poll catchup failed', e); } }; document.addEventListener('visibilitychange', onVisibilityChange); return () => document.removeEventListener('visibilitychange', onVisibilityChange); }, []); useEffect(() => { let cancelled = false; const loadAuth = async () => { try { const me = await RESILIA_API.authMe(); if (cancelled) return; if (me && me.authenticated) { setAuthState({ status: 'authenticated', user: me.user }); } else { setAuthState({ status: 'signed_out', user: null }); } } catch (e) { if (!cancelled) setAuthState({ status: 'signed_out', user: null }); } }; const onUnauthorized = () => setAuthState({ status: 'signed_out', user: null }); window.addEventListener('resilia:unauthorized', onUnauthorized); loadAuth(); return () => { cancelled = true; window.removeEventListener('resilia:unauthorized', onUnauthorized); }; }, []); useEffect(() => { if (authState.status !== 'authenticated') return; let cancelled = false; RESILIA_API.getDedupeSettings() .then(settings => { if (!cancelled) setDedupeSettings({ ...DEDUPE_DEFAULTS, ...(settings || {}) }); }) .catch(() => { if (!cancelled) setDedupeSettings(DEDUPE_DEFAULTS); }); return () => { cancelled = true; }; }, [authState.status]); // 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]); useEffect(() => { if (!directions.length || arDefaultDirectionsLoadedRef.current) return; const allDirectionIds = directions.map(direction => direction.id).filter(Boolean); if (!allDirectionIds.length) return; setArPickedDirections(new Set(allDirectionIds)); setArNumDirections(String(allDirectionIds.length)); arDefaultDirectionsLoadedRef.current = true; }, [directions]); const restoreSearchCacheOnce = async () => { if (searchCacheRestoredRef.current) return; searchCacheRestoredRef.current = true; setSearchCacheLoading(true); try { const cached = await RESILIA_API.getCache('last_search_results', { limit: 2000, runLimit: 20, }); const cachedAds = cached && cached.payload && Array.isArray(cached.payload.ads) ? cached.payload.ads : []; if (cachedAds.length) { const latestRunId = cached?.payload?.run_id ? String(cached.payload.run_id) : null; const cachedHistoryAds = Array.isArray(cached?.payload?.history_ads) ? cached.payload.history_ads : []; const mappedAds = [...cachedAds, ...cachedHistoryAds].map(a => { const adapted = RESILIA_ADAPT.adaptSearchAd(a, 'foreplay'); const runId = adapted.runId ? String(adapted.runId) : null; return { ...adapted, source: 'search', currentSearchRun: latestRunId ? runId === latestRunId : false, }; }); const seenCacheKeys = new Set(); const ads = mappedAds.filter(ad => { const key = resultKey(ad); if (seenCacheKeys.has(key)) return false; seenCacheKeys.add(key); return true; }); setResearchAds(prev => { const existingKeys = new Set(ads.map(resultKey)); const retained = prev.filter(a => a.source === 'autoresearch' || !existingKeys.has(resultKey(a))); const retainedSearchAds = retained .filter(a => a.source !== 'autoresearch') .map(a => ({ ...a, currentSearchRun: false })); return [...retained.filter(a => a.source === 'autoresearch'), ...ads, ...retainedSearchAds]; }); if (latestRunId) { const latestAds = ads.filter(a => a.currentSearchRun); setLatestSearchRun({ id: latestRunId, count: latestAds.length, labels: [cached.payload.query || cached.payload.competitor_id || 'Restored manual search'].filter(Boolean), duplicatesHidden: Number(cached.payload.duplicates_hidden || 0), }); } } } catch (e) { console.warn('restore search cache failed', e); } finally { setSearchCacheLoading(false); } }; const persistActiveSearchRuns = () => { try { const runs = [...activeSearchRunIdsRef.current.entries()] .map(([rowKey, runId]) => ({ rowKey, runId })) .filter(item => item.runId); if (runs.length) { window.localStorage.setItem(ACTIVE_SEARCH_RUNS_STORAGE_KEY, JSON.stringify(runs)); } else { window.localStorage.removeItem(ACTIVE_SEARCH_RUNS_STORAGE_KEY); } } catch { // localStorage can be unavailable in private/hardened contexts. } }; const loadPersistedSearchRuns = () => { try { const raw = window.localStorage.getItem(ACTIVE_SEARCH_RUNS_STORAGE_KEY); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed.filter(item => item && typeof item.runId === 'string' && item.runId) : []; } catch { return []; } }; const applyResumedSearchResponse = (runId, rowKey, resp) => { const seedArrivedAtMs = Date.now(); const ads = (resp?.ads || []).map(a => { const platform = a?.source_platform || a?.raw?.source_platform || 'foreplay'; const adapted = RESILIA_ADAPT.adaptSearchAd(a, platform); return { ...adapted, source: 'search', searchReturnLabel: 'Resumed manual search', searchRunId: runId, searchBatchId: 'resumed_manual', searchRowId: rowKey, currentSearchRun: true, arrivedAtMs: adapted.arrivedAtMs || seedArrivedAtMs, }; }); const seen = new Set(); const deduped = []; for (const ad of ads) { if (seen.has(ad.id)) continue; seen.add(ad.id); deduped.push(ad); } const historyAds = (resp?.history_ads || []).map(a => ({ ...RESILIA_ADAPT.adaptSearchAd(a, 'foreplay'), source: 'search', searchReturnLabel: 'Duplicate history', currentSearchRun: false, })); if (deduped.length) { setResearchAds(prev => { const keys = new Set([...deduped, ...historyAds].map(resultKey)); const remaining = prev .filter(ad => ad.searchRunId !== runId && !keys.has(resultKey(ad))) .map(ad => ( ad.source === 'autoresearch' ? ad : { ...ad, currentSearchRun: false } )); return [...deduped, ...historyAds, ...remaining]; }); } else if (historyAds.length) { setResearchAds(prev => { const keys = new Set(historyAds.map(resultKey)); const remaining = prev .filter(ad => !keys.has(resultKey(ad))) .map(ad => ( ad.source === 'autoresearch' ? ad : { ...ad, currentSearchRun: false } )); return [...historyAds, ...remaining]; }); } setLatestSearchRun({ id: runId, count: deduped.length, labels: ['Resumed manual search'], duplicatesHidden: Number(resp?.duplicates_hidden || 0), progress: resp?.progress || {}, }); }; const refreshDirectionCatalog = async () => { const [comps, dirs] = await Promise.all([ RESILIA_API.listCompetitors(), RESILIA_API.listDirections(), ]); const loadedDirections = dirs || []; directionsRef.current = loadedDirections; setCompetitors(comps || []); setDirections(loadedDirections); return loadedDirections; }; useEffect(() => { try { window.localStorage.setItem(TAB_STORAGE_KEY, tab); } catch { // localStorage can be unavailable in hardened/private browser contexts. } }, [tab]); useEffect(() => { if (authState.status !== 'authenticated') return; if (searchResumeStartedRef.current) return; const runs = loadPersistedSearchRuns(); if (!runs.length || !RESILIA_API.waitForSearchRun) return; searchResumeStartedRef.current = true; setSearching(true); runs.forEach(({ rowKey, runId }) => { const key = rowKey || `resumed-${runId}`; activeSearchRunIdsRef.current.set(key, runId); persistActiveSearchRuns(); RESILIA_API.waitForSearchRun(runId, { onProgress: progress => applyResumedSearchResponse(runId, key, progress), }).then(resp => { applyResumedSearchResponse(runId, key, resp); }).catch(e => { console.warn('resumed search polling failed', runId, e); }).finally(() => { activeSearchRunIdsRef.current.delete(key); persistActiveSearchRuns(); if (activeSearchRunIdsRef.current.size === 0) setSearching(false); }); }); }, [authState.status]); useEffect(() => { if (authState.status === 'authenticated' && !authState.user?.is_admin && ['accessRequests', 'adminDirections', 'adminWinningCriteria', 'adminGlobalCrawler', 'adminCosts', 'adminEvaluationQuotas', 'adminWorkspaces', 'adminUsers'].includes(tab)) { setTab('research'); } }, [authState.status, authState.user?.is_admin, tab]); // Startup bootstrap: one backend request returns the critical direction // catalog plus lightweight state snapshots. Heavy tab lists and cached // search ads are still deferred to the lazy tab loader below. useEffect(() => { if (authState.status !== 'authenticated') return undefined; let cancelled = false; setPhaseAComplete(false); tabLoadedRef.current = new Set(); searchCacheRestoredRef.current = false; (async () => { let loadedDirections = []; const restoreAutoRemodel = showAutoRemodelUI(); if (restoreAutoRemodel && !cancelled) setAutoRemodelRestoring(true); // Claim ownership of Auto-Remodel restore so the lazy loader does not // race this bootstrap request with a second state/snapshot fetch. if (restoreAutoRemodel) tabLoadedRef.current.add('autoRemodel'); try { const bootstrap = await RESILIA_API.dashboardBootstrap(); if (cancelled) return; if (!bootstrap) throw new Error('dashboard bootstrap unavailable'); loadedDirections = Array.isArray(bootstrap.directions) ? bootstrap.directions : []; directionsRef.current = loadedDirections; setCompetitors(Array.isArray(bootstrap.competitors) ? bootstrap.competitors : []); setDirections(loadedDirections); const summary = bootstrap.summary || {}; if (typeof summary.credits_remaining === 'number') { setPlatformCredits(pc => ({ ...pc, foreplay: summary.credits_remaining })); } const restoredAutoresearchState = bootstrap.autoresearch || null; const state = restoredAutoresearchState?.state || {}; if (typeof state.target_ads === 'number' && state.target_ads > 0) { autoresearchTargetRef.current = state.target_ads; setArTarget(String(state.target_ads)); } if (restoredAutoresearchState?.running) { const runId = state.run_id || `ar-restored-${Date.now()}`; autoresearchBatchIdRef.current = runId; autoresearchLoadedIdsRef.current = new Set(); autoresearchCurrentIdsRef.current = new Set(); setArRunning(true); setAutoresearchActive(true); setArSelected(new Set(state.selected || [])); setLatestAutoresearchRun({ id: runId, count: 0, status: 'running', labels: state.sources || [], }); setArProgress({ crawlState: state.run_phase || state.status || 'running', runPhase: state.run_phase || state.status || 'running', prefilledCount: Number(state.prefilled_count || state.result_count || 0), remainingTarget: Number(state.remaining_target || 0), directionsSatisfied: state.run_directions_done || 0, directionsTotal: state.run_directions_total || 0, platformAdsFetched: state.platform_ads_fetched || {}, platformProgress: state.platform_progress || {}, directionProgress: state.direction_progress || {}, coverageTotal: state.coverage_total || 0, coverageTouched: state.coverage_touched || 0, platformCounts: state.run_platform_counts || {}, }); startVisibilityAwarePoll('ar', pollWinners, AR_ACTIVE_POLL_INTERVAL_MS); pollWinners(); } const filterState = bootstrap.filter || null; if (filterState) { setFilterRunState(filterState); setFilterBusy(!!filterState.running); if (filterState.running) startFilterStatePolling(); } const remodelState = bootstrap.remodel || null; if (remodelState) { setRemodelRunState(remodelState); setRemodelBusy(!!remodelState.running); const restoreAdIds = remodelState?.state?.ad_ids || remodelState?.run?.state?.ad_ids || []; if (remodelState.running && restoreAdIds.length) { startVisibilityAwarePoll('remodel', () => { pollRemodelJobs(restoreAdIds, { source: 'manual' }); refreshRemodelRunState(); }, REMODEL_POLL_INTERVAL_MS); pollRemodelJobs(restoreAdIds, { source: 'manual' }); } } const auto = bootstrap.auto_remodel || null; if (restoreAutoRemodel && auto && auto.status === 'ok' && auto.state) { const restored = await applyAutoRemodelCurrentPayload(auto, loadedDirections); if (!restored) { await hydrateAutoRemodelPickedState(auto.state, loadedDirections).catch(err => { console.warn('bootstrap auto-remodel pre-hydrate failed', err); return false; }); } } else if (restoreAutoRemodel) { tabLoadedRef.current.delete('autoRemodel'); } } catch (e) { console.warn('dashboard bootstrap failed', e); showToast('Failed to load dashboard startup data: ' + e.message, 'Error'); tabLoadedRef.current.delete('autoRemodel'); } finally { if (!cancelled) { setAutoRemodelRestoring(false); setPhaseAComplete(true); } } // Phase C list calls (loadAutoresearchWinnersPage, loadFilterPage, // loadRemodelPage, hydrateAutoRemodelPickedState, history fetches) are // intentionally NOT awaited here — the lazy tab loader effect below // fires the right one based on the active tab. })(); return () => { cancelled = true; stopPoll('ar'); stopPoll('filter'); stopPoll('remodel'); stopPoll('compliance'); }; }, [authState.status]); // Lazy first-page loader. Fires whenever the active tab changes (or once // Phase A finishes for the initial tab). Each tab loads its own first page // exactly once per session — re-entering a loaded tab does not refetch // unless the user explicitly clicks Refresh / Load older. const loadTabIfNeeded = async (currentTab) => { if (!currentTab) return; if (tabLoadedRef.current.has(currentTab)) return; const dirs = directionsRef.current; tabLoadedRef.current.add(currentTab); try { if (currentTab === 'research' || currentTab === 'autoSearch') { if (currentTab === 'research') await restoreSearchCacheOnce(); const includeFailed = !searchPassesOnlyRef.current; await loadAutoresearchWinnersPage({ reset: true, directionSource: dirs, lightweight: !includeFailed, includeFailed, pageSize: includeFailed ? AUTORESEARCH_FAIL_DISPLAY_LIMIT : AUTORESEARCH_PAGE_SIZE, }); } else if (currentTab === 'filter') { await loadFilterPage({ reset: true }); } else if (currentTab === 'remodel') { await loadRemodelPage({ reset: true }); } else if (currentTab === 'compliance') { const [, state] = await Promise.all([ loadCompliancePage({ reset: true }), refreshComplianceRunState(), ]); if (state?.running) startCompliancePolling(); } else if (currentTab === 'staticAds') { const [, snap] = await Promise.all([ loadStaticAdsRuns(), refreshStaticAdsState(), ]); if (snap?.running) startStaticAdsPolling(); } else if (currentTab === 'autoRemodel' && showAutoRemodelUI()) { const auto = RESILIA_API.getAutoRemodelCurrent ? await RESILIA_API.getAutoRemodelCurrent() : await RESILIA_API.getAutoRemodelState(); if (auto && auto.status === 'ok' && auto.state) { const restored = await applyAutoRemodelCurrentPayload(auto, dirs); if (!restored) await hydrateAutoRemodelPickedState(auto.state, dirs); } } } catch (e) { console.warn(`tab ${currentTab} lazy load failed`, e); tabLoadedRef.current.delete(currentTab); } }; useEffect(() => { if (authState.status !== 'authenticated') return; if (!phaseAComplete) return; loadTabIfNeeded(tab); // tab + phaseAComplete trigger; loadTabIfNeeded reads the latest closures. // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, phaseAComplete, authState.status]); const showToast = (msg, badge) => { setToast({ msg, badge }); setTimeout(() => setToast(null), 3200); }; const updateDedupeSettings = async (patch) => { const next = { ...dedupeSettings, ...patch }; setDedupeSettings(next); setDedupeSaving(true); try { const saved = await RESILIA_API.updateDedupeSettings(next); setDedupeSettings({ ...DEDUPE_DEFAULTS, ...(saved || {}) }); showToast('Dedupe preference saved.', 'Dedupe'); } catch (e) { showToast(`Could not save dedupe preference: ${e.message}`, 'Error'); } finally { setDedupeSaving(false); } }; const directionMetaMap = (items) => Object.fromEntries((items || []).map(d => { const keywords = Array.isArray(d.keywords) ? d.keywords.filter(Boolean) : []; const label = d.display_name || d.brand_name || d.id; const searchLabel = keywords.length ? keywords.slice(0, 4).join(', ') : (d.brand_name || d.display_name || 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 adaptAutoresearchRows = (rows, directionSource = directionsRef.current) => { const dirMap = directionMetaMap(directionSource); const ads = []; const seen = new Set(); for (const row of rows || []) { if (!row?.ad_id) continue; const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row); attachDirectionMeta(adapted, dirMap); const key = autoresearchDisplayKey(adapted); if (seen.has(key)) continue; seen.add(key); ads.push({ ...adapted, source: 'autoresearch', autoresearchRunId: row.autoresearch_run_id || row.run_id || null, currentAutoresearchRun: row.current_autoresearch_run === true, currentRunLabel: row.current_autoresearch_run === true ? 'Current' : null, }); } return ads; }; const mergeRecentAds = (incoming, existing = []) => { const out = []; const seen = new Set(); [...(incoming || []), ...(existing || [])].forEach(ad => { if (!ad?.id || seen.has(ad.id)) return; seen.add(ad.id); out.push(ad); }); return out; }; const mergeCurrentPipelineAds = (incoming, existing = [], batchId = null) => { const currentIds = new Set((incoming || []).map(ad => ad?.id).filter(Boolean)); return mergeRecentAds( (incoming || []).map(ad => ({ ...ad, currentPipelineMove: true, pipelineMoveBatchId: batchId, currentRunLabel: 'Current', })), (existing || []).map(ad => ( currentIds.has(ad.id) ? ad : { ...ad, currentPipelineMove: false, currentRunLabel: null } )), ); }; const focusPipelineAds = (targetTab, ids) => { const cleanIds = [...new Set((ids || []).filter(Boolean))]; if (!cleanIds.length) return; pipelineFocusRef.current = { tab: targetTab, ids: cleanIds, tick: Date.now(), }; setPipelineFocusTick(pipelineFocusRef.current.tick); }; const appendOlderAds = (existing = [], incoming = []) => { const out = []; const seen = new Set(); [...(existing || []), ...(incoming || [])].forEach(ad => { if (!ad?.id || seen.has(ad.id)) return; seen.add(ad.id); out.push(ad); }); return out; }; const pipelineFallbackAd = (id, source = 'manual') => ({ id, brand: id || 'Recovered ad', platform: 'foreplay', copy: '', score: null, reasoning: null, daysRunning: 0, views: '—', thumb: RESILIA_ADAPT.gradFor(id || source), duration: '', scrapedAt: '—', cta: null, transcript: [], transcriptRaw: '', source, raw: { ad_id: id, source }, }); const adaptPipelineRows = (rows, source = 'manual') => (rows || []) .filter(row => row?.ad_id) .map(row => { const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row); const pipelineAt = row.filter_verdict?.filtered_at || row.saved_at || null; const pipelineAtMs = pipelineAt ? Date.parse(pipelineAt) : NaN; return { ...adapted, source, filterVerdict: row.filter_verdict || null, pipelineAt, arrivedAtMs: Number.isFinite(pipelineAtMs) ? pipelineAtMs : (adapted.arrivedAtMs || 0), }; }); const remodelEntryFromJobs = (jobs) => { const allRows = Array.isArray(jobs) ? jobs : []; const latestRunId = allRows.find(row => row?.run_id)?.run_id || null; const rows = latestRunId ? allRows.filter(row => row.run_id === latestRunId) : allRows; if (!rows.length) return { status: 'pending', variants: [] }; const done = rows.filter(j => j.status === 'done' && j.response_text); const failed = rows.filter(j => j.status === 'failed'); const pending = rows.filter(j => j.status !== 'done' && j.status !== 'failed'); const variantFromJob = (j, extra = {}) => ({ jobId: j.id, variantIdx: j.variant_idx || 0, remodelMethod: normalizeRemodelMethod(j.remodel_method), temperature: j.temperature, promotedAt: j.promoted_at || null, ...extra, }); if (done.length > 0 || failed.length > 0) { const variants = [ ...done.map(j => variantFromJob(j, { raw: j.response_text, lines: parseRemodelScript(j.response_text), })), ...failed.map(j => variantFromJob(j, { raw: '', lines: [{ tag: 'ERROR', body: j.error || 'Generation failed' }], })), ...pending.map(j => variantFromJob(j, { pending: true, raw: '', lines: [{ tag: 'REMODELING', body: 'Claude Sonnet is generating this remodel output.' }], })), ].sort((a, b) => (a.variantIdx || 0) - (b.variantIdx || 0)); return { status: pending.length ? 'pending' : 'ready', expectedVariants: variants.length, variants, }; } if (failed.length === rows.length) { return { status: 'error', variants: [], error: failed[0]?.error || 'Generation failed', }; } return { status: 'pending', variants: [] }; }; const groupJobsByAd = (jobs) => { const grouped = new Map(); (jobs || []).forEach(job => { if (!job?.ad_id) return; const key = job.payload?.result_uuid || job.payload?.resultUuid || job.ad_id; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(job); }); return grouped; }; // ---- Autoresearch ------------------------------------------------------- const loadAutoresearchWinnersPage = async ({ reset = false, offset = null, pageSize = AUTORESEARCH_PAGE_SIZE, directionSource = directionsRef.current, lightweight = false, includeFailed = false, runId = null, } = {}) => { const perDir = autoresearchTargetRef.current || boundedPositiveIntOrNull(arTarget) || 1; const serverNextOffset = Number(autoresearchPage?.nextOffset); const pageOffset = offset === null ? (reset ? 0 : (Number.isFinite(serverNextOffset) ? serverNextOffset : autoresearchLoadedIdsRef.current.size)) : Math.max(0, parseInt(offset, 10) || 0); const resolvedRunId = [runId, autoresearchBatchIdRef.current].find( (candidate) => typeof candidate === 'string' && candidate.startsWith('autoresearch-'), ) || null; const requestKey = JSON.stringify({ pageSize, perDir, pageOffset, lightweight, includeFailed, runId: resolvedRunId, }); const inFlight = autoresearchWinnersInFlightRef.current.get(requestKey); if (inFlight) return inFlight; const request = (async () => { setAutoresearchPage(prev => ({ ...prev, loading: true })); try { const page = await RESILIA_API.autoresearchWinners( pageSize, perDir, { offset: pageOffset, paged: true, lightweight, includeFailed, runId: resolvedRunId }, ); const rows = Array.isArray(page) ? page : (Array.isArray(page?.ads) ? page.ads : []); const historyRows = Array.isArray(page?.history_ads) ? page.history_ads : []; const currentBatchId = autoresearchBatchIdRef.current || 'latest-autoresearch'; const currentRunId = page?.current_run_id || currentBatchId; const pageAds = adaptAutoresearchRows(rows, directionSource); const currentPageAds = pageAds.filter(ad => ( ad.currentAutoresearchRun || (currentRunId && ad.autoresearchRunId === currentRunId) )); const historyPageAds = pageAds.filter(ad => !currentPageAds.includes(ad)); const historyAds = [ ...adaptAutoresearchRows(historyRows, directionSource), ...historyPageAds, ].map(ad => ({ ...ad, _arBatchId: ad._arBatchId || 'history', currentAutoresearchRun: false, currentRunLabel: null, })); const ads = currentPageAds.map(ad => ({ ...ad, _arBatchId: currentBatchId, currentAutoresearchRun: true, currentRunLabel: 'Current', })); const loadedIds = reset ? new Set() : new Set(autoresearchLoadedIdsRef.current); const currentIds = reset ? new Set() : new Set(autoresearchCurrentIdsRef.current); const loadedPlatformCounts = reset ? {} : { ...autoresearchLoadedPlatformCountsRef.current }; [...ads, ...historyAds].forEach(ad => { const key = autoresearchDisplayKey(ad); const isNewLoadedCard = !loadedIds.has(key); loadedIds.add(key); if (!ad.currentAutoresearchRun) return; const isNewCurrentCard = !currentIds.has(key); currentIds.add(key); if (isNewLoadedCard && isNewCurrentCard) { const platform = sourcePlatformForAd(ad); loadedPlatformCounts[platform] = Number(loadedPlatformCounts[platform] || 0) + 1; } }); autoresearchLoadedIdsRef.current = loadedIds; autoresearchCurrentIdsRef.current = currentIds; autoresearchLoadedPlatformCountsRef.current = loadedPlatformCounts; setResearchAds(prev => { const searchAds = prev.filter(a => a.source !== 'autoresearch'); const priorAutoAds = prev .filter(a => ( a.source === 'autoresearch' && (!a.currentAutoresearchRun || (a._arBatchId && a._arBatchId !== currentBatchId)) )) .map(a => ({ ...a, currentAutoresearchRun: false, currentRunLabel: null })); const currentAutoAds = reset ? [] : prev.filter(a => ( a.source === 'autoresearch' && a.currentAutoresearchRun && (a._arBatchId || currentBatchId) === currentBatchId )); const byId = new Map(currentAutoAds.map(ad => [autoresearchDisplayKey(ad), ad])); ads.forEach(ad => byId.set(autoresearchDisplayKey(ad), ad)); const currentKeys = new Set(Array.from(byId.values()).map(autoresearchDisplayKey)); const priorByKey = new Map(priorAutoAds.map(ad => [autoresearchDisplayKey(ad), ad])); historyAds.forEach(ad => { const key = autoresearchDisplayKey(ad); if (!currentKeys.has(key)) priorByKey.set(key, ad); }); // Layout: search ads on top (Bug 11 quick fix), then current batch // of autoresearch winners, then older autoresearch batches as // history beneath. const nextAds = [...searchAds, ...Array.from(byId.values()), ...Array.from(priorByKey.values())]; return nextAds; }); const hasMore = Array.isArray(page) ? ads.length >= AUTORESEARCH_PAGE_SIZE : !!page?.has_more; const total = Array.isArray(page) ? null : (page?.total ?? null); const currentRunTotal = Array.isArray(page) ? currentIds.size : Number(page?.current_run_total ?? currentIds.size); const currentRunPlatformTotals = ( !Array.isArray(page) && page?.current_run_platform_totals && typeof page.current_run_platform_totals === 'object' ) ? page.current_run_platform_totals : loadedPlatformCounts; const serverNextPageOffset = Number(page?.next_offset); const nextState = { loaded: loadedIds.size, total, hasMore, loading: false, nextOffset: Number.isFinite(serverNextPageOffset) ? serverNextPageOffset : null, }; setAutoresearchPage(nextState); setLatestAutoresearchRun(prev => ({ id: currentBatchId, count: currentRunTotal, total: total ?? prev?.total ?? null, status: prev?.status || 'restored', labels: prev?.labels || [], })); setArProgress(prev => { if (!prev) return prev; const platformProgress = { ...(prev.platformProgress || {}) }; Object.entries(currentRunPlatformTotals).forEach(([platform, count]) => { const entry = { ...(platformProgress[platform] || { source: platform }) }; entry.winners = Number(count || 0); entry.visible_winners = Number(count || 0); platformProgress[platform] = entry; }); return { ...prev, winners: currentRunTotal, loadedWinners: currentIds.size, totalWinners: currentRunTotal, platformProgress, }; }); if (loadedIds.size > 0) setAutoresearchActive(true); return { ads, ...nextState }; } finally { autoresearchWinnersInFlightRef.current.delete(requestKey); setAutoresearchPage(prev => ({ ...prev, loading: false })); } })(); autoresearchWinnersInFlightRef.current.set(requestKey, request); return request; }; const handleSearchPassesOnlyChange = (nextPassesOnly) => { setSearchPassesOnly(nextPassesOnly); searchPassesOnlyRef.current = nextPassesOnly; if (nextPassesOnly) return; loadAutoresearchWinnersPage({ reset: true, pageSize: AUTORESEARCH_FAIL_DISPLAY_LIMIT, directionSource: directionsRef.current, includeFailed: true, }).catch(err => showToast('Failed to load failing AutoSearch ads: ' + err.message, 'Error')); }; const loadFilterPage = async ({ reset = false } = {}) => { const offset = reset ? 0 : filterPage.loaded; setFilterPage(prev => ({ ...prev, loading: true })); try { const rows = await RESILIA_API.listFilterVerdicts({ passesOnly: false, source: 'manual', limit: PIPELINE_PAGE_SIZE, offset, }); const ads = adaptPipelineRows(rows, 'filter'); setFilteredAds(prev => reset ? mergeRecentAds(ads, []) : appendOlderAds(prev, ads)); const nextState = { loaded: offset + rows.length, hasMore: rows.length >= PIPELINE_PAGE_SIZE, loading: false, }; setFilterPage(nextState); return { ads, ...nextState }; } finally { setFilterPage(prev => ({ ...prev, loading: false })); } }; const loadRemodelPage = async ({ reset = false } = {}) => { const offset = reset ? 0 : remodelPage.loaded; setRemodelPage(prev => ({ ...prev, loading: true })); try { const jobs = []; const grouped = new Map(); let nextOffset = offset; let hasMore = false; let pageReads = 0; while (grouped.size < PIPELINE_PAGE_SIZE && pageReads < REMODEL_VARIANTS_MAX) { const pageJobs = await RESILIA_API.listRemodelJobs({ source: 'manual', limit: PIPELINE_PAGE_SIZE, offset: nextOffset, }); pageReads += 1; if (!pageJobs.length) { hasMore = false; break; } jobs.push(...pageJobs); nextOffset += pageJobs.length; groupJobsByAd(pageJobs).forEach((rows, key) => { const existing = grouped.get(key) || []; grouped.set(key, [...existing, ...rows]); }); hasMore = pageJobs.length >= PIPELINE_PAGE_SIZE; if (!hasMore) break; } const adIds = [...new Set((jobs || []).map(job => job.ad_id).filter(Boolean))]; const swipeRows = adIds.length ? await RESILIA_API.getSwipesBatch(adIds) : []; const rowsById = new Map((swipeRows || []).map(row => [row.ad_id, row])); const ads = [...grouped.entries()].map(([_key, rows]) => { const firstJob = rows[0] || {}; const payload = firstJob.payload && typeof firstJob.payload === 'object' ? firstJob.payload : null; const row = payload || rowsById.get(firstJob.ad_id); return row ? { ...RESILIA_ADAPT.adaptEvaluatedAd(row), source: 'remodel' } : pipelineFallbackAd(firstJob.ad_id, 'remodel'); }); setRemodelScripts(prev => { const next = { ...prev }; grouped.forEach((rows, key) => { next[key] = remodelEntryFromJobs(rows); }); return next; }); setRemodeledAds(prev => { if (!reset) return appendOlderAds(prev, ads); const currentSeeds = prev.filter(ad => ad.currentPipelineMove); return mergeRecentAds(ads, currentSeeds); }); const nextState = { loaded: nextOffset, hasMore, loading: false, }; setRemodelPage(nextState); return { ads, ...nextState }; } finally { setRemodelPage(prev => ({ ...prev, loading: false })); } }; const loadCompliancePage = async ({ reset = false } = {}) => { const offset = reset ? 0 : compliancePage.loaded; setCompliancePage(prev => ({ ...prev, loading: true })); try { const jobs = await RESILIA_API.listComplianceJobs({ limit: PIPELINE_PAGE_SIZE, offset, }); setComplianceJobs(prev => reset ? jobs : [...prev, ...jobs]); const nextState = { loaded: offset + jobs.length, hasMore: jobs.length >= PIPELINE_PAGE_SIZE, loading: false, }; setCompliancePage(nextState); return { jobs, ...nextState }; } finally { setCompliancePage(prev => ({ ...prev, loading: false })); } }; const refreshFilterRunState = async () => { const state = await RESILIA_API.getFilterState(); if (state) { setFilterRunState(state); setFilterBusy(!!state.running); if (!state.running && filterPollRef.current) { stopPoll('filter'); } } return state; }; const refreshComplianceRunState = async () => { const state = await RESILIA_API.getComplianceState(); if (state) { setComplianceRunState(state); setComplianceBusy(!!state.running); if (!state.running && compliancePollRef.current) { stopPoll('compliance'); } } return state; }; const startCompliancePolling = () => { startVisibilityAwarePoll('compliance', async () => { try { await Promise.all([ loadCompliancePage({ reset: true }), refreshComplianceRunState(), ]); } catch (e) { console.warn('compliance poll failed', e); } }, COMPLIANCE_POLL_INTERVAL_MS); }; const startFilterStatePolling = () => { startVisibilityAwarePoll('filter', async () => { try { await Promise.all([ loadFilterPage({ reset: true }), refreshFilterRunState(), ]); } catch (e) { console.warn('filter state poll failed', e); } }, FILTER_POLL_INTERVAL_MS); }; // ---- Static Ads (standalone image-ad surface) ------------------------- // /state returns the active run (with its variants) in one call; /runs is // the owner-scoped history. Polling stops once the active run is terminal. const refreshStaticAdsState = async () => { const snap = await RESILIA_API.getStaticAdsState(); if (!snap) return null; let run = snap.run || null; if (run) { saCurrentRunIdRef.current = run.run_id; } else if (saCurrentRunIdRef.current) { // No active run, but one just finished — re-fetch it so the completed // result + gallery stay visible (orchestrator emits variants only at done). run = await RESILIA_API.getStaticAdsRun(saCurrentRunIdRef.current); } setSaActiveRun(run); setSaStatus(run?.status || null); setSaState(run?.state || snap.state || null); setSaRunning(!!snap.running); if (!snap.running && saPollRef.current) stopPoll('staticAds'); return snap; }; const loadStaticAdsRuns = async () => { try { const out = await RESILIA_API.listStaticAdsRuns({ limit: 50 }); setSaRuns(Array.isArray(out?.runs) ? out.runs : []); } catch (e) { console.warn('static ads runs load failed', e); } }; const startStaticAdsPolling = () => { startVisibilityAwarePoll('staticAds', async () => { try { await Promise.all([refreshStaticAdsState(), loadStaticAdsRuns()]); } catch (e) { console.warn('static ads poll failed', e); } }, STATIC_ADS_POLL_INTERVAL_MS); }; const handleRunStaticAds = async ({ keyword, brand, nVariants, strictness }) => { setSaError(null); setSaStarting(true); try { const res = await RESILIA_API.runStaticAds({ keyword, brand, nVariants, strictness }); if (res?.run_id) saCurrentRunIdRef.current = res.run_id; await Promise.all([refreshStaticAdsState(), loadStaticAdsRuns()]); startStaticAdsPolling(); } catch (e) { setSaError(e.message || 'Failed to start run'); } finally { setSaStarting(false); } }; const handleStopStaticAds = async (runId) => { const id = runId || saActiveRun?.run_id; if (!id) return; try { await RESILIA_API.stopStaticAdsRun(id); await Promise.all([refreshStaticAdsState(), loadStaticAdsRuns()]); } catch (e) { console.warn('static ads stop failed', e); } }; const handleDeleteStaticAdsRun = async (runId) => { if (!runId) return; try { await RESILIA_API.deleteStaticAdsRun(runId); if (saCurrentRunIdRef.current === runId) saCurrentRunIdRef.current = null; if (saActiveRun?.run_id === runId) { setSaActiveRun(null); setSaStatus(null); setSaState(null); setSaRunning(false); } await loadStaticAdsRuns(); } catch (e) { console.warn('static ads delete failed', e); } }; // --- per-variant actions (#221) --- const handleSaveStaticAdsVariant = async (runId, variantId, saved) => { if (!runId || variantId == null) return; try { await RESILIA_API.saveStaticAdsVariant(runId, variantId, saved); await Promise.all([refreshStaticAdsState(), loadStaticAdsRuns()]); } catch (e) { console.warn('static ads variant save failed', e); setSaError(e.message || 'Failed to save variant'); } }; const handleRegenerateStaticAdsVariant = async (runId, variantId, promptOverride) => { if (!runId || variantId == null) return; setSaError(null); try { const res = await RESILIA_API.regenerateStaticAdsVariant(runId, variantId, promptOverride); if (res?.run_id) saCurrentRunIdRef.current = res.run_id; // Run flips to "running" server-side; resume polling so the new sibling appears. await Promise.all([refreshStaticAdsState(), loadStaticAdsRuns()]); startStaticAdsPolling(); } catch (e) { console.warn('static ads variant regenerate failed', e); setSaError(e.message || 'Failed to regenerate variant'); } }; const handleDeleteStaticAdsVariant = async (runId, variantId) => { if (!runId || variantId == null) return; try { await RESILIA_API.deleteStaticAdsVariant(runId, variantId); await Promise.all([refreshStaticAdsState(), loadStaticAdsRuns()]); } catch (e) { console.warn('static ads variant delete failed', e); setSaError(e.message || 'Failed to delete variant'); } }; const refreshStaticAds = async () => { await Promise.all([refreshStaticAdsState(), loadStaticAdsRuns()]); }; const refreshRemodelRunState = async () => { const state = await RESILIA_API.getRemodelState(); if (state) { setRemodelRunState(state); setRemodelBusy(!!state.running); if (!state.running && remodelPollRef.current) { stopPoll('remodel'); } if (!state.running) { remodelStartPendingRef.current = false; } } return state; }; const autoRemodelFallbackAd = (id, job = {}) => ({ id, brand: job.ad_id || id, platform: job.platform || 'foreplay', copy: '', score: null, reasoning: null, daysRunning: 0, directionLabel: 'Auto-Remodel', directionSearchLabel: 'Auto-Remodel', transcript: [], transcriptRaw: null, source: 'auto_remodel', }); const syncAutoRemodelState = (state) => { if (!state) return; setArRunId(state.run_id || null); arRunIdRef.current = state.run_id || null; setArStage(state.stage || null); arStageRef.current = 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); arTargetAdsRef.current = state.target_ads; } if (typeof state.n_variants === 'number') { setArNVariants(state.n_variants); arNVariantsRef.current = state.n_variants; } if (Object.prototype.hasOwnProperty.call(state, 'strategy')) { setAutoRemodelPageInstruction(capRemodelInstruction(state.strategy || '')); } const completedVariants = (parseInt(state.completed, 10) || 0) + (parseInt(state.failed, 10) || 0); if (completedVariants > 0) setArVariantsDone(completedVariants); const startedVariants = parseInt(state.jobs_started ?? state.jobs_created, 10); if (Number.isFinite(startedVariants) && startedVariants > 0) { setArVariantsTotalOverride(startedVariants); } else if (state.stage === 'cancelled' || state.stage === 'failed') { setArVariantsTotalOverride(null); setArVariantsDone(0); } if (Array.isArray(state.picked_ad_ids)) { setArPickedIds(state.picked_ad_ids); arPickedIdsRef.current = state.picked_ad_ids; } }; const searchFiltersPayload = (filters = {}) => { const out = {}; const minLikes = parseInt(filters.minLikes, 10); const maxLikes = parseInt(filters.maxLikes, 10); const minDailyLikes = parseInt(filters.minDailyLikes, 10); const maxDailyLikes = parseInt(filters.maxDailyLikes, 10); const adspyAgeMin = parseInt(filters.adspyAgeMin, 10); const adspyAgeMax = parseInt(filters.adspyAgeMax, 10); const adspyUserId = parseInt(filters.adspyUserId, 10); const adspyAffNetwork = parseInt(filters.adspyAffNetwork, 10); const fpMinDays = parseInt(filters.fpMinDays, 10); const bsMinViews = parseInt(filters.bsMinViews, 10); const bsMinLikes = parseInt(filters.bsMinLikes, 10); const bsMinEngagement = parseFloat(filters.bsMinEngagement); const bsMinReach = parseInt(filters.bsMinReach, 10); const bsMinSpend = parseInt(filters.bsMinSpend, 10); const bsMaxSpend = parseInt(filters.bsMaxSpend, 10); if (filters.fpFormat) out.foreplay_display_format = [filters.fpFormat]; if ((filters.fpPublisherPlatforms || '').trim()) out.foreplay_publisher_platform = filters.fpPublisherPlatforms.split(',').map(s => s.trim()).filter(Boolean); if ((filters.fpLanguages || '').trim()) out.foreplay_languages = filters.fpLanguages.split(',').map(s => s.trim()).filter(Boolean); if (filters.fpLiveStatus === 'live') out.foreplay_live = true; else if (filters.fpLiveStatus === 'inactive') out.foreplay_live = false; if (Number.isFinite(fpMinDays) && fpMinDays > 0) out.foreplay_running_duration_min_days = fpMinDays; if (filters.fpOrder) out.foreplay_order = filters.fpOrder; if (Number.isFinite(minLikes) && minLikes > 0) out.adspy_min_likes = minLikes; if (Number.isFinite(maxLikes) && maxLikes > 0 && maxLikes < 100000) out.adspy_max_likes = maxLikes; if (Number.isFinite(minDailyLikes) && minDailyLikes > 0) out.adspy_min_daily_likes = minDailyLikes; if (Number.isFinite(maxDailyLikes) && maxDailyLikes > 0 && maxDailyLikes < 1000) out.adspy_max_daily_likes = maxDailyLikes; if (filters.orderBy) out.adspy_order = filters.orderBy; if (filters.adspySiteType) out.adspy_site_type = filters.adspySiteType; if (filters.adspyMediaType) out.adspy_media_type = filters.adspyMediaType; if (filters.adspyGender) out.adspy_gender = filters.adspyGender; if (Number.isFinite(adspyAgeMin) && adspyAgeMin > 0) out.adspy_age_min = adspyAgeMin; if (Number.isFinite(adspyAgeMax) && adspyAgeMax > 0) out.adspy_age_max = adspyAgeMax; if ((filters.adspyCountries || '').trim()) out.countries = filters.adspyCountries.split(',').map(s => s.trim()).filter(Boolean); if ((filters.adspyLang || '').trim()) out.languages = [filters.adspyLang.trim()]; if ((filters.adspyCreatedFrom || '').trim()) out.adspy_created_from = filters.adspyCreatedFrom.trim(); if ((filters.adspyCreatedTo || '').trim()) out.adspy_created_to = filters.adspyCreatedTo.trim(); if ((filters.adspySeenFrom || '').trim()) out.adspy_seen_from = filters.adspySeenFrom.trim(); if ((filters.adspySeenTo || '').trim()) out.adspy_seen_to = filters.adspySeenTo.trim(); if ((filters.adspyUsername || '').trim()) out.adspy_username = filters.adspyUsername.trim(); if (Number.isFinite(adspyUserId) && adspyUserId > 0) out.adspy_user_id = adspyUserId; if (Number.isFinite(adspyAffNetwork) && adspyAffNetwork > 0) out.adspy_aff_network = adspyAffNetwork; if ((filters.adspyAffId || '').trim()) out.adspy_aff_id = filters.adspyAffId.trim(); if ((filters.adspyOfferId || '').trim()) out.adspy_offer_id = filters.adspyOfferId.trim(); if ((filters.adspyTech || '').trim()) out.adspy_tech = filters.adspyTech.split(',').map(s => s.trim()).filter(Boolean); if ((filters.adspyButtons || '').trim()) out.adspy_buttons = filters.adspyButtons.trim(); if ((filters.adspyLandingUrl || '').trim()) out.adspy_url_search_type = 'lp_urls'; else if ((filters.adspyUrl || '').trim()) out.adspy_url_search_type = 'urls'; 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 (Number.isFinite(bsMinReach) && bsMinReach > 0) out.brandsearch_min_reach = bsMinReach; if (Number.isFinite(bsMinSpend) && bsMinSpend > 0) out.brandsearch_min_spend = bsMinSpend; if (Number.isFinite(bsMaxSpend) && bsMaxSpend > 0) out.brandsearch_max_spend = bsMaxSpend; return Object.keys(out).length ? out : null; }; const searchSourceUrlsPayload = (filters = {}) => { const out = {}; const foreplayUrl = (filters.foreplayUrl || '').trim(); const adspyUrl = (filters.adspyUrl || '').trim(); const adspyLandingUrl = (filters.adspyLandingUrl || '').trim(); const brandsearchUrl = (filters.brandsearchUrl || '').trim(); if (foreplayUrl) out.foreplay = foreplayUrl; if (adspyUrl || adspyLandingUrl) out.adspy = adspyUrl || adspyLandingUrl; if (brandsearchUrl) out.brandsearch = brandsearchUrl; return Object.keys(out).length ? out : null; }; const expandAd = (ad, opts = {}) => { const drawerAd = opts.autoPlay ? { ...ad, __autoPlay: true } : ad; setExpandedAd(drawerAd); if (!ad) return; // Bug: the dashboard used to only hydrate transcripts for BrandSearch // ads. AdSpy and Foreplay video ads now also Whisper-transcribe on // demand server-side (search_service.get_ad_transcript), so trigger the // same hydration whenever the card has no real transcript yet. const hasTranscript = ad.isVoiceTranscript !== false && ad.transcriptSource !== 'copy_fallback' && ((ad.transcriptRaw && String(ad.transcriptRaw).trim()) || (Array.isArray(ad.transcript) && ad.transcript.length > 0)); if (hasTranscript) 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.sourcePlatform || item.source_platform || item.raw?.source_platform || item.platform, score: item.score, reasoning: item.reasoning, running_days: item.daysRunning, creative_url: item.creativeUrl, thumbnail_url: item.thumbnailUrl, audio_duration: detail.audio_duration, video_duration: item.raw?.video_duration || detail.raw?.video_duration || detail.audio_duration, transcript: detail.transcript, transcript_segments: detail.transcript_segments, transcript_status: detail.transcript_status, transcript_source: detail.transcript_source, is_voice_transcript: detail.is_voice_transcript, fallback_copy: detail.fallback_copy, transcript_provider: detail.transcript_provider, transcript_model: detail.transcript_model, transcript_word_count: detail.transcript_word_count, creative_url: detail.creative_url || detail.video_url || item.creativeUrl, raw: { ...(item.raw || {}), ...(detail.raw || {}) }, metrics: item.raw?.metrics || detail.raw?.metrics || {}, }, item.sourcePlatform || item.source_platform || item.raw?.source_platform || item.platform); adapted.directionId = item.directionId; adapted.directionLabel = item.directionLabel; adapted.directionSearchLabel = item.directionSearchLabel; return { ...item, ...adapted, source: item.source, resultUuid: item.resultUuid, cardKey: item.cardKey, runId: item.runId, arrivedAtMs: item.arrivedAtMs || adapted.arrivedAtMs || 0, }; }; 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 manualSearchLabelForRow = (row) => { if (row.competitorId) { if (row.competitorId === MANUAL_SEARCH_ALL_COMPETITORS_ID) return 'All competitors'; const competitor = competitors.find(c => String(c.id) === String(row.competitorId)); return competitor?.name || row.competitorId; } const keyword = String(row.query || '').trim(); if (keyword) return keyword; const urls = searchSourceUrlsPayload(row.filters); if (urls?.foreplay_url) return urls.foreplay_url; if (urls?.adspy_url) return urls.adspy_url; if (urls?.adspy_landing_url) return urls.adspy_landing_url; if (urls?.brandsearch_url) return urls.brandsearch_url; return null; }; const runSearchRows = async (targetRows, { global = false } = {}) => { searchStopRequestedRef.current = false; const activeRows = (targetRows || []).filter(row => { const sourceUrls = searchSourceUrlsPayload(row.filters); return row.query || row.competitorId || sourceUrls; }); if (!activeRows.length) { showToast('No searchable row configured. Add a keyword, competitor, or source URL.', 'Search'); return []; } const activeRowIds = activeRows.map(row => row.id).filter(id => id !== null && id !== undefined); const searchBatchId = `manual_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const searchRunLabels = [...new Set(activeRows.map(manualSearchLabelForRow).filter(Boolean))]; if (global) setSearching(true); setSearchingRowIds(prev => { const next = new Set(prev); activeRowIds.forEach(id => next.add(id)); return next; }); setResearchAds(prev => prev.map(ad => ( ad.source === 'autoresearch' ? ad : { ...ad, currentSearchRun: false } ))); setSearchWarnings([]); const collectedWarnings = []; const rowResults = new Map(); const rowDuplicatesHidden = new Map(); const rowProgress = new Map(); const combinedProgress = () => { const out = {}; for (const progress of rowProgress.values()) { if (!progress || typeof progress !== 'object') continue; for (const [source, value] of Object.entries(progress)) { out[source] = value; } } return out; }; const updateSearchSummary = (meta = {}) => { const count = [...rowResults.values()].reduce((n, ads) => n + ads.length, 0); const hidden = [...rowDuplicatesHidden.values()].reduce((n, value) => n + Number(value || 0), 0); setLatestSearchRun(prev => { const nextSearchPhase = meta.searchPhase ?? prev?.searchPhase ?? null; return { id: searchBatchId, count, labels: searchRunLabels, progress: combinedProgress(), sharedPoolPrefill: nextSearchPhase === 'live_search' ? null : meta.sharedPoolPrefill ?? prev?.sharedPoolPrefill ?? null, searchPhase: nextSearchPhase, duplicatesHidden: hidden, }; }); }; const runOneRow = async (row) => { const rowKey = row.id ?? `${searchBatchId}_${rowResults.size}`; const rowRunId = `${searchBatchId}_${rowKey}`; const sourceUrls = searchSourceUrlsPayload(row.filters); const searchReturnLabel = manualSearchLabelForRow(row); const platforms = Object.keys(row.platforms).filter(k => row.platforms[k]); const platformList = platforms.length ? platforms : ['foreplay']; const basePayload = { query: row.query, competitorId: row.competitorId, count: Math.min(row.count, 100), platforms: platformList, filters: searchFiltersPayload(row.filters), sourceUrls, }; const isCompetitorSearch = !!row.competitorId; const seedSearchProgress = () => { const seeded = {}; for (const platformKey of platformList) { if (isCompetitorSearch) { seeded[platformKey] = { source: platformKey, status: 'queued', stage: 'queued', unit: 'shared_pool', kept: 0, target: basePayload.count, target_kind: 'winning', message: `Waiting to check ${platformKey} shared pool.`, }; } else if (platformKey === 'brandsearch') { seeded[platformKey] = { source: platformKey, status: 'queued', stage: 'queued', unit: 'ads', kept: 0, target: basePayload.count, target_kind: 'winning', ads_checked: 0, message: `Waiting to search ${platformKey}.`, }; } else { seeded[platformKey] = { source: platformKey, status: 'queued', stage: 'queued', unit: 'pages', kept: 0, target: basePayload.count, target_kind: 'winning', pages_fetched: 0, raw_ads_seen: 0, message: `Waiting to search ${platformKey}.`, }; } } rowProgress.set(rowKey, seeded); updateSearchSummary({ searchPhase: isCompetitorSearch ? 'queued' : 'live_search', sharedPoolPrefill: null, }); }; const applySearchResponse = (resp, { partial = false } = {}) => { const seedArrivedAtMs = Date.now(); const ads = (resp?.ads || []).map(a => { const platform = a?.source_platform || a?.raw?.source_platform || 'foreplay'; const adapted = RESILIA_ADAPT.adaptSearchAd(a, platform); return { ...adapted, source: 'search', searchReturnLabel, searchRunId: rowRunId, searchBatchId, searchRowId: rowKey, currentSearchRun: true, arrivedAtMs: adapted.arrivedAtMs || seedArrivedAtMs, }; }); const historyAds = (resp?.history_ads || []).map(a => ({ ...RESILIA_ADAPT.adaptSearchAd(a, a?.source_platform || 'foreplay'), source: 'search', searchReturnLabel: 'Duplicate history', currentSearchRun: false, })); const deduped = uniqueSearchAds(ads); if (resp && typeof resp.duplicates_hidden === 'number') { rowDuplicatesHidden.set(rowKey, resp.duplicates_hidden); } if (resp && resp.progress && typeof resp.progress === 'object') { rowProgress.set(rowKey, { ...(rowProgress.get(rowKey) || {}), ...resp.progress }); } if (partial && !deduped.length && !historyAds.length) { if (resp && (resp.progress || typeof resp.duplicates_hidden === 'number')) { setLatestSearchRun({ id: searchBatchId, count: (rowResults.get(rowKey) || []).length, labels: searchRunLabels, duplicatesHidden: Number(rowDuplicatesHidden.get(rowKey) || 0), progress: combinedProgress(), sharedPoolPrefill: resp?.shared_pool_prefill ?? null, searchPhase: resp?.search_phase ?? null, }); } else { updateSearchSummary(); } return rowResults.get(rowKey) || []; } if (!partial || deduped.length) { rowResults.set(rowKey, deduped); } setResearchAds(prev => { const autoresearchAds = prev.filter(a => a.source === 'autoresearch'); const priorSearchAds = prev.filter(a => a.source !== 'autoresearch'); const merged = partial && !deduped.length ? (rowResults.get(rowKey) || []) : deduped; const newKeys = new Set([...merged, ...historyAds].map(autoresearchDisplayKey)); const remainingPrior = priorSearchAds .filter(a => !newKeys.has(autoresearchDisplayKey(a))) .map(a => ( a.searchRowId === rowKey ? { ...a, currentSearchRun: false } : a )); return [...merged, ...historyAds, ...remainingPrior, ...autoresearchAds]; }); updateSearchSummary({ sharedPoolPrefill: resp?.shared_pool_prefill ?? undefined, searchPhase: resp?.search_phase ?? undefined, }); return rowResults.get(rowKey) || deduped; }; const activeKey = String(rowKey); try { seedSearchProgress(); let resp = await RESILIA_API.search(basePayload); if (resp && resp.status === 'started' && resp.run_id && RESILIA_API.waitForSearchRun) { activeSearchRunIdsRef.current.set(activeKey, resp.run_id); persistActiveSearchRuns(); if (searchStopRequestedRef.current && RESILIA_API.cancelSearchRun) { await RESILIA_API.cancelSearchRun(resp.run_id); } resp = await RESILIA_API.waitForSearchRun(resp.run_id, { onProgress: progress => applySearchResponse(progress, { partial: true }), }); } const deduped = applySearchResponse(resp); if (resp && typeof resp.credits_remaining === 'number') { setPlatformCredits(pc => ({ ...pc, foreplay: resp.credits_remaining })); } if (resp && Array.isArray(resp.warnings) && resp.warnings.length) { for (const w of resp.warnings) { if (w && typeof w === 'object') { collectedWarnings.push({ ...w, query: row.query || null }); } } const labels = resp.warnings.map(w => w.source).filter(Boolean).join(', '); showToast(`Search completed with ${labels || resp.warnings.length} warning(s).`, 'Warn'); } return deduped; } catch (e) { updateSearchSummary(); if (searchStopRequestedRef.current || String(e.message || '').includes('cancelled')) { showToast(`Search stopped for ${row.query || row.competitorId}.`, 'Search'); } else { showToast(`Search failed for ${row.query || row.competitorId}: ${e.message}`, 'Error'); } return rowResults.get(rowKey) || []; } finally { activeSearchRunIdsRef.current.delete(activeKey); persistActiveSearchRuns(); if (row.id !== null && row.id !== undefined) { setSearchingRowIds(prev => { const next = new Set(prev); next.delete(row.id); return next; }); } } }; try { const results = (await Promise.all(activeRows.map(runOneRow))).flat(); // 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); } const hidden = [...rowDuplicatesHidden.values()].reduce((n, count) => n + Number(count || 0), 0); setLatestSearchRun({ id: searchBatchId, count: deduped.length, labels: searchRunLabels, duplicatesHidden: hidden, progress: combinedProgress(), }); if (searchStopRequestedRef.current) { showToast(`${deduped.length} ads kept from stopped search.`, 'Search'); } else if (deduped.length === 0) { showToast('No ads returned. Check your query or competitor.', 'Search'); } else { showToast( hidden ? `${deduped.length} ads fetched, ${hidden} duplicate(s) hidden.` : `${deduped.length} ads fetched (scored).`, 'Search', ); } setSearchWarnings(collectedWarnings); return deduped; } finally { if (global) setSearching(false); if (activeSearchRunIdsRef.current.size === 0) { searchStopRequestedRef.current = false; } setSearchingRowIds(prev => { const next = new Set(prev); activeRowIds.forEach(id => next.delete(id)); return next; }); } }; const runSearch = async () => { await runSearchRows(rows, { global: true }); }; const runSearchRow = async (rowId) => { const row = rows.find(item => item.id === rowId); if (!row) return; await runSearchRows([row]); }; const stopSearch = async () => { searchStopRequestedRef.current = true; const runIds = [...new Set([...activeSearchRunIdsRef.current.values()].filter(Boolean))]; if (runIds.length === 0) { setSearching(false); setSearchingRowIds(new Set()); showToast('Search stopping…', 'Search'); return; } await Promise.all(runIds.map(runId => ( RESILIA_API.cancelSearchRun(runId).catch(e => console.warn('search cancel failed', runId, e)) ))); setSearching(false); setSearchingRowIds(new Set()); showToast('Search stopping…', 'Search'); }; // ---- Autoresearch ------------------------------------------------------- const pollWinners = async () => { const pollGeneration = autoresearchRunGenerationRef.current; const pollStillCurrent = () => pollGeneration === autoresearchRunGenerationRef.current; try { // Always refresh from offset 0 so current-run ads stream in while prefill // grows. Incremental offsets belong to scroll load-more only (see // loadMoreAutoresearchWinners) — advancing offset during poll skips the // current-run slice once loadedIds > current_run_total. const pollOffset = 0; const activeRunId = [autoresearchBatchIdRef.current].find( (candidate) => typeof candidate === 'string' && candidate.startsWith('autoresearch-'), ) || null; const [summary, activeState, page] = await Promise.all([ RESILIA_API.summary(), RESILIA_API.getAutoresearchState(), loadAutoresearchWinnersPage({ reset: autoresearchLoadedIdsRef.current.size === 0, offset: pollOffset, pageSize: AUTORESEARCH_PAGE_SIZE, directionSource: directionsRef.current, includeFailed: !searchPassesOnlyRef.current, lightweight: true, runId: activeRunId, }), ]); if (!pollStillCurrent()) return; const loadedWinners = page.loaded ?? autoresearchCurrentIdsRef.current.size; const currentRunWinners = autoresearchCurrentIdsRef.current.size; const activeRunState = activeState?.state || {}; const backendRunning = !!activeState?.running; const useRunState = backendRunning || Number(activeRunState.run_directions_total || 0) > 0 || Object.keys(activeRunState.platform_ads_fetched || {}).length > 0 || Object.keys(activeRunState.platform_progress || {}).length > 0 || activeRunState?.shared_pool_prefill_only || activeRunState?.run_phase === 'shared_pool_ready' || activeRunState?.run_phase === 'prefilling_shared_pool'; const effectiveRunTotal = useRunState ? Number(activeRunState.run_directions_total || 0) : Number(summary?.run_directions_total || 0); const effectiveRunDone = Number(activeRunState.run_directions_done || 0); const poolOnlyComplete = Boolean( activeRunState?.shared_pool_prefill_only || activeRunState?.run_phase === 'shared_pool_ready' || activeState?.run?.status === 'done' ); const runLooksComplete = !backendRunning && ( poolOnlyComplete || (effectiveRunTotal > 0 && effectiveRunDone >= effectiveRunTotal) ); const effectiveMeta = resolveAutoresearchProgressMeta(activeRunState, summary, { looksComplete: runLooksComplete, useRunState, }); const effectiveCrawlState = effectiveMeta.crawlState; if (runLooksComplete || poolOnlyComplete || effectiveCrawlState === 'done') { setArRunning(false); setAutoresearchActive(false); } const effectiveCoverageTouched = Math.max( Number(activeRunState.coverage_touched || 0), Number(summary?.coverage_touched || 0), Number(summary?.run_directions_touched || 0), ); if (summary) { const poolWinners = Number( activeRunState.result_count || activeRunState.prefilled_count || 0, ); const usePoolCounts = poolOnlyComplete || poolWinners > 0; const runTotal = effectiveRunTotal || 0; const runTotalScored = usePoolCounts ? Number(summary.run_total_scored ?? poolWinners) : runTotal > 0 ? (summary.run_total_scored ?? 0) : summary.total_scored; setArProgress({ totalScored: runTotalScored, winners: currentRunWinners, loadedWinners, totalWinners: currentRunWinners > 0 ? currentRunWinners : usePoolCounts ? Math.max(Number(summary.run_winners ?? 0), poolWinners) : runTotal > 0 ? Number(summary.run_winners ?? 0) : 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: useRunState ? Number(activeRunState.run_directions_done || 0) : (runTotal > 0 ? summary.run_directions_done : summary.directions_satisfied), directionsTotal: runTotal > 0 ? runTotal : summary.directions_total, crawlState: effectiveCrawlState, runPhase: effectiveMeta.runPhase, prefilledCount: effectiveMeta.prefilledCount, remainingTarget: effectiveMeta.remainingTarget, creditsRemaining: summary.credits_remaining, platformAdsFetched: useRunState ? (activeRunState.platform_ads_fetched || {}) : (summary.platform_ads_fetched || {}), platformProgress: useRunState ? (activeRunState.platform_progress || {}) : (summary.platform_progress || {}), directionProgress: useRunState ? (activeRunState.direction_progress || {}) : (summary.direction_progress || {}), coverageTotal: useRunState ? Number(activeRunState.coverage_total || 0) : (summary.coverage_total || 0), coverageTouched: useRunState ? effectiveCoverageTouched : effectiveCoverageTouched, platformCounts: summary.run_platform_counts || {}, }); restartAutoresearchPoll(effectiveMeta.runPhase); if (typeof summary.credits_remaining === 'number') { setPlatformCredits(pc => ({ ...pc, foreplay: summary.credits_remaining })); } } if (summary && !runLooksComplete && !poolOnlyComplete && effectiveCrawlState !== 'idle' && effectiveCrawlState !== 'done') { setArRunning(true); setAutoresearchActive(true); setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'running', count: autoresearchCurrentIdsRef.current.size, }) : prev); } if (summary && (runLooksComplete || poolOnlyComplete || effectiveCrawlState === 'idle' || effectiveCrawlState === 'done') && !backendRunning) { const stillSavingPages = Boolean( page.hasMore && !poolOnlyComplete && effectiveCrawlState !== 'done' && effectiveMeta.runPhase !== 'done', ); if (stillSavingPages) { setArProgress(prev => prev ? ({ ...prev, crawlState: 'saving' }) : prev); setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'saving results', count: autoresearchCurrentIdsRef.current.size, }) : prev); } else { setArRunning(false); setAutoresearchActive(false); setArProgress(prev => { if (!prev) return prev; const total = Number(prev.directionsTotal || 0); return { ...prev, crawlState: 'done', directionsSatisfied: total > 0 ? total : prev.directionsSatisfied, }; }); setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'done', count: currentRunWinners }) : prev); stopPoll('ar'); showToast(`AutoSearch complete. ${currentRunWinners} ads loaded.`, 'AutoSearch'); } } } catch (e) { console.warn('poll winners failed', e); } }; const restartAutoresearchPoll = (runPhase) => { const normalized = runPhase || 'running'; if (normalized === 'done' || normalized === 'shared_pool_ready') { stopPoll('ar'); arPollPhaseRef.current = null; return; } if (arPollPhaseRef.current === normalized && arPollRef.current) return; arPollPhaseRef.current = normalized; const intervalMs = normalized === 'prefilling_shared_pool' ? AR_PREFILL_POLL_INTERVAL_MS : AR_ACTIVE_POLL_INTERVAL_MS; startVisibilityAwarePoll('ar', pollWinners, intervalMs); }; const runAutoresearch = async () => { const directionPool = directions; if (directionPool.length === 0) { showToast('No directions configured for the selected sources.', 'AutoSearch'); return; } // Direction selection: explicit checkbox picks win; otherwise run the // full direction pool. Autoresearch should never silently sample only a // few directions when the user expects the whole catalog. let ids; if (arPickedDirections.size > 0) { ids = directions.filter(d => arPickedDirections.has(d.id)).map(d => d.id); } else { ids = directionPool.map(d => d.id); setArPickedDirections(new Set(ids)); setArNumDirections(String(ids.length)); } if (ids.length === 0) { showToast('No directions resolved — check the picker or the cap.', 'AutoSearch'); return; } const sources = Object.keys(arSources).filter(k => arSources[k]); if (sources.length === 0) { showToast('Pick at least one source (Foreplay, AdSpy, …).', 'AutoSearch'); return; } setArSelected(new Set(ids)); setAutoresearchActive(true); setArProgress({ crawlState: 'prefilling_shared_pool', runPhase: 'prefilling_shared_pool', winners: 0, prefilledCount: 0, totalScored: 0, directionsSatisfied: 0, directionsTotal: ids.length * sources.length, platformProgress: {}, directionProgress: {}, }); // Bug retention — keep prior autoresearch winners on screen as a history // strip below the new batch. Stamp untagged old ads with a 'previous' // batch id, then bump the current batch id so new ads land on top. setResearchAds(prev => prev.map(ad => ( ad.source === 'autoresearch' && !ad._arBatchId ? { ...ad, _arBatchId: 'previous', currentAutoresearchRun: false, currentRunLabel: null } : ad.source === 'autoresearch' ? { ...ad, currentAutoresearchRun: false, currentRunLabel: null } : ad ))); const newBatchId = `ar-${Date.now()}`; autoresearchBatchIdRef.current = newBatchId; autoresearchLoadedIdsRef.current = new Set(); autoresearchCurrentIdsRef.current = new Set(); autoresearchLoadedPlatformCountsRef.current = {}; autoresearchLoadedPlatformCountsRef.current = {}; setAutoresearchPage({ loaded: 0, total: null, hasMore: false, loading: false }); setLatestAutoresearchRun({ id: newBatchId, count: 0, total: null, status: 'starting', labels: sources, }); arPollPhaseRef.current = null; stopPoll('ar'); autoresearchRunGenerationRef.current += 1; setArRunning(true); try { const perDirectionTarget = boundedPositiveIntOrNull(arTarget); if (arTarget && String(perDirectionTarget) !== arTarget) { setArTarget(perDirectionTarget === null ? '' : String(perDirectionTarget)); } autoresearchTargetRef.current = perDirectionTarget; const maxAdsPerDirection = boundedPositiveIntOrNull( arMaxAdsPerDirection, SEARCH_ADS_PER_DIRECTION_MAX, ); const maxAdsPerPlatform = boundedPositiveIntOrNull( arMaxAdsPerPlatform, SEARCH_ADS_PER_PLATFORM_MAX, ); const startResult = await RESILIA_API.startAutoresearch({ directionIds: ids, perDirectionTarget, platforms: sources, filters: searchFiltersPayload(arFilters), maxAdsPerDirection, maxAdsPerPlatform, force: true, }); if (startResult?.status === 'already_running') { const restored = startResult.state || {}; const state = restored.state || {}; const runId = state.run_id || restored.run?.run_id || newBatchId; const restoredSources = state.sources || restored.run?.platforms || sources; const restoredTarget = state.target_ads || state.max_total_winners || perDirectionTarget; if (restoredTarget) { autoresearchTargetRef.current = Number(restoredTarget); setArTarget(String(restoredTarget)); } autoresearchBatchIdRef.current = runId; setArRunning(true); setAutoresearchActive(true); setArSelected(new Set(state.selected || restored.run?.direction_ids || ids)); setLatestAutoresearchRun(prev => prev ? ({ ...prev, id: runId, status: state.job_status || state.crawl_status || state.status || 'running', labels: restoredSources, }) : { id: runId, count: 0, total: null, status: state.job_status || state.crawl_status || state.status || 'running', labels: restoredSources, }); setArProgress({ crawlState: state.run_phase || state.status || state.job_status || state.crawl_status || 'running', runPhase: state.run_phase || state.crawl_status || state.job_status || state.status || 'running', prefilledCount: Number(state.prefilled_count || state.result_count || 0), remainingTarget: Number(state.remaining_target || 0), winners: Number(state.result_count || 0), totalScored: 0, directionsSatisfied: state.run_directions_done || 0, directionsTotal: state.run_directions_total || 0, platformAdsFetched: state.platform_ads_fetched || {}, platformProgress: state.platform_progress || {}, directionProgress: state.direction_progress || {}, coverageTotal: state.coverage_total || 0, coverageTouched: state.coverage_touched || 0, platformCounts: state.run_platform_counts || {}, }); showToast('AutoSearch is already running. Showing the active run controls.', 'AutoSearch'); restartAutoresearchPoll(state.run_phase || state.crawl_status || 'running'); pollWinners(); return; } const backendRunId = startResult.autoresearch_run_id || newBatchId; autoresearchBatchIdRef.current = backendRunId; const prefilledCount = Number(startResult.prefilled_count || 0); if (startResult?.completed) { setArRunning(false); setAutoresearchActive(false); setArProgress(prev => prev ? ({ ...prev, crawlState: 'done', runPhase: startResult.run_phase || 'shared_pool_ready', prefilledCount, winners: prefilledCount, totalWinners: prefilledCount, totalScored: prefilledCount, }) : prev); setLatestAutoresearchRun(prev => prev ? ({ ...prev, id: backendRunId, count: prefilledCount, total: prefilledCount, status: 'done', }) : { id: backendRunId, count: prefilledCount, total: prefilledCount, status: 'done', labels: sources, }); await loadAutoresearchWinnersPage({ reset: true, directionSource: directionsRef.current, lightweight: true, runId: backendRunId, }); pollWinners(); showToast( prefilledCount ? `AutoSearch filled ${prefilledCount} ad(s) from the shared pool.` : 'AutoSearch completed with no new ads from the shared pool.', 'AutoSearch', ); return; } setArRunning(true); setLatestAutoresearchRun(prev => prev ? ({ ...prev, id: backendRunId, count: prefilledCount, status: 'running', }) : prev); setArProgress(prev => ({ ...(prev || {}), crawlState: startResult.run_phase || (prefilledCount > 0 ? 'awaiting_worker' : 'running'), runPhase: startResult.run_phase || prev?.runPhase || 'running', prefilledCount, remainingTarget: Number(startResult.remaining_target || 0), winners: prefilledCount, totalWinners: prefilledCount, })); const startToast = startResult.run_phase === 'prefilling_shared_pool' ? `AutoSearch started — loading ads from the shared pool…` : prefilledCount ? `AutoSearch started — ${prefilledCount} ad(s) loaded from the shared pool.` : `AutoSearch started for ${ids.length} direction(s) across ${sources.length} source(s).`; showToast(startToast, 'AutoSearch'); restartAutoresearchPoll(startResult.run_phase || 'prefilling_shared_pool'); pollWinners(); } catch (e) { stopPoll('ar'); arPollPhaseRef.current = null; setArRunning(false); setAutoresearchActive(false); setArProgress(prev => prev ? ({ ...prev, crawlState: 'idle' }) : prev); setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'failed' }) : prev); showToast('AutoSearch failed to start: ' + e.message, 'Error'); } }; const loadMoreAutoresearchWinners = async () => { try { const includeFailed = !searchPassesOnlyRef.current; const page = await loadAutoresearchWinnersPage({ includeFailed, lightweight: !includeFailed, pageSize: includeFailed ? AUTORESEARCH_FAIL_DISPLAY_LIMIT : AUTORESEARCH_PAGE_SIZE, }); if (page && !page.hasMore && page.loaded > 0) { showToast('No more AutoSearch ads to load.', 'AutoSearch'); } } catch (e) { showToast('Failed to load more AutoSearch ads: ' + e.message, 'Error'); } }; const stopAutoresearch = async () => { autoresearchRunGenerationRef.current += 1; setArRunning(false); setAutoresearchActive(false); stopPoll('ar'); try { await RESILIA_API.stopAutoresearch(); setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'cancelled' }) : prev); setArProgress(prev => prev ? ({ ...prev, crawlState: 'idle', runPhase: 'cancelled', }) : prev); showToast('AutoSearch stopped.', 'AutoSearch'); } 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 uniqueIds = [...new Set(adIds.filter(Boolean))]; // Retry budget kept low (2 attempts × 500ms) so this Phase C path does // not block the user-perceived load when called from the lazy tab // loader. Auto-Remodel SSE will fill in any swipe rows that arrive after // we give up here. const getSwipeWithRetry = async (id) => { for (let attempt = 0; attempt < 2; attempt += 1) { const swipe = await RESILIA_API.getSwipe(id); if (swipe) return swipe; await new Promise(resolve => setTimeout(resolve, 500)); } return null; }; const getSwipesWithRetry = async (ids) => { const rowsById = new Map(); try { for (let attempt = 0; attempt < 2 && rowsById.size < ids.length; attempt += 1) { const missingIds = ids.filter(id => !rowsById.has(id)); const rows = await RESILIA_API.getSwipesBatch(missingIds); rows.forEach(row => { if (row?.ad_id) rowsById.set(row.ad_id, row); }); if (rowsById.size >= ids.length) break; await new Promise(resolve => setTimeout(resolve, 500)); } return ids.map(id => rowsById.get(id)).filter(Boolean); } catch (e) { console.warn('swipe batch hydration failed; falling back to single-row fetches', e); return Promise.all(ids.map(getSwipeWithRetry)); } }; const swipeRows = await getSwipesWithRetry(uniqueIds); const rows = await Promise.all(swipeRows.map(async (swipe) => { const id = swipe?.ad_id; try { 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, transcript_segments: detail.transcript_segments, transcript_status: detail.transcript_status, transcript_source: detail.transcript_source, transcript_provider: detail.transcript_provider, transcript_model: detail.transcript_model, transcript_word_count: detail.transcript_word_count, is_voice_transcript: detail.is_voice_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); }; const adaptAutoRemodelSnapshotRows = (rows, directionSource = directionsRef.current, { current = false } = {}) => { const dirMap = directionMetaMap(directionSource); const ads = (rows || []) .filter(row => row?.ad_id && row.result_uuid) .map(row => { const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row); attachDirectionMeta(adapted, dirMap); return { ...adapted, source: 'auto_remodel', _arRunId: row.run_id || adapted.runId || null, currentAutoRemodelRun: current, }; }); return uniqueAutoRemodelAds(ads, dedupeSettings); }; const remodelEntryFromSnapshotRow = (row) => { const jobs = Array.isArray(row?.remodel_jobs) ? row.remodel_jobs : []; const doneJobs = jobs .filter(job => job && job.status === 'done' && job.response_text) .sort((a, b) => (a.variant_idx || 0) - (b.variant_idx || 0)); if (doneJobs.length) { return { status: 'ready', variants: doneJobs.map((job, index) => ({ jobId: job.id || null, variantIdx: job.variant_idx || index, raw: job.response_text, lines: parseRemodelScript(job.response_text), promotedAt: job.promoted_at || null, })), }; } const scripts = Array.isArray(row?.remodeled_scripts) ? row.remodeled_scripts.filter(Boolean) : []; if (scripts.length) { return { status: 'ready', variants: scripts.map((text, index) => ({ jobId: Array.isArray(row?.remodel_job_ids) ? row.remodel_job_ids[index] || null : null, variantIdx: index, raw: text, lines: parseRemodelScript(text), promotedAt: null, })), }; } const failed = jobs.find(job => job && job.status === 'failed'); if (failed) { const failedJobIds = jobs .filter(job => job && job.status === 'failed' && job.id) .map(job => job.id); return { status: 'error', variants: [], error: failed.error || 'Generation failed', failedJobIds, }; } return null; }; const seedAutoRemodelScriptsFromRows = (rows, ads) => { if (!Array.isArray(rows) || !Array.isArray(ads) || !rows.length || !ads.length) return; const rowsByKey = new Map(); rows.forEach(row => { if (!row?.ad_id) return; const instanceKey = row.result_uuid || row.resultUuid || row.payload?.result_uuid || row.payload?.resultUuid; rowsByKey.set(String(instanceKey || row.ad_id), row); if (!instanceKey) rowsByKey.set(String(row.ad_id), row); }); setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(ad => { const scriptKey = resultKey(ad); const row = rowsByKey.get(String(ad.resultUuid || ad.result_uuid || scriptKey)) || rowsByKey.get(String(ad.id)); const entry = remodelEntryFromSnapshotRow(row); if (entry) { next[scriptKey] = entry; } else if (!next[scriptKey] && !next[ad.id]) { next[scriptKey] = { status: 'pending', variants: [] }; } }); return next; }); }; const applyAutoRemodelCurrentPayload = async ( payload, directionSource = directionsRef.current, { announce = false, focus = false } = {}, ) => { if (!payload || payload.status !== 'ok' || !payload.state) return false; const rawState = payload.state; const terminalStages = new Set(['done', 'failed', 'cancelled']); const state = (!payload.running && !terminalStages.has(rawState.stage) && !terminalStages.has(rawState.status)) ? { ...rawState, stage: 'cancelled', status: 'cancelled', cancel_reason: rawState.cancel_reason || 'No active Auto-Remodel run.', } : rawState; syncAutoRemodelState(state); const pickedIds = Array.isArray(state.picked_ad_ids) ? state.picked_ad_ids.slice(0, AUTO_REMODEL_COUNT_MAX) : []; const rows = Array.isArray(payload.ads) ? payload.ads : []; const historyRows = []; const currentRunId = state.run_id || null; const snapshotAds = adaptAutoRemodelSnapshotRows(rows, directionSource, { current: true }) .map(ad => ({ ...ad, _arRunId: currentRunId || ad._arRunId, currentAutoRemodelRun: true, autoRemodelProvisional: false, })); const historyAds = adaptAutoRemodelSnapshotRows(historyRows, directionSource, { current: false }); if (!snapshotAds.length && !historyAds.length) return false; seedAutoRemodelScriptsFromRows(rows, snapshotAds); seedAutoRemodelScriptsFromRows(historyRows, historyAds); snapshotAds.forEach(ad => autoRemodelLoadedIdsRef.current.add(ad.id)); setAutoRemodelAds(prev => { const currentKeys = autoRemodelDedupeKeySet(snapshotAds, dedupeSettings); const historyKeys = autoRemodelDedupeKeySet(historyAds, dedupeSettings); const retainedCurrent = prev .filter(ad => ( currentRunId && ad.source === 'auto_remodel' && ad._arRunId === currentRunId && !ad.autoRemodelProvisional && !hasAutoRemodelDedupeOverlap(currentKeys, ad, dedupeSettings) )) .map(ad => ({ ...ad, currentAutoRemodelRun: true })); retainedCurrent.forEach(ad => addAutoRemodelDedupeKeys(currentKeys, ad, dedupeSettings)); const previous = prev .filter(ad => ( !hasAutoRemodelDedupeOverlap(currentKeys, ad, dedupeSettings) && !hasAutoRemodelDedupeOverlap(historyKeys, ad, dedupeSettings) && !(currentRunId && ad._arRunId === currentRunId && ad.autoRemodelProvisional) )) .map(ad => ({ ...ad, currentAutoRemodelRun: false })); return uniqueAutoRemodelAds([...snapshotAds, ...retainedCurrent, ...historyAds, ...previous], dedupeSettings); }); setAutoRemodelHydration({ total: pickedIds.length || snapshotAds.length, loaded: snapshotAds.length, loading: false, }); const snapshotIds = snapshotAds.map(ad => ad.id); if (snapshotIds.length) pollRemodelJobs(snapshotAds, autoRemodelCurrentJobOptions()); if (focus) setTab('autoRemodel'); if (announce) { showToast( `Auto-Remodel: restored ${snapshotAds.length} saved ad${snapshotAds.length === 1 ? '' : 's'} from this run.`, 'Auto-Remodel', ); } return true; }; const restorePreviousAutoRemodelHistory = async ({ excludeRunId = arRunIdRef.current, directionSource = directionsRef.current, limit = 200, } = {}) => { if (!RESILIA_API.autoRemodelHistory) return []; const page = await RESILIA_API.autoRemodelHistory(limit, excludeRunId); const rows = Array.isArray(page) ? page : (Array.isArray(page?.ads) ? page.ads : []); const historyAds = adaptAutoRemodelSnapshotRows(rows, directionSource, { current: false }); if (!historyAds.length) return []; seedAutoRemodelScriptsFromRows(rows, historyAds); setAutoRemodelAds(prev => { const existingKeys = autoRemodelDedupeKeySet(prev, dedupeSettings); const additions = historyAds.filter(ad => !hasAutoRemodelDedupeOverlap(existingKeys, ad, dedupeSettings)); return additions.length ? uniqueAutoRemodelAds([...prev, ...additions], dedupeSettings) : prev; }); pollAutoRemodelHistoryJobs(historyAds, { markMissingAsError: true }); return historyAds; }; const loadAutoRemodelPage = async ({ ids = arPickedIdsRef.current, directionSource = directionsRef.current, reset = false, pageSize = AUTO_REMODEL_PAGE_SIZE, } = {}) => { const allIds = [...new Set((ids || []).filter(Boolean))]; if (!allIds.length) return []; if (autoRemodelHydratingPageRef.current) { for (let attempt = 0; attempt < 20 && autoRemodelHydratingPageRef.current; attempt += 1) { await new Promise(resolve => setTimeout(resolve, 100)); } if (autoRemodelHydratingPageRef.current) return []; } autoRemodelHydratingPageRef.current = true; const loadedIds = reset ? new Set() : new Set(autoRemodelLoadedIdsRef.current); if (reset) { autoRemodelLoadedIdsRef.current = loadedIds; } const pageIds = allIds.filter(id => !loadedIds.has(id)).slice(0, pageSize); if (!pageIds.length) { setAutoRemodelHydration({ total: allIds.length, loaded: loadedIds.size, loading: false }); autoRemodelHydratingPageRef.current = false; return []; } setAutoRemodelHydration({ total: allIds.length, loaded: loadedIds.size, loading: true }); try { const hydratedAds = await hydratePickedAds(pageIds, directionSource); const currentRunId = arRunIdRef.current; const hydratedById = new Map(hydratedAds.map(ad => [ad.id, ad])); const pageAds = pageIds.map(id => { const base = hydratedById.get(id) || autoRemodelFallbackAd(id); const runId = currentRunId || base._arRunId || 'current'; return { ...base, _arRunId: runId, currentAutoRemodelRun: true, autoRemodelProvisional: false }; }); pageIds.forEach(id => loadedIds.add(id)); autoRemodelLoadedIdsRef.current = loadedIds; setAutoRemodelAds(prev => { // Current-run ads stay current even when they streamed via ad_scored // before the picked-id list caught up. const currentRunAds = prev.filter( ad => ad.source === 'auto_remodel' && currentRunId && ad._arRunId === currentRunId, ); const nextById = new Map(currentRunAds.map(ad => [ad.id, ad])); pageAds.forEach(ad => { const existing = nextById.get(ad.id); nextById.set(ad.id, mergeRichAdDetails(existing, { ...ad, cardKey: resultKey(ad) })); }); const allIdSet = new Set(allIds); const currentIds = new Set(allIds); [...nextById.values()].forEach(ad => { if (currentRunId && ad._arRunId === currentRunId && !ad.autoRemodelProvisional) currentIds.add(ad.id); }); const currentOrdered = allIds .filter(id => nextById.has(id)) .map(id => ({ ...nextById.get(id), _arRunId: currentRunId || nextById.get(id)._arRunId || 'current', currentAutoRemodelRun: true })); const streamedCurrent = [...nextById.values()] .filter(ad => currentIds.has(ad.id) && !allIdSet.has(ad.id) && !ad.autoRemodelProvisional) .map(ad => ({ ...ad, _arRunId: currentRunId || ad._arRunId || 'current', currentAutoRemodelRun: true })); const remainder = prev .filter(ad => !(ad.source === 'auto_remodel' && currentRunId && ad._arRunId === currentRunId)) .map(ad => ({ ...ad, currentAutoRemodelRun: false })); return uniqueAutoRemodelAds([...currentOrdered, ...streamedCurrent, ...remainder], dedupeSettings); }); setRemodelScripts(prev => { const next = { ...prev }; pageAds.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); setAutoRemodelHydration({ total: allIds.length, loaded: loadedIds.size, loading: false }); const pageAdIds = pageAds.map(ad => ad.id); if (pageAdIds.length) pollRemodelJobs(pageAds, autoRemodelCurrentJobOptions()); return pageAds; } finally { setAutoRemodelHydration(prev => ({ ...prev, loading: false })); autoRemodelHydratingPageRef.current = false; } }; const hydrateAutoRemodelPickedState = async ( state, directionSource = directionsRef.current, { announce = false, focus = false } = {}, ) => { if (!state || !Array.isArray(state.picked_ad_ids) || state.picked_ad_ids.length === 0) return false; const pickedIds = state.picked_ad_ids.slice(0, AUTO_REMODEL_COUNT_MAX); if (!pickedIds.length) return false; arPickedIdsRef.current = pickedIds; setArPickedIds(pickedIds); if (typeof state.target_ads === 'number') setArTargetAds(state.target_ads); if (typeof state.ads_found === 'number') setArAdsFound(Math.min(state.ads_found, pickedIds.length)); if (state.run_id && RESILIA_API.getAutoRemodelRunAds) { try { const snapshot = await RESILIA_API.getAutoRemodelRunAds(state.run_id); const snapshotRows = Array.isArray(snapshot) ? snapshot : (Array.isArray(snapshot?.ads) ? snapshot.ads : []); const currentRunId = state.run_id || null; const snapshotAds = adaptAutoRemodelSnapshotRows(snapshotRows, directionSource, { current: true }) .map(ad => ({ ...ad, _arRunId: currentRunId || ad._arRunId, currentAutoRemodelRun: true, autoRemodelProvisional: false, })); if (snapshotAds.length) { seedAutoRemodelScriptsFromRows(snapshotRows, snapshotAds); snapshotAds.forEach(ad => autoRemodelLoadedIdsRef.current.add(ad.id)); setAutoRemodelAds(prev => { const currentKeys = autoRemodelDedupeKeySet(snapshotAds, dedupeSettings); const retainedCurrent = prev .filter(ad => ( currentRunId && ad.source === 'auto_remodel' && ad._arRunId === currentRunId && !ad.autoRemodelProvisional && !hasAutoRemodelDedupeOverlap(currentKeys, ad, dedupeSettings) )) .map(ad => ({ ...ad, currentAutoRemodelRun: true })); retainedCurrent.forEach(ad => addAutoRemodelDedupeKeys(currentKeys, ad, dedupeSettings)); const previous = prev .filter(ad => ( !hasAutoRemodelDedupeOverlap(currentKeys, ad, dedupeSettings) && !(currentRunId && ad._arRunId === currentRunId && ad.autoRemodelProvisional) )) .map(ad => ({ ...ad, currentAutoRemodelRun: false })); return uniqueAutoRemodelAds([...snapshotAds, ...retainedCurrent, ...previous], dedupeSettings); }); setAutoRemodelHydration({ total: pickedIds.length, loaded: snapshotAds.length, loading: false }); const snapshotIds = snapshotAds.map(ad => ad.id); if (snapshotIds.length) pollRemodelJobs(snapshotAds, autoRemodelCurrentJobOptions()); if (focus) setTab('autoRemodel'); if (announce) { showToast( `Auto-Remodel: restored ${snapshotAds.length} saved ad${snapshotAds.length === 1 ? '' : 's'} from this run.`, 'Auto-Remodel', ); } return true; } // Snapshot returned 0 rows. For an active run, the early picks have // not been persisted yet — SSE will stream them in within seconds. // Falling through to ``loadAutoRemodelPage`` (which calls the heavy // ``/api/swipe/batch`` endpoint, currently 10–20 s under DB pool // contention) blocks the UI for no reason and is the main source of // the "Auto-Remodel ads appear 20 s late" symptom on refresh. if (state.status !== 'done') return false; } catch (e) { console.warn('auto-remodel run snapshot restore failed; falling back to swipe hydration', e); } } const ads = await loadAutoRemodelPage({ ids: pickedIds, directionSource, reset: true }); if (!ads.length) return false; setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); if (focus) setTab('autoRemodel'); if (announce) { showToast( `Auto-Remodel: showing ${ads.length} of ${pickedIds.length} saved ad${pickedIds.length === 1 ? '' : 's'} while variants generate.`, 'Auto-Remodel', ); } return true; }; useEffect(() => { const ids = arPickedIds || []; if (!ids.length || !['picked', 'filtering', 'filter_done', 'remodeling', 'done'].includes(arStage)) return; if (autoRemodelLoadedIdsRef.current.size > 0) return; let cancelled = false; (async () => { const ads = await loadAutoRemodelPage({ ids, reset: true }); if (cancelled || ads.length === 0) return; setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); })(); 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. const streamAutoresearchWinnerFromEvent = (ev) => { if (!ev || ev.type !== 'ad_scored' || !ev.ad_id) return false; if (searchPassesOnlyRef.current && ev.passes !== true) return false; const active = arRunningRef.current || autoresearchActiveRef.current; if (!active) return false; const dirMap = directionMetaMap(directionsRef.current); const synth = { ad_id: ev.ad_id, direction_id: ev.direction_id, score: ev.score, passes: ev.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 currentBatchId = autoresearchBatchIdRef.current || 'latest-autoresearch'; const arrivedAtMs = Date.now(); const ad = { ...adapted, source: 'autoresearch', _arBatchId: currentBatchId, currentAutoresearchRun: true, currentRunLabel: 'Current', autoresearchProvisional: true, arrivedAtMs, }; const key = autoresearchDisplayKey(ad); if (autoresearchLoadedIdsRef.current.has(key)) return false; const loadedIds = new Set(autoresearchLoadedIdsRef.current); const currentIds = new Set(autoresearchCurrentIdsRef.current); loadedIds.add(key); currentIds.add(key); autoresearchLoadedIdsRef.current = loadedIds; autoresearchCurrentIdsRef.current = currentIds; setResearchAds(prev => { const searchAds = prev.filter(a => a.source !== 'autoresearch'); const priorAutoAds = prev .filter(a => ( a.source === 'autoresearch' && (!a.currentAutoresearchRun || (a._arBatchId && a._arBatchId !== currentBatchId)) )) .map(a => ({ ...a, currentAutoresearchRun: false, currentRunLabel: null })); const currentAutoAds = prev.filter(a => ( a.source === 'autoresearch' && a.currentAutoresearchRun && (a._arBatchId || currentBatchId) === currentBatchId )); const byKey = new Map(currentAutoAds.map(item => [autoresearchDisplayKey(item), item])); byKey.set(key, { ...ad, cardKey: resultKey(ad) }); const nextAds = [...searchAds, ...Array.from(byKey.values()), ...priorAutoAds]; return nextAds; }); setAutoresearchPage(prev => ({ ...prev, loaded: Math.max(prev.loaded || 0, currentIds.size) })); setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'running', count: currentIds.size }) : prev); setAutoresearchActive(true); return true; }; useEffect(() => { if (authState.status !== 'authenticated') return undefined; const close = RESILIA_API.subscribe((ev) => { if (!ev || !ev.type) return; try { streamAutoresearchWinnerFromEvent(ev); } catch (streamErr) { throw streamErr; } 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. const previousRunId = arRunIdRef.current; setArRunId(prev => prev || ev.run_id); if (!arRunIdRef.current && ev.run_id) arRunIdRef.current = ev.run_id; if (arRunIdRef.current && ev.run_id && ev.run_id !== arRunIdRef.current) { return; } setArStage(ev.stage || null); arStageRef.current = 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); arTargetAdsRef.current = 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(AUTO_REMODEL_COUNT_MAX, target))); arPickedIdsRef.current = cappedIds; setArPickedIds(cappedIds); } // Pipeline now skips the Filter step entirely; the backend only picks // ads that meet the configured winning-score threshold. 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 target = ev.target_ads || arTargetAdsRef.current || ev.picked_ad_ids.length; const pickedIds = ev.picked_ad_ids.slice(0, Math.max(1, Math.min(AUTO_REMODEL_COUNT_MAX, target))); // Bug — the orchestrator emits ``picked`` once per batch as // ``_queue_fresh_remodels`` runs (not just at the very end), and // ``ev.picked_ad_ids`` is the running cumulative list. Calling // ``loadAutoRemodelPage({ reset: true })`` on every batch wiped // every ad of the current run from screen, flashed the // "Restoring Auto-Remodel results — Loading saved ads and // scripts" empty state (which is keyed on // ``ads.length === 0 && (restoring || loadingMore)``), then // re-hydrated from scratch — and any remodelled script that had // already streamed in disappeared until the next ``remodel_*`` // event re-populated it. Hydrate only the genuinely new ids and // leave the existing rendered ads / scripts in place. const alreadyLoaded = autoRemodelLoadedIdsRef.current; const newIds = (Array.isArray(ev.new_picked_ad_ids) && ev.new_picked_ad_ids.length ? ev.new_picked_ad_ids : pickedIds ).filter(id => id && !alreadyLoaded.has(id)); if (newIds.length === 0) { // Nothing new to render in this batch — still refresh the // remodel-job poll so the variant counter keeps ticking. const allLoadedIds = [...autoRemodelLoadedIdsRef.current]; if (allLoadedIds.length) { startVisibilityAwarePoll('remodel', () => pollRemodelJobs(autoRemodelCurrentPollTargetsForIds(allLoadedIds), autoRemodelCurrentJobOptions()), REMODEL_POLL_INTERVAL_MS, ); } return; } // Incremental hydration: keeps existing ads + scripts on screen // and just appends the new picks to the bottom. const ads = await loadAutoRemodelPage({ ids: pickedIds, reset: false }); setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); const allLoadedIds = [...autoRemodelLoadedIdsRef.current]; if (allLoadedIds.length) { startVisibilityAwarePoll('remodel', () => pollRemodelJobs(autoRemodelCurrentPollTargetsForIds(allLoadedIds), autoRemodelCurrentJobOptions()), REMODEL_POLL_INTERVAL_MS, ); } showToast( `Auto-Remodel: +${newIds.length} new ad${newIds.length === 1 ? '' : 's'} (${pickedIds.length}/${target}).`, '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', ); } const loadedIds = [...autoRemodelLoadedIdsRef.current]; if (loadedIds.length) pollRemodelJobs(autoRemodelCurrentPollTargetsForIds(loadedIds), autoRemodelCurrentJobOptions({ markMissingAsError: true })); } else if (ev.stage === 'failed') { showToast(`Auto-Remodel failed: ${ev.error || 'unknown'}`, 'Error'); } else if (ev.stage === 'cancelled') { showToast('Auto-Remodel stopped.', 'Auto-Remodel'); } })(); } 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). const isAutoRemodelAd = ev.ad_id && ( arPickedIdsRef.current.includes(ev.ad_id) || autoRemodelAdsRef.current.some(ad => ad.id === ev.ad_id) ); if (isAutoRemodelAd) { pollRemodelJobs(autoRemodelCurrentPollTargetsForIds([ev.ad_id]), autoRemodelCurrentJobOptions()); // 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.ad_id) { refreshManualRemodelAd(ev.ad_id, { runId: ev.run_id || null }); } } else if (ev.type === 'remodel_batch_completed') { const adIds = Array.isArray(ev.ad_ids) ? ev.ad_ids.filter(Boolean) : []; if ((ev.source || 'manual') === 'manual') { if (adIds.length) { adIds.forEach(adId => refreshManualRemodelAd(adId, { runId: ev.run_id || null })); } refreshRemodelRunState().catch(e => console.warn('remodel state refresh failed', e)); } } else if (ev.type === 'remodel_batch_failed') { refreshRemodelRunState().catch(e => console.warn('remodel state refresh failed', e)); } else if (ev.type === 'compliance_completed' || ev.type === 'compliance_failed' || ev.type === 'compliance_batch_completed') { loadCompliancePage({ reset: true }).catch(e => console.warn('compliance refresh failed', e)); refreshComplianceRunState().catch(e => console.warn('compliance state refresh failed', e)); } else if (ev.type === 'filter_ad_verdict' && ev.ad_id) { if (ev.source && ev.source !== 'manual') return; RESILIA_API.getSwipe(ev.ad_id) .then(row => { if (!row) return; const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row); const pipelineAt = row.filter_verdict?.filtered_at || row.saved_at || null; const pipelineAtMs = pipelineAt ? Date.parse(pipelineAt) : NaN; const ad = { ...adapted, source: 'filter', filterVerdict: row.filter_verdict || null, pipelineAt, arrivedAtMs: Number.isFinite(pipelineAtMs) ? pipelineAtMs : (adapted.arrivedAtMs || Date.now()), }; setFilteredAds(prev => mergeRecentAds([ad], prev)); }) .catch(e => console.warn('filter verdict swipe refresh failed', e)); } else if (ev.type === 'filter_completed') { if (ev.source && ev.source !== 'manual') return; loadFilterPage({ reset: true }).catch(e => console.warn('filter restore after completion failed', e)); } else if (ev.type === 'remodel_promoted') { if (ev.ad_id && arPickedIdsRef.current.includes(ev.ad_id)) { pollRemodelJobs(autoRemodelCurrentPollTargetsForIds([ev.ad_id]), autoRemodelCurrentJobOptions()); } showToast(`Promoted to Video Queue (job ${ev.job_id})`, 'Promote'); } else if (ev.type === 'filter_verdicts_deleted') { const deleted = new Set(ev.ad_ids || []); setFilteredAds(prev => prev.filter(a => !deleted.has(a.id))); } else if (ev.type === 'remodel_jobs_deleted') { const deleted = new Set(ev.ad_ids || []); let deletedKeys = []; setRemodeledAds(prev => { deletedKeys = prev .filter(a => deleted.has(a.id)) .map(a => resultKey(a)) .filter(Boolean); return prev.filter(a => !deleted.has(a.id)); }); setRemodelScripts(prev => { const next = { ...prev }; deleted.forEach(id => { delete next[id]; }); deletedKeys.forEach(key => { delete next[key]; }); return next; }); setSelRemodel(prev => new Set([...prev].filter(id => !deleted.has(id)))); } 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 Auto-Remodel results immediately so the user sees winners // landing while the all-source crawl is still running. 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; 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 arrivedAtMs = Date.now(); const ad = { ...adapted, source: 'auto_remodel', _arRunId: arRunIdRef.current, currentAutoRemodelRun: true, autoRemodelProvisional: true, arrivedAtMs, }; const dedupeSettingsNow = dedupeSettingsRef.current; const currentRunId = arRunIdRef.current; const currentRunAdsNow = autoRemodelAdsRef.current.filter( a => a.source === 'auto_remodel' && a._arRunId === currentRunId, ); const currentRunKeys = autoRemodelDedupeKeySet(currentRunAdsNow, dedupeSettingsNow); if (hasAutoRemodelDedupeOverlap(currentRunKeys, ad, dedupeSettingsNow)) return; if (currentRunAdsNow.length >= target) return; arAcceptedAdIdsRef.current.add(ev.ad_id); setArAdsFound(Math.min(currentRunAdsNow.length + 1, target)); 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, transcript_segments: detail.transcript_segments, transcript_status: detail.transcript_status, transcript_source: detail.transcript_source, transcript_provider: detail.transcript_provider, transcript_model: detail.transcript_model, transcript_word_count: detail.transcript_word_count, is_voice_transcript: detail.is_voice_transcript, raw: { ...(synth.raw || {}), ...(detail.raw || {}) }, }); patched.directionLabel = item.directionLabel; patched.directionSearchLabel = item.directionSearchLabel; return mergeRichAdDetails(item, { ...patched, source: item.source, _arRunId: item._arRunId, autoRemodelProvisional: item.autoRemodelProvisional, resultUuid: item.resultUuid, cardKey: item.cardKey, runId: item.runId, arrivedAtMs: item.arrivedAtMs || patched.arrivedAtMs || arrivedAtMs, }); }; setAutoRemodelAds(prev => uniqueAutoRemodelAds(prev.map(patchTranscript), dedupeSettingsRef.current)); }).catch(e => console.warn('auto-remodel transcript hydration failed', e)); setAutoRemodelAds(prev => { // Cap by current-run pick count, not by every auto_remodel ad on // screen — ads from prior runs are kept below the new ones for // history (Bug 8) and must not block new picks. const currentRunId = arRunIdRef.current; const currentRunAds = prev.filter( a => a.source === 'auto_remodel' && a._arRunId === currentRunId, ); const dedupeSettingsCurrent = dedupeSettingsRef.current; const currentRunKeysForAdd = autoRemodelDedupeKeySet(currentRunAds, dedupeSettingsCurrent); if (hasAutoRemodelDedupeOverlap(currentRunKeysForAdd, ad, dedupeSettingsCurrent)) return prev; if (currentRunAds.length >= target) return prev; // Append to the end of the current-run section so within-run order // matches scoring order, while previous-run ads stay below. const otherAds = prev.filter( a => !(a.source === 'auto_remodel' && a._arRunId === currentRunId), ); return uniqueAutoRemodelAds([...currentRunAds, { ...ad, cardKey: resultKey(ad) }, ...otherAds], dedupeSettingsCurrent); }); setRemodelScripts(prev => { const scriptKey = resultKey(ad); if (prev[scriptKey] || prev[ad.id]) return prev; return { ...prev, [scriptKey]: { status: 'scored', variants: [] } }; }); } }); 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 }, [authState.status]); const manualRemodelStrategyForAd = (adId) => mergeRemodelInstructions( remodelPageInstruction, remodelAdInstructions[adId], ); const autoRemodelStrategyForAd = (adId) => mergeRemodelInstructions( autoRemodelPageInstruction, autoRemodelAdInstructions[adId], ); const startAutoRemodel = async ({ mode, directionId, directionIds, count, nVariants, platforms, maxAdsPerDirection, maxAdsPerPlatform, strategy, }) => { const activeStage = arStageRef.current; const alreadyActive = arRunIdRef.current && !['done', 'failed', 'cancelled'].includes(activeStage); if (arStartPendingRef.current || alreadyActive) { showToast('Auto-Remodel is already starting or running. Use Stop Remodel if that run is stale.', 'Auto-Remodel'); return; } arStartPendingRef.current = true; setArStartPending(true); try { const out = await RESILIA_API.runAutoRemodel({ mode, directionId, directionIds, count, nVariants, platforms, maxAdsPerDirection, maxAdsPerPlatform, strategy: capRemodelInstruction(strategy || autoRemodelPageInstruction).trim(), }); if (out.status === 'already_running') { if (out.state) syncAutoRemodelState(out.state); else if (out.run_id) { setArRunId(out.run_id); arRunIdRef.current = out.run_id; } showToast('Auto-Remodel is already running. Use Stop Remodel if that run is stale.', 'Auto-Remodel'); return; } // Fresh run — clear stale state and keep the user on Auto-Remodel so // live scored ads and picked ads appear in the same panel. Stop the // manual-flow remodel poller too, otherwise it could overwrite the // orchestrator's state. if (remodelPollRef.current) { stopPoll('remodel'); } setArRunId(out.run_id); arRunIdRef.current = out.run_id; setArStage('queued'); arStageRef.current = 'queued'; setArLastEvent(null); setArPickedIds([]); arPickedIdsRef.current = []; // New Auto-Remodel runs replace the live view; old runs are available // only through the explicit "load older" action. setAutoRemodelAds([]); setRemodelScripts(prev => { const autoKeys = new Set(autoRemodelAdsRef.current.flatMap(ad => [ad.id, resultKey(ad)].filter(Boolean))); if (!autoKeys.size) return prev; const next = { ...prev }; autoKeys.forEach(key => { delete next[key]; }); return next; }); setAutoRemodelHistoryLoaded(false); // Loaded ids are scoped to the active run. Previous ads stay rendered, // but must not block a newly picked copy from hydrating as current. autoRemodelLoadedIdsRef.current = new Set(); setAutoRemodelHydration({ total: 0, loaded: 0, loading: false }); arAcceptedAdIdsRef.current = new Set(); setArDirectionsDone(0); setArDirectionsTotal(0); setArAdsFound(0); const requestedTarget = Math.max(1, Math.min(AUTO_REMODEL_COUNT_MAX, parseInt(count, 10) || 5)); setArTargetAds(requestedTarget); arTargetAdsRef.current = requestedTarget; setArVariantsDone(0); const requestedVariants = Math.max(1, parseInt(nVariants, 10) || 1); setArNVariants(requestedVariants); arNVariantsRef.current = requestedVariants; setArVariantsTotalOverride(null); // Bug 8 — do NOT clear autoRemodelAds or remodelScripts here. Previous- // run picks and their generated scripts stay visible underneath the new // run's results so the user keeps their history when they re-run. setTab('autoRemodel'); // Note — we deliberately do NOT clear ``researchAds`` here. Auto-Remodel // results live in ``autoRemodelAds`` (rendered on the Auto-Remodel tab); // the Research tab's autoresearch winners are independent state that the // user expects to find intact when they switch back. A previous version // wiped ``researchAds.filter(a => a.source === 'autoresearch')`` here, // which made the Research tab look empty after every Auto-Remodel run. showToast(`Auto-Remodel started (${out.run_id}) — staying here until picks land.`, 'Auto-Remodel'); (async () => { for (let attempt = 0; attempt < 8; attempt += 1) { const snapshot = RESILIA_API.getAutoRemodelCurrent ? await RESILIA_API.getAutoRemodelCurrent(out.run_id) : await RESILIA_API.getAutoRemodelState(out.run_id); if (snapshot && snapshot.status === 'ok' && snapshot.state) { const hydrated = await applyAutoRemodelCurrentPayload( snapshot, directionsRef.current, { announce: attempt > 0 }, ) || await hydrateAutoRemodelPickedState( snapshot.state, directionsRef.current, { announce: attempt > 0 }, ); if (hydrated) return; } await new Promise(resolve => setTimeout(resolve, 250)); } })().catch(e => console.warn('auto-remodel hydrate failed', e)); } catch (e) { showToast('Failed to start Auto-Remodel: ' + e.message, 'Error'); } finally { arStartPendingRef.current = false; setArStartPending(false); } }; const loadMoreAutoRemodelAds = async () => { try { await loadAutoRemodelPage({ ids: arPickedIdsRef.current }); } catch (e) { showToast('Failed to load more Auto-Remodel ads: ' + e.message, 'Error'); } }; const handleLoadAutoRemodelHistory = async () => { if (autoRemodelHistoryLoading) return; setAutoRemodelHistoryLoading(true); try { const ads = await restorePreviousAutoRemodelHistory({ excludeRunId: arRunIdRef.current, directionSource: directionsRef.current, }); setAutoRemodelHistoryLoaded(true); if (!ads || ads.length === 0) { showToast('No older Auto-Remodel runs to load.', 'Auto-Remodel'); } } catch (e) { showToast('Failed to load older Auto-Remodel runs: ' + e.message, 'Error'); } finally { setAutoRemodelHistoryLoading(false); } }; const clearAutoRemodelRunState = () => { if (remodelPollRef.current) stopPoll('remodel'); setArRunId(null); arRunIdRef.current = null; setArStage(null); arStageRef.current = null; setArLastEvent(null); setArPickedIds([]); arPickedIdsRef.current = []; autoRemodelLoadedIdsRef.current = new Set(); arAcceptedAdIdsRef.current = new Set(); setArDirectionsDone(0); setArDirectionsTotal(0); setArAdsFound(0); setArTargetAds(0); arTargetAdsRef.current = 0; setArVariantsDone(0); setArVariantsTotalOverride(null); setAutoRemodelHydration({ total: 0, loaded: 0, loading: false }); setAutoRemodelAds(prev => prev.map(ad => ({ ...ad, currentAutoRemodelRun: false }))); }; const stopAutoRemodel = async () => { try { const out = await RESILIA_API.stopAutoRemodel(); if (out.status === 'not_running') { clearAutoRemodelRunState(); showToast('Auto-Remodel is not running.', 'Auto-Remodel'); return; } clearAutoRemodelRunState(); const failedJobs = Number(out.jobs_failed || 0); const cancelledRuns = Number(out.runs_cancelled || 0); showToast( failedJobs || cancelledRuns ? `Auto-Remodel stopped: cancelled ${cancelledRuns} run(s), failed ${failedJobs} script job(s).` : 'Auto-Remodel stopping...', 'Auto-Remodel', ); } catch (e) { showToast('Failed to stop Auto-Remodel: ' + e.message, 'Error'); } }; const retryFailedAutoRemodel = async () => { try { const loadedIds = [...autoRemodelLoadedIdsRef.current]; const loadedIdSet = new Set(loadedIds); const loadedAds = loadedIds.length ? autoRemodelAds.filter(ad => loadedIdSet.has(ad.id)) : autoRemodelAds.filter(ad => ad.source === 'auto_remodel'); const out = await RESILIA_API.retryFailedAutoRemodel(25, { runId: arRunIdRef.current, nVariants: Math.max(1, parseInt(arNVariantsRef.current, 10) || 1), ads: loadedAds, }); let jobsStarted = Number(out.jobs_started || 0); const repairIds = loadedIds.length ? loadedIds : loadedAds.map(ad => ad.id).filter(Boolean); const targets = repairIds.length ? autoRemodelCurrentPollTargetsForIds(repairIds) : []; if (jobsStarted === 0 && loadedIds.length) { const fallbackJobs = await RESILIA_API.listRemodelJobs({ adIds: [...new Set(targets.map(item => (typeof item === 'object' ? item.id : item)).filter(Boolean))], resultUuids: [...new Set(targets .map(item => (typeof item === 'object' ? item.resultUuid || item.result_uuid : null)) .filter(Boolean))], source: 'auto_remodel', limitPerAd: 10, limit: 1000, }); const failedJobIds = [...new Set((fallbackJobs || []) .filter(job => job?.status === 'failed' && job.id) .map(job => job.id))]; for (const jobId of failedJobIds.slice(0, 25)) { await RESILIA_API.retryRemodelJob(jobId); jobsStarted += 1; } } if (jobsStarted === 0 && targets.length) { const retryableCards = targets .filter(item => typeof item === 'object') .filter(ad => { const entry = scriptEntryForAd(remodelScripts, ad); return entry?.status === 'error'; }); for (const ad of retryableCards.slice(0, 25)) { const strategy = manualRemodelStrategyForAd(ad.id); await RESILIA_API.runRemodel({ adIds: [ad.id], nVariants: 1, ads: [ad], batchStrategy: strategy, strategiesByAdId: strategy ? { [ad.id]: strategy } : null, source: 'auto_remodel', }); jobsStarted += 1; } } if (repairIds.length) { setRemodelScripts(prev => { const next = { ...prev }; loadedAds.forEach(ad => { const pending = { status: 'pending', variants: [] }; next[resultKey(ad)] = pending; }); repairIds.forEach(id => { if (!next[id]) next[id] = { status: 'pending', variants: [] }; }); return next; }); startVisibilityAwarePoll('remodel', () => pollRemodelJobs(autoRemodelCurrentPollTargetsForIds(repairIds), autoRemodelCurrentJobOptions()), REMODEL_POLL_INTERVAL_MS); pollRemodelJobs(autoRemodelCurrentPollTargetsForIds(repairIds), autoRemodelCurrentJobOptions()); } const created = Number(out.jobs_created || 0); const retried = Number(out.jobs_retried || 0); showToast( `Retry started for ${jobsStarted} Auto-Remodel job batch(es): ${retried} retried, ${created} created.`, 'Auto-Remodel', ); } catch (e) { showToast('Retry failed Auto-Remodel scripts failed: ' + e.message, 'Error'); } }; useEffect(() => { if (!arRunningOrch || !arRunId) return undefined; let cancelled = false; const reconcile = async () => { try { const snapshot = RESILIA_API.getAutoRemodelCurrent ? await RESILIA_API.getAutoRemodelCurrent(arRunIdRef.current) : await RESILIA_API.getAutoRemodelState(arRunIdRef.current); if (cancelled || !snapshot || snapshot.status !== 'ok' || !snapshot.state) return; const hydrated = await applyAutoRemodelCurrentPayload( snapshot, directionsRef.current, ); if (!hydrated) { syncAutoRemodelState(snapshot.state); } } catch (e) { console.warn('auto-remodel reconcile failed', e); } }; const timer = setInterval(reconcile, 3000); reconcile(); return () => { cancelled = true; clearInterval(timer); }; }, [arRunningOrch, arRunId]); const regenerateRemodelJob = async (jobId, options = {}) => { try { const locatedScriptKey = Object.keys(remodelScripts).find(id => { const e = remodelScripts[id]; return e && ( (Array.isArray(e.variants) && e.variants.some(v => v.jobId === jobId)) || (Array.isArray(e.failedJobIds) && e.failedJobIds.includes(jobId)) ); }); const scriptKey = options.scriptKey || locatedScriptKey || options.adId || null; const ad = [...remodeledAds, ...autoRemodelAds].find(item => ( (scriptKey && resultKey(item) === scriptKey) || item.id === options.adId || item.id === scriptKey )); const adId = options.adId || ad?.id || scriptKey; const source = options.source || (ad?.source === 'auto_remodel' ? 'auto_remodel' : 'manual'); const strategy = options.strategy || (adId ? manualRemodelStrategyForAd(adId) : null); const variantPlan = Array.isArray(options.variantPlan) && options.variantPlan.length ? options.variantPlan.slice(0, REMODEL_VARIANTS_MAX).map(row => remodelPlanRow(row.remodelMethod, row.temperature)) : [remodelPlanRow()]; let out; if (jobId && !options.variantPlan) { out = await RESILIA_API.retryRemodelJob(jobId, { strategy }); } else if (ad) { // Regenerate is replace-not-append: clear old jobs for this card/source // before queueing the fresh variant plan. await RESILIA_API.bulkDeleteRemodelJobs( [ad.id], source, { reason: 'replace' }, ); out = await RESILIA_API.runRemodel({ adIds: [ad.id], nVariants: variantPlan.length, variantPlan, ads: [ad], batchStrategy: strategy, strategiesByAdId: strategy ? { [ad.id]: strategy } : null, source, }); } else { throw new Error('No failed job id or ad payload available to retry'); } if (scriptKey || adId) { const pollTarget = ad || adId; setRemodelScripts(prev => { const next = { ...prev }; const pending = remodelPendingEntryForPlan(variantPlan); if (scriptKey) next[scriptKey] = pending; if (!ad?.resultUuid && adId) next[adId] = pending; return next; }); const pollOptions = source === 'auto_remodel' ? autoRemodelJobOptions({ expectedVariants: variantPlan.length, runId: out?.run_id || null }) : { source: 'manual', limitPerAd: variantPlan.length, expectedVariants: variantPlan.length, runId: out?.run_id || null }; startVisibilityAwarePoll('remodel', () => pollRemodelJobs([pollTarget], pollOptions), REMODEL_POLL_INTERVAL_MS); pollRemodelJobs([pollTarget], pollOptions); } showToast(`Regeneration started (${out.run_id || jobId || adId}).`, '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[resultKey(ad)] || 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'); } }; const readyRemodelJobIdsForSelection = (ads, selected) => { const out = []; (ads || []).forEach(ad => { if (!selected.has(ad.id)) return; const entry = scriptEntryForAd(remodelScripts, ad); if (!entry || !Array.isArray(entry.variants)) return; entry.variants.forEach(variant => { if (variant.jobId && !variant.pending && variant.raw) out.push(variant.jobId); }); }); return [...new Set(out)]; }; const readyRemodelVariantIds = (ads) => { const ids = new Set(); (ads || []).forEach(ad => { const entry = scriptEntryForAd(remodelScripts, ad); if (!entry || !Array.isArray(entry.variants)) return; entry.variants.forEach(variant => { if (variant.jobId && !variant.pending && variant.raw) ids.add(variant.jobId); }); }); return ids; }; const fetchReadyRemodelJobIdsForSelection = async (ads, selected, source) => { const picked = (ads || []).filter(ad => selected.has(ad.id)); if (!picked.length) return []; const adIds = [...new Set(picked.map(ad => ad.id).filter(Boolean))]; const resultUuids = [...new Set(picked.map(ad => ad.resultUuid || ad.result_uuid).filter(Boolean))]; try { const jobs = await RESILIA_API.listRemodelJobs({ adIds, resultUuids, source, limitPerAd: REMODEL_VARIANTS_MAX, limit: Math.max(PIPELINE_PAGE_SIZE, adIds.length * REMODEL_VARIANTS_MAX), }); const ready = (jobs || []) .filter(job => job.status === 'done' && job.response_text) .map(job => job.id) .filter(Boolean); if (ready.length) return [...new Set(ready)]; } catch (e) { console.warn('fetch selected remodel variants failed, falling back to local state', e); } return readyRemodelJobIdsForSelection(ads, selected); }; const sendSelectedToCompliance = async (source) => { const isAuto = source === 'auto_remodel'; const ads = isAuto ? autoRemodelAds : remodeledAds; const selected = isAuto ? selAutoRemodel : selRemodel; const selectedVariants = isAuto ? selAutoRemodelVariants : selRemodelVariants; let jobIds = [...selectedVariants]; if (selected.size) { const selectedAdJobIds = await fetchReadyRemodelJobIdsForSelection(ads, selected, source); jobIds = [...new Set([...jobIds, ...selectedAdJobIds])]; } if (!jobIds.length) { showToast('No ready remodel variants selected for compliance.', 'Compliance'); return; } setComplianceBusy(true); try { const out = await RESILIA_API.runCompliance({ remodelJobIds: jobIds, strictness: complianceStrictness, source, }); if (isAuto) setSelAutoRemodel(new Set()); else setSelRemodel(new Set()); if (isAuto) setSelAutoRemodelVariants(new Set()); else setSelRemodelVariants(new Set()); setSelCompliance(new Set(out.jobs || [])); setTab('compliance'); tabLoadedRef.current.delete('compliance'); await loadCompliancePage({ reset: true }); await refreshComplianceRunState(); startCompliancePolling(); showToast(`${jobIds.length} variant${jobIds.length === 1 ? '' : 's'} sent to Compliance.`, 'Compliance'); } catch (e) { showToast('Compliance run failed: ' + e.message, 'Error'); } finally { setComplianceBusy(false); } }; const convertComplianceToVideo = () => { const picked = complianceJobs.filter(job => selCompliance.has(job.id) && job.status === 'done'); if (!picked.length) { showToast('No completed compliance jobs selected.', 'Compliance'); return; } const resolveFinalTranscript = (job) => ( job.final_transcript || (job.compliant ? job.input_script : job.regenerated_transcript) || '' ); const ads = picked.map(job => { const base = job.ad ? RESILIA_ADAPT.adaptEvaluatedAd(job.ad) : pipelineFallbackAd(job.ad_id, 'compliance'); const finalTranscript = resolveFinalTranscript(job); return { ...base, source: 'compliance', complianceJobId: job.id, complianceFinalTranscript: finalTranscript, finalTranscript, }; }); setConvertedAds(prev => { const seen = new Set(prev.map(a => a.complianceJobId ? `c:${a.complianceJobId}` : `a:${a.id}`)); const next = [...prev]; ads.forEach(ad => { const key = ad.complianceJobId ? `c:${ad.complianceJobId}` : `a:${ad.id}`; if (!seen.has(key)) { seen.add(key); next.push(ad); } }); return next; }); setSelCompliance(new Set()); setTab('video'); }; const editComplianceTranscript = async (jobId, newText) => { if (!jobId) { showToast('No compliance job to edit yet — wait for it to finish.', 'Compliance'); return; } try { const out = await RESILIA_API.updateComplianceTranscript({ jobId, finalTranscript: newText }); const job = out.job || out; setComplianceJobs(prev => prev.map(item => (item.id === jobId ? { ...item, ...job } : item))); showToast('Edit saved.', 'Compliance'); } catch (e) { showToast('Save failed: ' + e.message, 'Error'); throw e; } }; const discardComplianceJob = async (jobId) => { if (!confirm('Discard this compliance result?')) return; try { await RESILIA_API.deleteComplianceJob(jobId); setComplianceJobs(prev => prev.filter(item => item.id !== jobId)); setSelCompliance(prev => { const next = new Set(prev); next.delete(jobId); return next; }); showToast('Compliance result discarded.', 'Compliance'); } catch (e) { showToast('Discard failed: ' + e.message, 'Error'); } }; const scopedResearchAdsForTab = (targetTab = tab) => researchAds.filter(ad => ( targetTab === 'autoSearch' ? ad.source === 'autoresearch' : ad.source !== 'autoresearch' )); // ---- Research-tab bulk delete ----------------------------------------- const bulkDeleteResearch = async () => { const ids = scopedResearchAdsForTab().filter(ad => selResearch.has(ad.id)).map(ad => ad.id); if (!ids.length) return; if (!confirm(`Remove ${ids.length} selected card${ids.length === 1 ? '' : 's'}?\n\nSearch history, AutoSearch history, and evaluated rows are updated. Swipe-file rows stay available.`)) return; try { const idSet = new Set(ids); const out = await RESILIA_API.bulkDeleteEvaluated(ids); setResearchAds(prev => prev.filter(a => !idSet.has(a.id))); setSelResearch(new Set()); showToast(`${out.removed} ad(s) removed.`, 'Delete'); } catch (e) { showToast('Bulk delete failed: ' + e.message, 'Error'); } }; const removeAdFromLocalViews = (adId) => { setResearchAds(prev => prev.filter(a => a.id !== adId)); setFilteredAds(prev => prev.filter(a => a.id !== adId)); setRemodeledAds(prev => prev.filter(a => a.id !== adId)); setAutoRemodelAds(prev => prev.filter(a => a.id !== adId)); setConvertedAds(prev => prev.filter(a => a.id !== adId)); const drop = (setter) => setter(prev => { if (!prev.has(adId)) return prev; const next = new Set(prev); next.delete(adId); return next; }); drop(setSelResearch); drop(setSelFilter); drop(setSelRemodel); drop(setSelAutoRemodel); setSelCompliance(prev => new Set([...prev].filter(jobId => { const job = complianceJobs.find(row => row.id === jobId); return job && job.ad_id !== adId; }))); }; const dislikeAd = async (ad) => { const adId = ad?.id || ad?.ad_id; if (!adId) return; if (!confirm('Dislike this ad?\n\nIt will disappear now and the same creative will be blocked from future searches.')) return; try { const out = await RESILIA_API.dislikeAd(ad); removeAdFromLocalViews(adId); showToast('Ad disliked and hidden permanently.', 'Dislike'); return out; } catch (e) { showToast('Dislike failed: ' + e.message, 'Error'); return null; } }; const loadMoreFilterAds = async () => { try { await loadFilterPage(); } catch (e) { showToast('Failed to load more filtered ads: ' + e.message, 'Error'); } }; const loadMoreRemodelAds = async () => { try { await loadRemodelPage(); } catch (e) { showToast('Failed to load more remodels: ' + e.message, 'Error'); } }; const loadMoreComplianceJobs = async () => { try { await loadCompliancePage(); } catch (e) { showToast('Failed to load more compliance jobs: ' + e.message, 'Error'); } }; const bulkDeleteFilter = async () => { const ids = [...selFilter]; if (!ids.length) return; if (!confirm(`Remove ${ids.length} filtered video${ids.length === 1 ? '' : 's'} from the Filter tab?\n\nSwipe-file rows stay available for search and future runs.`)) return; try { const out = await RESILIA_API.bulkDeleteFilterVerdicts(ids); setFilteredAds(prev => prev.filter(a => !selFilter.has(a.id))); setSelFilter(new Set()); showToast(`${out.removed} filtered video${out.removed === 1 ? '' : 's'} removed.`, 'Delete'); } catch (e) { showToast('Filter delete failed: ' + e.message, 'Error'); } }; const bulkDeleteRemodel = async () => { const ids = [...selRemodel]; if (!ids.length) return; if (!confirm(`Delete remodel jobs for ${ids.length} video${ids.length === 1 ? '' : 's'}?\n\nThis removes the scripts from the Remodel tab.`)) return; const selectedAds = remodeledAds.filter(a => selRemodel.has(a.id)); try { const out = await RESILIA_API.bulkDeleteRemodelJobs(ids); setRemodeledAds(prev => prev.filter(a => !selRemodel.has(a.id))); setRemodelScripts(prev => { const next = { ...prev }; selectedAds.forEach(ad => { delete next[ad.id]; const key = resultKey(ad); if (key) delete next[key]; }); return next; }); setSelRemodel(new Set()); setSelRemodelVariants(new Set()); showToast(`${out.removed} remodel job${out.removed === 1 ? '' : 's'} deleted.`, 'Delete'); } catch (e) { showToast('Remodel delete failed: ' + e.message, 'Error'); } }; const bulkDeleteAutoRemodel = async () => { const ids = [...selAutoRemodel]; if (!ids.length) return; if (!confirm(`Delete ${ids.length} Auto-Remodel video${ids.length === 1 ? '' : 's'}?\n\nThis removes saved Auto-Remodel result cards and their generated scripts. Swipe-file rows stay available for future searches.`)) return; const selectedAds = autoRemodelAds.filter(ad => selAutoRemodel.has(ad.id)); try { const out = await RESILIA_API.bulkDeleteAutoRemodelAds(ids); setAutoRemodelAds(prev => prev.filter(a => !selAutoRemodel.has(a.id))); const remainingPickedIds = arPickedIdsRef.current.filter(id => !selAutoRemodel.has(id)); arPickedIdsRef.current = remainingPickedIds; setArPickedIds(remainingPickedIds); autoRemodelLoadedIdsRef.current = new Set( [...autoRemodelLoadedIdsRef.current].filter(id => !selAutoRemodel.has(id)), ); setAutoRemodelHydration(prev => ({ ...prev, total: Math.max(0, (prev.total || 0) - ids.length), loaded: Math.max(0, (prev.loaded || 0) - ids.length), })); setRemodelScripts(prev => { const next = { ...prev }; selectedAds.forEach(ad => { delete next[ad.id]; const key = resultKey(ad); if (key) delete next[key]; }); return next; }); setAutoRemodelAdInstructions(prev => { const next = { ...prev }; ids.forEach(id => { delete next[id]; }); return next; }); setSelAutoRemodel(new Set()); showToast(`${out.removed} Auto-Remodel video${out.removed === 1 ? '' : 's'} deleted.`, 'Delete'); } catch (e) { showToast('Auto-Remodel 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; }); }; useEffect(() => { const focus = pipelineFocusRef.current; if (!focus || focus.tab !== tab || !focus.ids?.length) return undefined; const timer = setTimeout(() => { const cards = [...document.querySelectorAll('[data-ad-id]')]; const node = cards.find(el => focus.ids.includes(el.getAttribute('data-ad-id'))); if (!node) return; node.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (typeof node.focus === 'function') node.focus({ preventScroll: true }); }, 120); return () => clearTimeout(timer); }, [tab, pipelineFocusTick, filteredAds, remodeledAds]); const passToFilter = async () => { const pickedById = new Map(); scopedResearchAdsForTab().forEach(ad => { if (selResearch.has(ad.id) && !pickedById.has(ad.id)) pickedById.set(ad.id, ad); }); const picked = [...pickedById.values()]; if (picked.length === 0) return; setFilterBusy(true); const arrivedAtMs = Date.now(); const batchId = `filter-move-${arrivedAtMs}`; const movedIds = picked.map(a => a.id); const seededAds = picked.map(a => ({ ...a, source: 'filter', arrivedAtMs })); setFilteredAds(prev => mergeCurrentPipelineAds(seededAds, prev, batchId)); setSelResearch(new Set()); setSelFilter(new Set(movedIds)); tabLoadedRef.current.add('filter'); setTab('filter'); focusPipelineAds('filter', movedIds); try { await RESILIA_API.runFilter({ adIds: picked.map(a => a.id), ads: picked }); await refreshFilterRunState(); startFilterStatePolling(); showToast(`Filter started on ${picked.length} ads.`, 'Filter'); } catch (e) { showToast('Filter failed: ' + e.message, 'Error'); } finally { if (!filterPollRef.current) setFilterBusy(false); } }; const passWithoutFilter = async () => { const picked = scopedResearchAdsForTab().filter(a => selResearch.has(a.id)); if (picked.length === 0) return; if (remodelStartPendingRef.current) { showToast('Remodel is already starting. Wait a moment and try again.', 'Remodel'); return; } remodelStartPendingRef.current = true; setRemodelBusy(true); const arrivedAtMs = Date.now(); const batchId = `remodel-bypass-${arrivedAtMs}`; const movedIds = picked.map(a => a.id); const seededAds = picked.map(a => ({ ...a, source: 'remodel', arrivedAtMs })); setRemodeledAds(prev => mergeCurrentPipelineAds(seededAds, prev, batchId)); setRemodelScripts(prev => { const next = { ...prev }; picked.forEach(ad => { next[resultKey(ad)] = remodelPendingEntryForPlan(); }); return next; }); setSelResearch(new Set()); setSelRemodel(new Set(movedIds)); setTab('remodel'); focusPipelineAds('remodel', movedIds); try { await startRemodelAndPoll(picked); showToast(`${picked.length} ads passed without filtering. Remodeling…`, 'Bypass'); } catch (e) { setRemodelScripts(prev => { const next = { ...prev }; picked.forEach(ad => { next[resultKey(ad)] = 'error'; }); return next; }); showToast('Pass failed: ' + e.message, 'Error'); } finally { remodelStartPendingRef.current = false; if (!remodelPollRef.current) { setRemodelBusy(false); } } }; // 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. Manual polling stops when the backend run state reaches a // terminal status, so the progress banner cannot get stranded at 0/N. const autoRemodelJobOptions = (extra = {}) => ({ source: 'auto_remodel', limitPerAd: REMODEL_VARIANTS_MAX, stopWhenSettled: true, ...extra, }); const autoRemodelCurrentJobOptions = (extra = {}) => autoRemodelJobOptions(extra); const autoRemodelPollTargetsForIds = (ids) => { const idSet = new Set((ids || []).filter(Boolean)); if (!idSet.size) return []; const sourceAds = autoRemodelAdsRef.current.length ? autoRemodelAdsRef.current : autoRemodelAds; const targets = sourceAds.filter(ad => idSet.has(ad.id)); return targets.length ? targets : [...idSet]; }; const autoRemodelCurrentPollTargetsForIds = (ids) => { const currentRunId = arRunIdRef.current; const targets = autoRemodelPollTargetsForIds(ids); if (!currentRunId) return targets; const currentTargets = targets.filter(item => ( typeof item !== 'object' || item._arRunId === currentRunId || item.currentAutoRemodelRun )); return currentTargets.length ? currentTargets : targets; }; const pollAutoRemodelHistoryJobs = (ads, extra = {}) => { const grouped = new Map(); (ads || []).forEach(ad => { if (!ad || typeof ad !== 'object') return; const runId = ad._arRunId || null; const key = runId || '__unknown__'; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(ad); }); grouped.forEach((group, key) => { pollRemodelJobs( group, autoRemodelJobOptions({ ...extra, }), ); }); }; const refreshManualRemodelAd = async (adId, options = {}) => { if (!adId) return; try { const row = await RESILIA_API.getSwipe(adId); const ad = row ? { ...RESILIA_ADAPT.adaptEvaluatedAd(row), source: 'remodel' } : pipelineFallbackAd(adId, 'remodel'); setRemodeledAds(prev => mergeRecentAds([ad], prev)); await pollRemodelJobs([ad], { source: 'manual', ...options }); } catch (e) { console.warn('manual remodel refresh failed', e); await pollRemodelJobs([adId], { source: 'manual', ...options }); } }; const pollRemodelJobs = async (adIds, options = {}) => { const markMissingAsError = !!options.markMissingAsError; const stopWhenSettled = !!options.stopWhenSettled; try { const targets = (adIds || []) .map(item => { if (item && typeof item === 'object') { return { adId: item.id || item.ad_id, scriptKey: resultKey(item), resultUuid: item.resultUuid || item.result_uuid || null, }; } const id = String(item || '').trim(); const currentAutoRemodelAds = autoRemodelAdsRef.current.length ? autoRemodelAdsRef.current : autoRemodelAds; const existing = [...remodeledAds, ...currentAutoRemodelAds].find(ad => ad.id === id); return { adId: id, scriptKey: existing ? resultKey(existing) : id, resultUuid: existing?.resultUuid || existing?.result_uuid || null, }; }) .filter(t => t.adId && t.scriptKey); const cleanIds = [...new Set(targets.map(t => t.adId))]; const resultUuids = [...new Set(targets.map(t => t.resultUuid).filter(Boolean))]; if (!cleanIds.length) return; const jobs = await RESILIA_API.listRemodelJobs({ adIds: cleanIds, resultUuids, runId: options.runId || null, source: options.source || null, limitPerAd: options.limitPerAd || null, limit: Math.min(1000, cleanIds.length * Math.max(1, options.limitPerAd || 10)), }); const jobsByTarget = new Map(); (jobs || []).forEach(job => { if (!job?.ad_id) return; const payloadUuid = job.payload?.result_uuid || job.payload?.resultUuid || null; [...new Set([payloadUuid, job.ad_id].filter(Boolean))].forEach(key => { if (!jobsByTarget.has(key)) jobsByTarget.set(key, []); jobsByTarget.get(key).push(job); }); }); setRemodelScripts(prev => { const next = { ...prev }; let stillPending = 0; targets.forEach(({ adId, scriptKey, resultUuid }) => { const existingEntry = next[scriptKey] || next[adId]; const targetJobs = (resultUuid && jobsByTarget.get(resultUuid)) || jobsByTarget.get(adId) || []; const latestRunId = options.runId || targetJobs.find(job => job?.run_id)?.run_id || null; const jobs = latestRunId ? targetJobs.filter(job => job.run_id === latestRunId) : targetJobs; if (jobs.length === 0) { const hasExistingVariants = Array.isArray(existingEntry?.variants) && existingEntry.variants.length > 0; if (hasExistingVariants || existingEntry?.status === 'error') return; if (markMissingAsError) { next[scriptKey] = { status: 'error', variants: [], error: 'No remodel job was found for this ad yet. ' + 'Retry generation or check the Auto-Remodel run logs.', }; } else { if (!existingEntry || existingEntry.status === 'pending' || existingEntry.status === 'scored') { next[scriptKey] = { status: 'pending', variants: [] }; } stillPending++; } return; } 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; const expectedCount = Math.max( 1, options.expectedVariants || existingEntry?.expectedVariants || jobs.length, ); const variantFromJob = (j, extra = {}) => ({ jobId: j.id, variantIdx: j.variant_idx || 0, remodelMethod: normalizeRemodelMethod(j.remodel_method), temperature: j.temperature, promotedAt: j.promoted_at || null, ...extra, }); if (jobs.length > 0) { const pendingJobs = jobs.filter(j => j.status !== 'done' && j.status !== 'failed'); const mappedVariants = [ ...done.map(j => variantFromJob(j, { raw: j.response_text, lines: parseRemodelScript(j.response_text), })), ...failed.map(j => variantFromJob(j, { raw: '', lines: [{ tag: 'ERROR', body: j.error || 'Generation failed' }], })), ...pendingJobs.map(j => variantFromJob(j, { pending: true, raw: '', lines: [{ tag: 'REMODELING', body: 'Claude Sonnet is generating this remodel output.' }], })), ].sort((a, b) => (a.variantIdx || 0) - (b.variantIdx || 0)); const filledVariants = [...mappedVariants]; while (filledVariants.length < expectedCount) { const seed = existingEntry?.variants?.[filledVariants.length] || {}; filledVariants.push({ jobId: null, variantIdx: filledVariants.length + 1, pending: true, raw: '', lines: [{ tag: 'REMODELING', body: 'Claude Sonnet is generating this remodel output.' }], remodelMethod: normalizeRemodelMethod(seed.remodelMethod), temperature: seed.temperature ?? 0.5, promotedAt: null, }); } next[scriptKey] = { status: inFlight > 0 || filledVariants.some(v => v.pending) ? 'pending' : 'ready', expectedVariants: expectedCount, variants: filledVariants, failedJobIds: failed.filter(j => j.id).map(j => j.id), }; if (scriptKey !== adId) next[adId] = next[scriptKey]; if (inFlight > 0 || filledVariants.some(v => v.pending)) stillPending++; } else if (failed.length === jobs.length && jobs.length > 0) { next[scriptKey] = { status: 'error', variants: [], error: failed[0]?.error || 'Generation failed', failedJobIds: failed.filter(j => j.id).map(j => j.id), }; if (scriptKey !== adId) next[adId] = next[scriptKey]; } else { next[scriptKey] = { status: 'pending', variants: [] }; if (scriptKey !== adId) next[adId] = next[scriptKey]; stillPending++; } }); if (stopWhenSettled && stillPending === 0 && remodelPollRef.current) { stopPoll('remodel'); } return next; }); } catch (e) { console.warn('poll remodel jobs failed', e); } }; const startRemodelAndPoll = async (picked) => { const adIds = picked.map(a => a.id); const perAdStrategies = Object.fromEntries( picked .map(ad => [ad.id, capRemodelInstruction(remodelAdInstructions[ad.id] || '').trim()]) .filter(([, strategy]) => !!strategy), ); setRemodelScripts(prev => { const next = { ...prev }; picked.forEach(ad => { next[resultKey(ad)] = remodelPendingEntryForPlan(); }); return next; }); const out = await RESILIA_API.runRemodel({ adIds, nVariants: 1, variantPlan: [remodelPlanRow()], ads: picked, batchStrategy: capRemodelInstruction(remodelPageInstruction).trim(), strategiesByAdId: perAdStrategies, }); const runId = out.run_id || null; await refreshRemodelRunState(); startVisibilityAwarePoll('remodel', () => { pollRemodelJobs(picked, { source: 'manual', runId }); refreshRemodelRunState(); }, REMODEL_POLL_INTERVAL_MS); remodelStartPendingRef.current = false; pollRemodelJobs(picked, { source: 'manual', runId }); }; const runRemodel = async () => { const picked = filteredAds.filter(a => selFilter.has(a.id)); if (picked.length === 0) return; if (remodelStartPendingRef.current) { showToast('Remodel is already starting. Wait a moment and try again.', 'Remodel'); return; } remodelStartPendingRef.current = true; setRemodelBusy(true); const arrivedAtMs = Date.now(); const batchId = `remodel-move-${arrivedAtMs}`; const movedIds = picked.map(a => a.id); const seededAds = picked.map(a => ({ ...a, source: 'remodel', arrivedAtMs })); setRemodeledAds(prev => mergeCurrentPipelineAds(seededAds, prev, batchId)); setRemodelScripts(prev => { const next = { ...prev }; picked.forEach(ad => { next[resultKey(ad)] = remodelPendingEntryForPlan(); }); return next; }); setSelFilter(new Set()); setSelRemodel(new Set(movedIds)); setTab('remodel'); focusPipelineAds('remodel', movedIds); try { await startRemodelAndPoll(picked); showToast(`Remodel started on ${picked.length} ads (1 variant each).`, 'Remodel'); } catch (e) { setRemodelScripts(prev => { const next = { ...prev }; picked.forEach(ad => { next[resultKey(ad)] = 'error'; }); return next; }); showToast('Remodel failed: ' + e.message, 'Error'); } finally { remodelStartPendingRef.current = false; if (!remodelPollRef.current) { 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 signIn = () => { window.location.assign(RESILIA_API.loginUrl()); }; const signOut = async () => { await RESILIA_API.logout(); setAuthState({ status: 'signed_out', user: null }); }; const pipelineStatusText = (pipelineState, label) => { const run = pipelineState?.run; if (!run) return null; const effectiveStatus = pipelineState?.state?.effective_status || run.status; const total = run.total || pipelineState?.state?.job_count || 0; const completed = run.completed || 0; const failed = run.failed || 0; if (effectiveStatus === 'running') { return `${label} running: ${completed}/${total || '?'} processed${failed ? `, ${failed} failed` : ''}.`; } if (effectiveStatus === 'interrupted') { return `${label} was interrupted after ${completed}/${total || '?'} processed. Saved results are shown.`; } if (effectiveStatus === 'failed') { return `${label} finished with failures: ${completed}/${total || '?'} processed, ${failed} failed.`; } if (effectiveStatus === 'done' && total > 0) { return `${label} restored: ${completed}/${total} processed.`; } return null; }; const applyWinningCriteriaThresholds = React.useCallback((criteriaRows) => { const rows = Array.isArray(criteriaRows) ? criteriaRows : [criteriaRows].filter(Boolean); const thresholds = rows .map(row => [String(row?.platform || '').toLowerCase(), Number(row?.criteria?.pass_threshold)]) .filter(([platform, threshold]) => platform && Number.isFinite(threshold)); const sharedThreshold = thresholds.find(([platform]) => platform === 'foreplay')?.[1] ?? thresholds[0]?.[1]; if (!Number.isFinite(sharedThreshold)) return; const thresholdsByPlatform = Object.fromEntries( ['foreplay', 'adspy', 'brandsearch'].map(platform => [platform, sharedThreshold]), ); const applyThreshold = (ad) => { const threshold = thresholdsByPlatform[sourcePlatformForAd(ad)] ?? sharedThreshold; if (!Number.isFinite(threshold)) return ad; const score = Number(ad?.score); const raw = ad?.raw && typeof ad.raw === 'object' ? { ...ad.raw, pass_threshold: threshold, source_platform: sourcePlatformForAd(ad) } : ad?.raw; return { ...ad, sourcePlatform: sourcePlatformForAd(ad), source_platform: sourcePlatformForAd(ad), passThreshold: threshold, pass_threshold: threshold, passes: Number.isFinite(score) ? score >= threshold : ad.passes, raw, }; }; setResearchAds(prev => prev.map(applyThreshold)); setFilteredAds(prev => prev.map(applyThreshold)); setRemodeledAds(prev => prev.map(applyThreshold)); setAutoRemodelAds(prev => prev.map(applyThreshold)); setConvertedAds(prev => prev.map(applyThreshold)); setExpandedAd(prev => (prev ? applyThreshold(prev) : prev)); }, []); const manualSearchAdsForTab = scopedResearchAdsForTab('research'); const autoSearchAdsForTab = scopedResearchAdsForTab('autoSearch'); const researchSelectionAdsForTab = scopedResearchAdsForTab(tab); const researchSelectionIdsForTab = new Set(researchSelectionAdsForTab.map(a => a.id)); const researchSelectionCount = [...selResearch].filter(id => researchSelectionIdsForTab.has(id)).length; const autoSearchServerTotal = Number(autoresearchPage?.total ?? latestAutoresearchRun?.total); // Static Ads brand dropdown reuses the loaded competitor catalog (no new // fetch); keyword autocomplete flattens symptom/mechanism keyword seeds. const staticAdsBrandOptions = (directions || []) .filter(d => d && d.type === 'competitor') .map(d => ({ id: d.id, label: d.display_name || d.brand_name || d.name || d.id })); const staticAdsKeywordSuggestions = [...new Set( (directions || []).flatMap(d => (Array.isArray(d?.keywords) ? d.keywords : [])), )].filter(Boolean).slice(0, 60); // In-flight badge count, deduped by run_id across the live snapshot + history. const staticAdsInFlight = (() => { const byId = new Map(); (saRuns || []).forEach(r => { if (r?.run_id) byId.set(r.run_id, r.status); }); if (saActiveRun?.run_id) byId.set(saActiveRun.run_id, saActiveRun.status); let n = 0; byId.forEach(s => { if (s === 'queued' || s === 'running') n += 1; }); return n; })(); const counts = { staticAds: staticAdsInFlight, research: manualSearchAdsForTab.length, autoSearch: Number.isFinite(autoSearchServerTotal) ? Math.max(autoSearchAdsForTab.length, autoSearchServerTotal) : autoSearchAdsForTab.length, autoRemodel: autoRemodelAds.length || arPickedIds.length, filter: filteredAds.length, remodel: remodeledAds.length, compliance: complianceJobs.length, video: convertedAds.length, }; const remodeledAdIds = new Set(remodeledAds.map(ad => ad.id)); const autoRemodelAdIds = new Set(autoRemodelAds.map(ad => ad.id)); const remodelReadyVariantIds = readyRemodelVariantIds(remodeledAds); const autoRemodelReadyVariantIds = readyRemodelVariantIds(autoRemodelAds); const remodelSelectedAdCount = [...selRemodel].filter(id => remodeledAdIds.has(id)).length; const autoRemodelSelectedAdCount = [...selAutoRemodel].filter(id => autoRemodelAdIds.has(id)).length; const remodelSelectedVariantCount = [...selRemodelVariants].filter(id => remodelReadyVariantIds.has(id)).length; const autoRemodelSelectedVariantCount = [...selAutoRemodelVariants].filter(id => autoRemodelReadyVariantIds.has(id)).length; const remodelVariantOnlySelection = remodelSelectedAdCount === 0 && remodelSelectedVariantCount > 0; const autoRemodelVariantOnlySelection = autoRemodelSelectedAdCount === 0 && autoRemodelSelectedVariantCount > 0; const remodelSelectionCount = remodelVariantOnlySelection ? remodelSelectedVariantCount : remodelSelectedAdCount; const autoRemodelSelectionCount = autoRemodelVariantOnlySelection ? autoRemodelSelectedVariantCount : autoRemodelSelectedAdCount; const remodelSelectionTotal = remodelVariantOnlySelection ? remodelReadyVariantIds.size : remodeledAds.length; const autoRemodelSelectionTotal = autoRemodelVariantOnlySelection ? autoRemodelReadyVariantIds.size : autoRemodelAds.length; const isAdmin = !!authState.user?.is_admin || authState.user?.auth_method === 'disabled'; useEffect(() => { const prune = (setter, allowed) => { setter(prev => { const next = new Set([...prev].filter(id => allowed.has(id))); return next.size === prev.size ? prev : next; }); }; prune(setSelRemodel, remodeledAdIds); prune(setSelAutoRemodel, autoRemodelAdIds); prune(setSelRemodelVariants, remodelReadyVariantIds); prune(setSelAutoRemodelVariants, autoRemodelReadyVariantIds); // Selection is local UI state; prune it whenever visible cards/scripts change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [remodeledAds, autoRemodelAds, remodelScripts]); // group by platform const groupByPlatform = (ads) => { const g = { foreplay: [], adspy: [], brandsearch: [], metaads: [] }; ads.forEach(a => g[a.platform]?.push(a)); return g; }; if (authState.status !== 'authenticated') { return (
Resilia
); } return (
setTab('accessRequests')} onLogout={signOut} />
{isAdmin && ( setManagementCollapsed(v => !v)} onChange={setTab} /> )}
{tab === 'staticAds' && ( )} {tab === 'adminDirections' && isAdmin && ( )} {tab === 'adminWinningCriteria' && isAdmin && ( )} {tab === 'adminGlobalCrawler' && isAdmin && ( )} {tab === 'adminCosts' && isAdmin && ( )} {tab === 'adminEvaluationQuotas' && isAdmin && ( )} {tab === 'adminWorkspaces' && isAdmin && ( )} {tab === 'accessRequests' && isAdmin && ( )} {tab === 'adminUsers' && isAdmin && ( )} {(tab === 'research' || tab === 'autoSearch') && ( )} {tab === 'filter' && ( )} {showAutoRemodelUI() && tab === 'autoRemodel' && ( )} {tab === 'remodel' && ( Promise.all([ loadRemodelPage({ reset: true }), refreshRemodelRunState(), ])} onLoadMore={loadMoreRemodelAds} statusText={pipelineStatusText(remodelRunState, 'Remodel')} busy={remodelBusy} /> )} {tab === 'compliance' && ( loadCompliancePage({ reset: true })} onLoadMore={loadMoreComplianceJobs} onEditTranscript={editComplianceTranscript} onDiscard={discardComplianceJob} /> )} {tab === 'video' && ( )}
{(tab === 'research' || tab === 'autoSearch') && ( setSelResearch(new Set())} onSelectAll={() => setSelResearch(new Set(researchSelectionAdsForTab.map(a => a.id)))} /> )} {tab === 'filter' && ( setSelFilter(new Set())} onSelectAll={() => setSelFilter(new Set(filteredAds.map(a => a.id)))} /> )} {tab === 'remodel' && ( sendSelectedToCompliance('manual') : convert} secondaryLabel={remodelVariantOnlySelection ? null : (complianceBusy ? 'Sending…' : 'Send to Compliance')} secondaryIcon="shield" onSecondary={remodelVariantOnlySelection ? null : () => sendSelectedToCompliance('manual')} dangerLabel={remodelVariantOnlySelection ? null : 'Delete selected'} dangerIcon="trash" onDanger={remodelVariantOnlySelection ? null : bulkDeleteRemodel} onClear={() => { setSelRemodel(new Set()); setSelRemodelVariants(new Set()); }} onSelectAll={() => { if (remodelVariantOnlySelection) { setSelRemodel(new Set()); setSelRemodelVariants(new Set(remodelReadyVariantIds)); } else { setSelRemodel(new Set(remodeledAds.map(a => a.id))); setSelRemodelVariants(new Set()); } }} /> )} {showAutoRemodelUI() && tab === 'autoRemodel' && ( sendSelectedToCompliance('auto_remodel') : promoteAllAutoRemodelJobs} secondaryLabel={autoRemodelVariantOnlySelection ? null : (complianceBusy ? 'Sending…' : 'Send to Compliance')} secondaryIcon="shield" onSecondary={autoRemodelVariantOnlySelection ? null : () => sendSelectedToCompliance('auto_remodel')} dangerLabel={autoRemodelVariantOnlySelection ? null : 'Delete selected'} dangerIcon="trash" onDanger={autoRemodelVariantOnlySelection ? null : bulkDeleteAutoRemodel} onClear={() => { setSelAutoRemodel(new Set()); setSelAutoRemodelVariants(new Set()); }} onSelectAll={() => { if (autoRemodelVariantOnlySelection) { setSelAutoRemodel(new Set()); setSelAutoRemodelVariants(new Set(autoRemodelReadyVariantIds)); } else { setSelAutoRemodel(new Set(autoRemodelAds.map(a => a.id))); setSelAutoRemodelVariants(new Set()); } }} /> )} {tab === 'compliance' && ( setSelCompliance(new Set())} onSelectAll={() => setSelCompliance(new Set(complianceJobs.map(job => job.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 SectionLoadingPanel({ title = 'Loading data', body = 'Fetching from internet and database.' }) { return (
); } function ManagementSidebar({ active, collapsed, pendingAccessCount, onToggle, onChange }) { const items = [ { id: 'adminDirections', label: 'Direction Manager', icon: 'target', meta: 'Directions' }, { id: 'adminWinningCriteria', label: 'Winning Ads Criteria', icon: 'trend', meta: 'Scoring rules' }, { id: 'adminGlobalCrawler', label: 'Global Crawler', icon: 'loader', meta: 'Shared winners' }, { id: 'adminCosts', label: 'Cost Dashboard', icon: 'bolt', meta: 'Spend' }, { id: 'adminEvaluationQuotas', label: 'Evaluation Guardrails', icon: 'shield', meta: 'Daily quotas' }, { id: 'adminWorkspaces', label: 'User Work Viewer', icon: 'users', meta: 'User snapshots' }, { id: 'adminUsers', label: 'User Manager', icon: 'users', meta: 'Grant or revoke admin' }, { id: 'accessRequests', label: 'Access Manager', icon: 'shield', meta: `${pendingAccessCount || 0} pending`, count: pendingAccessCount || 0 }, ]; return ( ); } const blankDirectionForm = () => ({ display_name: '', search_method: 'keyword_discovery', keywords: '', platforms: { foreplay: true, adspy: true, brandsearch: true }, target_n: '5', status: 'active', platform_filters: emptyDirectionPlatformFilters(), }); function AdminDirectionManager({ onCatalogChanged }) { const [directions, setDirections] = useState([]); const [selectedId, setSelectedId] = useState(null); const [form, setForm] = useState(blankDirectionForm()); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [importStatus, setImportStatus] = useState(''); const importFileRef = useRef(null); const activeDirections = directions.filter(d => d.status !== 'archived'); const archivedCount = directions.length - activeDirections.length; const selected = directions.find(d => d.id === selectedId) || null; const splitKeywords = (value) => String(value || '') .split(/[\n,]+/) .map(item => item.trim()) .filter(Boolean); const keywordText = (direction) => { const keywords = Array.isArray(direction?.keywords) ? direction.keywords : []; return keywords.filter(Boolean).join('\n'); }; const platformsForDirection = (direction) => { const saved = Array.isArray(direction?.platforms) ? direction.platforms.map(item => String(item).toLowerCase()).filter(item => LIVE_SOURCES.includes(item)) : []; if (saved.length) return saved; const platformKeywords = direction?.platform_keywords || {}; const keyed = LIVE_SOURCES.filter(platform => Array.isArray(platformKeywords[platform]) && platformKeywords[platform].length); if (keyed.length) return keyed; return LIVE_SOURCES; }; const platformStateForDirection = (direction) => { const selected = new Set(platformsForDirection(direction)); return Object.fromEntries(LIVE_SOURCES.map(platform => [platform, selected.has(platform)])); }; const selectedFormPlatforms = () => LIVE_SOURCES.filter(platform => form.platforms?.[platform]); const platformFiltersForDirection = (direction) => Object.fromEntries( LIVE_SOURCES.map(platform => { const config = direction?.platform_configs?.[platform] || {}; const filters = config && typeof config === 'object' && !Array.isArray(config) && config.api_filters ? config.api_filters : config; return [platform, normalizeDirectionApiFilters(platform, filters || {})]; }), ); const methodLabel = (method) => method === 'brand_lookup' ? 'Brand lookup' : 'Keyword discovery'; const load = async () => { setLoading(true); setError(''); try { const directionData = await RESILIA_API.adminListDirections(); const nextDirections = directionData.directions || []; setDirections(nextDirections); if (!selectedId && nextDirections.length) setSelectedId(nextDirections[0].id); } catch (e) { setError(e.message || 'Admin data failed to load'); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); useEffect(() => { if (!selected) return; setForm({ display_name: selected.display_name || selected.brand_name || selected.id || '', search_method: selected.search_method || (selected.type === 'competitor' ? 'brand_lookup' : 'keyword_discovery'), keywords: keywordText(selected), platforms: platformStateForDirection(selected), target_n: String(selected.target_n || 5), status: selected.status || 'active', platform_filters: platformFiltersForDirection(selected), }); }, [selected]); const updateForm = (patch) => setForm(current => ({ ...current, ...patch })); const updatePlatform = (platform, value) => setForm(current => ({ ...current, platforms: { ...current.platforms, [platform]: value }, })); const updatePlatformFilterValue = (platform, key, value) => setForm(current => { const filters = { ...(current.platform_filters?.[platform] || {}) }; if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) delete filters[key]; else filters[key] = value; return { ...current, platform_filters: { ...current.platform_filters, [platform]: filters }, }; }); const updatePlatformFilterRange = (platform, key, index, value, numeric) => setForm(current => { const filters = { ...(current.platform_filters?.[platform] || {}) }; const existing = asFilterList(filters[key]); const next = [existing[0] || '', existing[1] || '']; if (value === '') next[index] = ''; else if (numeric) { const numberValue = Number(value); next[index] = Number.isFinite(numberValue) ? numberValue : value; } else { next[index] = value; } const compact = next.filter(item => item !== null && item !== undefined && item !== ''); if (compact.length) filters[key] = compact; else delete filters[key]; return { ...current, platform_filters: { ...current.platform_filters, [platform]: filters }, }; }); const newDirection = () => { setSelectedId(null); setForm(blankDirectionForm()); setError(''); setImportStatus(''); }; const exportDirections = async () => { setBusy(true); setError(''); setImportStatus(''); try { const payload = await RESILIA_API.adminExportDirections(); const blob = new Blob([`${JSON.stringify(payload, null, 2)}\n`], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const stamp = new Date().toISOString().slice(0, 10); a.href = url; a.download = `search_directions_${stamp}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); setImportStatus(`Exported ${Array.isArray(payload) ? payload.length : 0} directions.`); } catch (e) { setError(e.message || 'Export failed'); } finally { setBusy(false); } }; const importDirections = async (event) => { const file = event.target.files?.[0]; if (!file) return; setBusy(true); setError(''); setImportStatus(''); try { const text = await file.text(); let payload; try { payload = JSON.parse(text); } catch (parseError) { throw new Error(`Invalid JSON: ${parseError.message}`); } if (!Array.isArray(payload)) throw new Error('Import file must be a JSON array of directions'); const result = await RESILIA_API.adminImportDirections(payload); await load(); await onCatalogChanged?.(); if (Array.isArray(result.ids) && result.ids.length) setSelectedId(result.ids[0]); setImportStatus(`Imported ${result.imported || 0} directions (${result.created || 0} created, ${result.updated || 0} updated).`); } catch (e) { setError(e.message || 'Import failed'); } finally { setBusy(false); event.target.value = ''; } }; const syncDirectionKeywords = async (directionId, platforms, keywords) => { if (!selectedId || !directionId) return; const desired = new Set(); platforms.forEach(platform => keywords.forEach(keyword => desired.add(`${platform}\u0000${keyword.toLowerCase()}`))); const existingItems = Array.isArray(selected?.keyword_items) ? selected.keyword_items : []; const existing = new Set( existingItems.map(item => `${String(item.platform || 'all').toLowerCase()}\u0000${String(item.keyword || '').trim().toLowerCase()}`), ); for (const item of existingItems) { const key = `${String(item.platform || 'all').toLowerCase()}\u0000${String(item.keyword || '').trim().toLowerCase()}`; if (!desired.has(key)) await RESILIA_API.adminArchiveDirectionKeyword(directionId, item.id); } for (const platform of platforms) { for (const keyword of keywords) { const key = `${platform}\u0000${keyword.toLowerCase()}`; if (!existing.has(key)) await RESILIA_API.adminAddDirectionKeyword(directionId, { platform, keyword }); } } }; const renderPlatformFilterField = (platform, field, disabled) => { const filters = form.platform_filters?.[platform] || {}; const value = filters[field.key]; const title = `${PLATFORMS[platform]?.label || platform} ${field.key}`; if (field.type === 'select') { const currentValue = Array.isArray(value) ? (value[0] || '') : (value || ''); return ( ); } if (field.type === 'boolean') { const currentValue = value === true ? 'true' : value === false ? 'false' : ''; return ( ); } if (field.type === 'list') { return ( ); } if (field.type === 'range' || field.type === 'dateRange') { const range = asFilterList(value); const numeric = field.type === 'range'; return ( ); } return ( ); }; const saveDirection = async () => { setBusy(true); setError(''); try { const name = form.display_name.trim(); if (!name) throw new Error('Name is required'); const selectedPlatforms = selectedFormPlatforms(); if (!selectedPlatforms.length) throw new Error('Select at least one platform'); const keywords = splitKeywords(form.keywords); const firstLookup = keywords[0] || ''; const platformFilters = Object.fromEntries( LIVE_SOURCES.map(platform => [platform, { api_filters: cleanApiFilters(form.platform_filters?.[platform] || {}) }]), ); const body = { id: selectedId || null, type: form.search_method === 'brand_lookup' ? 'competitor' : 'keyword', display_name: name, description: null, search_method: form.search_method, brand_name: form.search_method === 'brand_lookup' ? name : null, brand_domain: form.search_method === 'brand_lookup' && firstLookup.includes('.') ? firstLookup : null, target_n: Math.max(1, Math.min(1000, parseInt(form.target_n, 10) || 5)), status: form.status || 'active', api_filters: {}, keywords: [], platforms: selectedPlatforms, platform_keywords: Object.fromEntries(selectedPlatforms.map(platform => [platform, keywords])), platform_configs: platformFilters, }; const out = await RESILIA_API.adminSaveDirection(body); const savedId = out.direction?.id || selectedId || null; if (savedId) await syncDirectionKeywords(savedId, selectedPlatforms, keywords); await load(); await onCatalogChanged?.(); setSelectedId(savedId || out.direction?.id || body.id); } catch (e) { setError(e.message || 'Save failed'); } finally { setBusy(false); } }; const archiveDirection = async () => { if (!selectedId || !confirm(`Archive ${selectedId}?`)) return; setBusy(true); setError(''); try { await RESILIA_API.adminArchiveDirection(selectedId); setSelectedId(null); setForm(blankDirectionForm()); await load(); await onCatalogChanged?.(); } catch (e) { setError(e.message || 'Archive failed'); } finally { setBusy(false); } }; return (
Direction Manager
{activeDirections.length} active · {archivedCount} archived
{error &&
{error}
} {importStatus &&
{importStatus}
}