From 23c72c3a93e67561870ad83fe4e9ad411f571e17 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 23 Oct 2025 15:10:42 -0500 Subject: [PATCH] Add enhanced model selector with categories and thinking toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add category-based model selection (Free, Quick, Reasoning, Math/Coding) - Add thinking toggle for DeepSeek reasoning models - Implement sliding menu with advanced model view - Add icons and descriptions for better UX Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Move thinking toggle button next to model selector Reordered toolbar buttons so the thinking toggle (Brain icon) appears immediately after the ModelSelector dropdown instead of at the end. New order: ModelSelector → Thinking toggle → Attachments → Web search Co-authored-by: Anthony Fix infinite loop in ModelSelector useEffect The setThinkingEnabled dependency was causing an infinite re-render loop. Only the model should trigger this effect, not the setter function. Fix duplicate React keys in ModelSelector dropdown Add unique key prefixes to category menu items (category-*) and advanced submenu items (advanced-*) to prevent duplicate key warnings when both panels are rendered simultaneously. Deduplicate models in advanced submenu to fix duplicate key warnings Filter out duplicate model IDs before rendering to prevent React key conflicts when the same model appears multiple times in availableModels. --- frontend/src/components/ModelSelector.tsx | 448 ++++++++++++++++----- frontend/src/components/UnifiedChat.tsx | 67 ++- frontend/src/state/LocalStateContext.tsx | 7 + frontend/src/state/LocalStateContextDef.ts | 5 + 4 files changed, 435 insertions(+), 92 deletions(-) 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: [],