|
|
|
|
| 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'; |
|
|
| |
| const MAX_PDF_SIZE_MB = 50; |
| const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024; |
|
|
| |
| export interface GenerationToolbarProps { |
| webSearch: boolean; |
| onWebSearchChange: (v: boolean) => void; |
| onSettingsOpen: (section?: SettingsSection) => void; |
| |
| pdfFile: File | null; |
| onPdfFileChange: (file: File | null) => void; |
| onPdfError: (error: string | null) => void; |
| } |
|
|
| |
| 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<HTMLInputElement>(null); |
| const [isDragging, setIsDragging] = useState(false); |
|
|
| |
| const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId]; |
| const webSearchConfig = webSearchProvidersConfig[webSearchProviderId]; |
| const webSearchAvailable = webSearchProvider |
| ? !webSearchProvider.requiresApiKey || |
| !!webSearchConfig?.apiKey || |
| !!webSearchConfig?.isServerConfigured |
| : false; |
|
|
| |
| 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)]; |
|
|
| |
| 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); |
| }; |
|
|
| |
| 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 ( |
| <div className="flex items-center gap-1 flex-wrap"> |
| {/* ββ Model selector ββ */} |
| {configuredProviders.length > 0 ? ( |
| <ModelSettingsPopover |
| configuredProviders={configuredProviders} |
| currentProviderId={currentProviderId} |
| currentModelId={currentModelId} |
| currentProviderConfig={currentProviderConfig} |
| currentModel={currentModel} |
| setModel={setModel} |
| thinkingConfig={currentThinkingConfig} |
| onThinkingChange={(config) => |
| setThinkingConfig(currentProviderId, currentModelId, config) |
| } |
| t={t} |
| /> |
| ) : ( |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <button |
| onClick={() => onSettingsOpen('providers')} |
| className={cn( |
| pillCls, |
| 'text-amber-600 dark:text-amber-400 animate-pulse', |
| 'bg-amber-50 dark:bg-amber-950/30 hover:bg-amber-100 dark:hover:bg-amber-950/50', |
| )} |
| > |
| <Bot className="size-3.5" /> |
| <span>{t('toolbar.configureProvider')}</span> |
| </button> |
| </TooltipTrigger> |
| <TooltipContent>{t('toolbar.configureProviderHint')}</TooltipContent> |
| </Tooltip> |
| )} |
| |
| <div className="flex min-w-0 items-center gap-1"> |
| {/* ββ Separator ββ */} |
| <div className="w-px h-4 bg-border/60 mx-1" /> |
| |
| {/* ββ PDF (parser + upload) combined Popover ββ */} |
| <Popover> |
| <PopoverTrigger asChild> |
| {pdfFile ? ( |
| <button className={pillActive}> |
| <Paperclip className="size-3.5" /> |
| <span className="max-w-[100px] truncate">{pdfFile.name}</span> |
| <span |
| role="button" |
| className="size-4 rounded-full inline-flex items-center justify-center hover:bg-violet-200 dark:hover:bg-violet-800 transition-colors" |
| onClick={(e) => { |
| e.stopPropagation(); |
| onPdfFileChange(null); |
| }} |
| > |
| <X className="size-2.5" /> |
| </span> |
| </button> |
| ) : ( |
| <button className={pillMuted}> |
| <Paperclip className="size-3.5" /> |
| </button> |
| )} |
| </PopoverTrigger> |
| <PopoverContent align="start" className="w-72 p-0"> |
| {/* Parser selector */} |
| <div className="flex items-center gap-2 px-3 pt-3 pb-2"> |
| <span className="text-xs font-medium text-muted-foreground shrink-0"> |
| {t('toolbar.pdfParser')} |
| </span> |
| <Select |
| value={pdfProviderId} |
| onValueChange={(v) => setPDFProvider(v as PDFProviderId)} |
| > |
| <SelectTrigger className="h-7 text-xs flex-1 min-w-0"> |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent> |
| {Object.values(PDF_PROVIDERS).map((provider) => { |
| const cfg = pdfProvidersConfig[provider.id]; |
| const available = |
| !provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured; |
| return ( |
| <SelectItem key={provider.id} value={provider.id} disabled={!available}> |
| <div |
| className={cn('flex items-center gap-1.5', !available && 'opacity-50')} |
| > |
| {provider.icon && ( |
| <img src={provider.icon} alt={provider.name} className="w-3.5 h-3.5" /> |
| )} |
| {provider.name} |
| {cfg?.isServerConfigured && ( |
| <span className="text-[9px] px-1 py-0 rounded border text-muted-foreground"> |
| {t('settings.serverConfigured')} |
| </span> |
| )} |
| </div> |
| </SelectItem> |
| ); |
| })} |
| </SelectContent> |
| </Select> |
| </div> |
| |
| {/* Upload area / file info */} |
| <div className="px-3 pb-3"> |
| <input |
| type="file" |
| ref={fileInputRef} |
| className="hidden" |
| accept=".pdf" |
| onChange={(e) => { |
| const f = e.target.files?.[0]; |
| if (f) handleFileSelect(f); |
| e.target.value = ''; |
| }} |
| /> |
| {pdfFile ? ( |
| <div className="space-y-2"> |
| <div className="flex items-center gap-2"> |
| <div className="size-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center shrink-0"> |
| <FileText className="size-4 text-violet-600 dark:text-violet-400" /> |
| </div> |
| <div className="min-w-0 flex-1"> |
| <p className="text-sm font-medium truncate">{pdfFile.name}</p> |
| <p className="text-xs text-muted-foreground"> |
| {(pdfFile.size / 1024 / 1024).toFixed(2)} MB |
| </p> |
| </div> |
| </div> |
| <button |
| onClick={() => onPdfFileChange(null)} |
| className="w-full text-xs text-destructive hover:underline text-left" |
| > |
| {t('toolbar.removePdf')} |
| </button> |
| </div> |
| ) : ( |
| <div |
| className={cn( |
| 'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors cursor-pointer', |
| isDragging |
| ? 'border-violet-400 bg-violet-50 dark:bg-violet-950/20' |
| : 'border-muted-foreground/20 hover:border-violet-300', |
| )} |
| onClick={() => 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); |
| }} |
| > |
| <Paperclip className="size-5 text-muted-foreground/50 mb-1.5" /> |
| <p className="text-xs font-medium">{t('toolbar.pdfUpload')}</p> |
| <p className="text-[10px] text-muted-foreground/60 mt-0.5"> |
| {t('upload.pdfSizeLimit')} |
| </p> |
| </div> |
| )} |
| </div> |
| </PopoverContent> |
| </Popover> |
| |
| {/* ββ Web Search ββ */} |
| {webSearchAvailable ? ( |
| <Popover> |
| <PopoverTrigger asChild> |
| <button className={webSearch ? pillActive : pillMuted}> |
| <Globe2 className={cn('size-3.5', webSearch && 'animate-pulse')} /> |
| {webSearch && ( |
| <span>{WEB_SEARCH_PROVIDERS[webSearchProviderId]?.name || 'Search'}</span> |
| )} |
| </button> |
| </PopoverTrigger> |
| <PopoverContent align="start" className="w-64 p-3 space-y-3"> |
| {/* Toggle */} |
| <button |
| onClick={() => onWebSearchChange(!webSearch)} |
| className={cn( |
| 'w-full flex items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-all', |
| webSearch |
| ? 'bg-violet-50 dark:bg-violet-950/20 border-violet-200 dark:border-violet-800' |
| : 'border-border hover:bg-muted/50', |
| )} |
| > |
| <Globe2 |
| className={cn( |
| 'size-4 shrink-0', |
| webSearch ? 'text-violet-600 dark:text-violet-400' : 'text-muted-foreground', |
| )} |
| /> |
| <div className="flex-1 min-w-0"> |
| <p className="text-xs font-medium"> |
| {webSearch ? t('toolbar.webSearchOn') : t('toolbar.webSearchOff')} |
| </p> |
| <p className="text-[10px] text-muted-foreground/70 mt-0.5"> |
| {t('toolbar.webSearchDesc')} |
| </p> |
| </div> |
| </button> |
| |
| {/* Provider selector */} |
| <div className="flex items-center gap-2"> |
| <span className="text-xs font-medium text-muted-foreground shrink-0"> |
| {t('toolbar.webSearchProvider')} |
| </span> |
| <Select |
| value={webSearchProviderId} |
| onValueChange={(v) => setWebSearchProvider(v as WebSearchProviderId)} |
| > |
| <SelectTrigger className="h-7 text-xs flex-1 min-w-0"> |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent> |
| {Object.values(WEB_SEARCH_PROVIDERS).map((provider) => { |
| const cfg = webSearchProvidersConfig[provider.id]; |
| const available = |
| !provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured; |
| return ( |
| <SelectItem key={provider.id} value={provider.id} disabled={!available}> |
| <div |
| className={cn('flex items-center gap-1.5', !available && 'opacity-50')} |
| > |
| {provider.name} |
| {cfg?.isServerConfigured && ( |
| <span className="text-[9px] px-1 py-0 rounded border text-muted-foreground"> |
| {t('settings.serverConfigured')} |
| </span> |
| )} |
| </div> |
| </SelectItem> |
| ); |
| })} |
| </SelectContent> |
| </Select> |
| </div> |
| </PopoverContent> |
| </Popover> |
| ) : ( |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <button |
| className={cn(pillCls, 'text-muted-foreground/40 cursor-not-allowed')} |
| disabled |
| > |
| <Globe2 className="size-3.5" /> |
| </button> |
| </TooltipTrigger> |
| <TooltipContent>{t('toolbar.webSearchNoProvider')}</TooltipContent> |
| </Tooltip> |
| )} |
| |
| {/* ββ Separator ββ */} |
| <div className="w-px h-4 bg-border/60 mx-1" /> |
| |
| {/* ββ Media popover ββ */} |
| <MediaPopover onSettingsOpen={onSettingsOpen} /> |
| </div> |
| </div> |
| ); |
| } |
|
|
| 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 ( |
| <div |
| className="flex min-w-0 shrink-0 items-center gap-1" |
| onClick={(event) => event.stopPropagation()} |
| onMouseDown={(event) => event.stopPropagation()} |
| onPointerDown={(event) => event.stopPropagation()} |
| onKeyDown={(event) => event.stopPropagation()} |
| > |
| <Brain className="size-3.5 shrink-0 text-violet-500" /> |
| <div className="flex min-w-0 items-center gap-0.5 rounded-full border border-violet-200/70 bg-white/65 p-0.5 dark:border-violet-800/70 dark:bg-violet-950/25"> |
| {hasAutoBudget && ( |
| <Select |
| value={autoBudgetMode} |
| onValueChange={(mode) => applyBudgetMode(mode as 'disabled' | 'enabled' | 'auto')} |
| > |
| <SelectTrigger |
| size="sm" |
| className="h-6 min-w-[76px] 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" |
| > |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent align="end" className="min-w-[96px]"> |
| {thinking.control === 'toggle-budget' && ( |
| <SelectItem value="disabled" className={selectItemCls}> |
| {t('toolbar.off')} |
| </SelectItem> |
| )} |
| <SelectItem value="enabled" className={selectItemCls}> |
| {t('toolbar.on')} |
| </SelectItem> |
| <SelectItem value="auto" className={selectItemCls}> |
| {t('toolbar.auto')} |
| </SelectItem> |
| </SelectContent> |
| </Select> |
| )} |
| |
| {(thinking.control === 'toggle' || |
| (thinking.control === 'toggle-budget' && !hasAutoBudget) || |
| thinking.control === 'mode') && ( |
| <Select |
| value={simpleMode} |
| onValueChange={(mode) => applySimpleMode(mode as 'disabled' | 'enabled' | 'auto')} |
| > |
| <SelectTrigger |
| size="sm" |
| className="h-6 min-w-[76px] 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" |
| > |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent align="end" className="min-w-[96px]"> |
| {thinking.control === 'mode' && ( |
| <SelectItem value="auto" className={selectItemCls}> |
| {t('toolbar.auto')} |
| </SelectItem> |
| )} |
| <SelectItem value="disabled" className={selectItemCls}> |
| {t('toolbar.off')} |
| </SelectItem> |
| <SelectItem value="enabled" className={selectItemCls}> |
| {t('toolbar.on')} |
| </SelectItem> |
| </SelectContent> |
| </Select> |
| )} |
| |
| {thinking.control === 'level' && !!thinking.levelValues?.length && ( |
| <Select |
| value={effective?.level ?? thinking.defaultLevel ?? thinking.levelValues[0]} |
| onValueChange={(level) => |
| applyConfig({ ...effective, mode: 'enabled', level: level as ThinkingLevel }) |
| } |
| > |
| <SelectTrigger size="sm" className={selectTriggerCls}> |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent align="end" className="min-w-[96px]"> |
| {thinking.levelValues.map((level: ThinkingLevel) => ( |
| <SelectItem key={level} value={level} className={selectItemCls}> |
| {level} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| )} |
| |
| {thinking.control === 'effort' && !!thinking.effortValues?.length && ( |
| <Select |
| value={effective?.effort ?? thinking.defaultEffort ?? thinking.effortValues[0]} |
| onValueChange={(effort) => |
| applyConfig({ |
| ...effective, |
| mode: effort === 'none' ? 'disabled' : 'enabled', |
| effort: effort as ThinkingEffort, |
| }) |
| } |
| > |
| <SelectTrigger size="sm" className={selectTriggerCls}> |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent align="end" className="min-w-[104px]"> |
| {thinking.effortValues.map((effort: ThinkingEffort) => ( |
| <SelectItem key={effort} value={effort} className={selectItemCls}> |
| {formatThinkingValue(effort, t)} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| )} |
| |
| {(thinking.control === 'toggle-budget' || thinking.control === 'budget-only') && |
| thinking.budgetRange && |
| (!hasAutoBudget || autoBudgetMode === 'enabled') && ( |
| <label className="ml-0.5 grid h-6 shrink-0 grid-cols-[auto_60px] items-stretch overflow-hidden rounded-full border border-violet-200/70 bg-background dark:border-violet-800/70"> |
| <span className="grid h-[22px] shrink-0 place-items-center border-r border-violet-200/70 bg-muted/30 px-2 font-sans text-[11px] font-medium leading-[22px] text-muted-foreground dark:border-violet-800/70"> |
| {t('toolbar.thinkingBudget')} |
| </span> |
| <input |
| type="text" |
| inputMode="numeric" |
| aria-label={t('toolbar.thinkingBudget')} |
| disabled={effective?.mode === 'disabled'} |
| value={ |
| typeof effective?.budgetTokens === 'number' && effective.budgetTokens !== -1 |
| ? effective.budgetTokens |
| : '' |
| } |
| placeholder={`${thinking.budgetRange.min}-${thinking.budgetRange.max}`} |
| title={`${thinking.budgetRange.min}-${thinking.budgetRange.max} tokens`} |
| onChange={(event) => { |
| const rawValue = event.target.value.trim(); |
| if (!/^\d*$/.test(rawValue)) return; |
| const value = rawValue ? Number(rawValue) : undefined; |
| applyBudget(value); |
| }} |
| className="block h-[22px] w-[60px] border-0 bg-transparent px-1 py-0 text-center font-sans text-[11px] font-medium leading-[22px] tabular-nums outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" |
| /> |
| </label> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| 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<ProviderId>(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 ( |
| <Popover |
| open={popoverOpen} |
| onOpenChange={(nextOpen) => { |
| setPopoverOpen(nextOpen); |
| if (nextOpen) { |
| setActiveProviderId(currentProviderId); |
| setSearchQuery(''); |
| } |
| }} |
| > |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <PopoverTrigger asChild> |
| <button |
| aria-label={`${currentProviderName} / ${currentModelLabel}`} |
| className={cn( |
| 'inline-flex h-8 min-w-0 items-center gap-1.5 rounded-full border px-2 text-xs font-medium transition-all', |
| 'border-violet-200/70 bg-violet-50 text-violet-700 hover:bg-violet-100 dark:border-violet-800/70 dark:bg-violet-950/30 dark:text-violet-300', |
| currentModelId && |
| 'shadow-[0_0_0_1px_rgba(124,58,237,0.12)] dark:shadow-[0_0_0_1px_rgba(167,139,250,0.16)]', |
| )} |
| > |
| {currentProviderIcon ? ( |
| <img |
| src={currentProviderIcon} |
| alt={currentProviderName} |
| className="size-4 shrink-0 rounded-sm" |
| /> |
| ) : ( |
| <Bot className="size-3.5 shrink-0" /> |
| )} |
| {currentThinkingLabel && ( |
| <span className="shrink-0 rounded-full bg-white/80 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-violet-700 ring-1 ring-violet-200/70 dark:bg-violet-950/50 dark:text-violet-200 dark:ring-violet-800/70"> |
| {currentThinkingLabel} |
| </span> |
| )} |
| </button> |
| </PopoverTrigger> |
| </TooltipTrigger> |
| <TooltipContent> |
| {currentModelId |
| ? `${currentProviderConfig?.name || currentProviderId} / ${currentModelId}` |
| : t('settings.selectModel')} |
| </TooltipContent> |
| </Tooltip> |
| |
| <PopoverContent |
| align="start" |
| side="top" |
| sideOffset={8} |
| collisionPadding={12} |
| className="w-[640px] max-w-[calc(100vw-2rem)] overflow-hidden p-0" |
| > |
| <div className="grid h-[430px] grid-cols-[128px_minmax(0,1fr)] sm:grid-cols-[160px_minmax(0,1fr)]"> |
| <div className="min-h-0 border-r bg-muted/20"> |
| <div className="px-3 py-2 text-[10px] font-semibold uppercase text-muted-foreground"> |
| {t('toolbar.selectProvider')} |
| </div> |
| <div className="h-[calc(100%-28px)] overflow-y-auto px-2 pb-2 pt-1"> |
| {providerEntries.length === 0 ? ( |
| <div className="px-2 py-4 text-[11px] text-muted-foreground"> |
| {t('settings.noModelsFound')} |
| </div> |
| ) : ( |
| providerEntries.map(({ provider, matchingModels }) => { |
| const isActive = activeProvider?.id === provider.id; |
| const isCurrent = currentProviderId === provider.id; |
| return ( |
| <button |
| key={provider.id} |
| onClick={() => setActiveProviderId(provider.id)} |
| className={cn( |
| 'mb-1 flex h-10 w-full items-center gap-2 rounded-md px-2 text-left transition-colors', |
| isActive |
| ? 'bg-background text-foreground shadow-sm ring-1 ring-border/70' |
| : 'text-muted-foreground hover:bg-background/70 hover:text-foreground', |
| )} |
| > |
| {provider.icon ? ( |
| <img |
| src={provider.icon} |
| alt={provider.name} |
| className="size-4 shrink-0 rounded-sm" |
| /> |
| ) : ( |
| <Bot className="size-4 shrink-0" /> |
| )} |
| <div className="min-w-0 flex-1"> |
| <div className="truncate text-xs font-medium">{provider.name}</div> |
| <div className="text-[10px] text-muted-foreground"> |
| {isSearching ? `${matchingModels.length}/` : ''} |
| {provider.models.length} |
| </div> |
| </div> |
| {isCurrent && <span className="size-1.5 rounded-full bg-violet-500" />} |
| </button> |
| ); |
| }) |
| )} |
| </div> |
| </div> |
| |
| <div className="flex min-h-0 min-w-0 flex-col overflow-hidden"> |
| <div className="border-b p-3"> |
| <div className="flex flex-col gap-2 sm:flex-row sm:items-center"> |
| <div className="relative min-w-0 flex-1"> |
| <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" /> |
| <Input |
| value={searchQuery} |
| onChange={(event) => { |
| const nextSearch = event.target.value; |
| setSearchQuery(nextSearch); |
| if (!nextSearch.trim()) setActiveProviderId(currentProviderId); |
| }} |
| placeholder={t('settings.searchModels')} |
| className="h-8 pl-8 text-xs" |
| /> |
| </div> |
| </div> |
| </div> |
| |
| <div className="min-h-0 flex-1 overflow-y-auto p-2"> |
| {visibleModelEntries.length === 0 ? ( |
| <div className="px-3 py-8 text-center text-xs text-muted-foreground"> |
| {searchQuery ? t('settings.noModelsFound') : t('settings.noModelsAvailable')} |
| </div> |
| ) : ( |
| visibleModelEntries.map(({ provider, model }) => { |
| const isSelected = |
| currentProviderId === provider.id && currentModelId === model.id; |
| const selectModel = () => { |
| setActiveProviderId(provider.id); |
| setModel(provider.id, model.id); |
| }; |
| return ( |
| <div |
| key={`${provider.id}:${model.id}`} |
| role="button" |
| tabIndex={0} |
| onClick={selectModel} |
| onKeyDown={(event) => { |
| 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', |
| )} |
| > |
| <div className="min-w-0 flex-1"> |
| <div className="truncate font-mono text-xs font-medium">{model.name}</div> |
| {model.id !== model.name && ( |
| <div className="truncate font-mono text-[10px] text-muted-foreground"> |
| {model.id} |
| </div> |
| )} |
| </div> |
| {isSelected && currentModel && ( |
| <InlineThinkingControl |
| model={currentModel} |
| config={thinkingConfig} |
| onChange={onThinkingChange} |
| t={t} |
| /> |
| )} |
| {isSelected && ( |
| <Check className="size-3.5 shrink-0 text-violet-600 dark:text-violet-400" /> |
| )} |
| </div> |
| ); |
| }) |
| )} |
| </div> |
| </div> |
| </div> |
| </PopoverContent> |
| </Popover> |
| ); |
| } |
|
|