Skip to content

Commit 4a89b78

Browse files
committed
Implement ads backend
1 parent 4c25ba0 commit 4a89b78

File tree

16 files changed

+497
-65
lines changed

16 files changed

+497
-65
lines changed

cli/src/commands/ads.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { saveSettings, loadSettings } from '../utils/settings'
2-
import { getCliEnv } from '../utils/env'
32
import { getSystemMessage } from '../utils/message-history'
43
import { logger } from '../utils/logger'
54

@@ -8,24 +7,14 @@ import type { ChatMessage } from '../types/chat'
87
export const handleAdsEnable = (): {
98
postUserMessage: (messages: ChatMessage[]) => ChatMessage[]
109
} => {
11-
const apiKey = getCliEnv().GRAVITY_API_KEY
12-
logger.info({ hasApiKey: !!apiKey }, '[gravity] Enabling ads')
10+
logger.info('[gravity] Enabling ads')
1311

1412
saveSettings({ adsEnabled: true })
1513

16-
if (!apiKey) {
17-
return {
18-
postUserMessage: (messages) => [
19-
...messages,
20-
getSystemMessage('Ads enabled, but GRAVITY_API_KEY is not set. Set the environment variable to see ads.'),
21-
],
22-
}
23-
}
24-
2514
return {
2615
postUserMessage: (messages) => [
2716
...messages,
28-
getSystemMessage('Ads enabled. You will see contextual ads above the input.'),
17+
getSystemMessage('Ads enabled. You will see contextual ads above the input and earn credits from impressions.'),
2918
],
3019
}
3120
}

cli/src/components/ad-banner.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
66
import { useTheme } from '../hooks/use-theme'
77
import { logger } from '../utils/logger'
88

9-
import type { AdResponse } from '@gravity-ai/api'
9+
import type { AdResponse } from '../hooks/use-gravity-ad'
1010

1111
interface AdBannerProps {
1212
ad: AdResponse
@@ -40,12 +40,10 @@ export const AdBanner: React.FC<AdBannerProps> = ({ ad }) => {
4040
}
4141
}, [ad.clickUrl])
4242

43-
// Use 'url' field for display domain (the actual destination), fallback to clickUrl
44-
const displayUrl = (ad as { url?: string }).url || ad.clickUrl
45-
const domain = displayUrl ? extractDomain(displayUrl) : ''
46-
const title = (ad as { title?: string }).title
47-
// Use title as CTA, or fallback to "Learn more" if there's a clickUrl
48-
const ctaText = title || (ad.clickUrl ? 'Learn more' : '')
43+
// Use 'url' field for display domain (the actual destination)
44+
const domain = extractDomain(ad.url)
45+
// Use title as CTA
46+
const ctaText = ad.title
4947

5048
// Calculate available width for ad text
5149
// Account for: padding (2), "Ad" label with space (3)

