import { useState, useRef, useMemo } 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 { PDF_PROVIDERS } from '@/lib/pdf/constants'; import type { PDFProviderId } from '@/lib/pdf/types'; import { WEB_SEARCH_PROVIDERS } 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'; // ─── Constants ─────────────────────────────────────────────── const MAX_PDF_SIZE_MB = 50; const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024; // ─── Types ─────────────────────────────────────────────────── export interface GenerationToolbarProps { webSearch: boolean; onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; // PDF pdfFile: File | null; onPdfFileChange: (file: File | null) => void; onPdfError: (error: string | null) => void; } // ─── Component ─────────────────────────────────────────────── export function GenerationToolbar({ webSearch, onWebSearchChange, onSettingsOpen, pdfFile, onPdfFileChange, onPdfError, }: 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 if the selected web search provider has a valid config (API key or server-configured) const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId]; const webSearchConfig = webSearchProvidersConfig[webSearchProviderId]; const webSearchAvailable = webSearchProvider ? !webSearchProvider.requiresApiKey || !!webSearchConfig?.apiKey || !!webSearchConfig?.isServerConfigured : false; // Configured LLM providers (only those with valid credentials + models + endpoint) const configuredProviders = providersConfig ? Object.entries(providersConfig) .filter( ([, config]) => (!config.requiresApiKey || config.apiKey || config.isServerConfigured) && config.models.length >= 1 && (config.baseUrl || config.defaultBaseUrl || config.serverBaseUrl), ) .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((m) => new Set(config.serverModels).has(m.id)) : config.models, })) : []; const currentProviderConfig = providersConfig?.[currentProviderId]; const currentModel = currentProviderConfig?.models.find((model) => model.id === currentModelId); const currentThinkingConfig = thinkingConfigs[getThinkingConfigKey(currentProviderId, currentModelId)]; // PDF handler const handleFileSelect = (file: File) => { if (file.type !== 'application/pdf') return; if (file.size > MAX_PDF_SIZE_BYTES) { onPdfError(t('upload.fileTooLarge')); return; } onPdfError(null); onPdfFileChange(file); }; // ─── 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 ── */}
{/* ── PDF (parser + upload) combined Popover ── */} {pdfFile ? ( ) : ( )} {/* Parser selector */}
{t('toolbar.pdfParser')}
{/* Upload area / file info */}
{ const f = e.target.files?.[0]; if (f) handleFileSelect(f); e.target.value = ''; }} /> {pdfFile ? (

{pdfFile.name}

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

) : (
fileInputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={(e) => { e.preventDefault(); setIsDragging(false); const f = e.dataTransfer.files?.[0]; if (f) handleFileSelect(f); }} >

{t('toolbar.pdfUpload')}

{t('upload.pdfSizeLimit')}

)}
{/* ── 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; const currentModelLabel = currentModel?.name || currentModelId || t('settings.selectModel'); const currentThinkingValue = getThinkingDisplayValue( currentModel?.capabilities?.thinking, thinkingConfig, ); const currentThinkingLabel = formatCompactThinkingValue(currentThinkingValue, t); return ( { setPopoverOpen(nextOpen); if (nextOpen) { setActiveProviderId(currentProviderId); setSearchQuery(''); } }} > {currentModelId ? `${currentProviderConfig?.name || currentProviderId} / ${currentModelId}` : t('settings.selectModel')}
{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 && ( )}
); }) )}
); }