import { useEffect, useState, type ComponentType } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useAddonStore } from '../../store/addonStore' import { useIsDark } from '../../hooks/useIsDark' import { useToast } from '../shared/Toast' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import EmptyState from '../shared/EmptyState' import AddonTile from './AddonTile' import AddonSubRow from './AddonSubRow' // 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, } function ImmichIcon({ size = 14 }: { size?: number }) { return ( ) } function SynologyIcon({ size = 14 }: { size?: number }) { return ( ) } const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, } interface Addon { id: string name: string description: string icon: string type: string enabled: boolean config?: Record } interface ProviderOption { key: string label: string description: string enabled: boolean toggle: () => Promise } interface AddonIconProps { name: string size?: number } function AddonIcon({ name, size = 20 }: AddonIconProps) { 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' }, } interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean } const COLLAB_SUB_FEATURES = [ { key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' }, { key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' }, { key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' }, { key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' }, { key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' }, ] as const 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 toast = useToast() const refreshGlobalAddons = useAddonStore(s => s.loadAddons) const [addons, setAddons] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { loadAddons().finally(() => setLoading(false)) }, []) const loadAddons = async () => { try { const data = await adminApi.addons() setAddons(data.addons) } catch (err: unknown) { toast.error(t('admin.addons.toast.error')) } } /** Optimistic flip, per-row rollback on failure. Rolling the whole addons * snapshot back instead would undo a toggle the user hit in parallel. */ const handleToggle = async (addon: Addon) => { const newEnabled = !addon.enabled setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) try { await adminApi.updateAddon(addon.id, { enabled: newEnabled }) } catch (err: unknown) { setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a)) toast.error(t('admin.addons.toast.error')) return } refreshGlobalAddons() // Journey off disables every photo provider with it, and the response carries // only Journey. Without re-reading, switching Journey back on brings the shelf // up with providers the database has long since turned off. if (addon.id === 'journey') await loadAddons() toast.success(t('admin.addons.toast.updated')) } const isPhotoProviderAddon = (addon: Addon) => { return addon.type === 'photo_provider' } const isPhotosAddon = (addon: Addon) => { const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase() return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) } const photoProviderAddons = addons.filter(isPhotoProviderAddon) const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) const globalAddons = addons.filter(a => a.type === 'global') const integrationAddons = addons.filter(a => a.type === 'integration') const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ key: provider.id, label: provider.name, description: provider.description, enabled: provider.enabled, toggle: () => handleToggle(provider), })) 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)} /> ) }) } 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} /> ) }) } // 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 261px, Journey 185px) set a section's height while its neighbours ran out early. Side by side the column heading 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.enabled).length const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length return (

{t('admin.addons.title')}

{t('admin.addons.subtitleBefore')} TREK {t('admin.addons.subtitleAfter')}

{totalCount > 0 && ( {enabledCount}/{totalCount} )}
{addons.length === 0 ? ( ) : (
{groups.map(g => { const meta = TYPE_META[g.key] return (
{g.addons.map(tile)}
) })}
)}
) } function GroupHead({ icon: Icon, label, hint, addons, t }: { icon: ComponentType<{ size?: number; className?: string }> 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 MASKED = '••••••••' const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1' /** Curated models the local extractor is tuned for, pullable via Ollama. The router drives * one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled * automatically, so the Qwen3 family works without any tuning. A host only needs one. */ const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [ { id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false }, ] /** * 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 toast = useToast() const cfg = (addon.config ?? {}) as Record const [provider, setProvider] = useState((cfg.provider as string) ?? 'local') const [model, setModel] = useState((cfg.model as string) ?? '') const [baseUrl, setBaseUrl] = useState((cfg.baseUrl as string) ?? '') const [apiKey, setApiKey] = useState((cfg.apiKey as string) ?? '') const [saving, setSaving] = useState(false) // Local-provider model management. const [installed, setInstalled] = useState([]) const [modelsErr, setModelsErr] = useState('') const [loadingModels, setLoadingModels] = useState(false) const [pulling, setPulling] = useState(null) const [pullPct, setPullPct] = useState(0) const [pullStatus, setPullStatus] = useState('') const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id)) const loadModels = async () => { if (provider !== 'local') return setLoadingModels(true) setModelsErr('') try { const res = await adminApi.llmLocalModels(effectiveUrl) setInstalled(res.models.map(m => m.name)) } catch (e: unknown) { setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server') setInstalled([]) } finally { setLoadingModels(false) } } // Load installed models when the local provider is active. useEffect(() => { if (provider === 'local') loadModels() // eslint-disable-next-line react-hooks/exhaustive-deps }, [provider]) const pull = async (id: string) => { if (pulling) return setPulling(id) setPullPct(0) setPullStatus('starting…') try { await adminApi.llmLocalPull(effectiveUrl, id, (p) => { if (p.error) throw new Error(p.error) if (p.status) setPullStatus(p.status) if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100)) }) toast.success('Model pulled') setModel(id) await loadModels() } catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Pull failed') } finally { setPulling(null) setPullPct(0) setPullStatus('') } } const save = async () => { setSaving(true) try { // Send the masked sentinel unchanged so the server keeps the stored key. await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } }) toast.success('Saved') } catch { toast.error('Failed to save') } finally { setSaving(false) } } const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-2.5 py-1.5 text-caption text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none' const labelCls = 'mb-1 block text-caption font-medium text-content-secondary' const providerOptions = [ { value: 'local', label: 'Local · OpenAI-compatible', icon: , badge: 'Ollama' }, { value: 'openai', label: 'OpenAI', icon: }, { value: 'anthropic', label: 'Anthropic', icon: }, ] /* Lives in the tile's shelf like the collab toggles, so everything is caption- sized and single-column — 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.

    Provider setProvider(String(v))} options={providerOptions} />
    {provider !== 'anthropic' && ( )} {provider === 'anthropic' && (

    Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.

    )} {/* 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}%` : ''}
    )}
    ) })}
    )}
  • ) } function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { const nameKey = `admin.addons.catalog.${addon.id}.name` const descKey = `admin.addons.catalog.${addon.id}.description` const translatedName = t(nameKey) const translatedDescription = t(descKey) return { name: translatedName !== nameKey ? translatedName : addon.name, description: translatedDescription !== descKey ? translatedDescription : addon.description, } }