import { Fragment, useState, useEffect, useMemo } from 'react' import { Dialog, Transition } from '@headlessui/react' import { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react' import { useQuery } from '@tanstack/react-query' import type { CategoryWithStatus, SpecTier, SpecResource } from '../../types/collections' import { resolveTierResources } from '~/lib/collections' import { formatBytes } from '~/lib/util' import api from '~/lib/api' import classNames from 'classnames' import DynamicIcon, { DynamicIconName } from './DynamicIcon' import StyledButton from './StyledButton' import KbGuardrailModal from './KbGuardrailModal' import { evaluateGuardrail, type GuardrailVerdict } from '~/lib/kb_guardrail' import { useSystemInfo } from '~/hooks/useSystemInfo' import { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData' /** * Filename for the embed-estimate registry lookup. Strips the URL path so * patterns like `wikipedia_en_simple_` continue to match upstream filenames * regardless of mirror domain. */ function resourceFilename(resource: SpecResource): string { const last = resource.url.split('/').pop() return last && last.length > 0 ? last : resource.id } interface TierSelectionModalProps { isOpen: boolean onClose: () => void category: CategoryWithStatus | null selectedTierSlug?: string | null onSelectTier: (category: CategoryWithStatus, tier: SpecTier) => void } const TierSelectionModal: React.FC = ({ isOpen, onClose, category, selectedTierSlug, onSelectTier, }) => { // Local selection state - initialized from prop const [localSelectedSlug, setLocalSelectedSlug] = useState(null) // Reset local selection when modal opens or category changes useEffect(() => { if (isOpen && category) { setLocalSelectedSlug(selectedTierSlug || null) } }, [isOpen, category, selectedTierSlug]) // Get all resources for a tier (including inherited resources). Defined as a // hook-safe closure (always callable, returns [] when no category) so the // memo below can depend on `category` without breaking hook order. const getAllResourcesForTier = (tier: SpecTier): SpecResource[] => { if (!category) return [] return resolveTierResources(tier, category.tiers) } // Pre-compute the selected tier's resources outside the JSX so hooks below // don't re-run on every render. Empty array when no selection. const selectedTierResources = useMemo(() => { if (!category || !localSelectedSlug) return [] const tier = category.tiers.find((t) => t.slug === localSelectedSlug) return tier ? resolveTierResources(tier, category.tiers) : [] }, [category, localSelectedSlug]) const embedEstimateRequest = useMemo( () => selectedTierResources.map((r) => ({ filename: resourceFilename(r), sizeBytes: Math.round(r.size_mb * 1024 * 1024), })), [selectedTierResources] ) const { data: embedEstimate, isLoading: isEstimating } = useQuery({ queryKey: ['embedEstimateBatch', embedEstimateRequest], queryFn: () => api.estimateEmbeddingBatch(embedEstimateRequest), enabled: embedEstimateRequest.length > 0, staleTime: 5 * 60_000, }) const { data: ingestPolicySetting } = useQuery({ queryKey: ['ingestPolicy'], queryFn: () => api.getSetting('rag.defaultIngestPolicy'), }) // System info for the disk-free side of the guardrail. Shared queryKey with // the home / easy-setup pages so we don't refetch when the user already has // a fresh copy in cache from a sibling component. const { data: systemInfo } = useSystemInfo({ enabled: true }) // Open state for the guardrail modal — separate from the tier modal so the // user sees the warning as an overlay without losing their tier selection // underneath. Cancel returns to the tier modal as-is; Proceed closes both // and runs the original onSelectTier path. const [guardrailVerdict, setGuardrailVerdict] = useState(null) // Compute disk-free bytes from system info; 0 means "unknown", which the // guardrail helper treats as "skip the relative-disk check". // Must be declared before the `!category` early return so the hook count // stays constant across renders (category transitions null → non-null when // the user opens the modal). const freeBytes = useMemo(() => { const primary = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize) if (!primary) return 0 return Math.max(0, primary.totalSize - primary.totalUsed) }, [systemInfo]) const ingestPolicy: 'Always' | 'Manual' = ingestPolicySetting?.value === 'Manual' ? 'Manual' : 'Always' if (!category) return null const getTierTotalSize = (tier: SpecTier): number => { return getAllResourcesForTier(tier).reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0) } const handleTierClick = (tier: SpecTier) => { // Toggle selection: if clicking the same tier, deselect it if (localSelectedSlug === tier.slug) { setLocalSelectedSlug(null) } else { setLocalSelectedSlug(tier.slug) } } /** * Runs the original onSelectTier-then-onClose flow. Pulled out of * handleSubmit so the guardrail modal's confirm path can call it after * the user has consented to the large operation. */ const finalizeSubmit = () => { if (!localSelectedSlug || !category) return const selectedTier = category.tiers.find((t) => t.slug === localSelectedSlug) if (selectedTier) { onSelectTier(category, selectedTier) } onClose() } const handleSubmit = () => { if (!localSelectedSlug || !category) return // Guardrail only runs when we have an estimate AND the global policy // would auto-index this batch. Under Manual the user has already opted // out of automatic ingestion, so the bulk-disk warning would be a false // alarm — the files would just queue as pending_decision. if (ingestPolicy === 'Always' && embedEstimate) { const verdict = evaluateGuardrail({ estimateBytes: embedEstimate.totalBytes, freeBytes, }) if (verdict.trips) { setGuardrailVerdict(verdict) return } } finalizeSubmit() } return ( <>
{/* Header */}
{category.name}

{category.description}

{/* Content */}

Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.

{category.tiers.map((tier) => { const totalSize = getTierTotalSize(tier) const isSelected = localSelectedSlug === tier.slug const includedTierName = tier.includesTier ? category.tiers.find(t => t.slug === tier.includesTier)?.name : null // Only show this tier's own resources (not inherited) const ownResources = tier.resources const ownResourceCount = ownResources.length return (
handleTierClick(tier)} className={classNames( 'border-2 rounded-lg p-5 cursor-pointer transition-all', isSelected ? 'border-desert-green bg-desert-green/5 shadow-md' : 'border-border-subtle hover:border-desert-green/50 hover:shadow-sm' )} >

{tier.name}

{includedTierName && ( (includes {includedTierName}) )}

{tier.description}

{/* Resources preview - only show this tier's own resources */}

{includedTierName ? ( <> {ownResourceCount} additional {ownResourceCount === 1 ? 'resource' : 'resources'} (plus everything in {includedTierName}) ) : ( <>{ownResourceCount} {ownResourceCount === 1 ? 'resource' : 'resources'} included )}

{ownResources.map((resource, idx) => (
{resource.title} ({formatBytes(resource.size_mb * 1024 * 1024, 0)})
))}
{formatBytes(totalSize, 1)}
{isSelected && }
) })}
{/* Embedding-cost preview — visible whenever a tier is selected. The estimate uses #891's ratio registry to project how much extra disk space the AI Assistant will need for these files on top of the raw downloads. */} {localSelectedSlug && embedEstimate && embedEstimate.totalBytes > 0 && (

+~{formatBytes(embedEstimate.totalBytes, 1)} {' '}of additional storage if these are indexed for the AI Assistant {embedEstimate.hasUnknown && ( (estimate excludes some files we have no prior data for) )} .

{ingestPolicy === 'Always' ? ( <> Your Auto-index setting is Always, so these files will be indexed automatically once downloaded. You can change this in the Knowledge Base settings. ) : ( <> Your Auto-index setting is Manual, so these files will sit unindexed until you opt in from the Knowledge Base settings. )}

)} {/* Info note */}

You can change your selection at any time. Click Submit to confirm your choice.

{/* Footer */}
0 && isEstimating)} > Submit
{guardrailVerdict && ( { setGuardrailVerdict(null) finalizeSubmit() }} onCancel={() => setGuardrailVerdict(null)} /> )} ) } export default TierSelectionModal