diff --git a/frontend/src/components/ModelSelector.tsx b/frontend/src/components/ModelSelector.tsx index 8f1571304..983c1a4fa 100644 --- a/frontend/src/components/ModelSelector.tsx +++ b/frontend/src/components/ModelSelector.tsx @@ -1,10 +1,22 @@ -import { ChevronDown, Check, Lock, Camera } from "lucide-react"; +import { + ChevronDown, + Check, + Lock, + Camera, + ChevronLeft, + Sparkles, + Zap, + Brain, + Code +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuTrigger + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel } from "@/components/ui/dropdown-menu"; import { useLocalState } from "@/state/useLocalState"; import { useOpenSecret } from "@opensecret/react"; @@ -95,6 +107,40 @@ export function getModelTokenLimit(modelId: string): number { return MODEL_CONFIG[modelId]?.tokenLimit || DEFAULT_TOKEN_LIMIT; } +// Model categories for simplified UI +type ModelCategory = "free" | "quick" | "reasoning" | "math" | "advanced"; + +const CATEGORY_MODELS = { + free: "llama-3.3-70b", + quick: "gpt-oss-120b", + reasoning_on: "deepseek-r1-0528", // R1 with thinking + reasoning_off: "deepseek-v31-terminus", // V3.1 without thinking + math: "qwen3-coder-480b" +}; + +const CATEGORY_CONFIG = { + free: { + label: "Free", + icon: Sparkles, + description: "Fast and capable" + }, + quick: { + label: "Quick", + icon: Zap, + description: "Balanced performance" + }, + reasoning: { + label: "Reasoning", + icon: Brain, + description: "Deep analysis" + }, + math: { + label: "Math/Coding", + icon: Code, + description: "Technical tasks" + } +}; + export function ModelSelector({ hasImages = false }: { hasImages?: boolean }) { const { model, @@ -102,7 +148,9 @@ export function ModelSelector({ hasImages = false }: { hasImages?: boolean }) { availableModels, setAvailableModels, billingStatus, - setHasWhisperModel + setHasWhisperModel, + thinkingEnabled, + setThinkingEnabled } = useLocalState(); const os = useOpenSecret(); const isFetching = useRef(false); @@ -110,6 +158,7 @@ export function ModelSelector({ hasImages = false }: { hasImages?: boolean }) { const availableModelsRef = useRef(availableModels); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); const [selectedModelName, setSelectedModelName] = useState(""); + const [showAdvanced, setShowAdvanced] = useState(false); // Use the passed hasImages prop directly const chatHasImages = hasImages; @@ -188,6 +237,29 @@ export function ModelSelector({ hasImages = false }: { hasImages?: boolean }) { } }, [os, setAvailableModels, setHasWhisperModel]); + // Sync thinking toggle when model changes externally + useEffect(() => { + if (model === CATEGORY_MODELS.reasoning_on) { + setThinkingEnabled(true); + } else if (model === CATEGORY_MODELS.reasoning_off) { + setThinkingEnabled(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [model]); + + // Get current category based on selected model + const getCurrentCategory = (): string => { + if (model === CATEGORY_MODELS.free) return "Free"; + if (model === CATEGORY_MODELS.quick) return "Quick"; + if (model === CATEGORY_MODELS.reasoning_on || model === CATEGORY_MODELS.reasoning_off) { + return "Reasoning"; + } + if (model === CATEGORY_MODELS.math) return "Math/Coding"; + // If in advanced mode, show model name + const config = MODEL_CONFIG[model]; + return config?.displayName || model; + }; + // Check if user has access to a model based on their plan const hasAccessToModel = (modelId: string) => { const config = MODEL_CONFIG[modelId]; @@ -215,6 +287,44 @@ export function ModelSelector({ hasImages = false }: { hasImages?: boolean }) { return true; }; + // Handle category selection + const handleCategorySelect = (category: ModelCategory) => { + if (category === "advanced") { + setShowAdvanced(true); + return; + } + + let targetModel: string; + switch (category) { + case "free": + targetModel = CATEGORY_MODELS.free; + break; + case "quick": + targetModel = CATEGORY_MODELS.quick; + break; + case "reasoning": + targetModel = thinkingEnabled + ? CATEGORY_MODELS.reasoning_on + : CATEGORY_MODELS.reasoning_off; + break; + case "math": + targetModel = CATEGORY_MODELS.math; + break; + default: + return; + } + + // Check access + if (!hasAccessToModel(targetModel)) { + const modelConfig = MODEL_CONFIG[targetModel]; + setSelectedModelName(modelConfig?.displayName || targetModel); + setUpgradeDialogOpen(true); + return; + } + + setModel(targetModel); + }; + // Get dynamic badges for a model based on billing status const getModelBadges = (modelId: string): string[] => { const config = MODEL_CONFIG[modelId]; @@ -299,107 +409,263 @@ export function ModelSelector({ hasImages = false }: { hasImages?: boolean }) { return {elements}; }; - // Show short name in the collapsed view (without badges) - const modelDisplay = ( -
-
- {MODEL_CONFIG[model]?.shortName || MODEL_CONFIG[model]?.displayName || model} -
-
- ); - - // Always show dropdown even with single model (it may be loading more) - return ( <> - + !open && setShowAdvanced(false)}> - - {availableModels && - Array.isArray(availableModels) && - // Filter out unknown models (not in MODEL_CONFIG), then sort: vision-capable first (if images present), then available, then restricted, then disabled - [...availableModels] - .filter((m) => MODEL_CONFIG[m.id] !== undefined) - .sort((a, b) => { - const aConfig = MODEL_CONFIG[a.id]; - const bConfig = MODEL_CONFIG[b.id]; - - // If chat has images, prioritize vision models - if (chatHasImages) { - const aHasVision = aConfig?.supportsVision || false; - const bHasVision = bConfig?.supportsVision || false; - if (aHasVision && !bHasVision) return -1; - if (!aHasVision && bHasVision) return 1; + +
+ {/* Main Category Menu */} +
+ Select Model + + + {/* Free Category */} + handleCategorySelect("free")}> +
+
+ +
+ {CATEGORY_CONFIG.free.label} + + {CATEGORY_CONFIG.free.description} + +
+
+ {model === CATEGORY_MODELS.free && } +
+
+ + {/* Quick Category */} + handleCategorySelect("quick")} + className={ + !hasAccessToModel(CATEGORY_MODELS.quick) + ? "hover:bg-purple-50 dark:hover:bg-purple-950/20" + : "" } - - const aDisabled = aConfig?.disabled || false; - const bDisabled = bConfig?.disabled || false; - const aRestricted = - (aConfig?.requiresPro || aConfig?.requiresStarter || false) && - !hasAccessToModel(a.id); - const bRestricted = - (bConfig?.requiresPro || bConfig?.requiresStarter || false) && - !hasAccessToModel(b.id); - - // Disabled models go last - if (aDisabled && !bDisabled) return 1; - if (!aDisabled && bDisabled) return -1; - - // Restricted models go after available but before disabled - if (aRestricted && !bRestricted) return 1; - if (!aRestricted && bRestricted) return -1; - - return 0; - }) - .map((availableModel) => { - const config = MODEL_CONFIG[availableModel.id]; - const isDisabled = config?.disabled || false; - const requiresPro = config?.requiresPro || false; - const requiresStarter = config?.requiresStarter || false; - const hasAccess = hasAccessToModel(availableModel.id); - const isRestricted = (requiresPro || requiresStarter) && !hasAccess; - - // Disable non-vision models if chat has images - const isDisabledDueToImages = chatHasImages && !config?.supportsVision; - const effectivelyDisabled = isDisabled || isDisabledDueToImages; - - return ( - { - if (effectivelyDisabled) return; - if (isRestricted) { - // Show upgrade dialog for restricted model - const modelConfig = MODEL_CONFIG[availableModel.id]; - setSelectedModelName(modelConfig?.displayName || availableModel.id); - setUpgradeDialogOpen(true); - } else { - setModel(availableModel.id); - } - }} - className={`flex items-center justify-between group ${ - effectivelyDisabled ? "opacity-50 cursor-not-allowed" : "" - } ${isRestricted ? "hover:bg-purple-50 dark:hover:bg-purple-950/20" : ""}`} - disabled={effectivelyDisabled} - > -
-
{getDisplayName(availableModel.id, true)}
+ > +
+
+ +
+
+ {CATEGORY_CONFIG.quick.label} + {!hasAccessToModel(CATEGORY_MODELS.quick) && ( + + )} +
+ + {CATEGORY_CONFIG.quick.description} + +
+
+ {model === CATEGORY_MODELS.quick && } +
+ + + {/* Reasoning Category */} + handleCategorySelect("reasoning")} + className={ + !hasAccessToModel( + thinkingEnabled ? CATEGORY_MODELS.reasoning_on : CATEGORY_MODELS.reasoning_off + ) + ? "hover:bg-purple-50 dark:hover:bg-purple-950/20" + : "" + } + > +
+
+ +
+
+ {CATEGORY_CONFIG.reasoning.label} + {!hasAccessToModel( + thinkingEnabled + ? CATEGORY_MODELS.reasoning_on + : CATEGORY_MODELS.reasoning_off + ) && } +
+ + {CATEGORY_CONFIG.reasoning.description} +
- {model === availableModel.id && } - - ); - })} +
+ {(model === CATEGORY_MODELS.reasoning_on || + model === CATEGORY_MODELS.reasoning_off) && ( + + )} +
+
+ + {/* Math/Coding Category */} + handleCategorySelect("math")} + className={ + !hasAccessToModel(CATEGORY_MODELS.math) + ? "hover:bg-purple-50 dark:hover:bg-purple-950/20" + : "" + } + > +
+
+ +
+
+ {CATEGORY_CONFIG.math.label} + {!hasAccessToModel(CATEGORY_MODELS.math) && ( + + )} +
+ + {CATEGORY_CONFIG.math.description} + +
+
+ {model === CATEGORY_MODELS.math && } +
+
+ + + + {/* Advanced Option */} + handleCategorySelect("advanced")} + onSelect={(e) => { + e.preventDefault(); + }} + > +
+ + Advanced +
+
+
+ + {/* Advanced Submenu */} +
+ { + e.preventDefault(); + setShowAdvanced(false); + }} + onSelect={(e) => { + e.preventDefault(); + }} + > + + Back + + + All Models + + {/* Scrollable container for models */} +
+ {availableModels && + Array.isArray(availableModels) && + [...availableModels] + .filter( + (m, index, self) => + MODEL_CONFIG[m.id] !== undefined && + self.findIndex((model) => model.id === m.id) === index + ) + .sort((a, b) => { + const aConfig = MODEL_CONFIG[a.id]; + const bConfig = MODEL_CONFIG[b.id]; + + // If chat has images, prioritize vision models + if (chatHasImages) { + const aHasVision = aConfig?.supportsVision || false; + const bHasVision = bConfig?.supportsVision || false; + if (aHasVision && !bHasVision) return -1; + if (!aHasVision && bHasVision) return 1; + } + + const aDisabled = aConfig?.disabled || false; + const bDisabled = bConfig?.disabled || false; + const aRestricted = + (aConfig?.requiresPro || aConfig?.requiresStarter || false) && + !hasAccessToModel(a.id); + const bRestricted = + (bConfig?.requiresPro || bConfig?.requiresStarter || false) && + !hasAccessToModel(b.id); + + // Disabled models go last + if (aDisabled && !bDisabled) return 1; + if (!aDisabled && bDisabled) return -1; + + // Restricted models go after available but before disabled + if (aRestricted && !bRestricted) return 1; + if (!aRestricted && bRestricted) return -1; + + return 0; + }) + .map((availableModel) => { + const config = MODEL_CONFIG[availableModel.id]; + const isDisabled = config?.disabled || false; + const requiresPro = config?.requiresPro || false; + const requiresStarter = config?.requiresStarter || false; + const hasAccess = hasAccessToModel(availableModel.id); + const isRestricted = (requiresPro || requiresStarter) && !hasAccess; + + // Disable non-vision models if chat has images + const isDisabledDueToImages = chatHasImages && !config?.supportsVision; + const effectivelyDisabled = isDisabled || isDisabledDueToImages; + + return ( + { + if (effectivelyDisabled) return; + if (isRestricted) { + const modelConfig = MODEL_CONFIG[availableModel.id]; + setSelectedModelName(modelConfig?.displayName || availableModel.id); + setUpgradeDialogOpen(true); + } else { + setModel(availableModel.id); + setShowAdvanced(false); + } + }} + className={`flex items-center justify-between group ${ + effectivelyDisabled ? "opacity-50 cursor-not-allowed" : "" + } ${isRestricted ? "hover:bg-purple-50 dark:hover:bg-purple-950/20" : ""}`} + disabled={effectivelyDisabled} + > +
+
{getDisplayName(availableModel.id, true)}
+
+ {model === availableModel.id && } +
+ ); + })} +
+
+
diff --git a/frontend/src/components/UnifiedChat.tsx b/frontend/src/components/UnifiedChat.tsx index a8b97b7d3..d3e0f2287 100644 --- a/frontend/src/components/UnifiedChat.tsx +++ b/frontend/src/components/UnifiedChat.tsx @@ -35,7 +35,8 @@ import { SquarePen, Search, Loader2, - Globe + Globe, + Brain } from "lucide-react"; import RecordRTC from "recordrtc"; import { useQueryClient } from "@tanstack/react-query"; @@ -2468,6 +2469,38 @@ export function UnifiedChat() { } /> + {/* Thinking toggle button - only visible when reasoning model is selected */} + {(localState.model === "deepseek-r1-0528" || + localState.model === "deepseek-v31-terminus") && ( + + )} + {/* Attachment dropdown */} @@ -2675,6 +2708,38 @@ export function UnifiedChat() { } /> + {/* Thinking toggle button - only visible when reasoning model is selected */} + {(localState.model === "deepseek-r1-0528" || + localState.model === "deepseek-v31-terminus") && ( + + )} + {/* Attachment dropdown */} diff --git a/frontend/src/state/LocalStateContext.tsx b/frontend/src/state/LocalStateContext.tsx index 9d768e8d1..138d8f916 100644 --- a/frontend/src/state/LocalStateContext.tsx +++ b/frontend/src/state/LocalStateContext.tsx @@ -40,6 +40,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) })(), availableModels: [llamaModel] as OpenSecretModel[], hasWhisperModel: true, // Default to true to avoid hiding button during loading + thinkingEnabled: false, // Default to reasoning without thinking (V3.1) billingStatus: null as BillingStatus | null, searchQuery: "", isSearchVisible: false, @@ -296,6 +297,10 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) setLocalState((prev) => ({ ...prev, hasWhisperModel: hasWhisper })); } + function setThinkingEnabled(enabled: boolean) { + setLocalState((prev) => ({ ...prev, thinkingEnabled: enabled })); + } + return ( void; + /** Whether thinking mode is enabled for reasoning models */ + thinkingEnabled: boolean; + setThinkingEnabled: (enabled: boolean) => void; userPrompt: string; systemPrompt: string | null; userImages: File[]; @@ -71,6 +74,8 @@ export const LocalStateContext = createContext({ setAvailableModels: () => void 0, hasWhisperModel: true, setHasWhisperModel: () => void 0, + thinkingEnabled: false, + setThinkingEnabled: () => void 0, userPrompt: "", systemPrompt: null, userImages: [],