diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index d7973a792..41d79a607 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,10 +1,7 @@ -import { useEffect, useId, useState, type ComponentType } from 'react' -import { adminApi } from '../../api/client' +import { useId, type ComponentType } from 'react' 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, RefreshCw } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared' import EmptyState from '../shared/EmptyState' @@ -17,6 +14,9 @@ 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, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel' +import { useAddonManager } from './addons/useAddonManager' +import { useLlmParsingConfig } from './addons/useLlmParsingConfig' // 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 @@ -52,24 +52,6 @@ const PROVIDER_ICONS: Record> = { ...DOCUMENT_PROVIDER_ICONS, } -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 @@ -99,91 +81,12 @@ const TYPE_META: Record; labelK 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: 'admin.collab.links.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 dark = useIsDark() - const toast = useToast() - const refreshGlobalAddons = useAddonStore(s => s.loadAddons) - const [addons, setAddons] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - void 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. - // Same for Documents: switching it off disables every document provider in - // the database, and the response carries only the addon itself. - if (addon.id === 'journey' || addon.id === 'documents') await loadAddons() - toast.success(t('admin.addons.toast.updated')) - } - - const isPhotoProviderAddon = (addon: Addon) => { - return addon.type === 'photo_provider' - } - - const isDocumentProviderAddon = (addon: Addon) => { - return addon.type === 'document_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 documentProviderAddons = addons.filter(isDocumentProviderAddon) - 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), - })) - const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({ - key: provider.id, - label: provider.name, - description: provider.description, - enabled: provider.enabled, - toggle: () => handleToggle(provider), - })) + const { + addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions, + } = useAddonManager() if (loading) { return ( @@ -365,17 +268,8 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: { ) } -const MASKED = '••••••••' 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' -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.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true }, -] /** * Instance-wide AI-parsing config. When set, applies to the whole instance and @@ -385,84 +279,12 @@ const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended */ function LlmParsingConfig({ addon }: { addon: Addon }) { const { t } = useTranslation() - 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 [vision, setVision] = useState(asLlmVision(cfg.vision)) - const [saving, setSaving] = useState(false) + const { + provider, setProvider, model, setModel, baseUrl, setBaseUrl, apiKey, setApiKey, vision, setVision, saving, + installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save, + } = useLlmParsingConfig(addon) const fieldId = useId() - // 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') void 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, vision } }) - toast.success('Saved') - } catch { - toast.error('Failed to save') - } finally { - setSaving(false) - } - } - const providerOptions = [ { value: 'local', label: 'Local · OpenAI-compatible', icon: , badge: 'Ollama' }, { value: 'openai', label: 'OpenAI', icon: }, @@ -584,16 +406,3 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { ) } - -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, - } -} - diff --git a/client/src/components/Admin/addons/addonModel.test.ts b/client/src/components/Admin/addons/addonModel.test.ts new file mode 100644 index 000000000..ffe596ba0 --- /dev/null +++ b/client/src/components/Admin/addons/addonModel.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { type Addon, getAddonLabel, isPhotosAddon } from './addonModel'; + +// FE-ADDONMODEL-001 to FE-ADDONMODEL-003 + +function addon(over: Partial = {}): Addon { + return { id: 'todo', name: 'Todo', description: 'Tasks', icon: 'ListChecks', type: 'trip', enabled: true, ...over }; +} + +describe('addonModel', () => { + it('FE-ADDONMODEL-001: a trip addon about photos is recognised by icon, id, name or description', () => { + expect(isPhotosAddon(addon())).toBe(false); + expect(isPhotosAddon(addon({ icon: 'Image' }))).toBe(true); + expect(isPhotosAddon(addon({ id: 'photos' }))).toBe(true); + expect(isPhotosAddon(addon({ description: 'Shared Memories' }))).toBe(true); + }); + + it('FE-ADDONMODEL-002: only trip addons count, whatever they mention', () => { + expect(isPhotosAddon(addon({ type: 'global', icon: 'Image' }))).toBe(false); + expect(isPhotosAddon(addon({ type: 'photo_provider', name: 'Photos' }))).toBe(false); + }); + + it('FE-ADDONMODEL-003: the catalog label wins, the server name and description are the fallback', () => { + const catalog: Record = { + 'admin.addons.catalog.todo.name': 'To-dos', + 'admin.addons.catalog.todo.description': 'Plan the tasks', + }; + const t = (k: string) => catalog[k] ?? k; + expect(getAddonLabel(t, addon())).toEqual({ name: 'To-dos', description: 'Plan the tasks' }); + expect(getAddonLabel(t, addon({ id: 'custom', name: 'Custom', description: 'Own' }))).toEqual({ + name: 'Custom', + description: 'Own', + }); + }); +}); diff --git a/client/src/components/Admin/addons/addonModel.ts b/client/src/components/Admin/addons/addonModel.ts new file mode 100644 index 000000000..580a3bd78 --- /dev/null +++ b/client/src/components/Admin/addons/addonModel.ts @@ -0,0 +1,79 @@ +import { BarChart3, Link2, MessageCircle, Sparkles, StickyNote } from 'lucide-react'; + +export interface Addon { + id: string; + name: string; + description: string; + icon: string; + type: string; + enabled: boolean; + config?: Record; +} + +export interface ProviderOption { + key: string; + label: string; + description: string; + enabled: boolean; + toggle: () => Promise; +} + +export interface CollabFeatures { + chat: boolean; + notes: boolean; + links?: boolean; + polls: boolean; + whatsnext: boolean; +} + +export 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: 'admin.collab.links.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; + +/** A trip addon that is really about photos; those live under Journey and stay out of the trip list. */ +export function isPhotosAddon(addon: Addon): boolean { + const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase(); + return ( + addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) + ); +} + +/** The catalog name and description for an addon, falling back to what the server sent. */ +export 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, + }; +} + +/** A stored API key comes back from the server as this mask. */ +export const MASKED = '••••••••'; +export 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. */ +export const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = + [ + { + id: 'qwen3.5:4b', + label: 'Qwen3.5 — 4B', + note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', + recommended: true, + vision: true, + }, + ]; diff --git a/client/src/components/Admin/addons/useAddonManager.test.ts b/client/src/components/Admin/addons/useAddonManager.test.ts new file mode 100644 index 000000000..501fc48d7 --- /dev/null +++ b/client/src/components/Admin/addons/useAddonManager.test.ts @@ -0,0 +1,118 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { adminApi } from '../../../api/client'; +import { useAddonStore } from '../../../store/addonStore'; +import type { Addon } from './addonModel'; +import { useAddonManager } from './useAddonManager'; + +// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-006 + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })); + +function addon(id: string, type: string, enabled = true, over: Partial = {}): Addon { + return { id, name: id, description: `${id} addon`, icon: 'Puzzle', type, enabled, ...over }; +} + +const ADDONS = [ + addon('todo', 'trip'), + addon('memories', 'trip', true, { icon: 'Image' }), + addon('journey', 'global'), + addon('documents', 'trip'), + addon('llm_parsing', 'integration', false), + addon('immich', 'photo_provider', false), + addon('gdrive', 'document_provider'), +]; + +let loadAddons: ReturnType Promise>>; + +beforeEach(() => { + toast.success.mockReset(); + toast.error.mockReset(); + loadAddons = vi.fn<() => Promise>().mockResolvedValue(undefined); + useAddonStore.setState({ loadAddons }); + vi.spyOn(adminApi, 'addons').mockResolvedValue({ addons: ADDONS }); + vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({}); +}); +afterEach(() => vi.restoreAllMocks()); + +async function loaded() { + const hook = renderHook(() => useAddonManager()); + await waitFor(() => expect(hook.result.current.loading).toBe(false)); + return hook; +} + +const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled; + +describe('useAddonManager', () => { + it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => { + const { result, rerender } = await loaded(); + rerender(); + expect(adminApi.addons).toHaveBeenCalledTimes(1); + const ids = (list: { id?: string; key?: string }[]) => list.map((a) => a.id ?? a.key); + expect(ids(result.current.tripAddons)).toEqual(['todo', 'documents']); + expect(ids(result.current.globalAddons)).toEqual(['journey']); + expect(ids(result.current.integrationAddons)).toEqual(['llm_parsing']); + expect(result.current.providerOptions).toEqual([ + expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }), + ]); + expect(result.current.documentProviderOptions.map((o) => o.key)).toEqual(['gdrive']); + }); + + it('FE-HOOK-ADDONMGR-002: a failed load toasts the addon error and ends loading', async () => { + vi.mocked(adminApi.addons).mockRejectedValue(new Error('down')); + const { result } = await loaded(); + expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error'); + expect(result.current.addons).toEqual([]); + }); + + it('FE-HOOK-ADDONMGR-003: a toggle flips at once, saves, refreshes the store and toasts', async () => { + const { result } = await loaded(); + await act(() => result.current.handleToggle(ADDONS[0])); + expect(adminApi.updateAddon).toHaveBeenCalledWith('todo', { enabled: false }); + expect(enabledOf(result.current.addons, 'todo')).toBe(false); + expect(loadAddons).toHaveBeenCalledTimes(1); + expect(adminApi.addons).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated'); + }); + + it('FE-HOOK-ADDONMGR-004: a failed toggle rolls back only its own row', async () => { + let fail!: (e: unknown) => void; + vi.mocked(adminApi.updateAddon) + .mockReturnValueOnce(new Promise((_, reject) => (fail = reject))) + .mockResolvedValueOnce({}); + const { result } = await loaded(); + let pending!: Promise; + act(() => { + pending = result.current.handleToggle(ADDONS[0]); + }); + await act(() => result.current.handleToggle(ADDONS[5])); + await act(async () => { + fail(new Error('x')); + await pending; + }); + expect(enabledOf(result.current.addons, 'todo')).toBe(true); + expect(enabledOf(result.current.addons, 'immich')).toBe(true); + expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error'); + expect(loadAddons).toHaveBeenCalledTimes(1); + }); + + it('FE-HOOK-ADDONMGR-005: toggling Journey or Documents reads the list again for the cascaded providers', async () => { + const { result } = await loaded(); + await act(() => result.current.handleToggle(ADDONS[2])); + expect(adminApi.addons).toHaveBeenCalledTimes(2); + await act(() => result.current.handleToggle(ADDONS[3])); + expect(adminApi.addons).toHaveBeenCalledTimes(3); + }); + + it('FE-HOOK-ADDONMGR-006: a provider option toggles its provider like any addon', async () => { + const { result } = await loaded(); + await act(() => result.current.providerOptions[0].toggle()); + expect(adminApi.updateAddon).toHaveBeenCalledWith('immich', { enabled: true }); + expect(result.current.providerOptions[0].enabled).toBe(true); + await act(() => result.current.documentProviderOptions[0].toggle()); + expect(adminApi.updateAddon).toHaveBeenLastCalledWith('gdrive', { enabled: false }); + }); +}); diff --git a/client/src/components/Admin/addons/useAddonManager.ts b/client/src/components/Admin/addons/useAddonManager.ts new file mode 100644 index 000000000..05c37729d --- /dev/null +++ b/client/src/components/Admin/addons/useAddonManager.ts @@ -0,0 +1,87 @@ +import { useEffect, useEffectEvent, useState } from 'react'; + +import { adminApi } from '../../../api/client'; +import { useTranslation } from '../../../i18n'; +import { useAddonStore } from '../../../store/addonStore'; +import { useToast } from '../../shared/Toast'; +import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel'; + +/** + * The addon switches behind both admin shells: the addon list loaded on mount, an + * optimistic toggle with a per row rollback, and the lists each shell groups its tiles + * and provider shelves from. The desktop columns and the phone cards render their own + * markup over it. + */ +export function useAddonManager() { + const { t } = useTranslation(); + const toast = useToast(); + const refreshGlobalAddons = useAddonStore((s) => s.loadAddons); + const [addons, setAddons] = useState([]); + const [loading, setLoading] = useState(true); + + const loadAddons = async () => { + try { + const data = await adminApi.addons(); + setAddons(data.addons); + } catch { + toast.error(t('admin.addons.toast.error')); + } + }; + + const loadOnMount = useEffectEvent(() => { + void loadAddons().finally(() => setLoading(false)); + }); + useEffect(() => { + loadOnMount(); + }, []); + + /** 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 { + 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. + // Same for Documents: switching it off disables every document provider in + // the database, and the response carries only the addon itself. + if (addon.id === 'journey' || addon.id === 'documents') await loadAddons(); + toast.success(t('admin.addons.toast.updated')); + }; + + const asOption = (provider: Addon): ProviderOption => ({ + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleToggle(provider), + }); + + 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 = addons.filter((a) => a.type === 'photo_provider').map(asOption); + // No credential form under these, unlike the photo providers: a document connection + // belongs to a trip and is entered there. The admin only decides whether a provider + // may be offered at all. + const documentProviderOptions = addons.filter((a) => a.type === 'document_provider').map(asOption); + + return { + addons, + loading, + handleToggle, + tripAddons, + globalAddons, + integrationAddons, + providerOptions, + documentProviderOptions, + }; +} diff --git a/client/src/components/Admin/addons/useLlmParsingConfig.test.ts b/client/src/components/Admin/addons/useLlmParsingConfig.test.ts new file mode 100644 index 000000000..adbde8631 --- /dev/null +++ b/client/src/components/Admin/addons/useLlmParsingConfig.test.ts @@ -0,0 +1,161 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { adminApi } from '../../../api/client'; +import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel'; +import { useLlmParsingConfig } from './useLlmParsingConfig'; + +// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-010 + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); + +function llm(config?: Record): Addon { + return { + id: 'llm_parsing', + name: 'AI', + description: '', + icon: 'Sparkles', + type: 'integration', + enabled: true, + config, + }; +} + +beforeEach(() => { + toast.success.mockReset(); + toast.error.mockReset(); + vi.spyOn(adminApi, 'llmLocalModels').mockResolvedValue({ models: [{ name: 'qwen3.5:4b', size: 1 }] }); + vi.spyOn(adminApi, 'llmLocalPull').mockResolvedValue(undefined); + vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({}); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('useLlmParsingConfig', () => { + it('FE-HOOK-LLMCFG-001: starts from the stored config, defaulting to the local provider', () => { + const { result } = renderHook(() => useLlmParsingConfig(llm())); + expect(result.current.provider).toBe('local'); + expect(result.current.model).toBe(''); + expect(result.current.vision).toBe('auto'); + const stored = renderHook(() => + useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o', apiKey: MASKED })) + ); + expect(stored.result.current.provider).toBe('openai'); + expect(stored.result.current.apiKey).toBe(MASKED); + }); + + it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => { + const { result } = renderHook(() => useLlmParsingConfig(llm())); + await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b'])); + expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL); + expect(result.current.isInstalled('qwen3.5:4b')).toBe(true); + expect(result.current.isInstalled('llama3')).toBe(false); + }); + + it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing until switched to local, then uses the typed URL', async () => { + const { result } = renderHook(() => + useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' })) + ); + expect(adminApi.llmLocalModels).not.toHaveBeenCalled(); + await act(() => result.current.loadModels()); + expect(adminApi.llmLocalModels).not.toHaveBeenCalled(); + act(() => result.current.setProvider('local')); + await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith('http://box:11434/v1')); + }); + + it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => { + vi.mocked(adminApi.llmLocalModels).mockRejectedValue(new Error('ECONNREFUSED')); + const { result } = renderHook(() => useLlmParsingConfig(llm())); + await waitFor(() => expect(result.current.modelsErr).toBe('ECONNREFUSED')); + expect(result.current.installed).toEqual([]); + expect(result.current.loadingModels).toBe(false); + vi.mocked(adminApi.llmLocalModels).mockRejectedValue('nope'); + await act(() => result.current.loadModels()); + expect(result.current.modelsErr).toBe('Could not reach the local LLM server'); + }); + + it('FE-HOOK-LLMCFG-005: a pull reports progress, selects the model and lists again', async () => { + let progress!: (p: { status?: string; total?: number; completed?: number; error?: string }) => void; + let finish!: () => void; + vi.mocked(adminApi.llmLocalPull).mockImplementation((_url, _id, onProgress) => { + progress = onProgress; + return new Promise((r) => (finish = r)); + }); + const { result } = renderHook(() => useLlmParsingConfig(llm())); + await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b'])); + let pending!: Promise; + act(() => { + pending = result.current.pull('qwen3.5:4b'); + }); + expect(result.current.pulling).toBe('qwen3.5:4b'); + expect(result.current.pullStatus).toBe('starting…'); + act(() => progress({ status: 'downloading', total: 200, completed: 50 })); + expect(result.current.pullStatus).toBe('downloading'); + expect(result.current.pullPct).toBe(25); + await act(async () => { + finish(); + await pending; + }); + expect(toast.success).toHaveBeenCalledWith('Model pulled'); + expect(result.current.model).toBe('qwen3.5:4b'); + expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2); + expect(result.current.pulling).toBeNull(); + expect(result.current.pullPct).toBe(0); + expect(result.current.pullStatus).toBe(''); + }); + + it('FE-HOOK-LLMCFG-006: a second pull while one runs is ignored', async () => { + vi.mocked(adminApi.llmLocalPull).mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useLlmParsingConfig(llm())); + act(() => { + void result.current.pull('a'); + }); + await act(() => result.current.pull('b')); + expect(adminApi.llmLocalPull).toHaveBeenCalledTimes(1); + }); + + it('FE-HOOK-LLMCFG-007: an error frame or a failed pull toasts the message', async () => { + vi.mocked(adminApi.llmLocalPull).mockImplementation(async (_url, _id, onProgress) => { + onProgress({ error: 'manifest unknown' }); + }); + const { result } = renderHook(() => useLlmParsingConfig(llm())); + await act(() => result.current.pull('x')); + expect(toast.error).toHaveBeenCalledWith('manifest unknown'); + vi.mocked(adminApi.llmLocalPull).mockRejectedValue('nope'); + await act(() => result.current.pull('x')); + expect(toast.error).toHaveBeenLastCalledWith('Pull failed'); + expect(result.current.pulling).toBeNull(); + }); + + it('FE-HOOK-LLMCFG-008: save sends the trimmed config with the masked key unchanged', async () => { + const { result } = renderHook(() => + useLlmParsingConfig(llm({ provider: 'openai', model: ' gpt-4o ', baseUrl: ' https://o/v1 ', apiKey: MASKED })) + ); + await act(() => result.current.save()); + expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { + config: { provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o/v1', apiKey: MASKED, vision: 'auto' }, + }); + expect(toast.success).toHaveBeenCalledWith('Saved'); + expect(result.current.saving).toBe(false); + }); + + it('FE-HOOK-LLMCFG-009: Anthropic is saved with an empty base URL, whatever was typed before', async () => { + const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' }))); + act(() => { + result.current.setProvider('anthropic'); + result.current.setVision('off'); + }); + await act(() => result.current.save()); + expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1]).toEqual({ + config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' }, + }); + }); + + it('FE-HOOK-LLMCFG-010: a failed save toasts and clears saving', async () => { + vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x')); + const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' }))); + await act(() => result.current.save()); + expect(toast.error).toHaveBeenCalledWith('Failed to save'); + expect(result.current.saving).toBe(false); + }); +}); diff --git a/client/src/components/Admin/addons/useLlmParsingConfig.ts b/client/src/components/Admin/addons/useLlmParsingConfig.ts new file mode 100644 index 000000000..bd73bf335 --- /dev/null +++ b/client/src/components/Admin/addons/useLlmParsingConfig.ts @@ -0,0 +1,126 @@ +import { type LlmVision, asLlmVision } from '@trek/shared'; +import { useEffect, useEffectEvent, useState } from 'react'; + +import { adminApi } from '../../../api/client'; +import { useToast } from '../../shared/Toast'; +import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel'; + +/** + * Instance-wide AI-parsing config behind both admin shells. When set, it 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 the installed Ollama models and can pull the recommended ones. + */ +export function useLlmParsingConfig(addon: Addon) { + const toast = useToast(); + const cfg = addon.config ?? {}; + 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 [vision, setVision] = useState(asLlmVision(cfg.vision)); + 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. Only a provider change + // triggers it; the base URL is read as it stands then (its own blur reloads). + const loadModelsForProvider = useEffectEvent(() => { + if (provider === 'local') void loadModels(); + }); + useEffect(() => { + loadModelsForProvider(); + }, [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, + vision, + }, + }); + toast.success('Saved'); + } catch { + toast.error('Failed to save'); + } finally { + setSaving(false); + } + }; + + return { + provider, + setProvider, + model, + setModel, + baseUrl, + setBaseUrl, + apiKey, + setApiKey, + vision, + setVision, + saving, + installed, + modelsErr, + loadingModels, + pulling, + pullPct, + pullStatus, + isInstalled, + loadModels, + pull, + save, + }; +} diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx index 4855a2d3c..b9731bd8b 100644 --- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx +++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx @@ -1,18 +1,26 @@ -import { useEffect, useState, type ComponentType } from 'react' -import { adminApi } from '../../../api/client' +import { type ComponentType } from 'react' import { useTranslation } from '../../../i18n' import { useSettingsStore } from '../../../store/settingsStore' -import { useAddonStore } from '../../../store/addonStore' -import { useToast } from '../../../components/shared/Toast' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, - MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Check, Loader2, + Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Check, Loader2, } from 'lucide-react' import DawarichIcon from '../../../components/shared/DawarichIcon' import AirTrailIcon from '../../../components/shared/AirTrailIcon' import { DOCUMENT_PROVIDER_ICONS } from '../../../components/shared/DocumentProviderIcons' import MToggle from '../../components/MToggle' -import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared' +import { LLM_VISION_MODES } from '@trek/shared' +import { + type Addon, + type CollabFeatures, + type ProviderOption, + COLLAB_SUB_FEATURES, + getAddonLabel, + MASKED, + RECOMMENDED_MODELS, +} from '../../../components/Admin/addons/addonModel' +import { useAddonManager } from '../../../components/Admin/addons/useAddonManager' +import { useLlmParsingConfig } from '../../../components/Admin/addons/useLlmParsingConfig' import { MAdminButton, MAdminCard, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi' const ICON_MAP = { @@ -42,24 +50,6 @@ const PROVIDER_ICONS: Record> = { ...DOCUMENT_PROVIDER_ICONS, } -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 @@ -77,91 +67,13 @@ function AddonIcon({ name, size = 18, enabled = true }: AddonIconProps) { return } -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: 'admin.collab.links.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 MAdminAddonManager({ 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([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - void 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')) - } - } - - const handleToggle = async (addon: Addon) => { - const newEnabled = !addon.enabled - // Optimistic flip, per-row rollback on failure. Rolling the whole addons snapshot - // back instead would undo a toggle the user hit in parallel. - setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) - try { - await adminApi.updateAddon(addon.id, { enabled: newEnabled }) - } catch (err: unknown) { - // Rollback - 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. Documents does - // the same to the document providers. - if (addon.id === 'journey' || addon.id === 'documents') 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 documentProviderAddons = addons.filter(a => a.type === 'document_provider') - 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), - })) - // No credential form under these, unlike the photo providers: a document - // connection belongs to a trip and is entered there. The admin only decides - // whether a provider may be offered at all. - const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({ - key: provider.id, - label: provider.name, - description: provider.description, - enabled: provider.enabled, - toggle: () => handleToggle(provider), - })) + const { + addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions, + } = useAddonManager() if (loading) { return ( @@ -273,18 +185,6 @@ function MGroupHead({ icon: Icon, label }: { icon: typeof Briefcase; label: stri ) } -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, - } -} - interface MAddonRowProps { addon: Addon onToggle: (addon: Addon) => void @@ -360,16 +260,6 @@ function MSubRow({ icon: Icon, providerIcon: ProviderIcon, title, subtitle, enab ) } -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.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true }, -] - /** * 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 @@ -378,82 +268,10 @@ const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended */ function LlmParsingConfig({ addon }: { addon: Addon }) { const { t } = useTranslation() - 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 [vision, setVision] = useState(asLlmVision(cfg.vision)) - 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') void 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, vision } }) - toast.success('Saved') - } catch { - toast.error('Failed to save') - } finally { - setSaving(false) - } - } + const { + provider, setProvider, model, setModel, baseUrl, setBaseUrl, apiKey, setApiKey, vision, setVision, saving, + installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save, + } = useLlmParsingConfig(addon) const sectionCls = 'font-geist text-[0.625rem] font-bold uppercase tracking-[0.06em] text-m-faint'