Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface CreditsInfo {
credits: number
dailyLimit: number
lastResetAt?: string
browserosId?: string
}

const CREDITS_QUERY_KEY = ['credits']
Expand Down
Original file line number Diff line number Diff line change
@@ -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)}`
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -209,6 +228,7 @@ export class ChatService {
browserContext,
mcpServerKey,
workingDir: request.userWorkingDir,
llmConfigKey,
}
sessionStore.set(request.conversationId, session)
}
Expand Down Expand Up @@ -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()
Expand All @@ -365,6 +386,7 @@ export class ChatService {
browserContext,
mcpServerKey,
workingDir: request.userWorkingDir,
llmConfigKey,
}
newSession.agent.messages = sanitizeMessagesForToolset(
previousMessages,
Expand All @@ -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 =
Expand Down
Loading
Loading