'use client'; import { useState, useRef, useMemo, useEffect } from 'react'; import { Bot, Brain, Check, Paperclip, FileText, X, Globe2, Search } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; import { isLLMProviderConfigured } from '@/lib/store/settings-validation'; import { PDF_PROVIDERS } from '@/lib/pdf/constants'; import type { PDFProviderId } from '@/lib/pdf/types'; import { WEB_SEARCH_PROVIDERS, getWebSearchProviderDisplayName, isWebSearchProviderConfigured, } from '@/lib/web-search/constants'; import type { WebSearchProviderId } from '@/lib/web-search/types'; import type { ProviderId } from '@/lib/ai/providers'; import type { ModelInfo, ThinkingConfig, ThinkingEffort, ThinkingLevel, } from '@/lib/types/provider'; import { getDefaultThinkingConfig, getThinkingDisplayValue, getThinkingConfigKey, normalizeThinkingConfig, supportsConfigurableThinking, } from '@/lib/ai/thinking-config'; import type { SettingsSection } from '@/lib/types/settings'; import { MediaPopover } from '@/components/generation/media-popover'; import { getAcceptStringForProviders, isMimeSupportedByProviders } from '@/lib/document/mime'; import { MAX_DOCUMENT_BUNDLE_FILES, MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES, } from '@/lib/document/bundle'; import { dedupeCourseMaterialFiles } from '@/lib/document/course-materials'; import type { SelectedCourseMaterial } from '@/lib/types/generation'; import { findModelById, modelIdsMatch } from '@/lib/ai/model-aliases'; // ─── Constants ─────────────────────────────────────────────── const MAX_COURSE_MATERIAL_SIZE_MB = 50; const MAX_COURSE_MATERIAL_SIZE_BYTES = MAX_COURSE_MATERIAL_SIZE_MB * 1024 * 1024; // ─── Types ─────────────────────────────────────────────────── export interface GenerationToolbarProps { webSearch: boolean; onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; // PDF courseMaterials: SelectedCourseMaterial[]; onCourseMaterialsAdd: (files: File[]) => void; onCourseMaterialRemove: (id: string) => void; onPdfError: (error: string | null) => void; /** * When set, the course-material add/remove affordances, the extractor * Select, and the web-search toggle are all disabled (the parent freezes the * material set and the session inputs for the duration of generate-prep). * The parent's handlers are inert under the same flag; this only mirrors it * in the UI. */ materialsLocked?: boolean; } // ─── Component ─────────────────────────────────────────────── export function GenerationToolbar({ webSearch, onWebSearchChange, onSettingsOpen, courseMaterials, onCourseMaterialsAdd, onCourseMaterialRemove, onPdfError, materialsLocked = false, }: GenerationToolbarProps) { const { t } = useI18n(); const currentProviderId = useSettingsStore((s) => s.providerId); const currentModelId = useSettingsStore((s) => s.modelId); const providersConfig = useSettingsStore((s) => s.providersConfig); const setModel = useSettingsStore((s) => s.setModel); const thinkingConfigs = useSettingsStore((s) => s.thinkingConfigs); const setThinkingConfig = useSettingsStore((s) => s.setThinkingConfig); const pdfProviderId = useSettingsStore((s) => s.pdfProviderId); const pdfProvidersConfig = useSettingsStore((s) => s.pdfProvidersConfig); const setPDFProvider = useSettingsStore((s) => s.setPDFProvider); const webSearchProviderId = useSettingsStore((s) => s.webSearchProviderId); const webSearchProvidersConfig = useSettingsStore((s) => s.webSearchProvidersConfig); const setWebSearchProvider = useSettingsStore((s) => s.setWebSearchProvider); const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); // Check web search availability. Keyless providers such as Brave should keep // the toolbar reachable even when the current API-key provider is not ready. const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId]; const webSearchConfig = webSearchProvidersConfig[webSearchProviderId]; const selectedWebSearchAvailable = webSearchProvider ? isWebSearchProviderConfigured(webSearchProvider, webSearchConfig) : false; const webSearchAvailable = Object.values(WEB_SEARCH_PROVIDERS).some((provider) => isWebSearchProviderConfigured(provider, webSearchProvidersConfig[provider.id]), ); // Configured LLM providers (only those with valid credentials + models + endpoint) const configuredProviders = providersConfig ? Object.entries(providersConfig) .filter(([, config]) => isLLMProviderConfigured(config)) .map(([id, config]) => ({ id: id as ProviderId, name: config.name, icon: config.icon, isServerConfigured: config.isServerConfigured, models: config.isServerConfigured && !config.apiKey && config.serverModels?.length ? config.models.filter((model) => config.serverModels?.some((serverModelId) => modelIdsMatch(id, model.id, serverModelId), ), ) : config.models, })) : []; const currentProviderConfig = providersConfig?.[currentProviderId]; const currentModel = findModelById( currentProviderId, currentProviderConfig?.models, currentModelId, ); const currentThinkingConfig = thinkingConfigs[getThinkingConfigKey(currentProviderId, currentModelId)]; // Course material handler. `plain-text` is always active alongside the // user-selected extractor so txt/md files remain uploadable without // configuring an external service. const activeDocumentProviderIds = useMemo( () => [pdfProviderId, 'plain-text'] as const, [pdfProviderId], ); const acceptForCurrentProvider = useMemo( () => getAcceptStringForProviders(activeDocumentProviderIds), [activeDocumentProviderIds], ); // If the user switches to a provider that doesn't support already attached // materials, drop only the incompatible files so the eventual extraction // request matches the current provider capability. useEffect(() => { const unsupportedMaterials = courseMaterials.filter( (file) => !isMimeSupportedByProviders( { mimeType: file.type, fileName: file.name }, activeDocumentProviderIds, ), ); if (unsupportedMaterials.length === 0) return; for (const file of unsupportedMaterials) { onCourseMaterialRemove(file.id); } onPdfError(t('upload.unsupportedCourseMaterial')); // Intentionally omit callbacks/t from deps: adding them would re-run this // provider capability cleanup on unrelated parent re-renders. // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeDocumentProviderIds, courseMaterials]); const handleFilesSelect = (incomingFiles: File[]) => { // Belt-and-braces mirror of the parent's freeze guard: while generate-prep // is running the material set must not change, whatever the UI state says. if (materialsLocked) return; const supportedFiles = incomingFiles.filter((file) => isMimeSupportedByProviders( { mimeType: file.type, fileName: file.name }, activeDocumentProviderIds, ), ); if (supportedFiles.length === 0) { onPdfError(t('upload.unsupportedCourseMaterial')); return; } if (supportedFiles.length !== incomingFiles.length) { onPdfError(t('upload.unsupportedCourseMaterial')); return; } if (supportedFiles.some((file) => file.size > MAX_COURSE_MATERIAL_SIZE_BYTES)) { onPdfError(t('upload.fileTooLarge')); return; } const dedupedFiles = dedupeCourseMaterialFiles(courseMaterials, supportedFiles); if (dedupedFiles.length === 0) return; if (courseMaterials.length + dedupedFiles.length > MAX_DOCUMENT_BUNDLE_FILES) { onPdfError(t('upload.courseMaterialCountLimit', { n: MAX_DOCUMENT_BUNDLE_FILES })); return; } const totalSize = courseMaterials.reduce((sum, file) => sum + file.size, 0) + dedupedFiles.reduce((sum, file) => sum + file.size, 0); if (totalSize > MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES) { onPdfError( t('upload.courseMaterialTotalSizeLimit', { n: Math.floor(MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES / 1024 / 1024), }), ); return; } onPdfError(null); onCourseMaterialsAdd(dedupedFiles); }; // ─── Pill button helper ───────────────────────────── const pillCls = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border'; const pillMuted = `${pillCls} border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`; const pillActive = `${pillCls} border-violet-200/60 dark:border-violet-700/50 bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300`; return (
{/* ── Model selector ── */} {configuredProviders.length > 0 ? ( setThinkingConfig(currentProviderId, currentModelId, config) } t={t} /> ) : ( {t('toolbar.configureProviderHint')} )}
{/* ── Separator ── */}
{/* ── Course material (extractor + upload) combined Popover ── */} {courseMaterials.length > 0 ? ( ) : ( )} {/* Extractor selector */}
{t('toolbar.documentExtractor')}
{/* Upload area / file info */}
{ const files = Array.from(e.target.files ?? []); if (files.length > 0) handleFilesSelect(files); e.target.value = ''; }} />
{ if (!materialsLocked) fileInputRef.current?.click(); }} onDragOver={(e) => { e.preventDefault(); if (!materialsLocked) setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={(e) => { e.preventDefault(); setIsDragging(false); if (materialsLocked) return; const files = Array.from(e.dataTransfer.files ?? []); if (files.length > 0) handleFilesSelect(files); }} >

{t('toolbar.courseMaterialUpload')}

{t('upload.courseMaterialSizeLimit')}

{t('upload.courseMaterialCountLimit', { n: MAX_DOCUMENT_BUNDLE_FILES })}

{courseMaterials.length > 0 && (

{t('toolbar.courseMaterialMergeOrder')}

{[...courseMaterials] .sort((a, b) => a.order - b.order) .map((file) => (

{file.order}. {file.name}

{(file.size / 1024 / 1024).toFixed(2)} MB

))}
)}
{/* ── Web Search ── */} {webSearchAvailable ? ( {/* Toggle */} {/* Provider selector */}
{t('toolbar.webSearchProvider')}
) : ( {t('toolbar.webSearchNoProvider')} )} {/* ── Separator ── */}
{/* ── Media popover ── */}
); } function formatThinkingValue(value?: string, t?: (key: string) => string) { if (!value) return ''; if (value === 'none') return t ? t('toolbar.off') : 'off'; if (t && (value === 'dynamic' || value === 'on' || value === 'off' || value === 'auto')) { return t(`toolbar.${value}`); } return value === 'xhigh' ? 'x-high' : value; } function formatCompactThinkingValue(value?: string, t?: (key: string) => string) { if (!value) return ''; const numericValue = Number(value); if (Number.isFinite(numericValue) && value.trim() !== '') { return numericValue >= 10000 ? `${Math.round(numericValue / 1000)}k` : `${numericValue}`; } return formatThinkingValue(value, t); } function InlineThinkingControl({ model, config, onChange, t, }: { model?: ModelInfo; config?: ThinkingConfig; onChange: (config: ThinkingConfig | undefined) => void; t: (key: string) => string; }) { const thinking = model?.capabilities?.thinking; if (!supportsConfigurableThinking(thinking)) return null; const effective = normalizeThinkingConfig(thinking, config) ?? getDefaultThinkingConfig(thinking); const applyConfig = (next: ThinkingConfig) => { onChange(normalizeThinkingConfig(thinking, next)); }; const applyBudget = (value: number | undefined) => { applyConfig({ ...effective, mode: effective?.mode ?? 'enabled', budgetTokens: value }); }; const defaultEnabledBudget = typeof thinking.defaultBudgetTokens === 'number' && thinking.defaultBudgetTokens > 0 ? thinking.defaultBudgetTokens : (thinking.budgetRange?.step ?? thinking.budgetRange?.min); const applyAutoBudget = () => { applyConfig({ ...effective, mode: 'auto', enabled: undefined, budgetTokens: -1 }); }; const applyBudgetMode = (mode: 'disabled' | 'enabled' | 'auto') => { if (mode === 'auto') { applyAutoBudget(); return; } applyConfig({ ...effective, mode, enabled: mode === 'enabled', budgetTokens: mode === 'enabled' && effective?.budgetTokens === -1 ? defaultEnabledBudget : effective?.budgetTokens, }); }; const applySimpleMode = (mode: 'disabled' | 'enabled' | 'auto') => { applyConfig({ ...effective, mode, enabled: mode === 'enabled' ? true : mode === 'disabled' ? false : undefined, }); }; const selectTriggerCls = 'h-6 min-w-[84px] rounded-full border-0 bg-violet-100 px-2 py-0 !text-[10px] font-medium leading-none text-violet-700 shadow-none focus-visible:ring-0 data-[size=sm]:h-6 dark:bg-violet-900/40 dark:text-violet-200 [&_svg]:size-3'; const selectItemCls = 'py-1 text-xs'; const hasAutoBudget = (thinking.control === 'toggle-budget' || thinking.control === 'budget-only') && !!thinking.budgetRange?.allowDynamic; const autoBudgetMode = effective?.budgetTokens === -1 && thinking.budgetRange?.allowDynamic ? 'auto' : effective?.mode === 'disabled' ? 'disabled' : 'enabled'; const simpleMode = thinking.control === 'mode' && effective?.mode === 'auto' ? 'auto' : effective?.mode === 'disabled' ? 'disabled' : 'enabled'; return (
event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()} >
{hasAutoBudget && ( )} {(thinking.control === 'toggle' || (thinking.control === 'toggle-budget' && !hasAutoBudget) || thinking.control === 'mode') && ( )} {thinking.control === 'level' && !!thinking.levelValues?.length && ( )} {thinking.control === 'effort' && !!thinking.effortValues?.length && ( )} {(thinking.control === 'toggle-budget' || thinking.control === 'budget-only') && thinking.budgetRange && (!hasAutoBudget || autoBudgetMode === 'enabled') && ( )}
); } // ─── ModelSettingsPopover (provider + model picker) ───── interface ConfiguredProvider { id: ProviderId; name: string; icon?: string; isServerConfigured?: boolean; models: ModelInfo[]; } function ModelSettingsPopover({ configuredProviders, currentProviderId, currentModelId, currentProviderConfig, currentModel, setModel, thinkingConfig, onThinkingChange, t, }: { configuredProviders: ConfiguredProvider[]; currentProviderId: ProviderId; currentModelId: string; currentProviderConfig: { name: string; icon?: string } | undefined; currentModel?: ModelInfo; setModel: (providerId: ProviderId, modelId: string) => void; thinkingConfig?: ThinkingConfig; onThinkingChange: (config: ThinkingConfig | undefined) => void; t: (key: string) => string; }) { const [popoverOpen, setPopoverOpen] = useState(false); const [activeProviderId, setActiveProviderId] = useState(currentProviderId); const [searchQuery, setSearchQuery] = useState(''); const currentProvider = configuredProviders.find((provider) => provider.id === currentProviderId); const searchTerm = searchQuery.trim().toLowerCase(); const isSearching = searchTerm.length > 0; const providerEntries = useMemo(() => { const matchesSearch = (model: ModelInfo) => !searchTerm || model.name.toLowerCase().includes(searchTerm) || model.id.toLowerCase().includes(searchTerm); return configuredProviders .map((provider) => ({ provider, matchingModels: provider.models.filter(matchesSearch), })) .filter((entry) => !isSearching || entry.matchingModels.length > 0); }, [configuredProviders, isSearching, searchTerm]); const activeProviderVisible = providerEntries.some( (entry) => entry.provider.id === activeProviderId, ); const firstVisibleProviderId = providerEntries[0]?.provider.id; const resolvedActiveProviderId = isSearching && !activeProviderVisible ? firstVisibleProviderId : activeProviderId; const activeProviderEntry = providerEntries.find((entry) => entry.provider.id === resolvedActiveProviderId) ?? providerEntries[0]; const activeProvider = activeProviderEntry?.provider; const visibleModelEntries = useMemo(() => { if (!activeProviderEntry) return []; const { provider, matchingModels } = activeProviderEntry; return matchingModels.map((model) => ({ provider, model })); }, [activeProviderEntry]); const currentProviderName = currentProvider?.name ?? currentProviderConfig?.name ?? currentProviderId; const currentProviderIcon = currentProvider?.icon ?? currentProviderConfig?.icon; // Under the #580 invariant this popover only renders when a usable provider // exists, which guarantees a concrete model — so the label is always // provider / model (no "Select Model" fallback state). const currentModelLabel = currentModel?.name || currentModelId; const currentThinkingValue = getThinkingDisplayValue( currentModel?.capabilities?.thinking, thinkingConfig, ); const currentThinkingLabel = formatCompactThinkingValue(currentThinkingValue, t); return ( { setPopoverOpen(nextOpen); if (nextOpen) { setActiveProviderId(currentProviderId); setSearchQuery(''); } }} > {`${currentProviderConfig?.name || currentProviderId} / ${currentModelId}`}
{t('toolbar.selectProvider')}
{providerEntries.length === 0 ? (
{t('settings.noModelsFound')}
) : ( providerEntries.map(({ provider, matchingModels }) => { const isActive = activeProvider?.id === provider.id; const isCurrent = currentProviderId === provider.id; return ( ); }) )}
{ const nextSearch = event.target.value; setSearchQuery(nextSearch); if (!nextSearch.trim()) setActiveProviderId(currentProviderId); }} placeholder={t('settings.searchModels')} className="h-8 pl-8 text-xs" />
{visibleModelEntries.length === 0 ? (
{searchQuery ? t('settings.noModelsFound') : t('settings.noModelsAvailable')}
) : ( visibleModelEntries.map(({ provider, model }) => { const isSelected = currentProviderId === provider.id && currentModelId === model.id; const selectModel = () => { setActiveProviderId(provider.id); setModel(provider.id, model.id); }; return (
{ if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); selectModel(); }} className={cn( 'mb-1 flex min-h-11 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left transition-colors', isSelected ? 'bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-950/25 dark:text-violet-300 dark:ring-violet-800' : 'hover:bg-muted/60', )} >
{model.name}
{model.id !== model.name && (
{model.id}
)}
{isSelected && currentModel && ( )} {isSelected && ( )}
); }) )}
); }