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",
|
||||
"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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -147,7 +147,7 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
|
|||
<Button
|
||||
onClick={handleManageSubscription}
|
||||
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'}
|
||||
</Button>
|
||||
|
|
|
@ -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<PaywallDialogProps> = ({
|
||||
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<HTMLDivElement>(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<PaywallDialogProps> = ({
|
|||
}, [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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`w-full
|
||||
${isBlocked ? 'relative' : ''}
|
||||
`}
|
||||
ref={containerRef}
|
||||
{children}
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
document.body.style.pointerEvents = 'auto';
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
|
||||
setTimeout(() => {
|
||||
if (onDialogClose) {
|
||||
onDialogClose();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderChildren()}
|
||||
</div>
|
||||
<DialogContent
|
||||
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}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{renderCustomPaywall ? (
|
||||
renderCustomPaywall({
|
||||
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 className="py-6">
|
||||
<div className="flex flex-col items-center text-center space-y-4">
|
||||
<div className="rounded-full bg-primary/10 p-3">
|
||||
<Rocket className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex items-center gap-2">
|
||||
<Button onClick={handleClose} variant="outline">
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpgrade}
|
||||
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700"
|
||||
>
|
||||
<Rocket className="mr-2 h-4 w-4" />
|
||||
{ctaText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Upgrade to unlock</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Get access to premium models and features
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex items-center gap-2">
|
||||
<Button
|
||||
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>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
|
@ -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: (
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
|
@ -76,7 +76,7 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
const {
|
||||
selectedModel,
|
||||
setSelectedModel: handleModelChange,
|
||||
subscriptionTier,
|
||||
subscriptionStatus,
|
||||
allModels: modelOptions,
|
||||
canAccessModel,
|
||||
} = 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">
|
||||
<CardContent className="shadow w-full p-1.5 pb-2 pt-3 bg-sidebar rounded-2xl border">
|
||||
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
|
||||
<CardContent className="w-full p-1.5 pb-2 pt-3 bg-sidebar rounded-2xl border">
|
||||
<UploadedFilesDisplay
|
||||
uploadedFiles={uploadedFiles}
|
||||
sandboxId={sandboxId}
|
||||
|
@ -220,7 +220,7 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
modelOptions={modelOptions}
|
||||
currentTier={subscriptionTier}
|
||||
subscriptionStatus={subscriptionStatus}
|
||||
canAccessModel={canAccessModel}
|
||||
/>
|
||||
</CardContent>
|
||||
|
|
|
@ -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<HTMLTextAreaElement, MessageInputProps>(
|
|||
selectedModel,
|
||||
onModelChange,
|
||||
modelOptions,
|
||||
currentTier,
|
||||
subscriptionStatus,
|
||||
canAccessModel,
|
||||
},
|
||||
ref,
|
||||
|
@ -87,10 +86,6 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [value, ref]);
|
||||
|
||||
const {
|
||||
subscriptionTier,
|
||||
} = useModelSelection();
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
@ -145,7 +140,6 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
modelOptions={modelOptions}
|
||||
currentTier={subscriptionTier}
|
||||
canAccessModel={canAccessModel}
|
||||
/>
|
||||
<Button
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
@ -14,14 +14,14 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check, ChevronDown, Crown, LockIcon } from 'lucide-react';
|
||||
import { ModelOption, SubscriptionTier } from './_use-model-selection';
|
||||
import { Check, ChevronDown, LockIcon, ZapIcon } from 'lucide-react';
|
||||
import { ModelOption, SubscriptionStatus } from './_use-model-selection';
|
||||
import { PaywallDialog } from '@/components/payment/paywall-dialog';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string;
|
||||
onModelChange: (modelId: string) => void;
|
||||
modelOptions: ModelOption[];
|
||||
currentTier: SubscriptionTier;
|
||||
canAccessModel: (modelId: string) => boolean;
|
||||
}
|
||||
|
||||
|
@ -29,33 +29,26 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
selectedModel,
|
||||
onModelChange,
|
||||
modelOptions,
|
||||
currentTier,
|
||||
canAccessModel,
|
||||
}) => {
|
||||
const selectedModelLabel =
|
||||
modelOptions.find((option) => option.id === selectedModel)?.label || '';
|
||||
const [paywallOpen, setPaywallOpen] = useState(false);
|
||||
const [lockedModel, setLockedModel] = useState<string | null>(null);
|
||||
|
||||
const handleModelSelection = (modelId: string) => {
|
||||
if (canAccessModel(modelId)) {
|
||||
onModelChange(modelId);
|
||||
const selectedLabel =
|
||||
modelOptions.find((o) => o.id === selectedModel)?.label || 'Select model';
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
if (canAccessModel(id)) {
|
||||
onModelChange(id);
|
||||
} else {
|
||||
setLockedModel(id);
|
||||
setPaywallOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const getModelTierInfo = (modelTier: string) => {
|
||||
switch (modelTier) {
|
||||
case 'base-only':
|
||||
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 };
|
||||
}
|
||||
const closeDialog = () => {
|
||||
setPaywallOpen(false);
|
||||
setLockedModel(null);
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -64,43 +57,49 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size='default'
|
||||
className="h-7 rounded-md text-muted-foreground shadow-none border-none focus:ring-0 w-auto px-2 py-0"
|
||||
size="default"
|
||||
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">
|
||||
<span>{selectedModelLabel}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
<div className="flex items-center gap-1 text-sm font-medium">
|
||||
<span>{selectedLabel}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50 ml-1" />
|
||||
</div>
|
||||
</Button>
|
||||
</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 (
|
||||
<TooltipProvider key={option.id}>
|
||||
<TooltipProvider key={opt.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className={`text-sm flex items-center justify-between cursor-pointer ${
|
||||
!isAccessible ? 'opacity-50' : ''
|
||||
}`}
|
||||
onClick={() => handleModelSelection(option.id)}
|
||||
className="text-sm py-3 px-3 flex items-start cursor-pointer rounded-md"
|
||||
onClick={() => handleSelect(opt.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.label}
|
||||
{icon}
|
||||
{!isAccessible && <LockIcon className="h-3 w-3 ml-1" />}
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
{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>
|
||||
{selectedModel === option.id && (
|
||||
<Check className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
{tooltip && (
|
||||
{!accessible && (
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
<p>{tooltip}</p>
|
||||
<p>Requires subscription to access</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
@ -109,6 +108,23 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
})}
|
||||
</DropdownMenuContent>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
};
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue