-
Notifications
You must be signed in to change notification settings - Fork 518
Expand file tree
/
Copy pathuse-gravity-ad.ts
More file actions
336 lines (290 loc) · 9.97 KB
/
use-gravity-ad.ts
File metadata and controls
336 lines (290 loc) · 9.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
import { Message, WEBSITE_URL } from '@codebuff/sdk'
import { useEffect, useRef, useState } from 'react'
import { getAdsEnabled } from '../commands/ads'
import { useChatStore } from '../state/chat-store'
import { isUserActive, subscribeToActivity } from '../utils/activity-tracker'
import { getAuthToken } from '../utils/auth'
import { logger } from '../utils/logger'
const AD_ROTATION_INTERVAL_MS = 60 * 1000 // 60 seconds per ad
const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then pause fetching new ads
const ACTIVITY_THRESHOLD_MS = 30_000 // 30 seconds idle threshold for fetching new ads
const MAX_AD_CACHE_SIZE = 50 // Maximum number of ads to keep in cache
// Ad response type (matches Gravity API response, credits added after impression)
export type AdResponse = {
adText: string
title: string
cta: string
url: string
favicon: string
clickUrl: string
impUrl: string
credits?: number // Set after impression is recorded (in cents)
}
export type GravityAdState = {
ad: AdResponse | null
isLoading: boolean
}
// Consolidated controller state for the ad rotation logic
type GravityController = {
cache: AdResponse[]
cacheIndex: number
impressionsFired: Set<string>
adsShownSinceActivity: number
tickInFlight: boolean
intervalId: ReturnType<typeof setInterval> | null
}
// Pure helper: add an ad to the cache (if not already present)
function addToCache(ctrl: GravityController, ad: AdResponse): void {
if (ctrl.cache.some((x) => x.impUrl === ad.impUrl)) return
if (ctrl.cache.length >= MAX_AD_CACHE_SIZE) ctrl.cache.shift()
ctrl.cache.push(ad)
}
// Pure helper: get the next cached ad (cycles through the cache)
function nextFromCache(ctrl: GravityController): AdResponse | null {
if (ctrl.cache.length === 0) return null
const ad = ctrl.cache[ctrl.cacheIndex % ctrl.cache.length]!
ctrl.cacheIndex = (ctrl.cacheIndex + 1) % ctrl.cache.length
return ad
}
/**
* Hook for fetching and rotating Gravity ads.
*
* Behavior:
* - Ads only start after the user sends their first message
* - Ads rotate every 60 seconds
* - After 3 ads without user activity, stops fetching new ads but continues cycling cached ads
* - Any user activity resets the counter and resumes fetching new ads
*
* Activity is tracked via the global activity-tracker module.
*/
export const useGravityAd = (): GravityAdState => {
const [ad, setAd] = useState<AdResponse | null>(null)
const [isLoading, setIsLoading] = useState(false)
// Use Zustand selector instead of manual subscription - only rerenders when value changes
const hasUserMessaged = useChatStore((s) =>
s.messages.some((m) => m.variant === 'user'),
)
// Single consolidated controller ref
const ctrlRef = useRef<GravityController>({
cache: [],
cacheIndex: 0,
impressionsFired: new Set(),
adsShownSinceActivity: 0,
tickInFlight: false,
intervalId: null,
})
// Ref for the tick function (avoids useCallback dependency issues)
const tickRef = useRef<() => void>(() => { })
// Fire impression and update credits (called when showing an ad)
const recordImpressionOnce = (impUrl: string): void => {
const ctrl = ctrlRef.current
if (ctrl.impressionsFired.has(impUrl)) return
ctrl.impressionsFired.add(impUrl)
const authToken = getAuthToken()
if (!authToken) {
logger.warn('[gravity] No auth token, skipping impression recording')
return
}
// Include mode in request - FREE mode should not grant credits
const agentMode = useChatStore.getState().agentMode
fetch(`${WEBSITE_URL}/api/v1/ads/impression`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ impUrl, mode: agentMode }),
})
.then((res) => res.json())
.then((data) => {
if (data.creditsGranted > 0) {
logger.info(
{ creditsGranted: data.creditsGranted },
'[gravity] Ad impression credits granted',
)
setAd((cur) =>
cur?.impUrl === impUrl
? { ...cur, credits: data.creditsGranted }
: cur,
)
}
})
.catch((err) => {
logger.debug({ err }, '[gravity] Failed to record ad impression')
})
}
// Show an ad and fire impression
const showAd = (next: AdResponse): void => {
setAd(next)
recordImpressionOnce(next.impUrl)
}
// Fetch an ad via web API
const fetchAd = async (): Promise<AdResponse | null> => {
if (!getAdsEnabled()) return null
const authToken = getAuthToken()
if (!authToken) {
logger.warn('[gravity] No auth token available')
return null
}
// Get message history from runState (populated after LLM responds)
const currentRunState = useChatStore.getState().runState
const messageHistory =
currentRunState?.sessionState?.mainAgentState?.messageHistory ?? []
const adMessages = convertToAdMessages(messageHistory)
// Also check UI messages for the latest user message
// (UI messages update immediately, runState.messageHistory updates after LLM responds)
const uiMessages = useChatStore.getState().messages
const lastUIMessage = [...uiMessages]
.reverse()
.find((msg) => msg.variant === 'user')
// If the latest UI user message isn't in our converted history, append it
// This ensures we always include the most recent user message even before LLM responds
if (lastUIMessage?.content) {
const lastAdUserMessage = [...adMessages]
.reverse()
.find((m) => m.role === 'user')
if (
!lastAdUserMessage ||
!lastAdUserMessage.content.includes(lastUIMessage.content)
) {
adMessages.push({
role: 'user',
content: `<user_message>${lastUIMessage.content}</user_message>`,
})
}
}
try {
const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
messages: adMessages,
sessionId: useChatStore.getState().chatSessionId,
device: getDeviceInfo(),
}),
})
if (!response.ok) {
logger.warn(
{ status: response.status, response: await response.json() },
'[gravity] Web API returned error',
)
return null
}
const data = await response.json()
return data.ad as AdResponse | null
} catch (err) {
logger.error({ err }, '[gravity] Failed to fetch ad')
return null
}
}
// Update tick function (uses ref to avoid useCallback dependency issues)
tickRef.current = () => {
void (async () => {
const ctrl = ctrlRef.current
if (ctrl.tickInFlight) return
ctrl.tickInFlight = true
try {
if (!getAdsEnabled()) return
// Derive "can fetch new ads" from counter and activity (no separate paused ref needed)
const canFetchNew =
ctrl.adsShownSinceActivity < MAX_ADS_AFTER_ACTIVITY &&
isUserActive(ACTIVITY_THRESHOLD_MS)
let next: AdResponse | null = null
if (canFetchNew) {
next = await fetchAd()
if (next) addToCache(ctrl, next)
}
// Fall back to cached ads if no new ad
if (!next) {
next = nextFromCache(ctrl)
}
if (next) {
ctrl.adsShownSinceActivity += 1
showAd(next)
}
} finally {
ctrl.tickInFlight = false
}
})()
}
// Reset ads shown counter on user activity
useEffect(() => {
if (!getAdsEnabled()) return
return subscribeToActivity(() => {
ctrlRef.current.adsShownSinceActivity = 0
})
}, [])
// Start rotation when user sends first message
useEffect(() => {
if (!hasUserMessaged || !getAdsEnabled()) return
setIsLoading(true)
// Fetch first ad immediately
void (async () => {
const firstAd = await fetchAd()
if (firstAd) {
addToCache(ctrlRef.current, firstAd)
showAd(firstAd)
ctrlRef.current.adsShownSinceActivity = 1
}
setIsLoading(false)
})()
// Start interval for rotation (consistent 60s intervals)
const id = setInterval(() => tickRef.current(), AD_ROTATION_INTERVAL_MS)
ctrlRef.current.intervalId = id
return () => {
clearInterval(id)
ctrlRef.current.intervalId = null
}
}, [hasUserMessaged])
return { ad: hasUserMessaged ? ad : null, isLoading }
}
type AdMessage = { role: 'user' | 'assistant'; content: string }
/**
* Convert LLM message history to ad API format.
* Includes only user and assistant messages.
*/
const convertToAdMessages = (messages: Message[]): AdMessage[] => {
const adMessages: AdMessage[] = messages
.filter(
(message) => message.role === 'assistant' || message.role === 'user',
)
.filter(
(message) =>
!message.tags || !message.tags.includes('INSTRUCTIONS_PROMPT'),
)
.map((message) => ({
role: message.role,
content: message.content
.filter((c) => c.type === 'text')
.map((c) => c.text.trim())
.filter((c) => c !== '')
.join('\n\n')
.trim(),
}))
.filter((message) => message.content !== '')
return adMessages
}
/** Device info sent to the ads API for targeting */
type DeviceInfo = {
os: 'macos' | 'windows' | 'linux'
timezone: string
locale: string
}
/** Get device info for ads API */
function getDeviceInfo(): DeviceInfo {
// Map Node.js platform to Gravity API os values
const platformToOs: Record<string, 'macos' | 'windows' | 'linux'> = {
darwin: 'macos',
win32: 'windows',
linux: 'linux',
}
const os = platformToOs[process.platform] ?? 'linux'
// Get IANA timezone (e.g., "America/New_York")
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
// Get locale (e.g., "en-US")
const locale = Intl.DateTimeFormat().resolvedOptions().locale
return { os, timezone, locale }
}