cli/src/components/usage-banner.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
4343
type: 'usage-response'
4444
usage: number
4545
remainingBalance: number | null
46-
balanceBreakdown?: { free: number; paid: number }
46+
balanceBreakdown?: { free: number; paid: number; ad?: number }
4747
next_quota_reset: string | null
4848
}>({
4949
queryKey: usageQueryKeys.current(),
@@ -83,6 +83,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => {
8383
sessionCreditsUsed,
8484
remainingBalance: activeData.remainingBalance,
8585
next_quota_reset: activeData.next_quota_reset,
86+
adCredits: activeData.balanceBreakdown?.ad,
8687
})
8788

8889
return (

cli/src/hooks/use-gravity-ad.ts

Lines changed: 80 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { Client } from '@gravity-ai/api'
1+
import { WEBSITE_URL } from '@codebuff/sdk'
22
import { useCallback, useEffect, useRef, useState } from 'react'
33

44
import { getAdsEnabled } from '../commands/ads'
55
import { useChatStore } from '../state/chat-store'
6-
import { getCliEnv } from '../utils/env'
6+
import { getAuthToken } from '../utils/auth'
77
import { logger } from '../utils/logger'
88

9-
import type { AdResponse } from '@gravity-ai/api'
109
import type { Message } from '@codebuff/common/types/messages/codebuff-message'
1110

1211
const MAX_MESSAGES_FOR_AD = 100
@@ -16,6 +15,17 @@ const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then s
1615

1716
type AdMessage = { role: 'user' | 'assistant'; content: string }
1817

18+
// Ad response type (matches Gravity API response)
19+
export type AdResponse = {
20+
adText: string
21+
title: string
22+
url: string
23+
favicon: string
24+
clickUrl: string
25+
impUrl: string
26+
payout: number
27+
}
28+
1929
/**
2030
* Extract text content from a Message's content array
2131
*/
@@ -71,7 +81,6 @@ export type GravityAdState = {
7181
export const useGravityAd = (): GravityAdState => {
7282
const [ad, setAd] = useState<AdResponse | null>(null)
7383
const [isLoading, setIsLoading] = useState(false)
74-
const clientRef = useRef<Client | null>(null)
7584
const impressionFiredRef = useRef<Set<string>>(new Set())
7685

7786
// Pre-fetched next ad ready to display
@@ -93,29 +102,42 @@ export const useGravityAd = (): GravityAdState => {
93102
// Get runState from chat store
94103
const runState = useChatStore((state) => state.runState)
95104

96-
// Initialize client on mount
97-
useEffect(() => {
98-
const apiKey = getCliEnv().GRAVITY_API_KEY
99-
logger.info(
100-
{ hasApiKey: !!apiKey, adsEnabled: getAdsEnabled() },
101-
'[gravity] Initializing Gravity ad client',
102-
)
103-
if (apiKey) {
104-
clientRef.current = new Client(apiKey)
105-
logger.info('[gravity] Gravity client initialized successfully')
106-
} else {
107-
logger.warn('[gravity] No GRAVITY_API_KEY found in environment')
108-
}
109-
}, [])
110-
111-
// Fire impression when ad changes
105+
// Fire impression via web API when ad changes (grants credits)
112106
useEffect(() => {
113107
if (ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) {
114108
impressionFiredRef.current.add(ad.impUrl)
115-
logger.info({ impUrl: ad.impUrl }, '[gravity] Firing ad impression')
116-
fetch(ad.impUrl).catch((err) => {
117-
logger.debug({ err }, '[gravity] Failed to fire ad impression')
109+
logger.info({ impUrl: ad.impUrl, payout: ad.payout }, '[gravity] Recording ad impression')
110+
111+
const authToken = getAuthToken()
112+
if (!authToken) {
113+
logger.warn('[gravity] No auth token, skipping impression recording')
114+
return
115+
}
116+
117+
// Call our web API to fire impression and grant credits
118+
fetch(`${WEBSITE_URL}/api/v1/ads/impression`, {
119+
method: 'POST',
120+
headers: {
121+
'Content-Type': 'application/json',
122+
'Authorization': `Bearer ${authToken}`,
123+
},
124+
body: JSON.stringify({
125+
impUrl: ad.impUrl,
126+
payout: ad.payout,
127+
}),
118128
})
129+
.then((res) => res.json())
130+
.then((data) => {
131+
if (data.creditsGranted > 0) {
132+
logger.info(
133+
{ creditsGranted: data.creditsGranted },
134+
'[gravity] Ad impression credits granted',
135+
)
136+
}
137+
})
138+
.catch((err) => {
139+
logger.debug({ err }, '[gravity] Failed to record ad impression')
140+
})
119141
}
120142
}, [ad])
121143

@@ -131,10 +153,15 @@ export const useGravityAd = (): GravityAdState => {
131153
}
132154
}, [])
133155

134-
// Fetch an ad and return it (for pre-fetching)
156+
// Fetch an ad via web API and return it (for pre-fetching)
135157
const fetchAdAsync = useCallback(async (): Promise<AdResponse | null> => {
136-
const client = clientRef.current
137-
if (!client || !getAdsEnabled()) return null
158+
if (!getAdsEnabled()) return null
159+
160+
const authToken = getAuthToken()
161+
if (!authToken) {
162+
logger.warn('[gravity] No auth token available')
163+
return null
164+
}
138165

139166
const currentRunState = useChatStore.getState().runState
140167
const messageHistory =
@@ -143,22 +170,38 @@ export const useGravityAd = (): GravityAdState => {
143170

144171
if (adMessages.length === 0) return null
145172

146-
logger.info('[gravity] Fetching ad from Gravity API')
173+
logger.info('[gravity] Fetching ad from web API')
147174

148175
try {
149-
const response = await client.getAd({ messages: adMessages })
176+
const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, {
177+
method: 'POST',
178+
headers: {
179+
'Content-Type': 'application/json',
180+
'Authorization': `Bearer ${authToken}`,
181+
},
182+
body: JSON.stringify({ messages: adMessages }),
183+
})
184+
185+
if (!response.ok) {
186+
logger.warn({ status: response.status }, '[gravity] Web API returned error')
187+
return null
188+
}
189+
190+
const data = await response.json()
191+
const ad = data.ad as AdResponse | null
192+
150193
logger.info(
151194
{
152-
hasAd: !!response,
153-
adText: response?.adText,
154-
title: (response as { title?: string })?.title,
155-
clickUrl: response?.clickUrl,
156-
impUrl: response?.impUrl,
157-
payout: response?.payout,
195+
hasAd: !!ad,
196+
adText: ad?.adText,
197+
title: ad?.title,
198+
clickUrl: ad?.clickUrl,
199+
impUrl: ad?.impUrl,
200+
payout: ad?.payout,
158201
},
159202
'[gravity] Received ad response',
160203
)
161-
return response
204+
return ad
162205
} catch (err) {
163206
logger.error({ err }, '[gravity] Failed to fetch ad')
164207
return null
@@ -231,9 +274,9 @@ export const useGravityAd = (): GravityAdState => {
231274
runState?.sessionState?.mainAgentState?.messageHistory ?? []
232275
const hasMessages = messageHistory.length > 0
233276
const adsEnabled = getAdsEnabled()
234-
const hasClient = !!clientRef.current
277+
const hasAuth = !!getAuthToken()
235278

236-
if (hasMessages && adsEnabled && hasClient && !isStartedRef.current) {
279+
if (hasMessages && adsEnabled && hasAuth && !isStartedRef.current) {
237280
logger.info('[gravity] Starting ad rotation')
238281
isStartedRef.current = true
239282
setIsLoading(true)

cli/src/hooks/use-usage-query.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ interface UsageResponse {
2020
balanceBreakdown?: {
2121
free: number
2222
paid: number
23+
ad?: number
24+
referral?: number
25+
admin?: number
2326
}
2427
next_quota_reset: string | null
2528
autoTopupEnabled?: boolean

cli/src/types/env.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,6 @@ export type CliEnv = BaseEnv & {
5252
OPEN_TUI_THEME?: string
5353
OPENTUI_THEME?: string
5454

55-
// Gravity AI Ads
56-
GRAVITY_API_KEY?: string
57-
5855
// Codebuff CLI-specific (set during binary build)
5956
CODEBUFF_IS_BINARY?: string
6057
CODEBUFF_CLI_VERSION?: string

cli/src/utils/env.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,6 @@ export const getCliEnv = (): CliEnv => ({
5252
OPEN_TUI_THEME: process.env.OPEN_TUI_THEME,
5353
OPENTUI_THEME: process.env.OPENTUI_THEME,
5454

55-
// Gravity AI Ads
56-
GRAVITY_API_KEY: process.env.GRAVITY_API_KEY,
57-
5855
// Binary build configuration
5956
CODEBUFF_IS_BINARY: process.env.CODEBUFF_IS_BINARY,
6057
CODEBUFF_CLI_VERSION: process.env.CODEBUFF_CLI_VERSION,

cli/src/utils/usage-banner-state.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export interface UsageBannerTextOptions {
6666
sessionCreditsUsed: number
6767
remainingBalance: number | null
6868
next_quota_reset: string | null
69+
/** Ad impression credits earned */
70+
adCredits?: number
6971
/** For testing purposes, allows overriding "today" */
7072
today?: Date
7173
}
@@ -87,6 +89,7 @@ export function generateUsageBannerText(
8789
sessionCreditsUsed,
8890
remainingBalance,
8991
next_quota_reset,
92+
adCredits,
9093
today = new Date(),
9194
} = options
9295

@@ -96,6 +99,11 @@ export function generateUsageBannerText(
9699
text += `. Credits remaining: ${remainingBalance.toLocaleString()}`
97100
}
98101

102+
// Show ad credits earned if any
103+
if (adCredits && adCredits > 0) {
104+
text += ` (${adCredits.toLocaleString()} from ads)`
105+
}
106+
99107
if (next_quota_reset) {
100108
const resetDate = new Date(next_quota_reset)
101109
const isToday = resetDate.toDateString() === today.toDateString()

common/src/constants/grant-priorities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { GrantType } from '@codebuff/common/types/grant'
22

33
export const GRANT_PRIORITIES: Record<GrantType, number> = {
44
free: 20,
5+
ad: 30, // Ad credits consumed after free, before referral
56
referral: 40,
67
admin: 60,
78
organization: 70,

common/src/types/grant.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ export type GrantType =
44
| 'purchase'
55
| 'admin'
66
| 'organization'
7+
| 'ad' // Credits earned from ads (impressions, clicks, acquisitions, etc.)
78

89
export const GrantTypeValues = [
910
'free',
1011
'referral',
1112
'purchase',
1213
'admin',
1314
'organization',
15+
'ad',
1416
] as const

0 commit comments

Comments
 (0)