diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 6663c7e1e..09727ea6e 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -174,7 +174,11 @@ export const Chat = ({ }) const hasSubscription = subscriptionData?.hasSubscription ?? false - const { adData, recordImpression } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription }) + const { adData, recordImpression } = useGravityAd({ + enabled: IS_FREEBUFF || !hasSubscription, + provider: 'gravity', + fallbackProvider: 'carbon', + }) // Set initial mode from CLI flag on mount useEffect(() => { diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index 251ca87c0..8505a717d 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -72,11 +72,12 @@ export const WaitingRoomScreen: React.FC = ({ // Always enable ads in the waiting room — this is where monetization lives. // forceStart bypasses the "wait for first user message" gate inside the hook, // which would otherwise block ads here since no conversation exists yet. - // Uses Carbon (BuySellAds); in-chat ads still use the Gravity default. + // Try Gravity first, then fall back to Carbon when Gravity doesn't fill. const { adData, recordImpression } = useGravityAd({ enabled: true, forceStart: true, - provider: 'carbon', + provider: 'gravity', + fallbackProvider: 'carbon', }) useFreebuffCtrlCExit() diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index e52b4bdd8..36a18faae 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -108,12 +108,15 @@ export const useGravityAd = (options?: { /** Skip the "wait for first user message" gate. Used by the freebuff * waiting room, which has no conversation but still needs ads. */ forceStart?: boolean - /** Which ad network to query. Defaults to Gravity. */ + /** Primary ad network to query. Defaults to Gravity. */ provider?: AdProvider + /** Backup ad network to try when the primary returns no fill or errors. */ + fallbackProvider?: AdProvider }): GravityAdState => { const enabled = options?.enabled ?? true const forceStart = options?.forceStart ?? false const provider: AdProvider = options?.provider ?? 'gravity' + const fallbackProvider = options?.fallbackProvider const [ad, setAd] = useState(null) const [adData, setAdData] = useState(null) const [isLoading, setIsLoading] = useState(false) @@ -278,49 +281,63 @@ export const useGravityAd = (options?: { } } - try { - const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify({ - provider, - messages: adMessages, - sessionId: useChatStore.getState().chatSessionId, - device: getDeviceInfo(), - // Carbon requires a real browser-ish useragent for targeting/fraud - // detection. Gravity ignores it. We source one centrally so every - // provider that needs it sees the same value. - userAgent: getAdUserAgent(), - }), - }) + const providersToTry = + fallbackProvider && fallbackProvider !== provider + ? [provider, fallbackProvider] + : [provider] - if (!response.ok) { - logger.warn( - { provider, status: response.status, response: await response.json() }, - '[ads] Web API returned error', - ) - return null - } + for (const providerToTry of providersToTry) { + try { + const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + provider: providerToTry, + messages: adMessages, + sessionId: useChatStore.getState().chatSessionId, + device: getDeviceInfo(), + // Carbon requires a real browser-ish useragent for targeting/fraud + // detection. Gravity ignores it. We source one centrally so every + // provider that needs it sees the same value. + userAgent: getAdUserAgent(), + }), + }) - const data = await response.json() - const variant = data.variant ?? 'banner' + if (!response.ok) { + logger.warn( + { + provider: providerToTry, + status: response.status, + response: await response.json(), + }, + '[ads] Web API returned error', + ) + continue + } - if (variant === 'choice' && Array.isArray(data.ads) && data.ads.length > 0) { - return { variant: 'choice', ads: data.ads as AdResponse[] } - } + const data = await response.json() + const variant = data.variant ?? 'banner' - if (data.ad) { - return { variant: 'banner', ad: data.ad as AdResponse } - } + if ( + variant === 'choice' && + Array.isArray(data.ads) && + data.ads.length > 0 + ) { + return { variant: 'choice', ads: data.ads as AdResponse[] } + } - return null - } catch (err) { - logger.error({ err }, '[ads] Failed to fetch ad') - return null + if (data.ad) { + return { variant: 'banner', ad: data.ad as AdResponse } + } + } catch (err) { + logger.error({ err, provider: providerToTry }, '[ads] Failed to fetch ad') + } } + + return null } // Update tick function (uses ref to avoid useCallback dependency issues) @@ -413,7 +430,7 @@ export const useGravityAd = (options?: { clearInterval(id) ctrlRef.current.intervalId = null } - }, [shouldStart, shouldHideAds]) + }, [shouldStart, shouldHideAds, provider, fallbackProvider]) // Don't return ad when ads should be hidden const visible = shouldStart && !shouldHideAds