diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index aecf76ab2..2cfb8ecee 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -16,17 +16,16 @@ "feature": "Addon manager", "hooks": [ "src/components/Admin/addons/addonModel.ts", - "src/components/Admin/addons/useAddonManager.ts", - "src/components/Admin/addons/useLlmParsingConfig.ts" + "src/components/Admin/addons/useAddonManager.ts" ], "desktop": ["src/components/Admin/AddonManager.tsx"], "mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"] }, { - "feature": "Addon manager: AI assistant", - "hooks": ["src/components/Admin/addons/useAiAdminConfig.ts"], - "desktop": ["src/components/Admin/addons/AiAdminConfig.tsx"], - "mobile": ["src/mobile/screens/admin/MAdminAiConfig.tsx"] + "feature": "Addon manager: AI tile", + "hooks": ["src/components/Admin/addons/useAiAddonTile.ts"], + "desktop": ["src/components/Admin/addons/AiAddonTile.tsx"], + "mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"] }, { "feature": "Admin notification preference matrix", diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index c5fc07f8c..1c9a1e571 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,6 +1,6 @@ -import { render, screen, waitFor } from '../../../tests/helpers/render'; +import { render, screen, waitFor, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; -import { delay, http, HttpResponse } from 'msw'; +import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { useSettingsStore } from '../../store/settingsStore'; @@ -24,23 +24,20 @@ function addonsRoute(addons: ReturnType[]) { return http.get('/api/admin/addons', () => HttpResponse.json({ addons })); } -function llmAddon(config: Record = {}) { +function llmAddon(config: Record = {}, enabled = true) { return buildAddon({ id: 'llm_parsing', name: 'AI Parsing', description: 'Extract bookings from files', icon: 'Sparkles', type: 'integration', - enabled: true, + enabled, config, }); } -function modelsRoute(names: string[], seen?: (string | null)[]) { - return http.get('/api/admin/llm/local/models', ({ request }) => { - seen?.push(new URL(request.url).searchParams.get('baseUrl')); - return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) }); - }); +function aiAddon(id: string, name: string, enabled: boolean) { + return buildAddon({ id, name, icon: id === 'mcp' ? 'Terminal' : 'Sparkles', type: 'integration', enabled }); } /** Every switch is labelled with the thing it controls, top-level tile or shelf @@ -576,286 +573,55 @@ describe('AddonManager', () => { expect(links.getAttribute('title')).not.toBe('No shared links yet'); }); - it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => { - server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }])); - render(); - - await screen.findByText('AI Parsing'); - // The catalog key now exists, so the tile shows the translation rather than falling - // back to the English description the server sent. - expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument(); - expect(screen.queryByText('Provider')).not.toBeInTheDocument(); - }); - - it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => { - server.use(addonsRoute([llmAddon({ provider: 'openai' })])); - render(); - - const label = await screen.findByText('Provider'); - // Inside the AI Parsing tile's
, like the collab toggles sit in theirs. - const tile = label.closest('article'); - expect(tile).not.toBeNull(); - expect(tile!.textContent).toContain('AI Parsing'); - }); - - it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => { - const user = userEvent.setup(); - const urls: (string | null)[] = []; - server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls)); - render(); - - await screen.findByText('Installed on the server'); - await screen.findByRole('button', { name: 'llama3:8b' }); - expect(urls[0]).toBe('http://localhost:11434/v1'); - - await user.click(screen.getByRole('button', { name: 'llama3:8b' })); - expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b'); - - // qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull" - await user.click(screen.getByRole('button', { name: 'Use' })); - expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b'); - expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled(); - }); - - it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => { - const user = userEvent.setup(); - let calls = 0; + it('FE-ADMIN-ADDON-020: the AI addons head the integration column as one tile with a switch each', async () => { server.use( - addonsRoute([llmAddon({ provider: 'local' })]), - http.get('/api/admin/llm/local/models', () => { - calls += 1; - return calls === 1 - ? HttpResponse.json({ error: 'down' }, { status: 500 }) - : HttpResponse.json({ models: [] }); - }), + addonsRoute([ + aiAddon('mcp', 'MCP', false), + llmAddon({}, false), + aiAddon('ai_assistant', 'AI Assistant', false), + buildAddon({ id: 'airtrail', name: 'AirTrail', type: 'integration', icon: 'Plane' }), + ]), ); render(); - // The server's reason is shown, not axios' status line (#2649) - await screen.findByText('down'); - expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Refresh' })); - await screen.findByText('No models installed yet — pull one below.'); - expect(calls).toBe(2); + const tile = (await screen.findByText('AI')).closest('article') as HTMLElement; + const switches = within(tile).getAllByRole('button').map((b) => b.getAttribute('aria-label')); + // In the tile's order, whatever order the server sent them in. + expect(switches).toEqual(['AI Assistant', 'AI Parsing', 'MCP']); + expect(within(tile).getByText('0/3')).toBeInTheDocument(); + // Their own icons, keyed by id: the icon column of an install still says Sparkles or Terminal. + expect(tile.querySelector('svg.lucide-messages-square')).toBeInTheDocument(); + expect(tile.querySelector('svg.lucide-scan-text')).toBeInTheDocument(); + expect(tile.querySelector('svg.lucide-cable')).toBeInTheDocument(); + // Nothing to set up while neither the assistant nor parsing is on. + expect(within(tile).queryByRole('button', { name: 'Set up AI' })).toBeNull(); + // AirTrail is still its own tile in the same column. + expect(screen.getByText('AirTrail').closest('article')).not.toBe(tile); }); - it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => { + it('FE-ADMIN-ADDON-021: with parsing on, the tile offers the settings dialog, which closes again', async () => { const user = userEvent.setup(); - const urls: (string | null)[] = []; - server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls)); + server.use( + addonsRoute([llmAddon({}), aiAddon('ai_assistant', 'AI Assistant', false), aiAddon('mcp', 'MCP', true)]), + http.get('/api/admin/ai/settings', () => + HttpResponse.json({ policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }), + ), + http.get('/api/admin/ai/connection', () => HttpResponse.json({ connection: null, prefill: null })), + http.get('/api/ai/status', () => HttpResponse.json({ error: 'not needed' }, { status: 404 })), + ); render(); - await screen.findByText('Installed on the server'); - expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); - - // A hand-typed base URL is used for the next lookup on blur - await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1'); - await user.tab(); - await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1')); - - await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ })); - await user.click(screen.getByRole('button', { name: 'OpenAI' })); - expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument(); - expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'OpenAI' })); - await user.click(screen.getByRole('button', { name: 'Anthropic' })); - expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument(); - // A gateway can stand in for Anthropic too, so the field stays (#2655). - expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue(''); - expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument(); - expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument(); + const tile = (await screen.findByText('AI')).closest('article') as HTMLElement; + expect(within(tile).getByText('2/3')).toBeInTheDocument(); + await user.click(within(tile).getByRole('button', { name: 'Set up AI' })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByRole('button', { name: 'AI Parsing' })).toBeInTheDocument(); + expect(within(dialog).queryByRole('button', { name: 'AI Assistant' })).toBeNull(); + const closes = within(dialog).getAllByRole('button', { name: 'Close' }); + await user.click(closes[closes.length - 1]); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); }); - it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => { - const user = userEvent.setup(); - let pulled: unknown = null; - let modelCalls = 0; - server.use( - addonsRoute([llmAddon({ provider: 'local' })]), - http.get('/api/admin/llm/local/models', () => { - modelCalls += 1; - return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] }); - }), - http.post('/api/admin/llm/local/pull', async ({ request }) => { - pulled = await request.json(); - await delay(150); - return new HttpResponse( - '{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n', - { headers: { 'Content-Type': 'application/x-ndjson' } }, - ); - }), - ); - render(<>); - - await screen.findByText('No models installed yet — pull one below.'); - await user.click(screen.getByRole('button', { name: 'Pull' })); - - await screen.findByText('Pulling…'); - expect(screen.getByText('starting…')).toBeInTheDocument(); - - await screen.findByText('Model pulled'); - expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' }); - expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b'); - await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled()); - }); - - it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => { - const user = userEvent.setup(); - const bodies: unknown[] = []; - server.use( - addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]), - modelsRoute([]), - http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })), - http.put('/api/admin/addons/llm_parsing', async ({ request }) => { - bodies.push(await request.json()); - return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error(); - }), - ); - render(<>); - - await screen.findByText('No models installed yet — pull one below.'); - await user.click(screen.getByRole('button', { name: 'Pull' })); - await screen.findByText('no disk space'); - expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled(); - - await user.click(screen.getByRole('button', { name: 'Save' })); - await screen.findByText('Saved'); - expect(bodies[0]).toEqual({ - config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' }, - }); - - await user.click(screen.getByRole('button', { name: 'Save' })); - await screen.findByText('Failed to save'); - }); - - it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => { - const user = userEvent.setup(); - const bodies: unknown[] = []; - server.use( - addonsRoute([llmAddon({ provider: 'local' })]), - modelsRoute([]), - http.put('/api/admin/addons/llm_parsing', async ({ request }) => { - bodies.push(await request.json()); - return HttpResponse.json({ success: true }); - }), - ); - render(<>); - - await screen.findByText('Installed on the server'); - expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument(); - - await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b '); - await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live'); - - await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ })); - await user.click(screen.getByRole('button', { name: 'OpenAI' })); - expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live'); - - await user.click(screen.getByRole('button', { name: 'Save' })); - await screen.findByText('Saved'); - // The model is trimmed before it is stored, the key is sent verbatim - expect(bodies[0]).toEqual({ - config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', vision: 'auto' }, - }); - }); - - it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => { - const user = userEvent.setup(); - const bodies: unknown[] = []; - server.use( - addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]), - modelsRoute([]), - http.put('/api/admin/addons/llm_parsing', async ({ request }) => { - bodies.push(await request.json()); - return HttpResponse.json({ success: true }); - }), - ); - render(<>); - - await screen.findByText('Installed on the server'); - - await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ })); - await user.click(screen.getByRole('button', { name: 'Anthropic' })); - await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001'); - await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live'); - - await user.click(screen.getByRole('button', { name: 'Save' })); - await screen.findByText('Saved'); - // The stale local base URL must not ride along to Anthropic — it would hijack the endpoint. - expect(bodies[0]).toEqual({ - config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' }, - }); - }); - - it('FE-ADMIN-ADDON-038: whether the model reads images is auto, on or off, kept as stored and saved as chosen', async () => { - const user = userEvent.setup(); - const bodies: unknown[] = []; - server.use( - addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'off' })]), - http.put('/api/admin/addons/llm_parsing', async ({ request }) => { - bodies.push(await request.json()); - return HttpResponse.json({ success: true }); - }), - ); - render(<>); - - // Stored off stays off; a cloud provider is told what Automatic means for it. - await user.click(await screen.findByRole('button', { name: 'No' })); - expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Yes' })); - - await user.click(screen.getByRole('button', { name: 'Save' })); - await screen.findByText('Saved'); - expect(bodies[0]).toMatchObject({ config: { provider: 'openai', vision: 'on' } }); - expect(bodies[0]).not.toHaveProperty('config.multimodal'); - }); - - it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => { - server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([])); - render(); - expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument(); - }); - - it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => { - const user = userEvent.setup(); - server.use( - addonsRoute([llmAddon({ provider: 'local' })]), - modelsRoute([]), - http.post('/api/admin/llm/local/pull', () => new HttpResponse( - '{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', - { headers: { 'Content-Type': 'application/x-ndjson' } }, - )), - ); - render(<>); - - await screen.findByText('No models installed yet — pull one below.'); - await user.click(screen.getByRole('button', { name: 'Pull' })); - - await screen.findByText('manifest not found'); - expect(screen.queryByText('Model pulled')).not.toBeInTheDocument(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled()); - expect(screen.queryByText('Pulling…')).not.toBeInTheDocument(); - }); - - it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => { - const user = userEvent.setup(); - const urls: (string | null)[] = []; - server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls)); - render(); - - await screen.findByText('Provider'); - expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument(); - - await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1'); - await user.tab(); - - await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument()); - expect(urls).toHaveLength(0); - }); it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => { server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); render( @@ -901,6 +667,10 @@ describe('AddonManager', () => { ); + // Folded like Collab's and Documents' options until the bar is opened. + await screen.findByRole('button', { name: /\d+ options?/, expanded: false }); + expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument(); + await unfoldShelves(); const toggle = await screen.findByRole('button', { name: 'wanderer' }); expect(isOn(toggle)).toBe(false); await userEvent.click(toggle); @@ -921,6 +691,7 @@ describe('AddonManager', () => { ); + await unfoldShelves(); await userEvent.click(await screen.findByRole('button', { name: 'wanderer' })); await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false)); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 4702cd32a..af0e36f6f 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,9 +1,7 @@ -import { useId, type ComponentType } from 'react' +import type { ComponentType } from 'react' import { useTranslation } from '../../i18n' import { useIsDark } from '../../hooks/useIsDark' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react' -import CustomSelect from '../shared/CustomSelect' -import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react' import EmptyState from '../shared/EmptyState' import DawarichIcon from '../shared/DawarichIcon' import AirTrailIcon from '../shared/AirTrailIcon' @@ -12,12 +10,10 @@ import AddonTile from './AddonTile' import AddonSubRow from './AddonSubRow' import { Tooltip } from '../shared/Tooltip' import { fs } from '../shared/DialogShell' -import { EditorField, INPUT, Segmented } from '../shared/dialogParts' -import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit' -import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel' +import { SettingsCard, StatusPill } from '../Settings/settingsKit' +import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel' import { useAddonManager } from './addons/useAddonManager' -import { useLlmParsingConfig } from './addons/useLlmParsingConfig' -import AiAdminConfig from './addons/AiAdminConfig' +import AiAddonTile from './addons/AiAddonTile' import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons' import WandererMark from '../Tours/wanderer/WandererMark' @@ -75,7 +71,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, const { t } = useTranslation() const dark = useIsDark() const { - addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions, + addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions, toursWanderer, toggleToursWanderer, mapSwitches, @@ -155,37 +151,29 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, ) }) } + // An array, not a fragment: the tile counts its rows to fold them behind the + // "N options" bar the way Collab's and Documents' are. if (addon.id === 'tours' && addon.enabled) { - return ( - <> + return [ + } + title={t('admin.toursWanderer.title')} + description={t('admin.toursWanderer.subtitle')} + enabled={toursWanderer} + onToggle={() => void toggleToursWanderer()} + />, + ...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => ( } - title={t('admin.toursWanderer.title')} - description={t('admin.toursWanderer.subtitle')} - enabled={toursWanderer} - onToggle={() => void toggleToursWanderer()} + key={key} + icon={} + title={t(titleKey)} + description={t(subtitleKey)} + enabled={enabled} + onToggle={() => void toggle()} /> - {mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => ( - } - title={t(titleKey)} - description={t(subtitleKey)} - enabled={enabled} - onToggle={() => void toggle()} - /> - ))} - - ) - } - // The AI-parsing settings hang off their tile like every other sub-feature, - // just as a form instead of toggle rows. - if (addon.id === 'llm_parsing' && addon.enabled) { - return - } - // The assistant's settings and the connection everyone runs on, the same way. - if (addon.id === 'ai_assistant' && addon.enabled) { - return + )), + ] } return null } @@ -214,9 +202,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, { key: 'trip', addons: tripAddons }, { key: 'global', addons: globalAddons }, { key: 'integration', addons: integrationAddons }, - ].filter(g => g.addons.length > 0) + ].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0)) const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length - const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length const subtitle = ( @@ -247,8 +235,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, const meta = TYPE_META[g.key] return (
- -
{g.addons.map(tile)}
+ +
+ {/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */} + {g.key === 'integration' && aiAddons.length > 0 && } + {g.addons.map(tile)} +
) })} @@ -287,140 +279,3 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: { ) } - -const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint' -const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60' - -/** - * Instance-wide AI-parsing config. When set, applies to the whole instance and - * overrides per-user config (see server llmConfig.ts). The API key is masked on - * read; an unchanged mask is treated as a no-op by the server. For the local - * provider, it also lists installed Ollama models and can pull NuExtract models. - */ -function LlmParsingConfig({ addon }: { addon: Addon }) { - const { t } = useTranslation() - const { - provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving, - installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save, - } = useLlmParsingConfig(addon) - const fieldId = useId() - - const providerOptions = [ - { value: 'local', label: 'Local · OpenAI-compatible', icon: , badge: 'Ollama' }, - { value: 'openai', label: 'OpenAI', icon: }, - { value: 'anthropic', label: 'Anthropic', icon: }, - ] - const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) })) - - /* Lives in the tile's shelf like the collab toggles, so it is one column of - eyebrow fields: the band this used to be had a whole page width. */ - return ( -
  • - - Instance-wide — applies to all users. Leave blank to let each user configure their own provider. - - - - setProvider(String(v))} options={providerOptions} /> - - - setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} /> - - - setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} /> - - - setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} /> - - - - - label={t('settings.aiParsing.multimodal')} - value={vision} - onChange={v => setVision(asLlmVision(v))} - options={visionOptions} - fill - /> - - - {/* Local model management (Ollama) */} - {provider === 'local' && ( -
    -
    - Installed on the server - -
    - {modelsErr &&

    {modelsErr}

    } - {!modelsErr && installed.length === 0 && !loadingModels && ( - No models installed yet — pull one below. - )} - {installed.length > 0 && ( -
    - {installed.map(name => ( - - ))} -
    - )} - -
    - Pull a recommended model - {RECOMMENDED_MODELS.map(m => { - const installedHere = isInstalled(m.id) - const isPulling = pulling === m.id - const active = model === m.id - return ( -
    -
    - {m.label} - {m.recommended && Recommended} - {installedHere ? ( - - ) : ( - - )} -
    - {isPulling && ( -
    -
    -
    -
    -
    {pullStatus}{pullPct ? ` · ${pullPct}%` : ''}
    -
    - )} -
    - ) - })} -
    -
    - )} - -
    - -
    -
  • - ) -} diff --git a/client/src/components/Admin/addons/AiAddonTile.tsx b/client/src/components/Admin/addons/AiAddonTile.tsx new file mode 100644 index 000000000..64120d26d --- /dev/null +++ b/client/src/components/Admin/addons/AiAddonTile.tsx @@ -0,0 +1,95 @@ +import { ChevronRight, Settings2, Sparkles } from 'lucide-react'; +import React from 'react'; +import { useTranslation } from '../../../i18n'; +import { StatusPill } from '../../Settings/settingsKit'; +import { fs } from '../../shared/DialogShell'; +import AddonSubRow from '../AddonSubRow'; +import { type Addon, getAddonLabel } from './addonModel'; +import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons'; +import AiSettingsDialog from './AiAdminConfig'; +import { useAiAddonTile } from './useAiAddonTile'; + +/** + * The AI addons as one tile at the head of the integration column: the + * assistant, AI parsing and MCP as switches in its shelf, and one button to + * the dialog that holds their settings, so a page with all three on stays as + * short as one with none. + */ +export default function AiAddonTile({ + addons, + onToggle, +}: { + addons: Addon[]; + onToggle: (addon: Addon) => void; +}): React.ReactElement { + const { t } = useTranslation(); + const tile = useAiAddonTile(addons); + const anyOn = tile.enabledCount > 0; + return ( +
    +
    +
    + +
    +
    +

    + {t('admin.addons.ai.name')} +

    +

    + {t('admin.addons.ai.description')} +

    +
    +
    + + {tile.enabledCount}/{addons.length} + +
    +
    + +
    +
      + {addons.map((addon) => { + const Icon = AI_ADDON_ICONS[addon.id as AiAddonId]; + const label = getAddonLabel(t, addon); + return ( + } + title={label.name} + description={label.description} + enabled={addon.enabled} + onToggle={() => onToggle(addon)} + /> + ); + })} +
    + {tile.canSetUp && ( + + )} +
    + + {tile.dialogOpen && } +
    + ); +} diff --git a/client/src/components/Admin/addons/AiAdminConfig.test.tsx b/client/src/components/Admin/addons/AiAdminConfig.test.tsx index 0f4927a46..f1b2ba6e7 100644 --- a/client/src/components/Admin/addons/AiAdminConfig.test.tsx +++ b/client/src/components/Admin/addons/AiAdminConfig.test.tsx @@ -1,9 +1,13 @@ -// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-004: the AI assistant's form on its addon tile. +// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile. import userEvent from '@testing-library/user-event'; import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared'; +import { delay, http, HttpResponse } from 'msw'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { server } from '../../../../tests/helpers/msw/server'; import { render, screen, waitFor } from '../../../../tests/helpers/render'; import { useAssistantStore } from '../../../store/assistantStore'; +import { ToastContainer } from '../../shared/Toast'; +import type { Addon } from './addonModel'; const repo = vi.hoisted(() => ({ status: vi.fn(), @@ -16,7 +20,7 @@ const repo = vi.hoisted(() => ({ })); vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo })); -const { default: AiAdminConfig } = await import('./AiAdminConfig'); +const { default: AiSettingsDialog } = await import('./AiAdminConfig'); const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }; const LIVE: AiAdminConnectionResponse = { @@ -32,12 +36,44 @@ const LIVE: AiAdminConnectionResponse = { prefill: null, }; -function renderConfig() { - return render( -
      - -
    +function addon(id: string, enabled: boolean, config?: Record): Addon { + return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon; +} + +const ASSISTANT = addon('ai_assistant', true); +const parsing = (config: Record = {}) => addon('llm_parsing', true, config); + +function renderDialog(addons: Addon[] = [ASSISTANT]) { + const onClose = vi.fn(); + render( + <> + + + ); + return { onClose }; +} + +/** The dialog opened on AI parsing's tab. */ +async function renderParsing(config: Record = {}) { + const user = userEvent.setup(); + renderDialog([ASSISTANT, parsing(config)]); + await user.click(await screen.findByRole('button', { name: 'AI Parsing' })); + return user; +} + +function modelsRoute(names: string[], seen?: (string | null)[]) { + return http.get('/api/admin/llm/local/models', ({ request }) => { + seen?.push(new URL(request.url).searchParams.get('baseUrl')); + return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) }); + }); +} + +function saveRoute(bodies: unknown[], fail = false) { + return http.put('/api/admin/addons/llm_parsing', async ({ request }) => { + bodies.push(await request.json()); + return fail ? HttpResponse.error() : HttpResponse.json({ success: true }); + }); } beforeEach(() => { @@ -51,32 +87,35 @@ beforeEach(() => { repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]); }); -describe('AiAdminConfig', () => { - it('FE-COMP-AI-ADMIN-001: shows who pays, the setup for everyone, the daily limit and the retention', async () => { - renderConfig(); +describe('the connection and the assistant', () => { + it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => { + const user = userEvent.setup(); + renderDialog(); expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true'); expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument(); expect(screen.getByText('No connection yet.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument(); expect(screen.queryByText('Sign in with ChatGPT')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'AI Assistant' })); expect(screen.getByDisplayValue('50')).toBeInTheDocument(); expect(screen.getByText('Keep chats')).toBeInTheDocument(); }); it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => { const user = userEvent.setup(); - renderConfig(); + renderDialog(); await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' })); await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument()); expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' }); - expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument(); expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'AI Assistant' })); + expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument(); }); it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => { const user = userEvent.setup(); repo.adminConnection.mockResolvedValue(LIVE); - renderConfig(); + renderDialog(); expect(await screen.findByText('OpenAI')).toBeInTheDocument(); expect(screen.getByText('…9f3c')).toBeInTheDocument(); expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument(); @@ -89,7 +128,7 @@ describe('AiAdminConfig', () => { it('FE-COMP-AI-ADMIN-004: removing the connection asks first', async () => { const user = userEvent.setup(); repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue({ connection: null, prefill: null }); - renderConfig(); + renderDialog(); await user.click(await screen.findByRole('button', { name: 'Remove connection' })); expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument(); const buttons = screen.getAllByRole('button', { name: 'Remove connection' }); @@ -97,4 +136,201 @@ describe('AiAdminConfig', () => { expect(await screen.findByText('No connection yet.')).toBeInTheDocument(); expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1); }); + + it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => { + const user = userEvent.setup(); + const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]); + expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument(); + const closes = screen.getAllByRole('button', { name: 'Close' }); + await user.click(closes[closes.length - 1]); + expect(onClose).toHaveBeenCalled(); + }); +}); + +describe('AI parsing', () => { + it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => { + repo.adminConnection.mockResolvedValue(LIVE); + server.use(modelsRoute([])); + const user = await renderParsing({ vision: 'auto' }); + expect(await screen.findByText('Shared')).toBeInTheDocument(); + expect(screen.getByText('gpt-5.5')).toBeInTheDocument(); + expect(screen.queryByLabelText('Model')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Use a different one' })); + expect(screen.getByLabelText('Model')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByLabelText('Model')).not.toBeInTheDocument(); + }); + + it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => { + const user = await renderParsing(); + expect( + await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.') + ).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Connection' })); + expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument(); + }); + + it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => { + const bodies: unknown[] = []; + repo.adminConnection.mockResolvedValue(LIVE); + server.use(modelsRoute([]), saveRoute(bodies)); + const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' }); + expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument(); + expect(screen.getByText('claude-x')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Use the shared connection' })); + await screen.findByText('AI settings saved'); + expect(bodies[0]).toEqual({ config: { vision: 'on' } }); + expect(await screen.findByText('Shared')).toBeInTheDocument(); + }); + + it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => { + const urls: (string | null)[] = []; + server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls)); + const user = await renderParsing({ provider: 'local', model: 'old' }); + await screen.findByText('Installed on the server'); + await user.click(await screen.findByRole('button', { name: 'llama3:8b' })); + expect(urls[0]).toBe('http://localhost:11434/v1'); + expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b'); + // qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull. + await user.click(screen.getByRole('button', { name: 'Use' })); + expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b'); + expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled(); + }); + + it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => { + let calls = 0; + server.use( + http.get('/api/admin/llm/local/models', () => { + calls += 1; + return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] }); + }) + ); + const user = await renderParsing({ provider: 'local', model: 'm' }); + await screen.findByText('down'); + expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Refresh' })); + await screen.findByText('No models installed yet. Pull one below.'); + expect(calls).toBe(2); + }); + + it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => { + const bodies: unknown[] = []; + server.use(modelsRoute([]), saveRoute(bodies)); + const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' }); + await screen.findByText('Installed on the server'); + await user.click(screen.getByRole('radio', { name: 'OpenAI' })); + expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue(''); + expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument(); + await user.click(screen.getByRole('radio', { name: 'Anthropic' })); + // A gateway can stand in for Anthropic too, so the field stays (#2655). + expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue(''); + expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument(); + await user.clear(screen.getByLabelText('Model')); + await user.type(screen.getByLabelText('Model'), ' claude-haiku '); + await user.type(screen.getByLabelText('API key'), 'sk-ant-live'); + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('AI settings saved'); + // The stale local address must not ride along to Anthropic, and the model is trimmed. + expect(bodies[0]).toEqual({ + config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' }, + }); + }); + + it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => { + const urls: (string | null)[] = []; + server.use(modelsRoute([], urls)); + const user = await renderParsing({ provider: 'local', model: 'm' }); + await screen.findByText('Installed on the server'); + await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1'); + await user.tab(); + await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1')); + const before = urls.length; + await user.click(screen.getByRole('radio', { name: 'OpenAI' })); + await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1'); + await user.tab(); + expect(urls).toHaveLength(before); + }); + + it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => { + let pulled: unknown = null; + let modelCalls = 0; + server.use( + http.get('/api/admin/llm/local/models', () => { + modelCalls += 1; + return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] }); + }), + http.post('/api/admin/llm/local/pull', async ({ request }) => { + pulled = await request.json(); + await delay(150); + return new HttpResponse( + '{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n', + { headers: { 'Content-Type': 'application/x-ndjson' } } + ); + }) + ); + const user = await renderParsing({ provider: 'local', model: 'm' }); + await screen.findByText('No models installed yet. Pull one below.'); + await user.click(screen.getByRole('button', { name: 'Pull' })); + expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0); + await screen.findByText('Model pulled'); + expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' }); + expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled()); + }); + + it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => { + server.use( + modelsRoute([]), + http.post( + '/api/admin/llm/local/pull', + () => + new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', { + headers: { 'Content-Type': 'application/x-ndjson' }, + }) + ) + ); + const user = await renderParsing({ provider: 'local', model: 'm' }); + await screen.findByText('No models installed yet. Pull one below.'); + await user.click(screen.getByRole('button', { name: 'Pull' })); + await screen.findByText('manifest not found'); + expect(screen.queryByText('Model pulled')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled()); + }); + + it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => { + const bodies: unknown[] = []; + server.use(modelsRoute([]), saveRoute(bodies)); + const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' }); + await screen.findByText('Installed on the server'); + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('AI settings saved'); + expect(bodies[0]).toEqual({ + config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' }, + }); + server.use(saveRoute(bodies, true)); + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('Could not save AI settings'); + }); + + it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => { + const bodies: unknown[] = []; + server.use(saveRoute(bodies)); + const user = await renderParsing({ + provider: 'openai', + model: 'gpt-4.1-mini', + baseUrl: '', + apiKey: 'sk', + vision: 'off', + }); + expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument(); + expect( + screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.') + ).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Yes' })); + await screen.findByText('AI settings saved'); + expect(bodies[0]).toEqual({ + config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' }, + }); + }); }); diff --git a/client/src/components/Admin/addons/AiAdminConfig.tsx b/client/src/components/Admin/addons/AiAdminConfig.tsx index 9e54a5a7d..268df650b 100644 --- a/client/src/components/Admin/addons/AiAdminConfig.tsx +++ b/client/src/components/Admin/addons/AiAdminConfig.tsx @@ -1,14 +1,25 @@ -import { Check, Loader2 } from 'lucide-react'; -import React, { useId } from 'react'; +import { Check, Loader2, Sparkles } from 'lucide-react'; +import React, { useId, useState } from 'react'; import { useTranslation } from '../../../i18n'; import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel'; import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel'; import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit'; import ConfirmDialog from '../../shared/ConfirmDialog'; import CustomSelect from '../../shared/CustomSelect'; -import { fs } from '../../shared/DialogShell'; +import { + DialogButton, + DialogFooter, + DialogHeader, + DialogShell, + DialogTile, + FooterSpacer, + NEUTRAL_TINT, + fs, +} from '../../shared/DialogShell'; import { NumericInput } from '../../shared/NumericInput'; -import { EditorField, INPUT } from '../../shared/dialogParts'; +import { EditorField, INPUT, Segmented } from '../../shared/dialogParts'; +import ParsingSettings from './ParsingSettings'; +import type { Addon } from './addonModel'; import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig'; type AiAdmin = ReturnType; @@ -125,28 +136,12 @@ function InstanceConnection({ A }: { A: AiAdmin }) { ); } -/** - * The AI assistant's settings on its addon tile, as a form in the shelf like - * AI parsing's: who pays, the connection for everyone, the daily limit while - * the instance pays, and how long chats are kept. Each change saves at once. - */ -export default function AiAdminConfig(): React.ReactElement { +/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */ +function AssistantLimits({ A }: { A: AiAdmin }) { const { t } = useTranslation(); - const A = useAiAdminConfig(); const fieldId = useId(); - - if (!A.loaded) { - return ( -
  • - -
  • - ); - } - return ( -
  • - - {A.instancePays && } +
    {A.instancePays && ( +
    + ); +} + +type AiTab = 'connection' | 'assistant' | 'parsing'; + +/** + * The AI settings behind the AI tile's button, in one dialog so the addon page + * stays short: the connection the assistant and AI parsing share (and who pays + * for it), the assistant's limits, and how bookings are read. A tab shows only + * while its addon is on. Each change saves at once. + */ +export default function AiSettingsDialog({ + addons, + onClose, +}: { + addons: Addon[]; + onClose: () => void; +}): React.ReactElement { + const { t } = useTranslation(); + const labelId = useId(); + const A = useAiAdminConfig(); + const assistantOn = addons.some((a) => a.id === 'ai_assistant' && a.enabled); + const parsing = addons.find((a) => a.id === 'llm_parsing' && a.enabled) ?? null; + const tabs: { value: AiTab; label: string }[] = [ + { value: 'connection', label: t('admin.addons.ai.tab.connection') }, + ...(assistantOn ? [{ value: 'assistant' as const, label: t('admin.addons.catalog.ai_assistant.name') }] : []), + ...(parsing ? [{ value: 'parsing' as const, label: t('admin.addons.catalog.llm_parsing.name') }] : []), + ]; + const [tab, setTab] = useState('connection'); + const shown = tabs.some((x) => x.value === tab) ? tab : 'connection'; + + return ( + + + + } + tint={NEUTRAL_TINT} + labelId={labelId} + eyebrow={t('admin.addons.ai.name')} + title={t('admin.addons.ai.setup')} + sub={t('admin.addons.ai.dialogSub')} + subWraps + onClose={onClose} + /> + } + footer={ + + + + {t('common.close')} + + + } + > + {tabs.length > 1 && ( + label={t('admin.addons.ai.setup')} value={shown} onChange={setTab} options={tabs} fill /> + )} + {!A.loaded ? ( +
    + +
    + ) : ( + <> + {shown === 'connection' && ( +
    + + {A.instancePays && } +
    + )} + {shown === 'assistant' && } + {shown === 'parsing' && parsing && ( + setTab('connection')} /> + )} + + )} -
  • + ); } diff --git a/client/src/components/Admin/addons/ParsingSettings.tsx b/client/src/components/Admin/addons/ParsingSettings.tsx new file mode 100644 index 000000000..8f11a004c --- /dev/null +++ b/client/src/components/Admin/addons/ParsingSettings.tsx @@ -0,0 +1,348 @@ +import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'; +import { Check, RefreshCw } from 'lucide-react'; +import React, { useId } from 'react'; +import { useTranslation } from '../../../i18n'; +import { ProviderMark } from '../../Settings/ai/AiConnectPanel'; +import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../../Settings/settingsKit'; +import { DialogSection, fs } from '../../shared/DialogShell'; +import { EditorField, INPUT, PANEL, Segmented } from '../../shared/dialogParts'; +import { type Addon, ANTHROPIC_HINT, MASKED, RECOMMENDED_MODELS } from './addonModel'; +import type { useAiAdminConfig } from './useAiAdminConfig'; +import { useLlmParsingConfig } from './useLlmParsingConfig'; + +type AiAdmin = ReturnType; +type Parsing = ReturnType; +type MarkKind = Parameters[0]['kind']; + +/** Parsing's own providers, with the mark the assistant shows for each. */ +const OWN_PROVIDERS = [ + { value: 'local', labelKey: 'settings.aiParsing.providerLocal', mark: 'custom' }, + { value: 'openai', labelKey: 'settings.aiParsing.providerOpenai', mark: 'openai' }, + { value: 'anthropic', labelKey: 'settings.aiParsing.providerAnthropic', mark: 'anthropic' }, +] as const; + +const markOf = (provider: string): MarkKind => OWN_PROVIDERS.find((p) => p.value === provider)?.mark ?? 'custom'; + +/** The connection parsing reads with, as one line: a mark, who answers, the model and where it comes from. */ +function ReadsWith({ mark, title, model, tag }: { mark: MarkKind; title: string; model: string; tag: string }) { + return ( +
    + +
    +

    + {title} +

    +

    + {model} +

    +
    + {tag} +
    + ); +} + +/** Which connection parsing reads with now, and the way to the other one. */ +function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onOpenConnection: () => void }) { + const { t } = useTranslation(); + const live = A.instancePays ? A.instance.live : null; + if (P.ownConnection) { + const own = OWN_PROVIDERS.find((p) => p.value === P.savedProvider); + return ( +
    + + {t('admin.addons.ai.parsing.ownHint')} + +
    + ); + } + return ( +
    + {live ? ( + + ) : ( + + {t(A.policy === 'user' ? 'admin.addons.ai.parsing.perUser' : 'admin.addons.ai.parsing.none')} + + )} +
    + {!live && A.policy !== 'user' && ( + + )} + {!P.formOpen && ( + + )} +
    +
    + ); +} + +/** The models on the local server to pick from, and the recommended ones to pull. */ +function LocalModels({ P }: { P: Parsing }) { + const { t } = useTranslation(); + return ( +
    +
    + + {t('admin.addons.ai.parsing.installed')} + + +
    + {P.modelsErr && ( +

    + {P.modelsErr} +

    + )} + {!P.modelsErr && P.installed.length === 0 && !P.loadingModels && ( + {t('admin.addons.ai.parsing.noModels')} + )} + {P.installed.length > 0 && ( +
    + {P.installed.map((name) => ( + + ))} +
    + )} +
    + + {t('admin.addons.ai.parsing.pullTitle')} + + {RECOMMENDED_MODELS.map((m) => { + const here = P.isInstalled(m.id); + const chosen = P.model === m.id; + return ( +
    +
    + + {m.label} + + {m.recommended && {t('assistant.model.recommended')}} + {here ? ( + + ) : ( + + )} +
    + {P.pulling === m.id && ( +
    +
    +
    +
    +
    + {P.pullStatus} + {P.pullPct ? ` ${P.pullPct}%` : ''} +
    +
    + )} +
    + ); + })} +
    +
    + ); +} + +/** A connection of parsing's own: the provider as tiles, its address, key and model. */ +function OwnConnectionForm({ P }: { P: Parsing }) { + const { t } = useTranslation(); + const fieldId = useId(); + return ( +
    +
    + {OWN_PROVIDERS.map((p) => { + const on = P.provider === p.value; + return ( + + ); + })} +
    + + P.setBaseUrl(e.target.value)} + onBlur={() => void P.loadModels()} + placeholder={P.baseUrlPlaceholder} + /> + + + P.setApiKey(e.target.value)} + placeholder={ + P.apiKey === MASKED ? MASKED : P.provider === 'local' ? t('admin.addons.ai.parsing.apiKeyOptional') : '' + } + /> + + + P.setModel(e.target.value)} + /> + + {P.provider === 'local' && } +
    + {!P.ownConnection && ( + + )} + +
    +
    + ); +} + +/** + * AI parsing in the AI dialog: which connection reads the bookings (the shared + * one, or one of its own an older install kept), a connection of its own when + * wanted, and whether the model reads images. Each part saves on its own. + */ +export default function ParsingSettings({ + addon, + A, + onOpenConnection, +}: { + addon: Addon; + A: AiAdmin; + onOpenConnection: () => void; +}): React.ReactElement { + const { t } = useTranslation(); + const P = useLlmParsingConfig(addon); + const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) })); + const local = + (P.ownConnection ? P.savedProvider : A.instance.live?.kind === 'custom' ? 'local' : 'cloud') === 'local'; + return ( +
    + + + + {P.formOpen && ( + + + + )} + + + label={t('settings.aiParsing.multimodal')} + value={P.vision} + onChange={(v) => P.setVision(asLlmVision(v))} + options={visionOptions} + fill + /> +
    + + {t(local ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')} + +
    +
    +
    + ); +} diff --git a/client/src/components/Admin/addons/aiAddons.ts b/client/src/components/Admin/addons/aiAddons.ts new file mode 100644 index 000000000..2e597bad1 --- /dev/null +++ b/client/src/components/Admin/addons/aiAddons.ts @@ -0,0 +1,32 @@ +import { Cable, type LucideIcon, MessagesSquare, ScanText } from 'lucide-react'; +import type { Addon } from './addonModel'; + +/** + * The three addons that talk to a language model, shown as one AI tile in the + * integration column: the assistant in the trip, AI parsing (booking import) + * and MCP (TREK inside other AI apps). Their ids, rows and settings stay their + * own; only the page groups them. Assistant and parsing share one connection. + */ +export const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const; +export type AiAddonId = (typeof AI_ADDON_IDS)[number]; + +/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */ +export const AI_ADDON_ICONS: Record = { + ai_assistant: MessagesSquare, + llm_parsing: ScanText, + mcp: Cable, +}; + +export function isAiAddon(addon: Addon): boolean { + return (AI_ADDON_IDS as readonly string[]).includes(addon.id); +} + +/** The AI addons this install has, in the tile's order. */ +export function aiAddonsOf(addons: Addon[]): Addon[] { + return AI_ADDON_IDS.flatMap((id) => addons.find((a) => a.id === id) ?? []); +} + +/** Whether the shared connection is used at all: the assistant or AI parsing is on. */ +export function usesConnection(addons: Addon[]): boolean { + return addons.some((a) => (a.id === 'ai_assistant' || a.id === 'llm_parsing') && a.enabled); +} diff --git a/client/src/components/Admin/addons/useAddonManager.test.ts b/client/src/components/Admin/addons/useAddonManager.test.ts index b150d5e3f..c6bee757c 100644 --- a/client/src/components/Admin/addons/useAddonManager.test.ts +++ b/client/src/components/Admin/addons/useAddonManager.test.ts @@ -26,6 +26,8 @@ const ADDONS = [ addon('llm_parsing', 'integration', false), addon('immich', 'photo_provider', false), addon('gdrive', 'document_provider'), + addon('airtrail', 'integration', false), + addon('mcp', 'integration'), ]; let loadAddons: ReturnType Promise>>; @@ -60,14 +62,16 @@ function mapSwitch(manager: ReturnType, key: 'hiking' | } describe('useAddonManager', () => { - it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => { + it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration, AI 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']); + // The AI addons leave the integration list for their own tile, in its order. + expect(ids(result.current.integrationAddons)).toEqual(['airtrail']); + expect(ids(result.current.aiAddons)).toEqual(['llm_parsing', 'mcp']); expect(result.current.providerOptions).toEqual([ expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }), ]); diff --git a/client/src/components/Admin/addons/useAddonManager.ts b/client/src/components/Admin/addons/useAddonManager.ts index 19c6d5d48..b2080ac4b 100644 --- a/client/src/components/Admin/addons/useAddonManager.ts +++ b/client/src/components/Admin/addons/useAddonManager.ts @@ -9,6 +9,7 @@ import { useTranslation } from '../../../i18n'; import { useAddonStore } from '../../../store/addonStore'; import { useToast } from '../../shared/Toast'; import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel'; +import { aiAddonsOf, isAiAddon } from './aiAddons'; /** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */ function loadToursFlag( @@ -139,7 +140,9 @@ export function useAddonManager() { 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'); + // The AI addons leave the list to sit together on one tile at its head. + const integrationAddons = addons.filter((a) => a.type === 'integration' && !isAiAddon(a)); + const aiAddons = aiAddonsOf(addons); 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 @@ -153,6 +156,7 @@ export function useAddonManager() { tripAddons, globalAddons, integrationAddons, + aiAddons, providerOptions, documentProviderOptions, toursWanderer, diff --git a/client/src/components/Admin/addons/useAiAddonTile.ts b/client/src/components/Admin/addons/useAiAddonTile.ts new file mode 100644 index 000000000..113bb94e9 --- /dev/null +++ b/client/src/components/Admin/addons/useAiAddonTile.ts @@ -0,0 +1,23 @@ +import { useState } from 'react'; +import { useSearchParams } from 'react-router'; +import type { Addon } from './addonModel'; +import { usesConnection } from './aiAddons'; + +/** + * The AI tile behind both admin shells: its three switches, how many are on, + * and the settings dialog. The dialog opens by itself when OpenRouter's + * sign-in returns here (`?ai=openrouter`), since the connection form that + * finishes it lives inside. + */ +export function useAiAddonTile(aiAddons: Addon[]) { + const [params] = useSearchParams(); + const [open, setOpen] = useState(() => params.get('ai') === 'openrouter'); + const canSetUp = usesConnection(aiAddons); + return { + enabledCount: aiAddons.filter((a) => a.enabled).length, + canSetUp, + dialogOpen: open && canSetUp, + openDialog: () => setOpen(true), + closeDialog: () => setOpen(false), + }; +} diff --git a/client/src/components/Admin/addons/useLlmParsingConfig.test.ts b/client/src/components/Admin/addons/useLlmParsingConfig.test.ts index c205c9dc0..11d2029df 100644 --- a/client/src/components/Admin/addons/useLlmParsingConfig.test.ts +++ b/client/src/components/Admin/addons/useLlmParsingConfig.test.ts @@ -5,10 +5,14 @@ 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-011 +// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-013 const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })); + +/** A local connection of parsing's own, so the form is open and lists the server's models. */ +const OWN_LOCAL = { provider: 'local', model: 'm' }; function llm(config?: Record): Addon { return { @@ -45,7 +49,7 @@ describe('useLlmParsingConfig', () => { }); it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => { - const { result } = renderHook(() => useLlmParsingConfig(llm())); + const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL))); 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); @@ -54,7 +58,7 @@ describe('useLlmParsingConfig', () => { it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => { const { result } = renderHook(() => - useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' })) + useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt', baseUrl: ' http://box:11434/v1 ' })) ); expect(adminApi.llmLocalModels).not.toHaveBeenCalled(); await act(() => result.current.loadModels()); @@ -69,13 +73,13 @@ describe('useLlmParsingConfig', () => { 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())); + const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL))); 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'); + expect(result.current.modelsErr).toBe('admin.addons.ai.parsing.unreachable'); }); it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => { @@ -83,7 +87,7 @@ describe('useLlmParsingConfig', () => { response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } }, }); vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused); - const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' }))); + const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL))); await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)')); }); @@ -94,14 +98,14 @@ describe('useLlmParsingConfig', () => { progress = onProgress; return new Promise((r) => (finish = r)); }); - const { result } = renderHook(() => useLlmParsingConfig(llm())); + const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL))); 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…'); + expect(result.current.pullStatus).toBe('admin.addons.ai.parsing.pulling'); act(() => progress({ status: 'downloading', total: 200, completed: 50 })); expect(result.current.pullStatus).toBe('downloading'); expect(result.current.pullPct).toBe(25); @@ -109,7 +113,7 @@ describe('useLlmParsingConfig', () => { finish(); await pending; }); - expect(toast.success).toHaveBeenCalledWith('Model pulled'); + expect(toast.success).toHaveBeenCalledWith('admin.addons.ai.parsing.pulled'); expect(result.current.model).toBe('qwen3.5:4b'); expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2); expect(result.current.pulling).toBeNull(); @@ -136,7 +140,7 @@ describe('useLlmParsingConfig', () => { 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(toast.error).toHaveBeenLastCalledWith('admin.addons.ai.parsing.pullFailed'); expect(result.current.pulling).toBeNull(); }); @@ -148,18 +152,16 @@ describe('useLlmParsingConfig', () => { 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(toast.success).toHaveBeenCalledWith('settings.aiParsing.toast.saved'); expect(result.current.saving).toBe(false); }); it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => { const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' }))); - act(() => { - result.current.setProvider('anthropic'); - result.current.setVision('off'); - }); + await act(async () => result.current.setVision('off')); + act(() => result.current.setProvider('anthropic')); await act(() => result.current.save()); - expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1]).toEqual({ + expect(vi.mocked(adminApi.updateAddon).mock.calls.at(-1)?.[1]).toEqual({ config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' }, }); }); @@ -212,7 +214,35 @@ describe('useLlmParsingConfig', () => { 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(toast.error).toHaveBeenCalledWith('settings.aiParsing.toast.saveError'); expect(result.current.saving).toBe(false); }); + + it('FE-HOOK-LLMCFG-012: without its own connection the form stays shut and asks the server nothing until opened', async () => { + const { result } = renderHook(() => useLlmParsingConfig(llm({ vision: 'on' }))); + expect(result.current.ownConnection).toBe(false); + expect(result.current.formOpen).toBe(false); + expect(adminApi.llmLocalModels).not.toHaveBeenCalled(); + act(() => result.current.startOwn()); + expect(result.current.formOpen).toBe(true); + await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL)); + // The vision setting alone is saved without a connection. + await act(async () => result.current.setVision('off')); + expect(adminApi.updateAddon).toHaveBeenLastCalledWith('llm_parsing', { config: { vision: 'off' } }); + act(() => result.current.cancelOwn()); + expect(result.current.formOpen).toBe(false); + }); + + it('FE-HOOK-LLMCFG-013: going back to the shared connection drops the own one and keeps the vision setting', async () => { + const { result } = renderHook(() => + useLlmParsingConfig(llm({ provider: 'anthropic', model: 'claude-x', apiKey: MASKED, vision: 'on' })) + ); + expect(result.current.ownConnection).toBe(true); + expect(result.current.savedModel).toBe('claude-x'); + await act(() => result.current.switchToShared()); + expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { config: { vision: 'on' } }); + expect(result.current.ownConnection).toBe(false); + expect(result.current.formOpen).toBe(false); + expect(result.current.model).toBe(''); + }); }); diff --git a/client/src/components/Admin/addons/useLlmParsingConfig.ts b/client/src/components/Admin/addons/useLlmParsingConfig.ts index 0e1b9d731..099651c31 100644 --- a/client/src/components/Admin/addons/useLlmParsingConfig.ts +++ b/client/src/components/Admin/addons/useLlmParsingConfig.ts @@ -2,24 +2,47 @@ import { type LlmVision, asLlmVision } from '@trek/shared'; import { useEffect, useEffectEvent, useState } from 'react'; import { adminApi } from '../../../api/client'; +import { useTranslation } from '../../../i18n'; import { getApiErrorMessage } from '../../../types'; import { useToast } from '../../shared/Toast'; import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel'; +/** What AI parsing keeps in its addon config: a connection of its own (or none) and the vision setting. */ +interface SavedParsing { + provider: string; + model: string; + baseUrl: string; + apiKey: string; + vision: LlmVision; +} + +function savedOf(cfg: Record): SavedParsing { + return { + provider: typeof cfg.provider === 'string' ? cfg.provider : '', + model: typeof cfg.model === 'string' ? cfg.model : '', + baseUrl: typeof cfg.baseUrl === 'string' ? cfg.baseUrl : '', + apiKey: typeof cfg.apiKey === 'string' ? cfg.apiKey : '', + vision: asLlmVision(cfg.vision), + }; +} + /** - * 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. + * AI parsing's admin settings behind both admin shells. Parsing reads with the + * AI connection the assistant runs on unless the instance keeps one of its own + * here; an install set up before the two shared one keeps its own until the + * admin switches it over. The API key is masked on read and an unchanged mask + * keeps the stored key. For a local server it lists the installed Ollama models + * and pulls the recommended ones. */ export function useLlmParsingConfig(addon: Addon) { + const { t } = useTranslation(); const toast = useToast(); - const cfg = addon.config ?? {}; - const [provider, setProviderState] = 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 [saved, setSaved] = useState(() => savedOf(addon.config ?? {})); + const [editing, setEditing] = useState(false); + const [provider, setProviderState] = useState(saved.provider || 'local'); + const [model, setModel] = useState(saved.model); + const [baseUrl, setBaseUrl] = useState(saved.baseUrl); + const [apiKey, setApiKey] = useState(saved.apiKey); const [saving, setSaving] = useState(false); // Local-provider model management. @@ -30,6 +53,10 @@ export function useLlmParsingConfig(addon: Addon) { const [pullPct, setPullPct] = useState(0); const [pullStatus, setPullStatus] = useState(''); + /** A connection of its own, which wins over the shared one. */ + const ownConnection = !!(saved.provider && saved.model); + const formOpen = ownConnection || editing; + /** * A different provider never inherits the last one's endpoint: an Ollama address * left in the field would quietly receive the OpenAI or Anthropic key. Back on the @@ -37,7 +64,7 @@ export function useLlmParsingConfig(addon: Addon) { * keep an address the admin typed for it (#2655); empty means the provider default. */ const setProvider = (next: string) => { - if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : ''); + if (next !== provider) setBaseUrl(next === saved.provider ? saved.baseUrl : ''); setProviderState(next); }; const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai; @@ -54,38 +81,38 @@ export function useLlmParsingConfig(addon: Addon) { setInstalled(res.models.map((m) => m.name)); } catch (e: unknown) { // The server says why (unreachable, key refused); axios' own status line does not. - setModelsErr(getApiErrorMessage(e, 'Could not reach the local LLM server')); + setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable'))); 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). + // Load installed models while the form shows the local provider. Only a provider + // change (or opening the form) triggers it; the base URL's own blur reloads. const loadModelsForProvider = useEffectEvent(() => { - if (provider === 'local') void loadModels(); + if (formOpen && provider === 'local') void loadModels(); }); useEffect(() => { loadModelsForProvider(); - }, [provider]); + }, [provider, formOpen]); const pull = async (id: string) => { if (pulling) return; setPulling(id); setPullPct(0); - setPullStatus('starting…'); + setPullStatus(t('admin.addons.ai.parsing.pulling')); 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'); + toast.success(t('admin.addons.ai.parsing.pulled')); setModel(id); await loadModels(); } catch (e: unknown) { - toast.error(e instanceof Error ? e.message : 'Pull failed'); + toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed')); } finally { setPulling(null); setPullPct(0); @@ -93,27 +120,52 @@ export function useLlmParsingConfig(addon: Addon) { } }; - const save = async () => { + /** Writes the config; the stored key stays as long as the mask is sent in its place. */ + const write = async (next: SavedParsing, config: Record): Promise => { 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: baseUrl.trim(), - apiKey, - vision, - }, - }); - toast.success('Saved'); + await adminApi.updateAddon(addon.id, { config }); + setSaved(next); + toast.success(t('settings.aiParsing.toast.saved')); + return true; } catch { - toast.error('Failed to save'); + toast.error(t('settings.aiParsing.toast.saveError')); + return false; } finally { setSaving(false); } }; + const ownConfig = (s: SavedParsing) => ({ + provider: s.provider, + model: s.model, + baseUrl: s.baseUrl, + apiKey: s.apiKey, + }); + + const save = async () => { + const next: SavedParsing = { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, vision: saved.vision }; + if (await write(next, { ...ownConfig(next), vision: next.vision })) setEditing(false); + }; + + /** Back on the shared connection: the own one and its key are dropped, the vision setting stays. */ + const switchToShared = async () => { + const next: SavedParsing = { provider: '', model: '', baseUrl: '', apiKey: '', vision: saved.vision }; + if (await write(next, { vision: next.vision })) { + setEditing(false); + setProviderState('local'); + setModel(''); + setBaseUrl(''); + setApiKey(''); + } + }; + + /** Saved at once, with the connection as it was saved: a draft in the form is not sent along. */ + const setVision = (value: LlmVision) => { + const next = { ...saved, vision: value }; + void write(next, ownConnection ? { ...ownConfig(saved), vision: value } : { vision: value }); + }; + return { provider, setProvider, @@ -124,9 +176,16 @@ export function useLlmParsingConfig(addon: Addon) { baseUrlPlaceholder, apiKey, setApiKey, - vision, + vision: saved.vision, setVision, saving, + ownConnection, + savedProvider: saved.provider, + savedModel: saved.model, + formOpen, + startOwn: () => setEditing(true), + cancelOwn: () => setEditing(false), + switchToShared, installed, modelsErr, loadingModels, diff --git a/client/src/components/Assistant/AssistantChangeCard.test.tsx b/client/src/components/Assistant/AssistantChangeCard.test.tsx index 76168b567..db833721c 100644 --- a/client/src/components/Assistant/AssistantChangeCard.test.tsx +++ b/client/src/components/Assistant/AssistantChangeCard.test.tsx @@ -226,10 +226,13 @@ describe('AssistantChangeCard: on the map', () => { expect(row).toHaveClass('bg-surface-hover'); }); - it('FE-AI-CARD-009: the days a waiting card touches read as a band, with the schedule folded away', () => { + it('FE-AI-CARD-009: the days a waiting card touches read as named stops, with the schedule folded away', () => { plannerHere(); renderCard([onTheMap('c1', 'Pantheon')]); expect(screen.getByText('Rome')).toBeInTheDocument(); + // Folded, each stop is a chip with its name, not a blank block behind a tooltip. + expect(screen.getByText('Louvre')).toBeInTheDocument(); + expect(screen.getAllByText('Pantheon')).toHaveLength(2); expect(screen.queryByText('New')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Show schedule' })); diff --git a/client/src/components/Assistant/AssistantChangeCard.tsx b/client/src/components/Assistant/AssistantChangeCard.tsx index a6a0ee181..007e16b88 100644 --- a/client/src/components/Assistant/AssistantChangeCard.tsx +++ b/client/src/components/Assistant/AssistantChangeCard.tsx @@ -25,14 +25,16 @@ import { blockKeyOf } from '../../store/assistantMapModel'; import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { useTripStore } from '../../store/tripStore'; import ToggleSwitch from '../Settings/ToggleSwitch'; -import { Tooltip } from '../shared/Tooltip'; import { useToast } from '../shared/Toast'; +import { Tooltip } from '../shared/Tooltip'; import AssistantCompareSwitch from './AssistantCompareSwitch'; import AssistantDayBand from './AssistantDayBand'; +import AssistantWeekOverview from './AssistantWeekOverview'; import { CHANGE_LABELS, type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts'; import { useAssistantView } from './assistantViewContext'; import { changePoints, type DayChange } from './dayAfterModel'; import { useChangeHover } from './useChangeHover'; +import { useWeekDays } from './useWeekDays'; /** * The changes the assistant proposes in one go, as one card in plain words: @@ -235,7 +237,11 @@ function Receipt({ )} - + @@ -392,6 +398,21 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] }) const changes = useMemo(() => dayChangesOf(items, excluded), [items, excluded]); const undoable = items.filter((i) => undoableNow(i, undone, now)).map((i) => i.toolCallId); + const week = useWeekDays(pending); + const [showAll, setShowAll] = useState(false); + // A plan over a week reads from its grid; the single changes wait behind one line. + const folded = week.length > 0 && !showAll; + + const toggleDay = (ids: readonly string[], include: boolean) => + setExcluded((prev) => { + const next = new Set(prev); + for (const id of ids) { + if (include) next.delete(id); + else next.add(id); + } + return next; + }); + const toggle = (toolCallId: string) => setExcluded((prev) => { const next = new Set(prev); @@ -420,18 +441,32 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] }) count={items.length} actions={} /> - {open && } + {open && week.length > 0 && ( + + )} + {open && !folded && } + {folded && ( + + )}
    {items.map((item) => item.state === 'pending' ? ( - 1} - disabled={isRunning} - onToggle={() => toggle(item.toolCallId)} - /> + folded ? null : ( + 1} + disabled={isRunning} + onToggle={() => toggle(item.toolCallId)} + /> + ) ) : ( { if (e.clipboardData.files.length > 0 && take(e.clipboardData.files)) e.preventDefault(); }} - className="max-h-48 min-h-[36px] flex-1 resize-none bg-transparent px-1.5 py-1.5 text-body text-content outline-none placeholder:text-content-faint disabled:cursor-not-allowed" + className="max-h-48 min-h-[36px] flex-1 resize-none bg-transparent px-1.5 py-1.5 text-body text-content outline-none placeholder:text-content-faint disabled:cursor-not-allowed dark:bg-transparent" /> s.thread.isRunning}> = { - kept: 'bg-surface-secondary', - new: 'border border-dashed border-success bg-success-soft', - removed: 'border border-edge-faint opacity-50', +const CHIP: Record = { + kept: 'border-transparent bg-surface-secondary text-content-muted', + new: 'border-dashed border-success bg-success-soft text-content', + removed: 'border-transparent text-content-faint line-through', }; -function Segment({ stop }: { stop: TimelineStop }) { +/** One stop of the day, named; the change it belongs to lights up with it. */ +function StopChip({ stop }: { stop: TimelineStop }) { const { tripId } = useAssistantView(); const { lit, handlers } = useChangeHover(tripId, stop.toolCallId); - const clash = stop.lead?.overlapsWith ? 'ring-1 ring-inset ring-danger' : ''; + const clash = stop.lead?.overlapsWith ? 'border-danger' : stopIssue(stop) ? 'border-warning' : ''; return ( - - - + + {stop.name} + ); } @@ -47,13 +58,19 @@ function Lead({ stop, timed }: { stop: TimelineStop; timed: boolean }) { const lead = stop.lead; if (!lead) return null; const travel = lead.travelMinutes !== null && lead.travelMinutes > 0; - if (!travel && lead.freeMinutes === null && !lead.overlapsWith) return null; + if (!travel && lead.freeMinutes === null && !lead.overlapsWith && lead.shortBy === null) return null; return (
  • {travel && {t('assistant.change.timeline.travel', { count: lead.travelMinutes ?? 0 })}} {lead.freeMinutes !== null && {t('assistant.change.timeline.gap', { count: lead.freeMinutes })}} + {lead.shortBy !== null && ( + + + )} {lead.overlapsWith && (