From 123e27643d1fcd9d983c41aada17db6336005902 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 21 Oct 2026 20:55:06 +0200 Subject: [PATCH] feat(assistant): one AI tile for assistant, parsing and MCP, a planner-aware chat The three AI addons share one tile in the integration column; a dialog holds the connection, the assistant's limits and AI parsing, which now reads with the assistant's connection unless an install keeps its own. Existing parsing configs stay where they are and keep winning. The chat sees the map on screen, offers next questions under an answer, shows its live step in the window head, previews waiting changes inside the day list, checks whether a day still works, folds a multi-day plan into a week grid, keeps per-trip notes and can estimate costs. An empty or cut-off answer gets one push instead of ending silently, and the assistant's own step and size limits are raised. --- client/scripts/feature-pairs.json | 11 +- .../components/Admin/AddonManager.test.tsx | 327 +++------------- client/src/components/Admin/AddonManager.tsx | 213 ++--------- .../components/Admin/addons/AiAddonTile.tsx | 95 +++++ .../Admin/addons/AiAdminConfig.test.tsx | 264 ++++++++++++- .../components/Admin/addons/AiAdminConfig.tsx | 128 +++++-- .../Admin/addons/ParsingSettings.tsx | 348 ++++++++++++++++++ .../src/components/Admin/addons/aiAddons.ts | 32 ++ .../Admin/addons/useAddonManager.test.ts | 8 +- .../Admin/addons/useAddonManager.ts | 6 +- .../components/Admin/addons/useAiAddonTile.ts | 23 ++ .../Admin/addons/useLlmParsingConfig.test.ts | 64 +++- .../Admin/addons/useLlmParsingConfig.ts | 125 +++++-- .../Assistant/AssistantChangeCard.test.tsx | 5 +- .../Assistant/AssistantChangeCard.tsx | 57 ++- .../Assistant/AssistantComposer.tsx | 2 +- .../components/Assistant/AssistantDayBand.tsx | 96 +++-- .../Assistant/AssistantDayPreview.tsx | 137 +++++++ .../Assistant/AssistantFollowUps.tsx | 58 +++ .../components/Assistant/AssistantHeader.tsx | 47 ++- .../Assistant/AssistantMemoryDialog.tsx | 112 ++++++ .../components/Assistant/AssistantMessage.tsx | 6 +- .../Assistant/AssistantPlaceCard.tsx | 125 ++++--- .../Assistant/AssistantPlaceStrip.test.tsx | 14 +- .../Assistant/AssistantPlaceStrip.tsx | 87 +++-- .../Assistant/AssistantPowerParts.test.tsx | 257 +++++++++++++ .../Assistant/AssistantWeekOverview.tsx | 65 ++++ .../components/Assistant/AssistantWindow.tsx | 1 + client/src/components/Assistant/assistant.css | 20 +- .../Assistant/assistantFollowUpRules.ts | 44 +++ .../components/Assistant/assistantModel.ts | 4 + .../Assistant/dayAfterModel.test.ts | 27 +- .../src/components/Assistant/dayAfterModel.ts | 34 +- .../Assistant/useAssistantMapFollow.ts | 33 +- .../components/Assistant/useAssistantMenu.ts | 70 ++++ .../Assistant/useAssistantPlaceStrip.ts | 15 +- .../Assistant/useFoundPlacePhoto.ts | 15 +- .../src/components/Assistant/useTripMemory.ts | 38 ++ .../src/components/Assistant/useWeekDays.ts | 48 +++ .../components/Map/AssistantMapLeaflet.tsx | 5 +- .../src/components/Map/assistantViewport.ts | 91 +++++ .../src/components/Map/useAssistantMapGL.ts | 2 + .../src/components/Planner/DayPlanSidebar.tsx | 19 +- .../components/Planner/usePlacesSidebar.ts | 5 +- .../components/Settings/IntegrationsTab.tsx | 10 +- .../Settings/ai/AiConnectionSection.tsx | 42 ++- .../components/Settings/ai/aiConnectModel.ts | 12 + .../Settings/useIntegrationGates.ts | 8 + .../screens/admin/MAdminAddonManager.tsx | 275 +++----------- .../screens/admin/MAdminAiConfig.test.tsx | 65 ---- .../mobile/screens/admin/MAdminAiConfig.tsx | 174 --------- .../screens/settings/MAiConnectionSection.tsx | 31 +- .../settings/MSettingsIntegrations.tsx | 6 +- .../trip/assistant/MAssistantScreen.tsx | 18 +- client/src/store/assistantStore.ts | 21 +- client/src/types.ts | 4 + .../mobile/admin/MAdminAddonManager.test.tsx | 270 ++------------ .../settings/MSettingsIntegrations.test.tsx | 25 +- server/src/nest/addons/addon.guard.ts | 4 +- .../nest/addons/require-addon.decorator.ts | 8 +- .../ai-connections/ai-admin.controller.ts | 3 +- .../ai-connections.constants.ts | 1 + .../ai-connections.controller.ts | 3 +- .../ai-connections/ai-connections.module.ts | 3 + .../ai-connections/ai-connections.service.ts | 39 +- .../providers/anthropic-messages.client.ts | 9 +- server/src/nest/assistant/assistant-agent.ts | 27 +- .../nest/assistant/assistant-cards.helpers.ts | 2 +- .../src/nest/assistant/assistant-context.ts | 68 +++- .../src/nest/assistant/assistant-history.ts | 2 +- server/src/nest/assistant/assistant-redact.ts | 2 +- .../assistant/assistant-tools.constants.ts | 18 +- .../src/nest/assistant/assistant.service.ts | 12 +- .../src/nest/llm-parse/llm-config.resolver.ts | 36 +- server/src/nest/llm-parse/llm-config.ts | 6 + .../shared-ai-connection.interface.ts | 19 + server/tests/helpers/plugin-host.ts | 7 + .../addons-config-encryption.test.ts | 4 +- server/tests/unit/nest/addon-guard.test.ts | 15 +- .../ai-connections.controller.test.ts | 6 +- .../ai-connections.service.test.ts | 39 ++ .../nest/assistant/assistant-agent.test.ts | 34 ++ .../assistant/assistant-cards.helpers.test.ts | 6 +- .../nest/assistant/assistant-context.test.ts | 38 +- .../llm-parse/llm-config.resolver.test.ts | 45 ++- .../unit/nest/llm-parse/llm-config.test.ts | 8 + shared/src/assistant/assistant.schema.spec.ts | 14 +- shared/src/assistant/assistant.schema.ts | 34 +- shared/src/i18n/ar/admin.ts | 27 ++ shared/src/i18n/ar/assistant.ts | 73 +++- shared/src/i18n/az/admin.ts | 28 ++ shared/src/i18n/az/assistant.ts | 59 ++- shared/src/i18n/br/admin.ts | 28 ++ shared/src/i18n/br/assistant.ts | 62 +++- shared/src/i18n/ca/admin.ts | 27 ++ shared/src/i18n/ca/assistant.ts | 62 +++- shared/src/i18n/cs/admin.ts | 27 ++ shared/src/i18n/cs/assistant.ts | 65 +++- shared/src/i18n/de/admin.ts | 27 ++ shared/src/i18n/de/assistant.ts | 60 ++- shared/src/i18n/en/admin.ts | 26 ++ shared/src/i18n/en/assistant.ts | 58 ++- shared/src/i18n/es/admin.ts | 27 ++ shared/src/i18n/es/assistant.ts | 62 +++- shared/src/i18n/et/admin.ts | 26 ++ shared/src/i18n/et/assistant.ts | 58 ++- shared/src/i18n/fr/admin.ts | 28 ++ shared/src/i18n/fr/assistant.ts | 63 +++- shared/src/i18n/gr/admin.ts | 28 ++ shared/src/i18n/gr/assistant.ts | 62 +++- shared/src/i18n/hu/admin.ts | 28 ++ shared/src/i18n/hu/assistant.ts | 62 +++- shared/src/i18n/id/admin.ts | 27 ++ shared/src/i18n/id/assistant.ts | 56 ++- shared/src/i18n/it/admin.ts | 27 ++ shared/src/i18n/it/assistant.ts | 64 +++- shared/src/i18n/ja/admin.ts | 26 ++ shared/src/i18n/ja/assistant.ts | 56 ++- shared/src/i18n/ko/admin.ts | 26 ++ shared/src/i18n/ko/assistant.ts | 53 ++- shared/src/i18n/nl/admin.ts | 27 ++ shared/src/i18n/nl/assistant.ts | 62 +++- shared/src/i18n/pl/admin.ts | 26 ++ shared/src/i18n/pl/assistant.ts | 67 +++- shared/src/i18n/ru/admin.ts | 26 ++ shared/src/i18n/ru/assistant.ts | 68 +++- shared/src/i18n/sk/admin.ts | 27 ++ shared/src/i18n/sk/assistant.ts | 68 +++- shared/src/i18n/sv/admin.ts | 26 ++ shared/src/i18n/sv/assistant.ts | 70 +++- shared/src/i18n/th/admin.ts | 26 ++ shared/src/i18n/th/assistant.ts | 57 ++- shared/src/i18n/tr/admin.ts | 28 ++ shared/src/i18n/tr/assistant.ts | 59 ++- shared/src/i18n/uk/admin.ts | 26 ++ shared/src/i18n/uk/assistant.ts | 66 +++- shared/src/i18n/vi/admin.ts | 27 ++ shared/src/i18n/vi/assistant.ts | 54 ++- shared/src/i18n/zh-TW/admin.ts | 25 ++ shared/src/i18n/zh-TW/assistant.ts | 52 ++- shared/src/i18n/zh/admin.ts | 25 ++ shared/src/i18n/zh/assistant.ts | 52 ++- 142 files changed, 5456 insertions(+), 1572 deletions(-) create mode 100644 client/src/components/Admin/addons/AiAddonTile.tsx create mode 100644 client/src/components/Admin/addons/ParsingSettings.tsx create mode 100644 client/src/components/Admin/addons/aiAddons.ts create mode 100644 client/src/components/Admin/addons/useAiAddonTile.ts create mode 100644 client/src/components/Assistant/AssistantDayPreview.tsx create mode 100644 client/src/components/Assistant/AssistantFollowUps.tsx create mode 100644 client/src/components/Assistant/AssistantMemoryDialog.tsx create mode 100644 client/src/components/Assistant/AssistantPowerParts.test.tsx create mode 100644 client/src/components/Assistant/AssistantWeekOverview.tsx create mode 100644 client/src/components/Assistant/assistantFollowUpRules.ts create mode 100644 client/src/components/Assistant/useAssistantMenu.ts create mode 100644 client/src/components/Assistant/useTripMemory.ts create mode 100644 client/src/components/Assistant/useWeekDays.ts create mode 100644 client/src/components/Map/assistantViewport.ts delete mode 100644 client/src/mobile/screens/admin/MAdminAiConfig.test.tsx delete mode 100644 client/src/mobile/screens/admin/MAdminAiConfig.tsx create mode 100644 server/src/nest/llm-parse/shared-ai-connection.interface.ts 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 && (