mirror of https://github.com/kortix-ai/suna.git
fix: custom modal handeling
This commit is contained in:
parent
d7bb27c73a
commit
b16a452920
|
@ -1067,7 +1067,7 @@ export default function ThreadPage({
|
|||
if (initialLoadCompleted.current && subscriptionData) {
|
||||
const hasSeenUpgradeDialog = localStorage.getItem('suna_upgrade_dialog_displayed');
|
||||
const isFreeTier = subscriptionStatus === 'no_subscription';
|
||||
if (!hasSeenUpgradeDialog && isFreeTier) {
|
||||
if (!hasSeenUpgradeDialog && isFreeTier && !isLocalMode()) {
|
||||
setShowUpgradeDialog(true);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-model'
|
|||
|
||||
export const STORAGE_KEY_MODEL = 'suna-preferred-model';
|
||||
export const STORAGE_KEY_CUSTOM_MODELS = 'customModels';
|
||||
export const DEFAULT_FREE_MODEL_ID = 'deepseek';
|
||||
export const DEFAULT_FREE_MODEL_ID = 'qwen3';
|
||||
export const DEFAULT_PREMIUM_MODEL_ID = 'sonnet-3.7';
|
||||
|
||||
export type SubscriptionStatus = 'no_subscription' | 'active';
|
||||
|
@ -19,6 +19,7 @@ export interface ModelOption {
|
|||
description?: string;
|
||||
top?: boolean;
|
||||
isCustom?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface CustomModel {
|
||||
|
@ -26,20 +27,156 @@ export interface CustomModel {
|
|||
label: string;
|
||||
}
|
||||
|
||||
const MODEL_DESCRIPTIONS: Record<string, string> = {
|
||||
'sonnet-3.7': 'Claude 3.7 Sonnet - Anthropic\'s powerful general-purpose AI assistant',
|
||||
'gpt-4.1': 'GPT-4.1 - OpenAI\'s most advanced model with enhanced reasoning',
|
||||
'gpt-4o': 'GPT-4o - Optimized for speed, reliability, and cost-effectiveness',
|
||||
'gpt-4-turbo': 'GPT-4 Turbo - OpenAI\'s powerful model with a great balance of performance and cost',
|
||||
'gpt-4': 'GPT-4 - OpenAI\'s highly capable model with advanced reasoning',
|
||||
'gemini-flash-2.5': 'Gemini Flash 2.5 - Google\'s fast, responsive AI model',
|
||||
'grok-3': 'Grok-3 - xAI\'s latest large language model with enhanced capabilities',
|
||||
'deepseek': 'DeepSeek - Free tier model with good general capabilities',
|
||||
'deepseek-r1': 'DeepSeek R1 - Advanced model with enhanced reasoning and coding capabilities',
|
||||
'grok-3-mini': 'Grok-3 Mini - Smaller, faster version of Grok-3 for simpler tasks',
|
||||
'qwen3': 'Qwen3 - Alibaba\'s powerful multilingual language model'
|
||||
// SINGLE SOURCE OF TRUTH for all model data
|
||||
export const MODELS = {
|
||||
// Premium high-priority models
|
||||
'sonnet-3.7': {
|
||||
tier: 'premium',
|
||||
priority: 100,
|
||||
recommended: true,
|
||||
lowQuality: false,
|
||||
description: 'Claude 3.7 Sonnet - Anthropic\'s powerful general-purpose AI assistant'
|
||||
},
|
||||
'claude-3.7': {
|
||||
tier: 'premium',
|
||||
priority: 100,
|
||||
recommended: true,
|
||||
lowQuality: false,
|
||||
description: 'Claude 3.7 - Anthropic\'s most powerful AI assistant'
|
||||
},
|
||||
'claude-3.7-reasoning': {
|
||||
tier: 'premium',
|
||||
priority: 100,
|
||||
recommended: true,
|
||||
lowQuality: false,
|
||||
description: 'Claude 3.7 with enhanced reasoning capabilities'
|
||||
},
|
||||
'gpt-4.1': {
|
||||
tier: 'premium',
|
||||
priority: 95,
|
||||
recommended: true,
|
||||
lowQuality: false,
|
||||
description: 'GPT-4.1 - OpenAI\'s most advanced model with enhanced reasoning'
|
||||
},
|
||||
'gemini-2.5-pro-preview': {
|
||||
tier: 'premium',
|
||||
priority: 95,
|
||||
recommended: true,
|
||||
lowQuality: false,
|
||||
description: 'Gemini Pro 2.5 - Google\'s latest powerful model with strong reasoning'
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
tier: 'premium',
|
||||
priority: 95,
|
||||
recommended: true,
|
||||
lowQuality: false,
|
||||
description: 'Gemini Pro 2.5 - Google\'s latest advanced model'
|
||||
},
|
||||
'claude-3.5': {
|
||||
tier: 'premium',
|
||||
priority: 90,
|
||||
recommended: true,
|
||||
lowQuality: false,
|
||||
description: 'Claude 3.5 - Anthropic\'s balanced model with solid capabilities'
|
||||
},
|
||||
'gemini-2.5': {
|
||||
tier: 'premium',
|
||||
priority: 90,
|
||||
recommended: true,
|
||||
lowQuality: false,
|
||||
description: 'Gemini 2.5 - Google\'s powerful versatile model'
|
||||
},
|
||||
'gemini-2.5-flash-preview': {
|
||||
tier: 'premium',
|
||||
priority: 90,
|
||||
recommended: true,
|
||||
lowQuality: false,
|
||||
description: 'Gemini Flash 2.5 - Google\'s fast, responsive AI model'
|
||||
},
|
||||
'gpt-4o': {
|
||||
tier: 'premium',
|
||||
priority: 85,
|
||||
recommended: false,
|
||||
lowQuality: false,
|
||||
description: 'GPT-4o - Optimized for speed, reliability, and cost-effectiveness'
|
||||
},
|
||||
'gpt-4-turbo': {
|
||||
tier: 'premium',
|
||||
priority: 85,
|
||||
recommended: false,
|
||||
lowQuality: false,
|
||||
description: 'GPT-4 Turbo - OpenAI\'s powerful model with a great balance of performance and cost'
|
||||
},
|
||||
'gpt-4': {
|
||||
tier: 'premium',
|
||||
priority: 80,
|
||||
recommended: false,
|
||||
lowQuality: false,
|
||||
description: 'GPT-4 - OpenAI\'s highly capable model with advanced reasoning'
|
||||
},
|
||||
'deepseek-chat-v3-0324': {
|
||||
tier: 'premium',
|
||||
priority: 75,
|
||||
recommended: true,
|
||||
lowQuality: false,
|
||||
description: 'DeepSeek Chat - Advanced AI assistant with strong reasoning'
|
||||
},
|
||||
|
||||
// Free tier models
|
||||
'deepseek-r1': {
|
||||
tier: 'free',
|
||||
priority: 60,
|
||||
recommended: false,
|
||||
lowQuality: false,
|
||||
description: 'DeepSeek R1 - Advanced model with enhanced reasoning and coding capabilities'
|
||||
},
|
||||
'deepseek': {
|
||||
tier: 'free',
|
||||
priority: 50,
|
||||
recommended: false,
|
||||
lowQuality: true,
|
||||
description: 'DeepSeek - Free tier model with good general capabilities'
|
||||
},
|
||||
'google/gemini-2.5-flash-preview': {
|
||||
tier: 'free',
|
||||
priority: 50,
|
||||
recommended: false,
|
||||
lowQuality: true,
|
||||
description: 'Gemini Flash - Google\'s faster, more efficient model'
|
||||
},
|
||||
'grok-3-mini': {
|
||||
tier: 'free',
|
||||
priority: 45,
|
||||
recommended: false,
|
||||
lowQuality: true,
|
||||
description: 'Grok-3 Mini - Smaller, faster version of Grok-3 for simpler tasks'
|
||||
},
|
||||
'qwen3': {
|
||||
tier: 'free',
|
||||
priority: 40,
|
||||
recommended: false,
|
||||
lowQuality: true,
|
||||
description: 'Qwen3 - Alibaba\'s powerful multilingual language model'
|
||||
},
|
||||
};
|
||||
|
||||
// Model tier definitions
|
||||
export const MODEL_TIERS = {
|
||||
premium: {
|
||||
requiresSubscription: true,
|
||||
baseDescription: 'Advanced model with superior capabilities'
|
||||
},
|
||||
free: {
|
||||
requiresSubscription: false,
|
||||
baseDescription: 'Available to all users'
|
||||
},
|
||||
custom: {
|
||||
requiresSubscription: false,
|
||||
baseDescription: 'User-defined model'
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to check if a user can access a model based on subscription status
|
||||
export const canAccessModel = (
|
||||
subscriptionStatus: SubscriptionStatus,
|
||||
requiresSubscription: boolean,
|
||||
|
@ -67,8 +204,8 @@ export const getPrefixedModelId = (modelId: string, isCustom: boolean): string =
|
|||
};
|
||||
|
||||
// Helper to get custom models from localStorage
|
||||
export const getCustomModels = () => {
|
||||
if (!isLocalMode()) return [];
|
||||
export const getCustomModels = (): CustomModel[] => {
|
||||
if (!isLocalMode() || typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const storedModels = localStorage.getItem(STORAGE_KEY_CUSTOM_MODELS);
|
||||
|
@ -77,32 +214,31 @@ export const getCustomModels = () => {
|
|||
const parsedModels = JSON.parse(storedModels);
|
||||
if (!Array.isArray(parsedModels)) return [];
|
||||
|
||||
// Ensure all custom models have openrouter/ prefix
|
||||
return parsedModels
|
||||
.filter((model: any) =>
|
||||
model && typeof model === 'object' &&
|
||||
typeof model.id === 'string' &&
|
||||
typeof model.label === 'string')
|
||||
.map((model: any) => ({
|
||||
...model,
|
||||
id: model.id.startsWith('openrouter/') ? model.id : `openrouter/${model.id}`
|
||||
}));
|
||||
typeof model.label === 'string');
|
||||
} catch (e) {
|
||||
console.error('Error parsing custom models:', e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to save model preference to localStorage safely
|
||||
const saveModelPreference = (modelId: string): void => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_MODEL, modelId);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save model preference to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const useModelSelection = () => {
|
||||
const [selectedModel, setSelectedModel] = useState(DEFAULT_FREE_MODEL_ID);
|
||||
const [customModels, setCustomModels] = useState<CustomModel[]>([]);
|
||||
|
||||
const { data: subscriptionData } = useSubscription();
|
||||
//MOCK
|
||||
// const subscriptionData = {
|
||||
// status: 'no_subscription'
|
||||
// };
|
||||
|
||||
const { data: modelsData, isLoading: isLoadingModels } = useAvailableModels({
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
@ -114,41 +250,39 @@ export const useModelSelection = () => {
|
|||
// Load custom models from localStorage
|
||||
useEffect(() => {
|
||||
if (isLocalMode() && typeof window !== 'undefined') {
|
||||
const savedCustomModels = localStorage.getItem(STORAGE_KEY_CUSTOM_MODELS);
|
||||
if (savedCustomModels) {
|
||||
try {
|
||||
setCustomModels(JSON.parse(savedCustomModels));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse custom models from localStorage', e);
|
||||
}
|
||||
}
|
||||
setCustomModels(getCustomModels());
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Generate model options list with consistent structure
|
||||
const MODEL_OPTIONS = useMemo(() => {
|
||||
let baseModels = [];
|
||||
let models = [];
|
||||
|
||||
// Default models if API data not available
|
||||
if (!modelsData?.models || isLoadingModels) {
|
||||
baseModels = [
|
||||
models = [
|
||||
{
|
||||
id: DEFAULT_FREE_MODEL_ID,
|
||||
label: 'DeepSeek',
|
||||
label: 'Qwen3',
|
||||
requiresSubscription: false,
|
||||
description: 'Free tier model with good general capabilities.'
|
||||
description: MODELS[DEFAULT_FREE_MODEL_ID]?.description || MODEL_TIERS.free.baseDescription,
|
||||
priority: MODELS[DEFAULT_FREE_MODEL_ID]?.priority || 50
|
||||
},
|
||||
{
|
||||
id: DEFAULT_PREMIUM_MODEL_ID,
|
||||
label: 'Claude 3.7 Sonnet',
|
||||
label: 'Sonnet 3.7',
|
||||
requiresSubscription: true,
|
||||
description: 'Anthropic\'s powerful general-purpose AI assistant.'
|
||||
description: MODELS[DEFAULT_PREMIUM_MODEL_ID]?.description || MODEL_TIERS.premium.baseDescription,
|
||||
priority: MODELS[DEFAULT_PREMIUM_MODEL_ID]?.priority || 100
|
||||
},
|
||||
];
|
||||
} else {
|
||||
const topModels = ['sonnet-3.7', 'gemini-flash-2.5'];
|
||||
baseModels = modelsData.models.map(model => {
|
||||
// Process API-provided models
|
||||
models = modelsData.models.map(model => {
|
||||
const shortName = model.short_name || model.id;
|
||||
const displayName = model.display_name || shortName;
|
||||
|
||||
// Format the display label
|
||||
let cleanLabel = displayName;
|
||||
if (cleanLabel.includes('/')) {
|
||||
cleanLabel = cleanLabel.split('/').pop() || cleanLabel;
|
||||
|
@ -160,16 +294,20 @@ export const useModelSelection = () => {
|
|||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
const isPremium = model?.requires_subscription || false;
|
||||
// Get model data from our central MODELS constant
|
||||
const modelData = MODELS[shortName] || {};
|
||||
const isPremium = model?.requires_subscription || modelData.tier === 'premium' || false;
|
||||
|
||||
return {
|
||||
id: shortName,
|
||||
label: cleanLabel,
|
||||
requiresSubscription: isPremium,
|
||||
description: MODEL_DESCRIPTIONS[shortName] ||
|
||||
(isPremium
|
||||
? 'Premium model with advanced capabilities'
|
||||
: 'Free tier model with good general capabilities'),
|
||||
top: topModels.includes(shortName)
|
||||
description: modelData.description ||
|
||||
(isPremium ? MODEL_TIERS.premium.baseDescription : MODEL_TIERS.free.baseDescription),
|
||||
top: modelData.priority >= 90, // Mark high-priority models as "top"
|
||||
priority: modelData.priority || 0,
|
||||
lowQuality: modelData.lowQuality || false,
|
||||
recommended: modelData.recommended || false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -180,97 +318,38 @@ export const useModelSelection = () => {
|
|||
id: model.id,
|
||||
label: model.label || formatModelName(model.id),
|
||||
requiresSubscription: false,
|
||||
description: 'Custom model',
|
||||
description: MODEL_TIERS.custom.baseDescription,
|
||||
top: false,
|
||||
isCustom: true
|
||||
isCustom: true,
|
||||
priority: 30, // Low priority by default
|
||||
lowQuality: false,
|
||||
recommended: false
|
||||
}));
|
||||
|
||||
return [...baseModels, ...customModelOptions];
|
||||
models = [...models, ...customModelOptions];
|
||||
}
|
||||
|
||||
return baseModels;
|
||||
// Sort models consistently in one place:
|
||||
// 1. First by free/premium (free first)
|
||||
// 2. Then by priority (higher first)
|
||||
// 3. Finally by name (alphabetical)
|
||||
return models.sort((a, b) => {
|
||||
// First by free/premium status
|
||||
if (a.requiresSubscription !== b.requiresSubscription) {
|
||||
return a.requiresSubscription ? 1 : -1;
|
||||
}
|
||||
|
||||
// Then by priority (higher first)
|
||||
if (a.priority !== b.priority) {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
|
||||
// Finally by name
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [modelsData, isLoadingModels, customModels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const savedModel = localStorage.getItem(STORAGE_KEY_MODEL);
|
||||
if (isLocalMode()) {
|
||||
if (savedModel && MODEL_OPTIONS.find(option => option.id === savedModel)) {
|
||||
setSelectedModel(savedModel);
|
||||
} else {
|
||||
setSelectedModel(DEFAULT_PREMIUM_MODEL_ID);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_MODEL, DEFAULT_PREMIUM_MODEL_ID);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save model preference to localStorage:', error);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscriptionStatus === 'active') {
|
||||
if (savedModel) {
|
||||
const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel);
|
||||
if (modelOption && canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) {
|
||||
setSelectedModel(savedModel);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedModel(DEFAULT_PREMIUM_MODEL_ID);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_MODEL, DEFAULT_PREMIUM_MODEL_ID);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save model preference to localStorage:', error);
|
||||
}
|
||||
}
|
||||
else if (savedModel) {
|
||||
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_FREE_MODEL_ID);
|
||||
}
|
||||
}
|
||||
else {
|
||||
setSelectedModel(DEFAULT_FREE_MODEL_ID);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load preferences from localStorage:', error);
|
||||
}
|
||||
}, [subscriptionStatus, MODEL_OPTIONS]);
|
||||
|
||||
const handleModelChange = (modelId: string) => {
|
||||
const modelOption = MODEL_OPTIONS.find(option => option.id === modelId);
|
||||
|
||||
// Check if it's a custom model (in local storage)
|
||||
let isCustomModel = false;
|
||||
if (isLocalMode() && !modelOption) {
|
||||
isCustomModel = getCustomModels().some(model => model.id === modelId);
|
||||
}
|
||||
|
||||
// Only return early if it's not a custom model and not in standard options
|
||||
if (!modelOption && !isCustomModel) {
|
||||
console.warn('Model not found in options:', modelId);
|
||||
return;
|
||||
}
|
||||
|
||||
// For standard models, check access permissions
|
||||
if (!isCustomModel && !isLocalMode() && !canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedModel(modelId);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_MODEL, modelId);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save model preference to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get filtered list of models the user can access (no additional sorting)
|
||||
const availableModels = useMemo(() => {
|
||||
return isLocalMode()
|
||||
? MODEL_OPTIONS
|
||||
|
@ -279,35 +358,97 @@ export const useModelSelection = () => {
|
|||
);
|
||||
}, [MODEL_OPTIONS, subscriptionStatus]);
|
||||
|
||||
// Initialize selected model from localStorage or defaults
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const savedModel = localStorage.getItem(STORAGE_KEY_MODEL);
|
||||
|
||||
// Local mode - allow any model
|
||||
if (isLocalMode()) {
|
||||
if (savedModel && MODEL_OPTIONS.find(option => option.id === savedModel)) {
|
||||
setSelectedModel(savedModel);
|
||||
} else {
|
||||
setSelectedModel(DEFAULT_PREMIUM_MODEL_ID);
|
||||
saveModelPreference(DEFAULT_PREMIUM_MODEL_ID);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Premium subscription - ALWAYS use premium model
|
||||
if (subscriptionStatus === 'active') {
|
||||
// If they had a premium model saved and it's still valid, use it
|
||||
const hasSavedPremiumModel = savedModel &&
|
||||
MODEL_OPTIONS.find(option =>
|
||||
option.id === savedModel &&
|
||||
option.requiresSubscription &&
|
||||
canAccessModel(subscriptionStatus, true)
|
||||
);
|
||||
|
||||
// Otherwise use the default premium model
|
||||
if (hasSavedPremiumModel) {
|
||||
setSelectedModel(savedModel!);
|
||||
} else {
|
||||
setSelectedModel(DEFAULT_PREMIUM_MODEL_ID);
|
||||
saveModelPreference(DEFAULT_PREMIUM_MODEL_ID);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No subscription - use saved model if accessible (free tier), or default free
|
||||
if (savedModel) {
|
||||
const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel);
|
||||
if (modelOption && canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) {
|
||||
setSelectedModel(savedModel);
|
||||
} else {
|
||||
setSelectedModel(DEFAULT_FREE_MODEL_ID);
|
||||
saveModelPreference(DEFAULT_FREE_MODEL_ID);
|
||||
}
|
||||
} else {
|
||||
setSelectedModel(DEFAULT_FREE_MODEL_ID);
|
||||
saveModelPreference(DEFAULT_FREE_MODEL_ID);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load preferences from localStorage:', error);
|
||||
setSelectedModel(DEFAULT_FREE_MODEL_ID);
|
||||
}
|
||||
}, [subscriptionStatus, MODEL_OPTIONS]);
|
||||
|
||||
// Handle model selection change
|
||||
const handleModelChange = (modelId: string) => {
|
||||
const modelOption = MODEL_OPTIONS.find(option => option.id === modelId);
|
||||
const isCustomModel = isLocalMode() && customModels.some(model => model.id === modelId);
|
||||
|
||||
// Check if model exists
|
||||
if (!modelOption && !isCustomModel) {
|
||||
console.warn('Model not found in options:', modelId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check access permissions (except for custom models in local mode)
|
||||
if (!isCustomModel && !isLocalMode() &&
|
||||
!canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedModel(modelId);
|
||||
saveModelPreference(modelId);
|
||||
};
|
||||
|
||||
// Get the actual model ID to send to the backend
|
||||
const getActualModelId = (modelId: string): string => {
|
||||
// First, check if this is a standard model directly
|
||||
const model = MODEL_OPTIONS.find(m => m.id === modelId);
|
||||
|
||||
// If it's a standard model, use its ID as is
|
||||
if (model && !model.isCustom) {
|
||||
return modelId;
|
||||
}
|
||||
|
||||
// For custom models, ensure they have the openrouter/ prefix
|
||||
if (model?.isCustom || isLocalMode() && getCustomModels().some(m => m.id === modelId)) {
|
||||
return modelId.startsWith('openrouter/') ? modelId : `openrouter/${modelId}`;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
// No need for automatic prefixing in most cases - just return as is
|
||||
return modelId;
|
||||
};
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
setSelectedModel: (modelId: string) => {
|
||||
handleModelChange(modelId);
|
||||
},
|
||||
setSelectedModel: handleModelChange,
|
||||
subscriptionStatus,
|
||||
availableModels,
|
||||
allModels: MODEL_OPTIONS,
|
||||
allModels: MODEL_OPTIONS, // Already pre-sorted
|
||||
customModels,
|
||||
// This is the model ID that should be sent to the backend
|
||||
getActualModelId,
|
||||
canAccessModel: (modelId: string) => {
|
||||
if (isLocalMode()) return true;
|
||||
|
@ -319,3 +460,5 @@ export const useModelSelection = () => {
|
|||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Export the hook but not any sorting logic - sorting is handled internally
|
|
@ -70,24 +70,25 @@ export const CustomModelDialog: React.FC<CustomModelDialogProps> = ({
|
|||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-[425px]"
|
||||
className="sm:max-w-[430px]"
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'add' ? 'Add Custom Model' : 'Edit Custom Model'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the model ID for your custom model. The display name will be auto-generated.
|
||||
Enter the model ID (use <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">openrouter/</code> prefix for OpenRouter models). See <a href="https://docs.litellm.ai/docs/" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:text-blue-600 underline">LiteLLM docs</a>; you may need to modify <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">llm.py</code>.
|
||||
</DialogDescription>
|
||||
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Label htmlFor="modelId" className="text-right">
|
||||
Model ID
|
||||
</Label>
|
||||
<Input
|
||||
id="modelId"
|
||||
placeholder="e.g. gpt-4-vision"
|
||||
placeholder="e.g. openrouter/meta-llama/llama-4-maverick"
|
||||
value={formData.id}
|
||||
onChange={handleChange}
|
||||
className="col-span-3"
|
||||
|
|
|
@ -23,7 +23,8 @@ import {
|
|||
DEFAULT_FREE_MODEL_ID,
|
||||
DEFAULT_PREMIUM_MODEL_ID,
|
||||
formatModelName,
|
||||
getCustomModels
|
||||
getCustomModels,
|
||||
MODELS // Import the centralized MODELS constant
|
||||
} from './_use-model-selection';
|
||||
import { PaywallDialog } from '@/components/payment/paywall-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
@ -31,29 +32,12 @@ import { useRouter } from 'next/navigation';
|
|||
import { isLocalMode } from '@/lib/config';
|
||||
import { CustomModelDialog, CustomModelFormData } from './custom-model-dialog';
|
||||
|
||||
// Model capabilities map
|
||||
const MODEL_CAPABILITIES = {
|
||||
'gpt-4o': { recommended: false, lowQuality: false },
|
||||
'sonnet-3.7': { recommended: true, lowQuality: false },
|
||||
'qwen3': { recommended: false, lowQuality: true },
|
||||
'deepseek': { recommended: false, lowQuality: true },
|
||||
'gemini-2.5': { recommended: true, lowQuality: false },
|
||||
'gemini-2.5-flash-preview': { recommended: true, lowQuality: false },
|
||||
'gemini-2.5-pro-preview': { recommended: true, lowQuality: false },
|
||||
'deepseek-chat-v3-0324': { recommended: true, lowQuality: false },
|
||||
'google/gemini-2.5-flash-preview': { recommended: false, lowQuality: true },
|
||||
'claude-3.5': { recommended: true, lowQuality: false },
|
||||
'claude-3.7': { recommended: true, lowQuality: false },
|
||||
'claude-3.7-reasoning': { recommended: true, lowQuality: false },
|
||||
'gemini-2.5-pro': { recommended: true, lowQuality: false },
|
||||
'grok-3-mini': { recommended: false, lowQuality: true },
|
||||
};
|
||||
|
||||
interface CustomModel {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string;
|
||||
onModelChange: (modelId: string) => void;
|
||||
|
@ -101,50 +85,54 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
// Get current custom models from state
|
||||
const currentCustomModels = customModels || [];
|
||||
|
||||
// Enhance model options with capabilities
|
||||
const enhancedModelOptions = [...modelOptions, ...currentCustomModels
|
||||
// Only add custom models that aren't already in modelOptions
|
||||
.filter(model => !modelOptions.some(m => m.id === model.id))
|
||||
.map(model => ({
|
||||
// Enhance model options with capabilities - using a Map to ensure uniqueness
|
||||
const modelMap = new Map();
|
||||
|
||||
// First add all standard models to the map
|
||||
modelOptions.forEach(model => {
|
||||
modelMap.set(model.id, {
|
||||
...model,
|
||||
isCustom: false
|
||||
});
|
||||
});
|
||||
|
||||
// Then add custom models, overriding any with the same ID
|
||||
currentCustomModels.forEach(model => {
|
||||
if (!modelMap.has(model.id)) {
|
||||
modelMap.set(model.id, {
|
||||
...model,
|
||||
requiresSubscription: false,
|
||||
top: false,
|
||||
isCustom: true
|
||||
}))].map(model => {
|
||||
const baseCapabilities = MODEL_CAPABILITIES[model.id] || { recommended: false, lowQuality: false };
|
||||
return {
|
||||
...model,
|
||||
capabilities: baseCapabilities
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Convert map back to array
|
||||
const enhancedModelOptions = Array.from(modelMap.values());
|
||||
|
||||
// Filter models based on search query
|
||||
const filteredOptions = enhancedModelOptions.filter((opt) =>
|
||||
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
opt.id.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Get free models from modelOptions
|
||||
// Get free models from modelOptions (helper function)
|
||||
const getFreeModels = () => modelOptions.filter(m => !m.requiresSubscription).map(m => m.id);
|
||||
|
||||
// Sort models - free first, then premium, then by name
|
||||
const sortedModels = filteredOptions.sort((a, b) => {
|
||||
// First by free/premium status
|
||||
const aIsFree = getFreeModels().some(id => a.id.includes(id)) || !a.requiresSubscription;
|
||||
const bIsFree = getFreeModels().some(id => b.id.includes(id)) || !b.requiresSubscription;
|
||||
// No sorting needed - models are already sorted in the hook
|
||||
const sortedModels = filteredOptions;
|
||||
|
||||
if (aIsFree !== bIsFree) {
|
||||
return aIsFree ? -1 : 1;
|
||||
// Simplified premium models function - just filter without sorting
|
||||
const getPremiumModels = () => {
|
||||
return modelOptions
|
||||
.filter(m => m.requiresSubscription)
|
||||
.map((m, index) => ({
|
||||
...m,
|
||||
uniqueKey: getUniqueModelKey(m, index)
|
||||
}));
|
||||
}
|
||||
|
||||
// Then by "top" status
|
||||
if (a.top !== b.top) {
|
||||
return a.top ? -1 : 1;
|
||||
}
|
||||
|
||||
// Finally by name
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
// Make sure model IDs are unique for rendering
|
||||
const getUniqueModelKey = (model: any, index: number): string => {
|
||||
return `model-${model.id}-${index}`;
|
||||
|
@ -170,8 +158,6 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
const selectedLabel =
|
||||
enhancedModelOptions.find((o) => o.id === selectedModel)?.label || 'Select model';
|
||||
|
||||
const isLowQualitySelected = MODEL_CAPABILITIES[selectedModel]?.lowQuality || false;
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
// Check if it's a custom model
|
||||
const isCustomModel = customModels.some(model => model.id === id);
|
||||
|
@ -240,12 +226,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
const openEditCustomModelDialog = (model: CustomModel, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
|
||||
// Remove openrouter/ prefix when showing in the edit form
|
||||
const displayModelId = model.id.startsWith('openrouter/')
|
||||
? model.id.replace('openrouter/', '')
|
||||
: model.id;
|
||||
|
||||
setDialogInitialData({ id: displayModelId, label: model.label });
|
||||
setDialogInitialData({ id: model.id, label: model.label });
|
||||
setEditingModelId(model.id); // Keep the original ID with prefix for reference
|
||||
setDialogMode('edit');
|
||||
setIsCustomModelDialogOpen(true);
|
||||
|
@ -254,24 +235,12 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
|
||||
// Handle saving a custom model
|
||||
const handleSaveCustomModel = (formData: CustomModelFormData) => {
|
||||
// Ensure modelId is properly formatted
|
||||
let modelId = formData.id.trim();
|
||||
// Get model ID without automatically adding prefix
|
||||
const modelId = formData.id.trim();
|
||||
|
||||
// For add mode, always add the prefix if missing
|
||||
if (dialogMode === 'add') {
|
||||
if (!modelId.startsWith('openrouter/')) {
|
||||
modelId = `openrouter/${modelId}`;
|
||||
}
|
||||
} else {
|
||||
// For edit mode, maintain the prefix status of the original ID
|
||||
const originalHadPrefix = editingModelId?.startsWith('openrouter/') || false;
|
||||
|
||||
if (originalHadPrefix && !modelId.startsWith('openrouter/')) {
|
||||
modelId = `openrouter/${modelId}`;
|
||||
}
|
||||
}
|
||||
|
||||
const modelLabel = formData.label.trim() || formatModelName(modelId.replace('openrouter/', ''));
|
||||
// Generate display name based on model ID (remove prefix if present for display name)
|
||||
const displayId = modelId.startsWith('openrouter/') ? modelId.replace('openrouter/', '') : modelId;
|
||||
const modelLabel = formData.label.trim() || formatModelName(displayId);
|
||||
|
||||
if (!modelId) return;
|
||||
|
||||
|
@ -291,37 +260,43 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
? [...customModels, { id: modelId, label: modelLabel }]
|
||||
: customModels.map(model => model.id === editingModelId ? { id: modelId, label: modelLabel } : model);
|
||||
|
||||
// Update state
|
||||
setCustomModels(updatedModels);
|
||||
|
||||
// Save to storage first
|
||||
// Save to localStorage first
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_CUSTOM_MODELS, JSON.stringify(updatedModels));
|
||||
} catch (error) {
|
||||
console.error('Failed to save custom models to localStorage:', error);
|
||||
}
|
||||
|
||||
// Force a UI update cycle then update selection
|
||||
// IMPORTANT: Use requestAnimationFrame to ensure DOM updates before selection changes
|
||||
// Update state with new models
|
||||
setCustomModels(updatedModels);
|
||||
|
||||
// Handle model selection changes
|
||||
if (dialogMode === 'add') {
|
||||
// Always select newly added models
|
||||
requestAnimationFrame(() => {
|
||||
// Direct model change for immediate effect
|
||||
onModelChange(modelId);
|
||||
|
||||
// Also save the selection to localStorage
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_MODEL, modelId);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save selected model to localStorage:', error);
|
||||
}
|
||||
});
|
||||
} else if (selectedModel === editingModelId) {
|
||||
// For edits, only update if the edited model was selected
|
||||
requestAnimationFrame(() => {
|
||||
onModelChange(modelId);
|
||||
});
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_MODEL, modelId);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save selected model to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Force dropdown to close to ensure fresh data on next open
|
||||
setIsOpen(false);
|
||||
|
||||
// Force a UI refresh by delaying the state update
|
||||
setTimeout(() => {
|
||||
setHighlightedIndex(-1);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Handle closing the custom model dialog
|
||||
|
@ -344,18 +319,10 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
|
||||
// Close dropdown first to force a refresh on next open
|
||||
setIsOpen(false);
|
||||
|
||||
// Filter out the model to delete
|
||||
const updatedCustomModels = customModels.filter(model => model.id !== modelId);
|
||||
|
||||
// Update state
|
||||
setCustomModels(updatedCustomModels);
|
||||
|
||||
// Force a UI update by using requestAnimationFrame
|
||||
requestAnimationFrame(() => {
|
||||
// Update localStorage directly
|
||||
// Update localStorage first to ensure data consistency
|
||||
if (isLocalMode() && typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_CUSTOM_MODELS, JSON.stringify(updatedCustomModels));
|
||||
|
@ -364,7 +331,10 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// If the deleted model was selected, switch to a default model
|
||||
// Update state with the new list
|
||||
setCustomModels(updatedCustomModels);
|
||||
|
||||
// Check if we need to change the selected model
|
||||
if (selectedModel === modelId) {
|
||||
const defaultModel = isLocalMode() ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID;
|
||||
onModelChange(defaultModel);
|
||||
|
@ -374,19 +344,26 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
console.warn('Failed to update selected model in localStorage:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Force dropdown to close to ensure proper refresh on next open
|
||||
setIsOpen(false);
|
||||
|
||||
// Force a UI refresh by scheduling a state update after React completes this render cycle
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const renderModelOption = (opt: any, index: number) => {
|
||||
// Custom models are always accessible in local mode
|
||||
const isCustomModel = customModels.some(model => model.id === opt.id);
|
||||
const accessible = isCustomModel ? true : canAccessModel(opt.id);
|
||||
const isCustom = opt.isCustom || customModels.some(model => model.id === opt.id);
|
||||
const accessible = isCustom ? true : canAccessModel(opt.id);
|
||||
|
||||
// Fix the highlighting logic to use the index parameter instead of searching in filteredOptions
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
const isPremium = opt.requiresSubscription;
|
||||
const isLowQuality = MODEL_CAPABILITIES[opt.id]?.lowQuality || false;
|
||||
const isRecommended = MODEL_CAPABILITIES[opt.id]?.recommended || false;
|
||||
const isLowQuality = MODELS[opt.id]?.lowQuality || false;
|
||||
const isRecommended = MODELS[opt.id]?.recommended || false;
|
||||
|
||||
return (
|
||||
<TooltipProvider key={opt.uniqueKey || `model-${opt.id}-${index}`}>
|
||||
|
@ -417,7 +394,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
<Crown className="h-3.5 w-3.5 text-blue-500" />
|
||||
)}
|
||||
{/* Custom model actions */}
|
||||
{isLocalMode() && isCustomModel && (
|
||||
{isLocalMode() && isCustom && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
@ -458,7 +435,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
<TooltipContent side="left" className="text-xs max-w-xs">
|
||||
<p>Recommended for optimal performance</p>
|
||||
</TooltipContent>
|
||||
) : isCustomModel ? (
|
||||
) : isCustom ? (
|
||||
<TooltipContent side="left" className="text-xs max-w-xs">
|
||||
<p>Custom model</p>
|
||||
</TooltipContent>
|
||||
|
@ -468,16 +445,20 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// Update filtered options when customModels changes
|
||||
// Update filtered options when customModels or search query changes
|
||||
useEffect(() => {
|
||||
// Recalculate filtered options when custom models change
|
||||
const newFilteredOptions = enhancedModelOptions.filter((opt) =>
|
||||
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
opt.id.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
// Force the component to re-render with new data
|
||||
// Force reset of enhancedModelOptions whenever customModels change
|
||||
// The next render will regenerate enhancedModelOptions with the updated modelMap
|
||||
setHighlightedIndex(-1);
|
||||
}, [customModels, searchQuery]);
|
||||
setSearchQuery('');
|
||||
|
||||
// Force React to fully re-evaluate the component rendering
|
||||
if (isOpen) {
|
||||
// If dropdown is open, briefly close and reopen to force refresh
|
||||
setIsOpen(false);
|
||||
setTimeout(() => setIsOpen(true), 10);
|
||||
}
|
||||
}, [customModels]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
@ -489,7 +470,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
className="h-8 rounded-lg text-muted-foreground shadow-none border-none focus:ring-0 px-3"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-sm font-medium">
|
||||
{isLowQualitySelected && (
|
||||
{MODELS[selectedModel]?.lowQuality && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
@ -546,10 +527,10 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Show capabilities */}
|
||||
{MODEL_CAPABILITIES[model.id]?.lowQuality && (
|
||||
{(MODELS[model.id]?.lowQuality || false) && (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
|
||||
)}
|
||||
{MODEL_CAPABILITIES[model.id]?.recommended && (
|
||||
{(MODELS[model.id]?.recommended || false) && (
|
||||
<Brain className="h-3.5 w-3.5 text-blue-500" />
|
||||
)}
|
||||
{selectedModel === model.id && (
|
||||
|
@ -559,7 +540,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
</DropdownMenuItem>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{MODEL_CAPABILITIES[model.id]?.lowQuality && (
|
||||
{MODELS[model.id]?.lowQuality && (
|
||||
<TooltipContent side="left" className="text-xs max-w-xs">
|
||||
<p>Basic model with limited capabilities</p>
|
||||
</TooltipContent>
|
||||
|
@ -578,7 +559,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
|
||||
{/* Premium models container with paywall overlay */}
|
||||
<div className="relative h-40 overflow-hidden px-2">
|
||||
{uniqueModels
|
||||
{getPremiumModels()
|
||||
.filter(m =>
|
||||
m.requiresSubscription &&
|
||||
(m.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
|
@ -598,7 +579,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Show capabilities */}
|
||||
{MODEL_CAPABILITIES[model.id]?.recommended && (
|
||||
{MODELS[model.id]?.recommended && (
|
||||
<Brain className="h-3.5 w-3.5 text-blue-500" />
|
||||
)}
|
||||
<Crown className="h-3.5 w-3.5 text-blue-500" />
|
||||
|
@ -645,6 +626,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
<div className="px-3 py-3 flex justify-between items-center">
|
||||
<span className="text-xs font-medium text-muted-foreground">All Models</span>
|
||||
{isLocalMode() && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
@ -656,6 +640,12 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Add a custom model
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{uniqueModels.filter(m =>
|
||||
|
|
Loading…
Reference in New Issue