From 1d106426dec1c0b6b87efa19304133fb1af4aa88 Mon Sep 17 00:00:00 2001 From: Soumyadas15 Date: Wed, 7 May 2025 01:44:50 +0530 Subject: [PATCH] chore(ui): model selector, paywall, switch to qwen 235B --- backend/agent/api.py | 2 +- backend/services/billing.py | 2 +- .../billing/account-billing-status.tsx | 2 +- .../src/components/payment/paywall-dialog.tsx | 286 ++++++------------ frontend/src/components/thread/chat-input.tsx | 2 +- .../thread/chat-input/_use-model-selection.ts | 131 ++++---- .../thread/chat-input/chat-input.tsx | 8 +- .../thread/chat-input/message-input.tsx | 10 +- .../thread/chat-input/model-selector.tsx | 114 ++++--- frontend/src/lib/home.tsx | 8 +- 10 files changed, 230 insertions(+), 335 deletions(-) diff --git a/backend/agent/api.py b/backend/agent/api.py index d376d039..deb2aaf3 100644 --- a/backend/agent/api.py +++ b/backend/agent/api.py @@ -42,7 +42,7 @@ MODEL_NAME_ALIASES = { "grok-3": "xai/grok-3-fast-latest", "deepseek": "deepseek/deepseek-chat", "grok-3-mini": "xai/grok-3-mini-fast-beta", - "qwen3-4b": "openrouter/qwen/qwen3-4b:free", + "qwen3": "openrouter/qwen/qwen3-235b-a22b", # Also include full names as keys to ensure they map to themselves "anthropic/claude-3-7-sonnet-latest": "anthropic/claude-3-7-sonnet-latest", diff --git a/backend/services/billing.py b/backend/services/billing.py index df3debec..109237dc 100644 --- a/backend/services/billing.py +++ b/backend/services/billing.py @@ -21,7 +21,7 @@ stripe.api_key = config.STRIPE_SECRET_KEY router = APIRouter(prefix="/billing", tags=["billing"]) SUBSCRIPTION_TIERS = { - config.STRIPE_FREE_TIER_ID: {'name': 'free', 'minutes': 10}, + config.STRIPE_FREE_TIER_ID: {'name': 'free', 'minutes': 60}, config.STRIPE_TIER_2_20_ID: {'name': 'tier_2_20', 'minutes': 120}, # 2 hours config.STRIPE_TIER_6_50_ID: {'name': 'tier_6_50', 'minutes': 360}, # 6 hours config.STRIPE_TIER_12_100_ID: {'name': 'tier_12_100', 'minutes': 720}, # 12 hours diff --git a/frontend/src/components/billing/account-billing-status.tsx b/frontend/src/components/billing/account-billing-status.tsx index 862eedd4..c42baf27 100644 --- a/frontend/src/components/billing/account-billing-status.tsx +++ b/frontend/src/components/billing/account-billing-status.tsx @@ -147,7 +147,7 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) { diff --git a/frontend/src/components/payment/paywall-dialog.tsx b/frontend/src/components/payment/paywall-dialog.tsx index 959cea6d..8b401684 100644 --- a/frontend/src/components/payment/paywall-dialog.tsx +++ b/frontend/src/components/payment/paywall-dialog.tsx @@ -9,79 +9,32 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Badge } from '@/components/ui/badge'; -import { - AlertTriangle, - Crown, - Check, - X, - Sparkles, - Rocket, - Zap, - Lock, -} from 'lucide-react'; -import React, { - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +import { Rocket } from 'lucide-react'; +import React, { useCallback, useEffect } from 'react'; export interface PaywallDialogProps { cancelText?: string; - children?: - | ((props: { isDisabled: boolean }) => React.ReactNode) - | React.ReactNode; + children?: React.ReactNode; ctaText?: string; - currentTier: 'free' | 'basic' | 'pro'; open: boolean; description?: string; - forceDisableContent?: boolean; onDialogClose?: () => void; onUpgradeClick?: () => void; - paywallContent?: ReactNode; upgradeUrl?: string; - renderCustomPaywall?: (props: { - currentTier: 'free' | 'basic' | 'pro'; - requiredTier: 'free' | 'basic' | 'pro'; - onCancel: () => void; - onUpgrade: () => void; - }) => ReactNode; - requiredTier: 'free' | 'basic' | 'pro'; title?: string; - featureDescription?: string; } -const tierValue = { - free: 0, - basic: 1, - pro: 2, -}; - export const PaywallDialog: React.FC = ({ cancelText = 'Maybe Later', children, open = false, - ctaText = 'Upgrade to Pro', - currentTier, - description = 'This feature requires a Pro subscription.', - forceDisableContent = false, + ctaText = 'Upgrade Now', + description = 'This feature requires an upgrade to access.', onDialogClose, onUpgradeClick, - paywallContent, - upgradeUrl = '/pricing', - renderCustomPaywall, - requiredTier = 'pro', - title = 'Pro Feature', - featureDescription = 'Unlock advanced features with a Pro subscription', + upgradeUrl = '/settings/billing', + title = 'Upgrade Required', }) => { - const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); - - const hasRequiredTier = tierValue[currentTier] >= tierValue[requiredTier]; - const isBlocked = !hasRequiredTier || forceDisableContent; - const handleUpgrade = useCallback(() => { if (onUpgradeClick) { onUpgradeClick(); @@ -91,167 +44,106 @@ export const PaywallDialog: React.FC = ({ }, [onUpgradeClick, upgradeUrl]); const handleClose = useCallback(() => { - setIsOpen(false); if (onDialogClose) { onDialogClose(); } }, [onDialogClose]); useEffect(() => { - if (isBlocked && containerRef.current) { - const overlay = document.createElement('div'); - overlay.style.position = 'absolute'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.width = '100%'; - overlay.style.height = '100%'; - overlay.style.zIndex = '100'; + return () => { + document.body.classList.remove('overflow-hidden'); + document.body.style.pointerEvents = 'auto'; + + const strayBackdrops = document.querySelectorAll('[data-backdrop]'); + strayBackdrops.forEach(element => element.remove()); + }; + }, []); - const showPaywall = (e: Event) => { - e.preventDefault(); - e.stopPropagation(); - setIsOpen(true); - }; + useEffect(() => { + if (!open) { + document.body.classList.remove('overflow-hidden'); + document.body.style.pointerEvents = 'auto'; - overlay.addEventListener('click', showPaywall, true); - overlay.addEventListener('dragenter', showPaywall, true); - overlay.addEventListener( - 'dragover', - (e) => { - e.preventDefault(); - e.stopPropagation(); - }, - true, - ); - overlay.addEventListener('drop', showPaywall, true); - - containerRef.current.style.position = 'relative'; - containerRef.current.appendChild(overlay); - return () => { - if (containerRef.current?.contains(overlay)) { - containerRef.current.removeChild(overlay); + const overlays = document.querySelectorAll('[role="dialog"]'); + overlays.forEach(overlay => { + if (!overlay.closest('[open="true"]')) { + overlay.remove(); } - }; + }); } - return undefined; - }, [isBlocked, setIsOpen]); - - const renderChildren = () => { - if (typeof children === 'function') { - return children({ isDisabled: false }); - } - return children; - }; - - const getTierBadge = (tier: 'free' | 'basic' | 'pro') => { - switch (tier) { - case 'free': - return ( - - Free - - ); - case 'basic': - return ( - - Basic - - ); - case 'pro': - return ( - - Pro - - ); - } - }; + }, [open]); return ( <> -
{ + if (!isOpen) { + document.body.style.pointerEvents = 'auto'; + document.body.classList.remove('overflow-hidden'); + + setTimeout(() => { + if (onDialogClose) { + onDialogClose(); + } + }, 0); + } + }} > - {renderChildren()} -
+ { + handleClose(); + document.body.style.pointerEvents = 'auto'; + }} + > + + {title} + {description} + - - - {renderCustomPaywall ? ( - renderCustomPaywall({ - currentTier, - requiredTier, - onCancel: handleClose, - onUpgrade: handleUpgrade, - }) - ) : ( - <> - -
- - {title} - - - {requiredTier === 'pro' && ( - - Pro - - )} -
- {description} -
- -
- {paywallContent ?? ( -
-
-
-
- -
-
- -
-

Pro Feature

-

- Upgrade to Pro to avail this feature -

-
- - - Pro Exclusive - -
- )} +
+
+
+
- - - - - - )} +
+

