mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
1ad2e92142
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.
**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.
`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.
**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.
Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.
sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
611 lines
33 KiB
TypeScript
611 lines
33 KiB
TypeScript
import { useEffect, useState, type ComponentType } from 'react'
|
|
import { adminApi } from '../../api/client'
|
|
import { useTranslation } from '../../i18n'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { useAddonStore } from '../../store/addonStore'
|
|
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 } from 'lucide-react'
|
|
import CustomSelect from '../shared/CustomSelect'
|
|
|
|
const ICON_MAP = {
|
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
|
|
}
|
|
|
|
function ImmichIcon({ size = 14 }: { size?: number }) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
|
<path d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321" fill="currentColor" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function SynologyIcon({ size = 14 }: { size?: number }) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
|
<path d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z" fill="currentColor" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
|
|
immich: ImmichIcon,
|
|
synologyphotos: SynologyIcon,
|
|
}
|
|
|
|
interface Addon {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
icon: string
|
|
type: string
|
|
enabled: boolean
|
|
config?: Record<string, unknown>
|
|
}
|
|
|
|
interface ProviderOption {
|
|
key: string
|
|
label: string
|
|
description: string
|
|
enabled: boolean
|
|
toggle: () => Promise<void>
|
|
}
|
|
|
|
interface AddonIconProps {
|
|
name: string
|
|
size?: number
|
|
}
|
|
|
|
function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
|
const Icon = ICON_MAP[name] || Puzzle
|
|
return <Icon size={size} />
|
|
}
|
|
|
|
interface CollabFeatures { chat: boolean; notes: 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: '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 dm = useSettingsStore(s => s.settings.dark_mode)
|
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
const toast = useToast()
|
|
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
|
const [addons, setAddons] = useState<Addon[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
useEffect(() => {
|
|
loadAddons()
|
|
}, [])
|
|
|
|
const loadAddons = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await adminApi.addons()
|
|
setAddons(data.addons)
|
|
} catch (err: unknown) {
|
|
toast.error(t('admin.addons.toast.error'))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleToggle = async (addon: Addon) => {
|
|
const newEnabled = !addon.enabled
|
|
// Optimistic update
|
|
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
|
try {
|
|
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
|
refreshGlobalAddons()
|
|
toast.success(t('admin.addons.toast.updated'))
|
|
} catch (err: unknown) {
|
|
// Rollback
|
|
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
|
|
toast.error(t('admin.addons.toast.error'))
|
|
}
|
|
}
|
|
|
|
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 handleTogglePhotoProvider = async (providerAddon: Addon) => {
|
|
const enableProvider = !providerAddon.enabled
|
|
const prev = addons
|
|
|
|
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
|
|
|
|
try {
|
|
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
|
|
refreshGlobalAddons()
|
|
toast.success(t('admin.addons.toast.updated'))
|
|
} catch {
|
|
setAddons(prev)
|
|
toast.error(t('admin.addons.toast.error'))
|
|
}
|
|
}
|
|
|
|
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
|
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
|
|
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: () => handleTogglePhotoProvider(provider),
|
|
}))
|
|
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="p-8 text-center">
|
|
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
|
<div className="px-6 py-4 border-b border-edge-secondary">
|
|
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
|
|
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
|
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
|
</p>
|
|
</div>
|
|
|
|
{addons.length === 0 ? (
|
|
<div className="p-8 text-center text-sm text-content-faint">
|
|
{t('admin.addons.noAddons')}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{/* Trip Addons */}
|
|
{tripAddons.length > 0 && (
|
|
<div>
|
|
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
|
<Briefcase size={13} className="text-content-muted" />
|
|
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
|
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
|
</span>
|
|
</div>
|
|
{tripAddons.map(addon => (
|
|
<div key={addon.id}>
|
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
|
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
|
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
|
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
|
|
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
|
|
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
|
</span>
|
|
<button type="button" onClick={onToggleBagTracking}
|
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
|
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
|
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
|
<div className="space-y-2">
|
|
{COLLAB_SUB_FEATURES.map(feat => {
|
|
const enabled = collabFeatures[feat.key]
|
|
const Icon = feat.icon
|
|
return (
|
|
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
|
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
|
|
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
|
|
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
|
</span>
|
|
<button type="button" onClick={() => onToggleCollabFeature(feat.key)}
|
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
|
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Global Addons */}
|
|
{globalAddons.length > 0 && (
|
|
<div>
|
|
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
|
<Globe size={13} className="text-content-muted" />
|
|
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
|
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
|
</span>
|
|
</div>
|
|
{globalAddons.map(addon => (
|
|
<div key={addon.id}>
|
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
|
{/* Memories providers as sub-items under Journey addon */}
|
|
{addon.id === 'journey' && providerOptions.length > 0 && (
|
|
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
|
<div className="space-y-2">
|
|
{providerOptions.map(provider => {
|
|
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
|
return (
|
|
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
|
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
|
|
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
|
|
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
|
</span>
|
|
<button type="button"
|
|
onClick={provider.toggle}
|
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
|
>
|
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
|
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Integration Addons */}
|
|
{integrationAddons.length > 0 && (
|
|
<div>
|
|
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
|
<Link2 size={13} className="text-content-muted" />
|
|
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
|
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
|
</span>
|
|
</div>
|
|
{integrationAddons.map(addon => (
|
|
<div key={addon.id}>
|
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
|
{addon.id === 'llm_parsing' && addon.enabled && (
|
|
<LlmParsingConfig addon={addon} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<string, unknown>
|
|
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
|
|
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
|
|
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
|
|
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
// Local-provider model management.
|
|
const [installed, setInstalled] = useState<string[]>([])
|
|
const [modelsErr, setModelsErr] = useState('')
|
|
const [loadingModels, setLoadingModels] = useState(false)
|
|
const [pulling, setPulling] = useState<string | null>(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-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
|
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
|
|
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
|
|
|
|
const providerOptions = [
|
|
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
|
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
|
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
|
]
|
|
|
|
return (
|
|
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
|
|
<div className="max-w-2xl space-y-6">
|
|
<p className="text-xs text-content-faint">
|
|
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
|
|
</p>
|
|
|
|
{/* Connection */}
|
|
<section className="space-y-3">
|
|
<div className={sectionCls}>Connection</div>
|
|
<div>
|
|
<span className={labelCls}>Provider</span>
|
|
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
|
</div>
|
|
{provider !== 'anthropic' && (
|
|
<label className="block">
|
|
<span className={labelCls}>Base URL</span>
|
|
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
|
</label>
|
|
)}
|
|
<label className="block">
|
|
<span className={labelCls}>API key</span>
|
|
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
|
</label>
|
|
{provider === 'anthropic' && (
|
|
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
|
)}
|
|
</section>
|
|
|
|
{/* Model */}
|
|
<section className="space-y-3">
|
|
<div className={sectionCls}>Model</div>
|
|
<label className="block">
|
|
<span className="sr-only">Model</span>
|
|
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
|
</label>
|
|
|
|
{/* Local model management (Ollama) */}
|
|
{provider === 'local' && (
|
|
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
|
|
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
|
{loadingModels ? 'Loading…' : 'Refresh'}
|
|
</button>
|
|
</div>
|
|
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
|
|
{!modelsErr && installed.length === 0 && !loadingModels && (
|
|
<p className="text-xs text-content-faint">No models installed yet — pull one below.</p>
|
|
)}
|
|
{installed.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{installed.map(name => (
|
|
<button type="button"
|
|
key={name}
|
|
title={name}
|
|
onClick={() => setModel(name)}
|
|
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
|
>
|
|
{name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-t border-edge-secondary pt-3">
|
|
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
|
|
<div className="space-y-1">
|
|
{RECOMMENDED_MODELS.map(m => {
|
|
const installedHere = isInstalled(m.id)
|
|
const isPulling = pulling === m.id
|
|
const active = model === m.id
|
|
return (
|
|
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-content">{m.label}</span>
|
|
{m.recommended && (
|
|
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-content-faint">{m.note}</div>
|
|
{isPulling && (
|
|
<div className="mt-1.5">
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
|
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
|
</div>
|
|
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{installedHere ? (
|
|
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
|
{active ? 'Selected' : 'Use'}
|
|
</button>
|
|
) : (
|
|
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
|
{isPulling ? 'Pulling…' : 'Pull'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface AddonRowProps {
|
|
addon: Addon
|
|
onToggle: (addon: Addon) => void
|
|
t: (key: string) => string
|
|
statusOverride?: boolean
|
|
hideToggle?: boolean
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
|
const isComingSoon = false
|
|
const label = getAddonLabel(t, addon)
|
|
const displayName = nameOverride || label.name
|
|
const displayDescription = descriptionOverride || label.description
|
|
const enabledState = statusOverride ?? addon.enabled
|
|
return (
|
|
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
|
{/* Icon */}
|
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
|
|
<AddonIcon name={addon.icon} size={20} />
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-content">{displayName}</span>
|
|
{isComingSoon && (
|
|
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
|
|
Coming Soon
|
|
</span>
|
|
)}
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
|
|
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
|
|
</div>
|
|
|
|
{/* Toggle */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
|
|
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
|
</span>
|
|
{!hideToggle && (
|
|
<button type="button"
|
|
onClick={() => !isComingSoon && onToggle(addon)}
|
|
disabled={isComingSoon}
|
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
|
>
|
|
<span
|
|
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
|
|
style={{
|
|
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
|
}}
|
|
/>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|