mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(admin): share the addon manager and the AI parsing config between desktop and phone
useAddonManager now loads and toggles the addons and builds the provider shelves, useLlmParsingConfig holds the AI parsing form with the Ollama model list and pull, and addonModel keeps the shared types, collab sub features, labels and recommended models. Both shells keep their own markup.
This commit is contained in:
@@ -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<string, ComponentType<{ size?: number }>> = {
|
||||
...DOCUMENT_PROVIDER_ICONS,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -99,91 +81,12 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; 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<Addon[]>([])
|
||||
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<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 [vision, setVision] = useState<LlmVision>(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<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') 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: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
@@ -584,16 +406,3 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> = {}): 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<string, string> = {
|
||||
'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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderOption {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
toggle: () => Promise<void>;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
@@ -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> = {}): 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<typeof vi.fn<() => Promise<void>>>;
|
||||
|
||||
beforeEach(() => {
|
||||
toast.success.mockReset();
|
||||
toast.error.mockReset();
|
||||
loadAddons = vi.fn<() => Promise<void>>().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<void>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<Addon[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>): 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<void>((r) => (finish = r));
|
||||
});
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||
let pending!: Promise<void>;
|
||||
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<void>(() => {}));
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<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 [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision));
|
||||
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. 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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, ComponentType<{ size?: number }>> = {
|
||||
...DOCUMENT_PROVIDER_ICONS,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -77,91 +67,13 @@ function AddonIcon({ name, size = 18, enabled = true }: AddonIconProps) {
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
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<Addon[]>([])
|
||||
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<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 [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision))
|
||||
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') 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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user