Upgrade to unlock

+

+ Get access to premium models and features +

+
+
+
+ + + + +
); -}; +}; \ No newline at end of file diff --git a/frontend/src/components/thread/chat-input.tsx b/frontend/src/components/thread/chat-input.tsx index 940ecbc3..0a6e123a 100644 --- a/frontend/src/components/thread/chat-input.tsx +++ b/frontend/src/components/thread/chat-input.tsx @@ -57,7 +57,7 @@ const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; // Local storage keys const STORAGE_KEY_MODEL = 'suna-preferred-model'; -const DEFAULT_MODEL_ID = 'sonnet-3.7'; // Define default model ID +const DEFAULT_MODEL_ID = 'qwen3'; // Define default model ID interface ChatInputProps { onSubmit: ( diff --git a/frontend/src/components/thread/chat-input/_use-model-selection.ts b/frontend/src/components/thread/chat-input/_use-model-selection.ts index ced7718a..d3a18a84 100644 --- a/frontend/src/components/thread/chat-input/_use-model-selection.ts +++ b/frontend/src/components/thread/chat-input/_use-model-selection.ts @@ -4,104 +4,97 @@ import { useSubscription } from '@/hooks/react-query/subscriptions/use-subscript import { useState, useEffect } from 'react'; export const STORAGE_KEY_MODEL = 'suna-preferred-model'; -export const DEFAULT_MODEL_ID = 'qwen3-4b'; +export const DEFAULT_MODEL_ID = 'qwen3'; -export type SubscriptionTier = 'free' | 'base' | 'extra'; - -export type ModelTier = 'free' | 'base-only' | 'extra-only'; +export type SubscriptionStatus = 'no_subscription' | 'active'; export interface ModelOption { id: string; label: string; - tier: ModelTier; + requiresSubscription: boolean; + description?: string; } export const MODEL_OPTIONS: ModelOption[] = [ - { id: 'qwen3-4b', label: 'Qwen 3', tier: 'free' }, - { id: 'sonnet-3.7', label: 'Sonnet 3.7', tier: 'base-only' }, + { + id: 'qwen3', + label: 'Free', + requiresSubscription: false, + description: 'Limited capabilities. Upgrade for full performance.' + }, + { + id: 'sonnet-3.7', + label: 'Standard', + requiresSubscription: true, + description: 'Excellent for complex tasks and nuanced conversations' + }, ]; export const canAccessModel = ( - subscriptionTier: SubscriptionTier, - modelTier: ModelTier, + subscriptionStatus: SubscriptionStatus, + requiresSubscription: boolean, ): boolean => { - switch (modelTier) { - case 'free': - return true; - case 'base-only': - return subscriptionTier === 'base' || subscriptionTier === 'extra'; - case 'extra-only': - return subscriptionTier === 'extra'; - default: - return false; - } + return subscriptionStatus === 'active' || !requiresSubscription; }; export const useModelSelection = () => { const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID); - + const { data: subscriptionData } = useSubscription(); - const subscriptionTier: SubscriptionTier = (() => { - if (!subscriptionData) return 'free'; - if (subscriptionData.plan_name === 'free') return 'free'; - if (subscriptionData.plan_name === 'base') return 'base'; - if (subscriptionData.plan_name === 'extra') return 'extra'; - return 'free'; - })(); + + const subscriptionStatus: SubscriptionStatus = subscriptionData?.status === 'active' + ? 'active' + : 'no_subscription'; useEffect(() => { - if (typeof window !== 'undefined') { - try { - const savedModel = localStorage.getItem(STORAGE_KEY_MODEL); - if ( - savedModel && - MODEL_OPTIONS.some((option) => option.id === savedModel) - ) { - const modelOption = MODEL_OPTIONS.find( - (option) => option.id === savedModel, - ); - if ( - modelOption && - canAccessModel(subscriptionTier, modelOption.tier) - ) { - setSelectedModel(savedModel); - } else { - setSelectedModel(DEFAULT_MODEL_ID); - } - } else if (savedModel) { - localStorage.removeItem(STORAGE_KEY_MODEL); - } - } catch (error) { - console.warn('Failed to load preferences from localStorage:', error); + if (typeof window === 'undefined') return; + + try { + const savedModel = localStorage.getItem(STORAGE_KEY_MODEL); + if (!savedModel) return; + + const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel); + + if (modelOption && canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) { + setSelectedModel(savedModel); + } else { + localStorage.removeItem(STORAGE_KEY_MODEL); + setSelectedModel(DEFAULT_MODEL_ID); } + } catch (error) { + console.warn('Failed to load preferences from localStorage:', error); } - }, [subscriptionTier]); + }, [subscriptionStatus]); - const handleModelChange = (value: string) => { - const modelOption = MODEL_OPTIONS.find((option) => option.id === value); - if (modelOption && canAccessModel(subscriptionTier, modelOption.tier)) { - setSelectedModel(value); - try { - localStorage.setItem(STORAGE_KEY_MODEL, value); - } catch (error) { - console.warn('Failed to save model preference to localStorage:', error); - } + const handleModelChange = (modelId: string) => { + const modelOption = MODEL_OPTIONS.find(option => option.id === modelId); + + if (!modelOption || !canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) { + return; + } + + setSelectedModel(modelId); + try { + localStorage.setItem(STORAGE_KEY_MODEL, modelId); + } catch (error) { + console.warn('Failed to save model preference to localStorage:', error); } }; - const availableModels = MODEL_OPTIONS.filter((model) => - canAccessModel(subscriptionTier, model.tier), - ); - return { selectedModel, setSelectedModel: handleModelChange, - subscriptionTier, - availableModels, + subscriptionStatus, + availableModels: MODEL_OPTIONS.filter(model => + canAccessModel(subscriptionStatus, model.requiresSubscription) + ), allModels: MODEL_OPTIONS, canAccessModel: (modelId: string) => { - const model = MODEL_OPTIONS.find((m) => m.id === modelId); - return model ? canAccessModel(subscriptionTier, model.tier) : false; + const model = MODEL_OPTIONS.find(m => m.id === modelId); + return model ? canAccessModel(subscriptionStatus, model.requiresSubscription) : false; }, + isSubscriptionRequired: (modelId: string) => { + return MODEL_OPTIONS.find(m => m.id === modelId)?.requiresSubscription || false; + } }; -}; +}; \ No newline at end of file diff --git a/frontend/src/components/thread/chat-input/chat-input.tsx b/frontend/src/components/thread/chat-input/chat-input.tsx index 78869495..96f2535d 100644 --- a/frontend/src/components/thread/chat-input/chat-input.tsx +++ b/frontend/src/components/thread/chat-input/chat-input.tsx @@ -76,7 +76,7 @@ export const ChatInput = forwardRef( const { selectedModel, setSelectedModel: handleModelChange, - subscriptionTier, + subscriptionStatus, allModels: modelOptions, canAccessModel, } = useModelSelection(); @@ -188,8 +188,8 @@ export const ChatInput = forwardRef( } }} > -
- +
+ ( selectedModel={selectedModel} onModelChange={handleModelChange} modelOptions={modelOptions} - currentTier={subscriptionTier} + subscriptionStatus={subscriptionStatus} canAccessModel={canAccessModel} /> diff --git a/frontend/src/components/thread/chat-input/message-input.tsx b/frontend/src/components/thread/chat-input/message-input.tsx index 126932e0..7dac5c69 100644 --- a/frontend/src/components/thread/chat-input/message-input.tsx +++ b/frontend/src/components/thread/chat-input/message-input.tsx @@ -6,7 +6,6 @@ import { cn } from '@/lib/utils'; import { UploadedFile } from './chat-input'; import { FileUploadHandler } from './file-upload-handler'; import { ModelSelector } from './model-selector'; -import { useModelSelection } from './_use-model-selection'; interface MessageInputProps { value: string; @@ -31,7 +30,7 @@ interface MessageInputProps { selectedModel: string; onModelChange: (model: string) => void; modelOptions: any[]; - currentTier: string; + subscriptionStatus: string; canAccessModel: (model: string) => boolean; } @@ -60,7 +59,7 @@ export const MessageInput = forwardRef( selectedModel, onModelChange, modelOptions, - currentTier, + subscriptionStatus, canAccessModel, }, ref, @@ -87,10 +86,6 @@ export const MessageInput = forwardRef( return () => window.removeEventListener('resize', adjustHeight); }, [value, ref]); - const { - subscriptionTier, - } = useModelSelection(); - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -145,7 +140,6 @@ export const MessageInput = forwardRef( selectedModel={selectedModel} onModelChange={onModelChange} modelOptions={modelOptions} - currentTier={subscriptionTier} canAccessModel={canAccessModel} /> - - {modelOptions.map((option) => { - const { icon, tooltip } = getModelTierInfo(option.tier); - const isAccessible = canAccessModel(option.id); + + {modelOptions.map((opt) => { + const accessible = canAccessModel(opt.id); return ( - + handleModelSelection(option.id)} + className="text-sm py-3 px-3 flex items-start cursor-pointer rounded-md" + onClick={() => handleSelect(opt.id)} > -
- {option.label} - {icon} - {!isAccessible && } +
+
+
+ {opt.id === 'sonnet-3.7' && ( + + )} + {opt.label} + {!accessible && } +
+ {selectedModel === opt.id && ( + + )} +
+
+ {opt.description} +
- {selectedModel === option.id && ( - - )} - {tooltip && ( + {!accessible && ( -

{tooltip}

+

Requires subscription to access

)} @@ -109,6 +108,23 @@ export const ModelSelector: React.FC = ({ })} + + {paywallOpen && ( + m.id === lockedModel + )?.label}` + : 'Subscribe to access premium models' + } + ctaText="Subscribe Now" + cancelText="Maybe Later" + /> + )}
); -}; +}; \ No newline at end of file diff --git a/frontend/src/lib/home.tsx b/frontend/src/lib/home.tsx index 47f9c768..2abcfb82 100644 --- a/frontend/src/lib/home.tsx +++ b/frontend/src/lib/home.tsx @@ -111,8 +111,8 @@ export const siteConfig = { buttonText: 'Hire Suna', buttonColor: 'bg-secondary text-white', isPopular: false, - hours: '10 min', - features: ['Public Projects'], + hours: '60 min', + features: ['Public Projects', 'Basic Model (Limited capabilities)'], stripePriceId: config.SUBSCRIPTION_TIERS.FREE.priceId, upgradePlans: [], }, @@ -127,7 +127,7 @@ export const siteConfig = { features: [ '2 hours', 'Private projects', - 'Team functionality (coming soon)', + 'Access to intelligent Model (Full Suna)', ], stripePriceId: config.SUBSCRIPTION_TIERS.TIER_2_20.priceId, upgradePlans: [], @@ -140,7 +140,7 @@ export const siteConfig = { buttonColor: 'bg-secondary text-white', isPopular: false, hours: '6 hours', - features: ['Unlimited seats'], + features: ['Suited to you needs'], upgradePlans: [ { hours: '6 hours',