mirror of https://github.com/kortix-ai/suna.git
chore(ui): model selector, paywall, switch to qwen 235B
This commit is contained in:
parent
0ec8235d5c
commit
1d106426de
|
@ -42,7 +42,7 @@ MODEL_NAME_ALIASES = {
|
||||||
"grok-3": "xai/grok-3-fast-latest",
|
"grok-3": "xai/grok-3-fast-latest",
|
||||||
"deepseek": "deepseek/deepseek-chat",
|
"deepseek": "deepseek/deepseek-chat",
|
||||||
"grok-3-mini": "xai/grok-3-mini-fast-beta",
|
"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
|
# Also include full names as keys to ensure they map to themselves
|
||||||
"anthropic/claude-3-7-sonnet-latest": "anthropic/claude-3-7-sonnet-latest",
|
"anthropic/claude-3-7-sonnet-latest": "anthropic/claude-3-7-sonnet-latest",
|
||||||
|
|
|
@ -21,7 +21,7 @@ stripe.api_key = config.STRIPE_SECRET_KEY
|
||||||
router = APIRouter(prefix="/billing", tags=["billing"])
|
router = APIRouter(prefix="/billing", tags=["billing"])
|
||||||
|
|
||||||
SUBSCRIPTION_TIERS = {
|
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_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_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
|
config.STRIPE_TIER_12_100_ID: {'name': 'tier_12_100', 'minutes': 720}, # 12 hours
|
||||||
|
|
|
@ -147,7 +147,7 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
|
||||||
<Button
|
<Button
|
||||||
onClick={handleManageSubscription}
|
onClick={handleManageSubscription}
|
||||||
disabled={isManaging}
|
disabled={isManaging}
|
||||||
className="w-full bg-primary text-white hover:bg-primary/90 shadow-md hover:shadow-lg transition-all"
|
className="w-full bg-primary hover:bg-primary/90 shadow-md hover:shadow-lg transition-all"
|
||||||
>
|
>
|
||||||
{isManaging ? 'Loading...' : 'Manage Subscription'}
|
{isManaging ? 'Loading...' : 'Manage Subscription'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -9,79 +9,32 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Rocket } from 'lucide-react';
|
||||||
import {
|
import React, { useCallback, useEffect } from 'react';
|
||||||
AlertTriangle,
|
|
||||||
Crown,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
Sparkles,
|
|
||||||
Rocket,
|
|
||||||
Zap,
|
|
||||||
Lock,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import React, {
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
export interface PaywallDialogProps {
|
export interface PaywallDialogProps {
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
children?:
|
children?: React.ReactNode;
|
||||||
| ((props: { isDisabled: boolean }) => React.ReactNode)
|
|
||||||
| React.ReactNode;
|
|
||||||
ctaText?: string;
|
ctaText?: string;
|
||||||
currentTier: 'free' | 'basic' | 'pro';
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
forceDisableContent?: boolean;
|
|
||||||
onDialogClose?: () => void;
|
onDialogClose?: () => void;
|
||||||
onUpgradeClick?: () => void;
|
onUpgradeClick?: () => void;
|
||||||
paywallContent?: ReactNode;
|
|
||||||
upgradeUrl?: string;
|
upgradeUrl?: string;
|
||||||
renderCustomPaywall?: (props: {
|
|
||||||
currentTier: 'free' | 'basic' | 'pro';
|
|
||||||
requiredTier: 'free' | 'basic' | 'pro';
|
|
||||||
onCancel: () => void;
|
|
||||||
onUpgrade: () => void;
|
|
||||||
}) => ReactNode;
|
|
||||||
requiredTier: 'free' | 'basic' | 'pro';
|
|
||||||
title?: string;
|
title?: string;
|
||||||
featureDescription?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tierValue = {
|
|
||||||
free: 0,
|
|
||||||
basic: 1,
|
|
||||||
pro: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PaywallDialog: React.FC<PaywallDialogProps> = ({
|
export const PaywallDialog: React.FC<PaywallDialogProps> = ({
|
||||||
cancelText = 'Maybe Later',
|
cancelText = 'Maybe Later',
|
||||||
children,
|
children,
|
||||||
open = false,
|
open = false,
|
||||||
ctaText = 'Upgrade to Pro',
|
ctaText = 'Upgrade Now',
|
||||||
currentTier,
|
description = 'This feature requires an upgrade to access.',
|
||||||
description = 'This feature requires a Pro subscription.',
|
|
||||||
forceDisableContent = false,
|
|
||||||
onDialogClose,
|
onDialogClose,
|
||||||
onUpgradeClick,
|
onUpgradeClick,
|
||||||
paywallContent,
|
upgradeUrl = '/settings/billing',
|
||||||
upgradeUrl = '/pricing',
|
title = 'Upgrade Required',
|
||||||
renderCustomPaywall,
|
|
||||||
requiredTier = 'pro',
|
|
||||||
title = 'Pro Feature',
|
|
||||||
featureDescription = 'Unlock advanced features with a Pro subscription',
|
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const hasRequiredTier = tierValue[currentTier] >= tierValue[requiredTier];
|
|
||||||
const isBlocked = !hasRequiredTier || forceDisableContent;
|
|
||||||
|
|
||||||
const handleUpgrade = useCallback(() => {
|
const handleUpgrade = useCallback(() => {
|
||||||
if (onUpgradeClick) {
|
if (onUpgradeClick) {
|
||||||
onUpgradeClick();
|
onUpgradeClick();
|
||||||
|
@ -91,167 +44,106 @@ export const PaywallDialog: React.FC<PaywallDialogProps> = ({
|
||||||
}, [onUpgradeClick, upgradeUrl]);
|
}, [onUpgradeClick, upgradeUrl]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setIsOpen(false);
|
|
||||||
if (onDialogClose) {
|
if (onDialogClose) {
|
||||||
onDialogClose();
|
onDialogClose();
|
||||||
}
|
}
|
||||||
}, [onDialogClose]);
|
}, [onDialogClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isBlocked && containerRef.current) {
|
return () => {
|
||||||
const overlay = document.createElement('div');
|
document.body.classList.remove('overflow-hidden');
|
||||||
overlay.style.position = 'absolute';
|
document.body.style.pointerEvents = 'auto';
|
||||||
overlay.style.top = '0';
|
|
||||||
overlay.style.left = '0';
|
const strayBackdrops = document.querySelectorAll('[data-backdrop]');
|
||||||
overlay.style.width = '100%';
|
strayBackdrops.forEach(element => element.remove());
|
||||||
overlay.style.height = '100%';
|
};
|
||||||
overlay.style.zIndex = '100';
|
}, []);
|
||||||
|
|
||||||
const showPaywall = (e: Event) => {
|
useEffect(() => {
|
||||||
e.preventDefault();
|
if (!open) {
|
||||||
e.stopPropagation();
|
document.body.classList.remove('overflow-hidden');
|
||||||
setIsOpen(true);
|
document.body.style.pointerEvents = 'auto';
|
||||||
};
|
|
||||||
|
|
||||||
overlay.addEventListener('click', showPaywall, true);
|
const overlays = document.querySelectorAll('[role="dialog"]');
|
||||||
overlay.addEventListener('dragenter', showPaywall, true);
|
overlays.forEach(overlay => {
|
||||||
overlay.addEventListener(
|
if (!overlay.closest('[open="true"]')) {
|
||||||
'dragover',
|
overlay.remove();
|
||||||
(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);
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
return undefined;
|
}, [open]);
|
||||||
}, [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 (
|
|
||||||
<Badge variant="outline" className="text-xs font-normal">
|
|
||||||
Free
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
case 'basic':
|
|
||||||
return (
|
|
||||||
<Badge variant="secondary" className="text-xs font-normal">
|
|
||||||
Basic
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
case 'pro':
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="bg-gradient-to-r from-indigo-500 to-purple-600 text-xs font-normal"
|
|
||||||
>
|
|
||||||
Pro
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
{children}
|
||||||
className={`w-full
|
|
||||||
${isBlocked ? 'relative' : ''}
|
<Dialog
|
||||||
`}
|
open={open}
|
||||||
ref={containerRef}
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
document.body.style.pointerEvents = 'auto';
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onDialogClose) {
|
||||||
|
onDialogClose();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{renderChildren()}
|
<DialogContent
|
||||||
</div>
|
className="sm:max-w-md"
|
||||||
|
onEscapeKeyDown={() => {
|
||||||
|
handleClose();
|
||||||
|
document.body.style.pointerEvents = 'auto';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<Dialog onOpenChange={setIsOpen} open={open}>
|
<div className="py-6">
|
||||||
<DialogContent className="sm:max-w-md">
|
<div className="flex flex-col items-center text-center space-y-4">
|
||||||
{renderCustomPaywall ? (
|
<div className="rounded-full bg-primary/10 p-3">
|
||||||
renderCustomPaywall({
|
<Rocket className="h-6 w-6 text-primary" />
|
||||||
currentTier,
|
|
||||||
requiredTier,
|
|
||||||
onCancel: handleClose,
|
|
||||||
onUpgrade: handleUpgrade,
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
{title}
|
|
||||||
<Crown className="h-5 w-5 text-yellow-500" />
|
|
||||||
</DialogTitle>
|
|
||||||
{requiredTier === 'pro' && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="bg-gradient-to-r from-indigo-500 to-purple-600"
|
|
||||||
>
|
|
||||||
Pro
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="py-8">
|
|
||||||
{paywallContent ?? (
|
|
||||||
<div className="flex flex-col items-center text-center space-y-6">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute -inset-1 rounded-full bg-gradient-to-r from-indigo-500 to-purple-600 opacity-75 blur"></div>
|
|
||||||
<div className="relative rounded-full p-3">
|
|
||||||
<Crown className="h-8 w-8 text-yellow-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-lg font-medium">Pro Feature</h3>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
Upgrade to Pro to avail this feature
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="bg-gradient-to-r from-indigo-500 to-purple-600 py-1 px-3 text-sm"
|
|
||||||
>
|
|
||||||
Pro Exclusive
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<Button onClick={handleClose} variant="outline">
|
<h3 className="text-lg font-medium">Upgrade to unlock</h3>
|
||||||
{cancelText}
|
<p className="text-muted-foreground">
|
||||||
</Button>
|
Get access to premium models and features
|
||||||
<Button
|
</p>
|
||||||
onClick={handleUpgrade}
|
</div>
|
||||||
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700"
|
</div>
|
||||||
>
|
</div>
|
||||||
<Rocket className="mr-2 h-4 w-4" />
|
|
||||||
{ctaText}
|
<DialogFooter className="flex items-center gap-2">
|
||||||
</Button>
|
<Button
|
||||||
</DialogFooter>
|
onClick={() => {
|
||||||
</>
|
document.body.style.pointerEvents = 'auto';
|
||||||
)}
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
document.body.style.pointerEvents = 'auto';
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
handleUpgrade();
|
||||||
|
}}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<Rocket className="h-4 w-4" />
|
||||||
|
{ctaText}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -57,7 +57,7 @@ const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||||
|
|
||||||
// Local storage keys
|
// Local storage keys
|
||||||
const STORAGE_KEY_MODEL = 'suna-preferred-model';
|
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 {
|
interface ChatInputProps {
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
|
|
|
@ -4,104 +4,97 @@ import { useSubscription } from '@/hooks/react-query/subscriptions/use-subscript
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export const STORAGE_KEY_MODEL = 'suna-preferred-model';
|
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 SubscriptionStatus = 'no_subscription' | 'active';
|
||||||
|
|
||||||
export type ModelTier = 'free' | 'base-only' | 'extra-only';
|
|
||||||
|
|
||||||
export interface ModelOption {
|
export interface ModelOption {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
tier: ModelTier;
|
requiresSubscription: boolean;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MODEL_OPTIONS: ModelOption[] = [
|
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 = (
|
export const canAccessModel = (
|
||||||
subscriptionTier: SubscriptionTier,
|
subscriptionStatus: SubscriptionStatus,
|
||||||
modelTier: ModelTier,
|
requiresSubscription: boolean,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
switch (modelTier) {
|
return subscriptionStatus === 'active' || !requiresSubscription;
|
||||||
case 'free':
|
|
||||||
return true;
|
|
||||||
case 'base-only':
|
|
||||||
return subscriptionTier === 'base' || subscriptionTier === 'extra';
|
|
||||||
case 'extra-only':
|
|
||||||
return subscriptionTier === 'extra';
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useModelSelection = () => {
|
export const useModelSelection = () => {
|
||||||
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID);
|
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID);
|
||||||
|
|
||||||
const { data: subscriptionData } = useSubscription();
|
const { data: subscriptionData } = useSubscription();
|
||||||
const subscriptionTier: SubscriptionTier = (() => {
|
|
||||||
if (!subscriptionData) return 'free';
|
const subscriptionStatus: SubscriptionStatus = subscriptionData?.status === 'active'
|
||||||
if (subscriptionData.plan_name === 'free') return 'free';
|
? 'active'
|
||||||
if (subscriptionData.plan_name === 'base') return 'base';
|
: 'no_subscription';
|
||||||
if (subscriptionData.plan_name === 'extra') return 'extra';
|
|
||||||
return 'free';
|
|
||||||
})();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window === 'undefined') return;
|
||||||
try {
|
|
||||||
const savedModel = localStorage.getItem(STORAGE_KEY_MODEL);
|
try {
|
||||||
if (
|
const savedModel = localStorage.getItem(STORAGE_KEY_MODEL);
|
||||||
savedModel &&
|
if (!savedModel) return;
|
||||||
MODEL_OPTIONS.some((option) => option.id === savedModel)
|
|
||||||
) {
|
const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel);
|
||||||
const modelOption = MODEL_OPTIONS.find(
|
|
||||||
(option) => option.id === savedModel,
|
if (modelOption && canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) {
|
||||||
);
|
setSelectedModel(savedModel);
|
||||||
if (
|
} else {
|
||||||
modelOption &&
|
localStorage.removeItem(STORAGE_KEY_MODEL);
|
||||||
canAccessModel(subscriptionTier, modelOption.tier)
|
setSelectedModel(DEFAULT_MODEL_ID);
|
||||||
) {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load preferences from localStorage:', error);
|
||||||
}
|
}
|
||||||
}, [subscriptionTier]);
|
}, [subscriptionStatus]);
|
||||||
|
|
||||||
const handleModelChange = (value: string) => {
|
const handleModelChange = (modelId: string) => {
|
||||||
const modelOption = MODEL_OPTIONS.find((option) => option.id === value);
|
const modelOption = MODEL_OPTIONS.find(option => option.id === modelId);
|
||||||
if (modelOption && canAccessModel(subscriptionTier, modelOption.tier)) {
|
|
||||||
setSelectedModel(value);
|
if (!modelOption || !canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) {
|
||||||
try {
|
return;
|
||||||
localStorage.setItem(STORAGE_KEY_MODEL, value);
|
}
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to save model preference to localStorage:', error);
|
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 {
|
return {
|
||||||
selectedModel,
|
selectedModel,
|
||||||
setSelectedModel: handleModelChange,
|
setSelectedModel: handleModelChange,
|
||||||
subscriptionTier,
|
subscriptionStatus,
|
||||||
availableModels,
|
availableModels: MODEL_OPTIONS.filter(model =>
|
||||||
|
canAccessModel(subscriptionStatus, model.requiresSubscription)
|
||||||
|
),
|
||||||
allModels: MODEL_OPTIONS,
|
allModels: MODEL_OPTIONS,
|
||||||
canAccessModel: (modelId: string) => {
|
canAccessModel: (modelId: string) => {
|
||||||
const model = MODEL_OPTIONS.find((m) => m.id === modelId);
|
const model = MODEL_OPTIONS.find(m => m.id === modelId);
|
||||||
return model ? canAccessModel(subscriptionTier, model.tier) : false;
|
return model ? canAccessModel(subscriptionStatus, model.requiresSubscription) : false;
|
||||||
},
|
},
|
||||||
|
isSubscriptionRequired: (modelId: string) => {
|
||||||
|
return MODEL_OPTIONS.find(m => m.id === modelId)?.requiresSubscription || false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
|
@ -76,7 +76,7 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
||||||
const {
|
const {
|
||||||
selectedModel,
|
selectedModel,
|
||||||
setSelectedModel: handleModelChange,
|
setSelectedModel: handleModelChange,
|
||||||
subscriptionTier,
|
subscriptionStatus,
|
||||||
allModels: modelOptions,
|
allModels: modelOptions,
|
||||||
canAccessModel,
|
canAccessModel,
|
||||||
} = useModelSelection();
|
} = useModelSelection();
|
||||||
|
@ -188,8 +188,8 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full bg-muted/30 text-sm flex flex-col justify-between items-start rounded-lg border-b">
|
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
|
||||||
<CardContent className="shadow w-full p-1.5 pb-2 pt-3 bg-sidebar rounded-2xl border">
|
<CardContent className="w-full p-1.5 pb-2 pt-3 bg-sidebar rounded-2xl border">
|
||||||
<UploadedFilesDisplay
|
<UploadedFilesDisplay
|
||||||
uploadedFiles={uploadedFiles}
|
uploadedFiles={uploadedFiles}
|
||||||
sandboxId={sandboxId}
|
sandboxId={sandboxId}
|
||||||
|
@ -220,7 +220,7 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onModelChange={handleModelChange}
|
onModelChange={handleModelChange}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
currentTier={subscriptionTier}
|
subscriptionStatus={subscriptionStatus}
|
||||||
canAccessModel={canAccessModel}
|
canAccessModel={canAccessModel}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
@ -6,7 +6,6 @@ import { cn } from '@/lib/utils';
|
||||||
import { UploadedFile } from './chat-input';
|
import { UploadedFile } from './chat-input';
|
||||||
import { FileUploadHandler } from './file-upload-handler';
|
import { FileUploadHandler } from './file-upload-handler';
|
||||||
import { ModelSelector } from './model-selector';
|
import { ModelSelector } from './model-selector';
|
||||||
import { useModelSelection } from './_use-model-selection';
|
|
||||||
|
|
||||||
interface MessageInputProps {
|
interface MessageInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -31,7 +30,7 @@ interface MessageInputProps {
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onModelChange: (model: string) => void;
|
onModelChange: (model: string) => void;
|
||||||
modelOptions: any[];
|
modelOptions: any[];
|
||||||
currentTier: string;
|
subscriptionStatus: string;
|
||||||
canAccessModel: (model: string) => boolean;
|
canAccessModel: (model: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +59,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
modelOptions,
|
modelOptions,
|
||||||
currentTier,
|
subscriptionStatus,
|
||||||
canAccessModel,
|
canAccessModel,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
|
@ -87,10 +86,6 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||||
return () => window.removeEventListener('resize', adjustHeight);
|
return () => window.removeEventListener('resize', adjustHeight);
|
||||||
}, [value, ref]);
|
}, [value, ref]);
|
||||||
|
|
||||||
const {
|
|
||||||
subscriptionTier,
|
|
||||||
} = useModelSelection();
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -145,7 +140,6 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onModelChange={onModelChange}
|
onModelChange={onModelChange}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
currentTier={subscriptionTier}
|
|
||||||
canAccessModel={canAccessModel}
|
canAccessModel={canAccessModel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
@ -14,14 +14,14 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Check, ChevronDown, Crown, LockIcon } from 'lucide-react';
|
import { Check, ChevronDown, LockIcon, ZapIcon } from 'lucide-react';
|
||||||
import { ModelOption, SubscriptionTier } from './_use-model-selection';
|
import { ModelOption, SubscriptionStatus } from './_use-model-selection';
|
||||||
|
import { PaywallDialog } from '@/components/payment/paywall-dialog';
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onModelChange: (modelId: string) => void;
|
onModelChange: (modelId: string) => void;
|
||||||
modelOptions: ModelOption[];
|
modelOptions: ModelOption[];
|
||||||
currentTier: SubscriptionTier;
|
|
||||||
canAccessModel: (modelId: string) => boolean;
|
canAccessModel: (modelId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,33 +29,26 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
modelOptions,
|
modelOptions,
|
||||||
currentTier,
|
|
||||||
canAccessModel,
|
canAccessModel,
|
||||||
}) => {
|
}) => {
|
||||||
const selectedModelLabel =
|
const [paywallOpen, setPaywallOpen] = useState(false);
|
||||||
modelOptions.find((option) => option.id === selectedModel)?.label || '';
|
const [lockedModel, setLockedModel] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleModelSelection = (modelId: string) => {
|
const selectedLabel =
|
||||||
if (canAccessModel(modelId)) {
|
modelOptions.find((o) => o.id === selectedModel)?.label || 'Select model';
|
||||||
onModelChange(modelId);
|
|
||||||
|
const handleSelect = (id: string) => {
|
||||||
|
if (canAccessModel(id)) {
|
||||||
|
onModelChange(id);
|
||||||
|
} else {
|
||||||
|
setLockedModel(id);
|
||||||
|
setPaywallOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getModelTierInfo = (modelTier: string) => {
|
const closeDialog = () => {
|
||||||
switch (modelTier) {
|
setPaywallOpen(false);
|
||||||
case 'base-only':
|
setLockedModel(null);
|
||||||
return {
|
|
||||||
icon: <Crown className="h-3 w-3 text-blue-500" />,
|
|
||||||
tooltip: 'Requires Pro plan or higher',
|
|
||||||
};
|
|
||||||
case 'extra-only':
|
|
||||||
return {
|
|
||||||
icon: <Crown className="h-3 w-3 text-yellow-500" />,
|
|
||||||
tooltip: 'Requires Pro plan',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return { icon: null, tooltip: null };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -64,43 +57,49 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size='default'
|
size="default"
|
||||||
className="h-7 rounded-md text-muted-foreground shadow-none border-none focus:ring-0 w-auto px-2 py-0"
|
className="h-8 rounded-md text-muted-foreground shadow-none border-none focus:ring-0 px-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-1 text-sm font-medium">
|
||||||
<span>{selectedModelLabel}</span>
|
<span>{selectedLabel}</span>
|
||||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
<ChevronDown className="h-3 w-3 opacity-50 ml-1" />
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
|
||||||
{modelOptions.map((option) => {
|
|
||||||
const { icon, tooltip } = getModelTierInfo(option.tier);
|
|
||||||
const isAccessible = canAccessModel(option.id);
|
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" className="w-64 p-1">
|
||||||
|
{modelOptions.map((opt) => {
|
||||||
|
const accessible = canAccessModel(opt.id);
|
||||||
return (
|
return (
|
||||||
<TooltipProvider key={option.id}>
|
<TooltipProvider key={opt.id}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className={`text-sm flex items-center justify-between cursor-pointer ${
|
className="text-sm py-3 px-3 flex items-start cursor-pointer rounded-md"
|
||||||
!isAccessible ? 'opacity-50' : ''
|
onClick={() => handleSelect(opt.id)}
|
||||||
}`}
|
|
||||||
onClick={() => handleModelSelection(option.id)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col w-full">
|
||||||
{option.label}
|
<div className="flex items-center justify-between w-full">
|
||||||
{icon}
|
<div className="flex items-center gap-2">
|
||||||
{!isAccessible && <LockIcon className="h-3 w-3 ml-1" />}
|
{opt.id === 'sonnet-3.7' && (
|
||||||
|
<ZapIcon className="h-4 w-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium">{opt.label}</span>
|
||||||
|
{!accessible && <LockIcon className="h-3 w-3 ml-1 text-gray-400" />}
|
||||||
|
</div>
|
||||||
|
{selectedModel === opt.id && (
|
||||||
|
<Check className="h-4 w-4 text-blue-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{opt.description}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedModel === option.id && (
|
|
||||||
<Check className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{tooltip && (
|
{!accessible && (
|
||||||
<TooltipContent side="left" className="text-xs">
|
<TooltipContent side="left" className="text-xs">
|
||||||
<p>{tooltip}</p>
|
<p>Requires subscription to access</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
)}
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -109,6 +108,23 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||||
})}
|
})}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{paywallOpen && (
|
||||||
|
<PaywallDialog
|
||||||
|
open={true}
|
||||||
|
onDialogClose={closeDialog}
|
||||||
|
title="Premium Model"
|
||||||
|
description={
|
||||||
|
lockedModel
|
||||||
|
? `Subscribe to access ${modelOptions.find(
|
||||||
|
(m) => m.id === lockedModel
|
||||||
|
)?.label}`
|
||||||
|
: 'Subscribe to access premium models'
|
||||||
|
}
|
||||||
|
ctaText="Subscribe Now"
|
||||||
|
cancelText="Maybe Later"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -111,8 +111,8 @@ export const siteConfig = {
|
||||||
buttonText: 'Hire Suna',
|
buttonText: 'Hire Suna',
|
||||||
buttonColor: 'bg-secondary text-white',
|
buttonColor: 'bg-secondary text-white',
|
||||||
isPopular: false,
|
isPopular: false,
|
||||||
hours: '10 min',
|
hours: '60 min',
|
||||||
features: ['Public Projects'],
|
features: ['Public Projects', 'Basic Model (Limited capabilities)'],
|
||||||
stripePriceId: config.SUBSCRIPTION_TIERS.FREE.priceId,
|
stripePriceId: config.SUBSCRIPTION_TIERS.FREE.priceId,
|
||||||
upgradePlans: [],
|
upgradePlans: [],
|
||||||
},
|
},
|
||||||
|
@ -127,7 +127,7 @@ export const siteConfig = {
|
||||||
features: [
|
features: [
|
||||||
'2 hours',
|
'2 hours',
|
||||||
'Private projects',
|
'Private projects',
|
||||||
'Team functionality (coming soon)',
|
'Access to intelligent Model (Full Suna)',
|
||||||
],
|
],
|
||||||
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_2_20.priceId,
|
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_2_20.priceId,
|
||||||
upgradePlans: [],
|
upgradePlans: [],
|
||||||
|
@ -140,7 +140,7 @@ export const siteConfig = {
|
||||||
buttonColor: 'bg-secondary text-white',
|
buttonColor: 'bg-secondary text-white',
|
||||||
isPopular: false,
|
isPopular: false,
|
||||||
hours: '6 hours',
|
hours: '6 hours',
|
||||||
features: ['Unlimited seats'],
|
features: ['Suited to you needs'],
|
||||||
upgradePlans: [
|
upgradePlans: [
|
||||||
{
|
{
|
||||||
hours: '6 hours',
|
hours: '6 hours',
|
||||||
|
|
Loading…
Reference in New Issue