import { useId, type ComponentType } from 'react' import { useTranslation } from '../../i18n' import { useIsDark } from '../../hooks/useIsDark' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared' import EmptyState from '../shared/EmptyState' import DawarichIcon from '../shared/DawarichIcon' import AirTrailIcon from '../shared/AirTrailIcon' import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons' import AddonTile from './AddonTile' import AddonSubRow from './AddonSubRow' import { Tooltip } from '../shared/Tooltip' import { fs } from '../shared/DialogShell' import { EditorField, INPUT, Segmented } from '../shared/dialogParts' import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit' import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel' import { useAddonManager } from './addons/useAddonManager' import { useLlmParsingConfig } from './addons/useLlmParsingConfig' import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons' import WandererMark from '../Tours/wanderer/WandererMark' // Keys are the `icon` column from the addons table (see server seeds.ts); anything // unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which // used to land on the fallback. const ICON_MAP = { ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles, Mountain, // Road trip was seeded as 'Route'; the car says what it is better than a line does. Route: CarFront, // Dawarich ships a brand mark rather than a lucide name, the same way the photo // providers below do. Keyed by the string in the addons table. Dawarich: DawarichIcon, } const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, // The document providers' own marks, single-colour so they read as glyphs in // a row of lucide icons and invert with the theme. ...DOCUMENT_PROVIDER_ICONS, } interface AddonIconProps { name: string size?: number /** A switched-off addon greys its icon out, brand marks included. */ enabled?: boolean } function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) { // The brand marks are badges, not glyphs: they fill the framed slot instead of // sitting in the middle of it at glyph size, and they grey out when the addon // is off — the lucide glyphs get that from the slot's text colour, an // has to be told. if (name === 'Dawarich') return // 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE // means every existing install still carries it — so the brand is keyed on // that rather than on a new name no row would ever have. if (name === 'Plane') return const Icon = ICON_MAP[name] || Puzzle return } /** What each type means, shown on the tile itself now that the sections are gone. The * hint rides along as the tooltip, so "Global" still explains itself. */ const TYPE_META: Record; labelKey: string; hintKey: string }> = { trip: { icon: Briefcase, labelKey: 'admin.addons.type.trip', hintKey: 'admin.addons.tripHint' }, global: { icon: Globe, labelKey: 'admin.addons.type.global', hintKey: 'admin.addons.globalHint' }, integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' }, } export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) { const { t } = useTranslation() const dark = useIsDark() const { addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions, toursWanderer, toggleToursWanderer, mapSwitches, } = useAddonManager() if (loading) { return (
) } /** Bag tracking, the collab features and the photo providers all hang off their * parent as shelf rows, and each shelf only shows while its parent is on — * a provider toggled under a disabled Journey would hit the server's 409. */ const shelfFor = (addon: Addon) => { if (addon.id === 'packing' && addon.enabled && onToggleBagTracking) { return ( } title={t('admin.bagTracking.title')} description={t('admin.bagTracking.subtitle')} enabled={!!bagTrackingEnabled} onToggle={onToggleBagTracking} /> ) } if (addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature) { return COLLAB_SUB_FEATURES.map(feat => { const Icon = feat.icon return ( } title={t(feat.titleKey)} description={t(feat.subtitleKey)} enabled={collabFeatures[feat.key]} onToggle={() => onToggleCollabFeature(feat.key)} /> ) }) } // Document providers are the Documents tile's shelf, the way photo providers // are Journey's. Unlike those they carry no credential form here: a document // connection belongs to a trip, not to a user, so it is entered in the trip's // file manager. The admin decides only whether a provider may be offered. if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) { return documentProviderOptions.map(provider => { const ProviderIcon = PROVIDER_ICONS[provider.key] return ( : undefined} title={provider.label} description={provider.description} enabled={provider.enabled} onToggle={provider.toggle} /> ) }) } if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) { return providerOptions.map(provider => { const ProviderIcon = PROVIDER_ICONS[provider.key] return ( : undefined} title={provider.label} description={provider.description} enabled={provider.enabled} onToggle={provider.toggle} /> ) }) } if (addon.id === 'tours' && addon.enabled) { return ( <> } title={t('admin.toursWanderer.title')} description={t('admin.toursWanderer.subtitle')} enabled={toursWanderer} onToggle={() => void toggleToursWanderer()} /> {mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => ( } title={t(titleKey)} description={t(subtitleKey)} enabled={enabled} onToggle={() => void toggle()} /> ))} ) } // The AI-parsing settings hang off their tile like every other sub-feature, // just as a form instead of toggle rows. if (addon.id === 'llm_parsing' && addon.enabled) { return } return null } const tile = (addon: Addon) => { const label = getAddonLabel(t, addon) return ( } name={label.name} description={label.description} enabled={addon.enabled} onToggle={() => handleToggle(addon)} > {shelfFor(addon)} ) } /* One column per type, side by side, instead of three stacked sections. Stacked, each section opened its own set of columns and the tall tiles (Collab, Journey) set a section's height while its neighbours ran out early. Side by side the column's head carries the type, so the tiles need no badge of their own. */ const groups = [ { key: 'trip', addons: tripAddons }, { key: 'global', addons: globalAddons }, { key: 'integration', addons: integrationAddons }, ].filter(g => g.addons.length > 0) const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length const subtitle = ( {t('admin.addons.subtitleBefore')} TREK {t('admin.addons.subtitleAfter')} ) return ( 0 ? ( 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount} ) : undefined} > {addons.length === 0 ? ( ) : (
{groups.map(g => { const meta = TYPE_META[g.key] return (
{g.addons.map(tile)}
) })}
)}
) } /** The head band of one type's column: icon tile, the type, its count and what it means. */ function GroupHead({ icon: Icon, label, hint, addons, t }: { icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }> label: string hint: string addons: Addon[] t: (key: string, params?: Record) => string }) { const enabled = addons.filter(a => a.enabled).length return (

{label}

{enabled}/{addons.length}
{/* Says what the type means: the only place that still explains it. */}

{hint}

) } const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint' const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60' /** * Instance-wide AI-parsing config. When set, applies to the whole instance and * overrides per-user config (see server llmConfig.ts). The API key is masked on * read; an unchanged mask is treated as a no-op by the server. For the local * provider, it also lists installed Ollama models and can pull NuExtract models. */ function LlmParsingConfig({ addon }: { addon: Addon }) { const { t } = useTranslation() const { provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving, installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save, } = useLlmParsingConfig(addon) const fieldId = useId() const providerOptions = [ { value: 'local', label: 'Local · OpenAI-compatible', icon: , badge: 'Ollama' }, { value: 'openai', label: 'OpenAI', icon: }, { value: 'anthropic', label: 'Anthropic', icon: }, ] const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) })) /* Lives in the tile's shelf like the collab toggles, so it is one column of eyebrow fields: the band this used to be had a whole page width. */ return (
  • Instance-wide — applies to all users. Leave blank to let each user configure their own provider. setProvider(String(v))} options={providerOptions} /> setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} /> setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} /> setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} /> label={t('settings.aiParsing.multimodal')} value={vision} onChange={v => setVision(asLlmVision(v))} options={visionOptions} fill /> {/* Local model management (Ollama) */} {provider === 'local' && (
    Installed on the server
    {modelsErr &&

    {modelsErr}

    } {!modelsErr && installed.length === 0 && !loadingModels && ( No models installed yet — pull one below. )} {installed.length > 0 && (
    {installed.map(name => ( ))}
    )}
    Pull a recommended model {RECOMMENDED_MODELS.map(m => { const installedHere = isInstalled(m.id) const isPulling = pulling === m.id const active = model === m.id return (
    {m.label} {m.recommended && Recommended} {installedHere ? ( ) : ( )}
    {isPulling && (
    {pullStatus}{pullPct ? ` · ${pullPct}%` : ''}
    )}
    ) })}
    )}
  • ) }