mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
feat: Twitter share referral UI for credit rewards (#729)
* feat: add Twitter share referral UI and expose browserosId When credits are exhausted, users now see a "Share on Twitter" CTA with a pre-filled tweet URL and an input to paste their tweet link. Reusable ShareForCredits component used in both ChatError and UsagePage. Server's GET /credits now includes browserosId for the extension to pass to the referral service. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rebuild chat session on provider change * fix: address Greptile review comments - Move referral service URL to EXTERNAL_URLS - Guard submitReferral on !response.ok - Remove stale TODO comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { ExternalLink, Loader2, Send } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCredits, useInvalidateCredits } from '@/lib/credits/useCredits'
|
||||
import {
|
||||
getShareOnTwitterUrl,
|
||||
submitReferral,
|
||||
} from '@/lib/referral/submit-referral'
|
||||
|
||||
interface ShareForCreditsProps {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export const ShareForCredits: FC<ShareForCreditsProps> = ({ compact }) => {
|
||||
const [tweetUrl, setTweetUrl] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean
|
||||
message: string
|
||||
} | null>(null)
|
||||
|
||||
const { data } = useCredits()
|
||||
const invalidateCredits = useInvalidateCredits()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!tweetUrl.trim() || !data?.browserosId) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const res = await submitReferral(tweetUrl.trim(), data.browserosId)
|
||||
if (res.success) {
|
||||
setResult({
|
||||
success: true,
|
||||
message: `${res.creditsAdded ?? 200} credits added!`,
|
||||
})
|
||||
setTweetUrl('')
|
||||
invalidateCredits()
|
||||
} else {
|
||||
setResult({
|
||||
success: false,
|
||||
message: res.reason ?? 'Submission failed. Please try again.',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
setResult({
|
||||
success: false,
|
||||
message: 'Network error. Please try again.',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-2' : 'space-y-3'}>
|
||||
<p className={compact ? 'text-muted-foreground text-xs' : 'text-sm'}>
|
||||
Share BrowserOS on Twitter to earn 200 bonus credits!
|
||||
</p>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full gap-2" asChild>
|
||||
<a
|
||||
href={getShareOnTwitterUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Share on Twitter
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Already shared? Paste your tweet link:
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://x.com/..."
|
||||
value={tweetUrl}
|
||||
onChange={(e) => setTweetUrl(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !tweetUrl.trim()}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<p
|
||||
className={
|
||||
result.success
|
||||
? 'text-green-600 text-xs dark:text-green-400'
|
||||
: 'text-destructive text-xs'
|
||||
}
|
||||
>
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, Clock, Coins, CreditCard, Zap } from 'lucide-react'
|
||||
import { AlertCircle, Clock, Coins, Gift, Zap } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { ShareForCredits } from '@/components/referral/ShareForCredits'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getCreditBarColor,
|
||||
@@ -105,20 +106,11 @@ export const UsagePage: FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="flex items-center gap-2 font-semibold text-sm">
|
||||
Need more credits?
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
Coming soon
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Additional credit packages will be available soon
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Gift className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-semibold text-sm">Earn More Credits</span>
|
||||
</div>
|
||||
<ShareForCredits />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/5 p-5">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { ShareForCredits } from '@/components/referral/ShareForCredits'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const SURVEY_DIRECTIONS = [
|
||||
@@ -122,15 +123,22 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
View troubleshooting guide
|
||||
</a>
|
||||
)}
|
||||
{isCreditsExhausted && url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
{isCreditsExhausted && (
|
||||
<>
|
||||
<div className="w-full border-border/50 border-t pt-3">
|
||||
<ShareForCredits compact />
|
||||
</div>
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isRateLimit && !isCreditsExhausted && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface CreditsInfo {
|
||||
credits: number
|
||||
dailyLimit: number
|
||||
lastResetAt?: string
|
||||
browserosId?: string
|
||||
}
|
||||
|
||||
const CREDITS_QUERY_KEY = ['credits']
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
|
||||
interface ReferralResult {
|
||||
success: boolean
|
||||
creditsAdded?: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export async function submitReferral(
|
||||
tweetUrl: string,
|
||||
browserosId: string,
|
||||
): Promise<ReferralResult> {
|
||||
const response = await fetch(
|
||||
`${EXTERNAL_URLS.REFERRAL_SERVICE}/referral/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tweetUrl, browserosId }),
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
reason: `Request failed with status ${response.status}`,
|
||||
}
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export function getShareOnTwitterUrl(): string {
|
||||
const text = 'I use @browseros_ai to browse the web with AI. Check it out!'
|
||||
return `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export interface AgentSession {
|
||||
mcpServerKey?: string
|
||||
/** Workspace directory when the session was created, for change detection. */
|
||||
workingDir?: string
|
||||
/** LLM config used when the session was created, for provider/model changes. */
|
||||
llmConfigKey?: string
|
||||
}
|
||||
|
||||
export class SessionStore {
|
||||
|
||||
@@ -25,7 +25,7 @@ export function createCreditsRoutes(deps: CreditsDeps) {
|
||||
return new Hono().get('/', async (c) => {
|
||||
try {
|
||||
const credits = await fetchCredits(gatewayBaseUrl, browserosId)
|
||||
return c.json(credits)
|
||||
return c.json({ ...credits, browserosId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch credits', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -65,6 +65,7 @@ export class ChatService {
|
||||
declinedApps: request.declinedApps,
|
||||
browserosId: this.deps.browserosId,
|
||||
}
|
||||
const llmConfigKey = this.buildLlmConfigKey(agentConfig)
|
||||
|
||||
let session = sessionStore.get(request.conversationId)
|
||||
let isNewSession = false
|
||||
@@ -144,6 +145,24 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect provider/model/auth change mid-conversation -> rebuild session.
|
||||
// The AI SDK agent captures the language model at construction time, so a
|
||||
// reused session would keep calling the previous provider.
|
||||
if (session && session.llmConfigKey !== llmConfigKey) {
|
||||
logger.info('LLM config changed mid-conversation, rebuilding session', {
|
||||
conversationId: request.conversationId,
|
||||
provider: agentConfig.provider,
|
||||
model: agentConfig.model,
|
||||
})
|
||||
session = await this.rebuildSession(
|
||||
session,
|
||||
request,
|
||||
agentConfig,
|
||||
mcpServerKey,
|
||||
llmConfigKey,
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
isNewSession = true
|
||||
let hiddenPageId: number | undefined
|
||||
@@ -209,6 +228,7 @@ export class ChatService {
|
||||
browserContext,
|
||||
mcpServerKey,
|
||||
workingDir: request.userWorkingDir,
|
||||
llmConfigKey,
|
||||
}
|
||||
sessionStore.set(request.conversationId, session)
|
||||
}
|
||||
@@ -341,6 +361,7 @@ export class ChatService {
|
||||
request: ChatRequest,
|
||||
agentConfig: ResolvedAgentConfig,
|
||||
mcpServerKey: string,
|
||||
llmConfigKey = this.buildLlmConfigKey(agentConfig),
|
||||
): Promise<AgentSession> {
|
||||
const previousMessages = session.agent.messages
|
||||
await session.agent.dispose()
|
||||
@@ -365,6 +386,7 @@ export class ChatService {
|
||||
browserContext,
|
||||
mcpServerKey,
|
||||
workingDir: request.userWorkingDir,
|
||||
llmConfigKey,
|
||||
}
|
||||
newSession.agent.messages = sanitizeMessagesForToolset(
|
||||
previousMessages,
|
||||
@@ -374,6 +396,26 @@ export class ChatService {
|
||||
return newSession
|
||||
}
|
||||
|
||||
private buildLlmConfigKey(config: ResolvedAgentConfig): string {
|
||||
return JSON.stringify({
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
upstreamProvider: config.upstreamProvider,
|
||||
resourceName: config.resourceName,
|
||||
region: config.region,
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
sessionToken: config.sessionToken,
|
||||
accountId: config.accountId,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
reasoningSummary: config.reasoningSummary,
|
||||
contextWindowSize: config.contextWindowSize,
|
||||
supportsImages: config.supportsImages,
|
||||
})
|
||||
}
|
||||
|
||||
private buildMcpServerKey(browserContext?: BrowserContext): string {
|
||||
const managed = browserContext?.enabledMcpServers?.slice().sort() ?? []
|
||||
const custom =
|
||||
|
||||
@@ -44,11 +44,19 @@ const createAgentUIStreamResponseSpy = mock(
|
||||
},
|
||||
)
|
||||
|
||||
const resolveLLMConfigSpy = mock(async () => ({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5',
|
||||
apiKey: 'test-key',
|
||||
}))
|
||||
const resolveLLMConfigSpy = mock(
|
||||
async (config: {
|
||||
provider?: string
|
||||
model?: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
}) => ({
|
||||
provider: config.provider ?? 'openai',
|
||||
model: config.model ?? 'gpt-5',
|
||||
apiKey: config.apiKey ?? 'test-key',
|
||||
baseUrl: config.baseUrl,
|
||||
}),
|
||||
)
|
||||
|
||||
mock.module('ai', () => ({
|
||||
createAgentUIStreamResponse: createAgentUIStreamResponseSpy,
|
||||
@@ -288,4 +296,65 @@ describe('ChatService scheduled task hidden page lifecycle', () => {
|
||||
})
|
||||
expect(browser.closePage).toHaveBeenCalledWith(88)
|
||||
})
|
||||
|
||||
it('rebuilds an existing session when the LLM provider changes', async () => {
|
||||
const firstAgent = createFakeAgent()
|
||||
agentToReturn = firstAgent
|
||||
streamResponseHandler = async ({ onFinish }) => {
|
||||
await onFinish({ messages: agentToReturn?.messages ?? [] })
|
||||
return new Response('ok')
|
||||
}
|
||||
|
||||
const browser = {
|
||||
resolveTabIds: mock(async () => new Map<number, number>()),
|
||||
}
|
||||
const sessionStore = createSessionStore()
|
||||
const service = new ChatService({
|
||||
sessionStore: sessionStore as never,
|
||||
klavisClient: {} as never,
|
||||
browser: browser as never,
|
||||
registry: {} as never,
|
||||
})
|
||||
const conversationId = crypto.randomUUID()
|
||||
const createCallsBefore = createAgentSpy.mock.calls.length
|
||||
|
||||
await service.processMessage(
|
||||
{
|
||||
conversationId,
|
||||
message: 'First message',
|
||||
provider: 'browseros',
|
||||
model: 'browseros-auto',
|
||||
mode: 'agent',
|
||||
origin: 'sidepanel',
|
||||
} as never,
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
const secondAgent = createFakeAgent()
|
||||
agentToReturn = secondAgent
|
||||
|
||||
await service.processMessage(
|
||||
{
|
||||
conversationId,
|
||||
message: 'Second message',
|
||||
provider: 'chatgpt-pro',
|
||||
model: 'gpt-5.3-codex',
|
||||
mode: 'agent',
|
||||
origin: 'sidepanel',
|
||||
} as never,
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(createAgentSpy.mock.calls.length).toBe(createCallsBefore + 2)
|
||||
expect(firstAgent.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(sessionStore.get(conversationId)?.agent).toBe(secondAgent)
|
||||
|
||||
const latestCreateArgs = createAgentSpy.mock.calls.at(-1)?.[0] as {
|
||||
resolvedConfig: { provider: string; model: string }
|
||||
}
|
||||
expect(latestCreateArgs.resolvedConfig).toMatchObject({
|
||||
provider: 'chatgpt-pro',
|
||||
model: 'gpt-5.3-codex',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,4 +19,5 @@ export const EXTERNAL_URLS = {
|
||||
QWEN_DEVICE_CODE: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
|
||||
QWEN_OAUTH_TOKEN: 'https://chat.qwen.ai/api/v1/oauth2/token',
|
||||
QWEN_CODE_API: 'https://portal.qwen.ai/v1',
|
||||
REFERRAL_SERVICE: 'https://browseros-referral.fly.dev',
|
||||
} as const
|
||||
|
||||
Reference in New Issue
Block a user