From 214984f398ced4ef0b45244a5a2cea99549552dc Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 21 Oct 2026 18:52:44 +0200 Subject: [PATCH] feat(assistant): trip assistant chat with your own AI connection Adds an in-app assistant that reads and plans the open trip. Users connect their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or OpenRouter key) or use the one the admin set up, including a custom OpenAI-compatible server. It is an addon and only shows while a connection is usable. The chat runs on the existing MCP tools, pinned to the trip. Every change is held as a change card the user applies or discards, and applied changes can be undone. On the desktop it is a floating window that can be moved and resized; on the phone it is fullscreen. Search results show as place cards that can go onto a day, weather lookups as a strip, proposed places as ghost markers on the map with a before/after switch, and an answer can be shared into the trip chat. Booking files can be dropped in and are read into change cards. --- client/package.json | 1 + client/scripts/feature-pairs.json | 27 + client/scripts/format-baseline.json | 2 - client/scripts/size-baseline.json | 4 +- client/src/App.tsx | 8 + client/src/api/assistant.test.ts | 133 ++ client/src/api/assistant.ts | 186 ++ client/src/api/uiMessageStream.test.ts | 150 ++ client/src/api/uiMessageStream.ts | 111 + client/src/components/Admin/AddonManager.tsx | 5 + .../Admin/addons/AiAdminConfig.test.tsx | 100 + .../components/Admin/addons/AiAdminConfig.tsx | 190 ++ .../Admin/addons/useAiAdminConfig.test.ts | 167 ++ .../Admin/addons/useAiAdminConfig.ts | 218 ++ .../Assistant/AssistantAskButton.test.tsx | 56 + .../Assistant/AssistantAskButton.tsx | 34 + .../Assistant/AssistantChangeCard.test.tsx | 348 +++ .../Assistant/AssistantChangeCard.tsx | 474 ++++ .../Assistant/AssistantCompareSwitch.tsx | 58 + .../Assistant/AssistantComposer.test.tsx | 163 ++ .../Assistant/AssistantComposer.tsx | 185 ++ .../components/Assistant/AssistantDayBand.tsx | 186 ++ .../Assistant/AssistantError.test.tsx | 128 ++ .../components/Assistant/AssistantError.tsx | 62 + .../components/Assistant/AssistantHeader.tsx | 183 ++ .../Assistant/AssistantHints.test.tsx | 88 + .../components/Assistant/AssistantHints.tsx | 114 + .../Assistant/AssistantHost.test.tsx | 93 + .../components/Assistant/AssistantHost.tsx | 43 + .../Assistant/AssistantLauncher.test.tsx | 128 ++ .../Assistant/AssistantLauncher.tsx | 101 + .../Assistant/AssistantLiveStep.test.tsx | 81 + .../Assistant/AssistantLiveStep.tsx | 24 + .../Assistant/AssistantMarkdown.test.tsx | 48 + .../Assistant/AssistantMarkdown.tsx | 64 + .../Assistant/AssistantMentionPopover.tsx | 133 ++ .../Assistant/AssistantMentionText.test.tsx | 42 + .../Assistant/AssistantMentionText.tsx | 26 + .../components/Assistant/AssistantMessage.tsx | 158 ++ .../Assistant/AssistantPlaceCard.tsx | 204 ++ .../Assistant/AssistantPlaceStrip.test.tsx | 228 ++ .../Assistant/AssistantPlaceStrip.tsx | 112 + .../Assistant/AssistantPrivacyBanner.tsx | 68 + .../Assistant/AssistantShareDialog.tsx | 52 + .../Assistant/AssistantSurface.test.tsx | 182 ++ .../components/Assistant/AssistantSurface.tsx | 49 + .../Assistant/AssistantSurfaceLazy.tsx | 34 + .../components/Assistant/AssistantThread.tsx | 91 + .../Assistant/AssistantToolSteps.tsx | 47 + .../Assistant/AssistantTripPicker.tsx | 61 + .../Assistant/AssistantWeatherStrip.test.tsx | 92 + .../Assistant/AssistantWeatherStrip.tsx | 118 + .../components/Assistant/AssistantWelcome.tsx | 79 + .../Assistant/AssistantWindow.test.tsx | 184 ++ .../components/Assistant/AssistantWindow.tsx | 294 +++ client/src/components/Assistant/assistant.css | 257 +++ .../Assistant/assistantHintRules.test.ts | 372 +++ .../Assistant/assistantHintRules.ts | 491 ++++ .../Assistant/assistantHintsTime.ts | 104 + .../Assistant/assistantMentionModel.test.ts | 101 + .../Assistant/assistantMentionModel.ts | 161 ++ .../Assistant/assistantModel.test.ts | 179 ++ .../components/Assistant/assistantModel.ts | 275 +++ .../Assistant/assistantParts.test.ts | 324 +++ .../components/Assistant/assistantParts.ts | 248 ++ .../Assistant/assistantShare.test.ts | 119 + .../components/Assistant/assistantShare.ts | 107 + .../Assistant/assistantShowOnMap.test.ts | 28 + .../Assistant/assistantShowOnMap.ts | 22 + .../Assistant/assistantViewContext.ts | 25 + .../Assistant/dayAfterModel.test.ts | 142 ++ .../src/components/Assistant/dayAfterModel.ts | 232 ++ .../Assistant/providerCatalog.test.ts | 45 + .../components/Assistant/providerCatalog.ts | 121 + .../components/Assistant/useAssistantAsk.ts | 20 + .../Assistant/useAssistantAttach.ts | 84 + .../components/Assistant/useAssistantFocus.ts | 75 + .../Assistant/useAssistantHints.test.tsx | 138 ++ .../components/Assistant/useAssistantHints.ts | 195 ++ .../Assistant/useAssistantHotkey.ts | 34 + .../Assistant/useAssistantLiveStep.ts | 41 + .../Assistant/useAssistantMapFollow.ts | 25 + .../Assistant/useAssistantMentions.ts | 82 + .../Assistant/useAssistantPlaceDrop.test.tsx | 129 ++ .../Assistant/useAssistantPlaceDrop.ts | 73 + .../Assistant/useAssistantPlaceStrip.ts | 154 ++ .../Assistant/useAssistantRuntime.ts | 44 + .../Assistant/useAssistantShare.test.tsx | 201 ++ .../components/Assistant/useAssistantShare.ts | 87 + .../Assistant/useAssistantSuggestions.ts | 48 + .../Assistant/useAssistantThread.test.tsx | 97 + .../Assistant/useAssistantThread.ts | 69 + .../Assistant/useAssistantTripPicker.test.ts | 74 + .../Assistant/useAssistantTripPicker.ts | 72 + .../components/Assistant/useChangeHover.ts | 48 + .../Assistant/useFoundPlacePhoto.ts | 60 + .../Assistant/useTypewriterPlaceholder.ts | 57 + .../Collab/CollabAssistantBadge.test.tsx | 25 + .../Collab/CollabAssistantBadge.tsx | 28 + .../Collab/CollabChatMessages.test.tsx | 10 +- .../components/Collab/CollabChatMessages.tsx | 8 +- client/src/components/Collab/collabModel.ts | 8 + client/src/components/Layout/Navbar.tsx | 4 + .../Map/AssistantCameraController.tsx | 45 + .../components/Map/AssistantGhostLayer.tsx | 58 + .../Map/AssistantMapLeaflet.test.tsx | 100 + .../components/Map/AssistantMapLeaflet.tsx | 33 + client/src/components/Map/MapView.tsx | 17 +- client/src/components/Map/MapViewGL.tsx | 23 +- .../components/Map/assistantCamera.test.ts | 73 + client/src/components/Map/assistantCamera.ts | 116 + client/src/components/Map/assistantGhost.css | 106 + .../Map/assistantGhostMarker.test.ts | 57 + .../components/Map/assistantGhostMarker.ts | 117 + client/src/components/Map/categoryIconSvg.ts | 20 + client/src/components/Map/mapViewContract.ts | 52 + .../Map/useAssistantCameraGL.test.ts | 90 + .../components/Map/useAssistantCameraGL.ts | 54 + .../Map/useAssistantGhostMarkersGL.test.ts | 67 + .../Map/useAssistantGhostMarkersGL.ts | 83 + .../src/components/Map/useAssistantMapGL.ts | 68 + .../src/components/Planner/PlaceFormModal.tsx | 2 +- .../src/components/Planner/PlaceInspector.tsx | 16 +- .../Planner/placeCategoryGuess.test.ts | 53 - .../components/Planner/placeCategoryGuess.ts | 72 - .../Settings/IntegrationsTab.test.tsx | 70 +- .../components/Settings/IntegrationsTab.tsx | 11 +- .../Settings/ai/AiConnectPanel.test.tsx | 166 ++ .../components/Settings/ai/AiConnectPanel.tsx | 451 ++++ .../ai/AiConnectionSection.mapFollow.test.tsx | 80 + .../Settings/ai/AiConnectionSection.test.tsx | 143 ++ .../Settings/ai/AiConnectionSection.tsx | 273 +++ .../Settings/ai/ChatGptPasteStep.tsx | 169 ++ .../Settings/ai/ClaudeConnectorCard.test.tsx | 62 + .../Settings/ai/ClaudeConnectorCard.tsx | 98 + .../Settings/ai/aiConnectModel.test.ts | 96 + .../components/Settings/ai/aiConnectModel.ts | 123 + .../Settings/ai/useAiConnect.test.ts | 252 ++ .../components/Settings/ai/useAiConnect.ts | 336 +++ .../Settings/ai/useAiConnections.test.ts | 208 ++ .../Settings/ai/useAiConnections.ts | 211 ++ .../Settings/ai/useClaudeConnector.ts | 56 + .../Settings/useIntegrationGates.test.ts | 3 +- .../Settings/useIntegrationGates.ts | 5 +- client/src/components/shared/DialogShell.tsx | 6 +- client/src/components/shared/Kbd.test.tsx | 25 + client/src/components/shared/Kbd.tsx | 25 + client/src/components/shared/Tooltip.test.tsx | 21 +- client/src/components/shared/Tooltip.tsx | 12 +- client/src/hooks/useDragResize.test.ts | 255 +++ client/src/hooks/useDragResize.ts | 363 +++ client/src/hooks/useKeyboardInset.test.ts | 60 + client/src/hooks/useKeyboardInset.ts | 29 + .../screens/admin/MAdminAddonManager.tsx | 6 + .../screens/admin/MAdminAiConfig.test.tsx | 65 + .../mobile/screens/admin/MAdminAiConfig.tsx | 174 ++ .../settings/MAiConnectionSection.test.tsx | 86 + .../screens/settings/MAiConnectionSection.tsx | 204 ++ .../settings/MSettingsIntegrations.tsx | 37 +- .../trip/assistant/MAssistantScreen.tsx | 336 +++ .../src/mobile/screens/trip/map/MMapArea.tsx | 5 + .../mobile/screens/trip/sheets/MMehrSheet.tsx | 32 +- .../screens/trip/sheets/MPlaceSheet.test.tsx | 26 +- .../screens/trip/sheets/MPlaceSheet.tsx | 9 +- .../screens/trip/sheets/MTripSheets.tsx | 3 + .../mobile/screens/trip/tabs/MCollabChat.tsx | 7 + client/src/pages/OAuthAuthorizePage.test.tsx | 23 +- client/src/pages/OAuthAuthorizePage.tsx | 8 +- client/src/pages/TripPlannerPage.tsx | 4 +- .../pages/oauthAuthorize/useOAuthAuthorize.ts | 3 +- client/src/pages/tripPlanner/PlanMapStage.tsx | 6 +- .../tripPlanner/useAssistantDeepLink.test.ts | 61 + .../pages/tripPlanner/useAssistantDeepLink.ts | 52 + .../tripPlanner/useAssistantMapLink.test.ts | 304 +++ .../pages/tripPlanner/useAssistantMapLink.ts | 316 +++ .../src/pages/tripPlanner/useTripPlanner.ts | 14 +- client/src/repo/aiConnectionsRepo.test.ts | 125 + client/src/repo/aiConnectionsRepo.ts | 124 + .../repo/assistantRepo.foundPlaces.test.ts | 54 + client/src/repo/assistantRepo.share.test.ts | 63 + client/src/repo/assistantRepo.test.ts | 108 + client/src/repo/assistantRepo.ts | 95 + client/src/store/addonStore.ts | 4 + client/src/store/assistantHintsStore.test.ts | 66 + client/src/store/assistantHintsStore.ts | 81 + client/src/store/assistantMapModel.test.ts | 181 ++ client/src/store/assistantMapModel.ts | 205 ++ .../src/store/assistantMessages.cards.test.ts | 72 + client/src/store/assistantMessages.test.ts | 199 ++ client/src/store/assistantMessages.ts | 218 ++ client/src/store/assistantStore.map.test.ts | 312 +++ client/src/store/assistantStore.test.ts | 323 +++ client/src/store/assistantStore.ts | 724 ++++++ client/src/store/settingsStore.ts | 2 + client/src/types.ts | 4 + client/src/utils/reducedMotion.test.ts | 27 + client/src/utils/reducedMotion.ts | 15 + client/tests/helpers/storeRegistry.ts | 4 + .../mobile/trip/MAssistantScreen.test.tsx | 207 ++ .../unit/mobile/trip/MCollabChat.test.tsx | 8 + .../unit/mobile/trip/MMehrSheet.test.tsx | 49 +- client/tests/unit/stores/addonStore.test.ts | 14 + client/vite.config.js | 4 +- package-lock.json | 2034 ++++++++++++++++- server/scripts/migrate-encryption.ts | 20 + server/scripts/repository-owners.json | 5 + server/src/addons.ts | 1 + .../src/db/entities/AiConnections.entity.ts | 55 + .../db/entities/AssistantMessages.entity.ts | 37 + .../db/entities/AssistantThreads.entity.ts | 44 + .../db/entities/AssistantToolCalls.entity.ts | 46 + .../src/db/entities/AssistantUsage.entity.ts | 30 + .../src/db/entities/CollabMessages.entity.ts | 2 + server/src/db/entities/Trips.entity.ts | 3 + server/src/db/entities/Users.entity.ts | 9 + server/src/db/entities/index.ts | 10 + server/src/db/json-columns.ts | 71 + server/src/db/kysely/AiConnections.table.ts | 19 + .../src/db/kysely/AssistantMessages.table.ts | 14 + .../src/db/kysely/AssistantThreads.table.ts | 12 + .../src/db/kysely/AssistantToolCalls.table.ts | 18 + server/src/db/kysely/AssistantUsage.table.ts | 12 + server/src/db/kysely/CollabMessages.table.ts | 1 + server/src/db/kysely/db.ts | 10 + ...46200_a_user_can_connect_an_ai_provider.ts | 52 + ...p_assistant_remembers_its_conversations.ts | 86 + ...sage_can_say_it_came_from_the_assistant.ts | 16 + .../repositories/AiConnections.repository.ts | 178 ++ .../AssistantMessages.repository.ts | 99 + .../AssistantThreads.repository.ts | 122 + .../AssistantToolCalls.repository.ts | 191 ++ .../repositories/AssistantUsage.repository.ts | 63 + .../repositories/CollabMessages.repository.ts | 7 +- .../repositories/OauthClients.repository.ts | 68 + server/src/db/seeders/AddonSeeder.ts | 9 + server/src/nest-mcp/registry.ts | 2 + server/src/nest-mcp/types.ts | 11 + server/src/nest/admin/admin.service.ts | 10 +- .../ai-connections/ai-admin.controller.ts | 83 + .../nest/ai-connections/ai-auth-flow.store.ts | 57 + .../ai-chat-client.interface.ts | 62 + .../ai-connections/ai-chat-client.service.ts | 63 + .../ai-connections/ai-connection.resolver.ts | 29 + .../ai-connections.constants.ts | 88 + .../ai-connections.controller.ts | 125 + .../nest/ai-connections/ai-connections.dto.ts | 42 + .../ai-connections/ai-connections.module.ts | 56 + .../ai-connections/ai-connections.service.ts | 378 +++ .../ai-connections/ai-connections.types.ts | 22 + .../nest/ai-connections/ai-refresh-lock.ts | 25 + server/src/nest/ai-connections/ai-secrets.ts | 49 + .../ai-connections/ai-settings.service.ts | 74 + .../nest/ai-connections/ai-usage.service.ts | 39 + .../ai-connections/chatgpt-auth.service.ts | 395 ++++ .../ai-connections/model-catalog.service.ts | 243 ++ .../ai-connections/openrouter-auth.service.ts | 91 + .../providers/anthropic-messages.client.ts | 183 ++ .../providers/openai-chat.client.ts | 171 ++ .../providers/openai-responses.client.ts | 177 ++ .../providers/provider-errors.ts | 85 + .../ai-connections/providers/provider-http.ts | 124 + .../ai-connections/providers/sse-lines.ts | 136 ++ server/src/nest/app.module.ts | 4 + server/src/nest/assistant/assistant-agent.ts | 339 +++ .../assistant-attachments.controller.ts | 105 + .../assistant-booking-import.service.ts | 90 + .../nest/assistant/assistant-cards.helpers.ts | 282 +++ .../src/nest/assistant/assistant-context.ts | 392 ++++ .../assistant-found-places.service.ts | 223 ++ .../src/nest/assistant/assistant-history.ts | 260 +++ server/src/nest/assistant/assistant-redact.ts | 149 ++ .../nest/assistant/assistant-retention.job.ts | 61 + .../nest/assistant/assistant-share.service.ts | 71 + .../assistant/assistant-stream.registry.ts | 40 + server/src/nest/assistant/assistant-title.ts | 21 + .../assistant/assistant-tools.constants.ts | 299 +++ .../nest/assistant/assistant-tools.service.ts | 257 +++ .../nest/assistant/assistant-undo.helpers.ts | 100 + .../nest/assistant/assistant.controller.ts | 164 ++ server/src/nest/assistant/assistant.dto.ts | 24 + server/src/nest/assistant/assistant.module.ts | 63 + server/src/nest/assistant/assistant.prompt.ts | 33 + .../src/nest/assistant/assistant.service.ts | 984 ++++++++ .../assistant/tool-call-preview.helpers.ts | 536 +++++ server/src/nest/assistant/ui-stream.writer.ts | 141 ++ server/src/nest/collab/collab.service.ts | 4 +- .../src/nest/common/app-settings.registry.ts | 9 + server/src/nest/common/sse-stream.ts | 74 + .../nest/common/validate-managed-routes.ts | 28 + server/src/nest/health/features.controller.ts | 4 + .../src/nest/llm-parse/llm-config.resolver.ts | 10 + server/src/nest/llm-parse/llm-parse.module.ts | 6 +- .../src/nest/llm-parse/llm-parse.service.ts | 8 + .../nest/oauth/claude-connector.controller.ts | 26 + .../nest/oauth/claude-connector.service.ts | 31 + server/src/nest/oauth/oauth-cimd.ts | 172 ++ .../nest/oauth/oauth-token-retention.job.ts | 14 + server/src/nest/oauth/oauth.module.ts | 11 +- server/src/nest/oauth/oauth.service.ts | 103 +- server/src/nest/oidc/oidc.module.ts | 2 + .../platform/discovery-metadata.service.ts | 2 + .../nest/plugins/protocol/output-contract.ts | 3 +- server/tests/e2e/ai-connections.e2e.test.ts | 258 +++ server/tests/e2e/assistant.e2e.test.ts | 973 ++++++++ server/tests/e2e/oauth.e2e.test.ts | 30 + server/tests/helpers/test-db.ts | 9 + server/tests/integration/mcp.test.ts | 2 + .../tests/integration/oauth-authorize.test.ts | 2 + server/tests/integration/oauth.test.ts | 3 + server/tests/unit/db/entities-index.test.ts | 9 +- .../tests/unit/db/entity-class-fields.test.ts | 6 +- server/tests/unit/db/legacy-baseline.test.ts | 3 + .../unit/db/migrate-encryption-parity.test.ts | 10 + .../AiConnections.repository.test.ts | 98 + .../AssistantConversations.repository.test.ts | 242 ++ .../CollabMessages.repository.test.ts | 11 + .../OauthClients.repository.test.ts | 71 + .../unit/nest-mcp/registry-include.test.ts | 93 + server/tests/unit/nest/admin.service.test.ts | 13 + .../ai-connection.resolver.test.ts | 53 + .../ai-connections.controller.test.ts | 208 ++ .../ai-connections.helpers.test.ts | 143 ++ .../ai-connections.service.test.ts | 355 +++ .../chatgpt-auth.service.test.ts | 438 ++++ .../model-catalog.service.test.ts | 231 ++ .../openrouter-auth.service.test.ts | 144 ++ .../ai-connections/provider-adapters.test.ts | 502 ++++ .../nest/ai-connections/sse-lines.test.ts | 77 + .../nest/assistant/assistant-agent.test.ts | 525 +++++ .../assistant-attachments.controller.test.ts | 142 ++ .../assistant-booking-import.service.test.ts | 125 + .../assistant/assistant-cards.helpers.test.ts | 335 +++ .../nest/assistant/assistant-context.test.ts | 213 ++ .../assistant-found-places.service.test.ts | 186 ++ .../nest/assistant/assistant-history.test.ts | 325 +++ .../nest/assistant/assistant-redact.test.ts | 122 + .../assistant/assistant-retention.job.test.ts | 70 + .../assistant/assistant-share.service.test.ts | 140 ++ .../nest/assistant/assistant-small.test.ts | 65 + .../assistant/assistant-tools.service.test.ts | 236 ++ .../assistant/assistant-undo.helpers.test.ts | 123 + .../assistant/assistant.controller.test.ts | 193 ++ .../tool-call-preview.helpers.test.ts | 344 +++ .../nest/assistant/ui-stream.writer.test.ts | 174 ++ .../nest/claude-connector.controller.test.ts | 67 + .../unit/nest/discovery.controller.test.ts | 1 + .../unit/nest/features.controller.test.ts | 28 +- .../llm-parse/llm-config.resolver.test.ts | 17 + .../nest/llm-parse/llm-parse.service.test.ts | 8 +- server/tests/unit/nest/oauth-cimd.test.ts | 195 ++ .../nest/oauth-token-retention.job.test.ts | 15 +- server/tests/unit/nest/oauth.service.test.ts | 172 +- .../unit/nest/process-state-ports.test.ts | 21 +- server/tests/unit/nest/sse-stream.test.ts | 115 + server/vitest.config.ts | 4 + shared/scripts/i18n-parity.mjs | 1 + shared/scripts/i18n-untranslated.mjs | 14 +- shared/src/ai/ai-connections.schema.spec.ts | 147 ++ shared/src/ai/ai-connections.schema.ts | 219 ++ .../assistant/__fixtures__/ui-stream.v1.txt | 38 + .../assistant/assistant-share.schema.spec.ts | 22 + .../src/assistant/assistant-share.schema.ts | 15 + shared/src/assistant/assistant.schema.spec.ts | 430 ++++ shared/src/assistant/assistant.schema.ts | 507 ++++ shared/src/collab/collab.schema.spec.ts | 33 + shared/src/collab/collab.schema.ts | 6 + shared/src/i18n/ar/admin.ts | 2 + shared/src/i18n/ar/assistant.ts | 421 ++++ shared/src/i18n/ar/collab.ts | 2 + shared/src/i18n/ar/index.ts | 2 + shared/src/i18n/ar/oauth.ts | 1 + shared/src/i18n/az/admin.ts | 3 + shared/src/i18n/az/assistant.ts | 343 +++ shared/src/i18n/az/collab.ts | 2 + shared/src/i18n/az/index.ts | 2 + shared/src/i18n/az/oauth.ts | 1 + shared/src/i18n/br/admin.ts | 3 + shared/src/i18n/br/assistant.ts | 342 +++ shared/src/i18n/br/collab.ts | 2 + shared/src/i18n/br/index.ts | 2 + shared/src/i18n/br/oauth.ts | 1 + shared/src/i18n/ca/admin.ts | 3 + shared/src/i18n/ca/assistant.ts | 345 +++ shared/src/i18n/ca/collab.ts | 2 + shared/src/i18n/ca/index.ts | 2 + shared/src/i18n/ca/oauth.ts | 1 + shared/src/i18n/cs/admin.ts | 3 + shared/src/i18n/cs/assistant.ts | 361 +++ shared/src/i18n/cs/collab.ts | 2 + shared/src/i18n/cs/index.ts | 2 + shared/src/i18n/cs/oauth.ts | 1 + shared/src/i18n/de/admin.ts | 3 + shared/src/i18n/de/assistant.ts | 342 +++ shared/src/i18n/de/collab.ts | 2 + shared/src/i18n/de/index.ts | 2 + shared/src/i18n/de/oauth.ts | 1 + shared/src/i18n/en/admin.ts | 3 + shared/src/i18n/en/assistant.ts | 339 +++ shared/src/i18n/en/collab.ts | 2 + shared/src/i18n/en/index.ts | 2 + shared/src/i18n/en/oauth.ts | 1 + shared/src/i18n/es/admin.ts | 3 + shared/src/i18n/es/assistant.ts | 340 +++ shared/src/i18n/es/collab.ts | 2 + shared/src/i18n/es/index.ts | 2 + shared/src/i18n/es/oauth.ts | 1 + shared/src/i18n/et/admin.ts | 3 + shared/src/i18n/et/assistant.ts | 343 +++ shared/src/i18n/et/collab.ts | 2 + shared/src/i18n/et/index.ts | 2 + shared/src/i18n/et/oauth.ts | 1 + shared/src/i18n/fr/admin.ts | 3 + shared/src/i18n/fr/assistant.ts | 346 +++ shared/src/i18n/fr/collab.ts | 2 + shared/src/i18n/fr/index.ts | 2 + shared/src/i18n/fr/oauth.ts | 1 + shared/src/i18n/gr/admin.ts | 3 + shared/src/i18n/gr/assistant.ts | 346 +++ shared/src/i18n/gr/collab.ts | 2 + shared/src/i18n/gr/index.ts | 2 + shared/src/i18n/gr/oauth.ts | 1 + shared/src/i18n/hu/admin.ts | 3 + shared/src/i18n/hu/assistant.ts | 347 +++ shared/src/i18n/hu/collab.ts | 2 + shared/src/i18n/hu/index.ts | 2 + shared/src/i18n/hu/oauth.ts | 1 + shared/src/i18n/i18n-untranslated.spec.ts | 1 + shared/src/i18n/id/admin.ts | 3 + shared/src/i18n/id/assistant.ts | 323 +++ shared/src/i18n/id/collab.ts | 2 + shared/src/i18n/id/index.ts | 2 + shared/src/i18n/id/oauth.ts | 1 + shared/src/i18n/it/admin.ts | 3 + shared/src/i18n/it/assistant.ts | 346 +++ shared/src/i18n/it/collab.ts | 2 + shared/src/i18n/it/index.ts | 2 + shared/src/i18n/it/oauth.ts | 1 + shared/src/i18n/ja/admin.ts | 3 + shared/src/i18n/ja/assistant.ts | 320 +++ shared/src/i18n/ja/collab.ts | 2 + shared/src/i18n/ja/index.ts | 2 + shared/src/i18n/ja/oauth.ts | 1 + shared/src/i18n/ko/admin.ts | 3 + shared/src/i18n/ko/assistant.ts | 318 +++ shared/src/i18n/ko/collab.ts | 2 + shared/src/i18n/ko/index.ts | 2 + shared/src/i18n/ko/oauth.ts | 1 + shared/src/i18n/nl/admin.ts | 3 + shared/src/i18n/nl/assistant.ts | 343 +++ shared/src/i18n/nl/collab.ts | 2 + shared/src/i18n/nl/index.ts | 2 + shared/src/i18n/nl/oauth.ts | 1 + shared/src/i18n/pl/admin.ts | 3 + shared/src/i18n/pl/assistant.ts | 384 ++++ shared/src/i18n/pl/collab.ts | 2 + shared/src/i18n/pl/index.ts | 2 + shared/src/i18n/pl/oauth.ts | 1 + shared/src/i18n/ru/admin.ts | 3 + shared/src/i18n/ru/assistant.ts | 383 ++++ shared/src/i18n/ru/collab.ts | 2 + shared/src/i18n/ru/index.ts | 2 + shared/src/i18n/ru/oauth.ts | 1 + shared/src/i18n/sk/admin.ts | 3 + shared/src/i18n/sk/assistant.ts | 360 +++ shared/src/i18n/sk/collab.ts | 2 + shared/src/i18n/sk/index.ts | 2 + shared/src/i18n/sk/oauth.ts | 1 + shared/src/i18n/sv/admin.ts | 3 + shared/src/i18n/sv/assistant.ts | 339 +++ shared/src/i18n/sv/collab.ts | 2 + shared/src/i18n/sv/index.ts | 2 + shared/src/i18n/sv/oauth.ts | 1 + shared/src/i18n/th/admin.ts | 2 + shared/src/i18n/th/assistant.ts | 316 +++ shared/src/i18n/th/collab.ts | 2 + shared/src/i18n/th/index.ts | 2 + shared/src/i18n/th/oauth.ts | 1 + shared/src/i18n/tr/admin.ts | 3 + shared/src/i18n/tr/assistant.ts | 342 +++ shared/src/i18n/tr/collab.ts | 2 + shared/src/i18n/tr/index.ts | 2 + shared/src/i18n/tr/oauth.ts | 1 + shared/src/i18n/uk/admin.ts | 3 + shared/src/i18n/uk/assistant.ts | 383 ++++ shared/src/i18n/uk/collab.ts | 2 + shared/src/i18n/uk/index.ts | 2 + shared/src/i18n/uk/oauth.ts | 1 + shared/src/i18n/vi/admin.ts | 3 + shared/src/i18n/vi/assistant.ts | 320 +++ shared/src/i18n/vi/collab.ts | 2 + shared/src/i18n/vi/index.ts | 2 + shared/src/i18n/vi/oauth.ts | 1 + shared/src/i18n/zh-TW/admin.ts | 2 + shared/src/i18n/zh-TW/assistant.ts | 316 +++ shared/src/i18n/zh-TW/collab.ts | 2 + shared/src/i18n/zh-TW/index.ts | 2 + shared/src/i18n/zh-TW/oauth.ts | 1 + shared/src/i18n/zh/admin.ts | 2 + shared/src/i18n/zh/assistant.ts | 316 +++ shared/src/i18n/zh/collab.ts | 2 + shared/src/i18n/zh/index.ts | 2 + shared/src/i18n/zh/oauth.ts | 1 + shared/src/index.ts | 4 + shared/src/place/place-kind.spec.ts | 62 + shared/src/place/place-kind.ts | 219 ++ 505 files changed, 51841 insertions(+), 227 deletions(-) create mode 100644 client/src/api/assistant.test.ts create mode 100644 client/src/api/assistant.ts create mode 100644 client/src/api/uiMessageStream.test.ts create mode 100644 client/src/api/uiMessageStream.ts create mode 100644 client/src/components/Admin/addons/AiAdminConfig.test.tsx create mode 100644 client/src/components/Admin/addons/AiAdminConfig.tsx create mode 100644 client/src/components/Admin/addons/useAiAdminConfig.test.ts create mode 100644 client/src/components/Admin/addons/useAiAdminConfig.ts create mode 100644 client/src/components/Assistant/AssistantAskButton.test.tsx create mode 100644 client/src/components/Assistant/AssistantAskButton.tsx create mode 100644 client/src/components/Assistant/AssistantChangeCard.test.tsx create mode 100644 client/src/components/Assistant/AssistantChangeCard.tsx create mode 100644 client/src/components/Assistant/AssistantCompareSwitch.tsx create mode 100644 client/src/components/Assistant/AssistantComposer.test.tsx create mode 100644 client/src/components/Assistant/AssistantComposer.tsx create mode 100644 client/src/components/Assistant/AssistantDayBand.tsx create mode 100644 client/src/components/Assistant/AssistantError.test.tsx create mode 100644 client/src/components/Assistant/AssistantError.tsx create mode 100644 client/src/components/Assistant/AssistantHeader.tsx create mode 100644 client/src/components/Assistant/AssistantHints.test.tsx create mode 100644 client/src/components/Assistant/AssistantHints.tsx create mode 100644 client/src/components/Assistant/AssistantHost.test.tsx create mode 100644 client/src/components/Assistant/AssistantHost.tsx create mode 100644 client/src/components/Assistant/AssistantLauncher.test.tsx create mode 100644 client/src/components/Assistant/AssistantLauncher.tsx create mode 100644 client/src/components/Assistant/AssistantLiveStep.test.tsx create mode 100644 client/src/components/Assistant/AssistantLiveStep.tsx create mode 100644 client/src/components/Assistant/AssistantMarkdown.test.tsx create mode 100644 client/src/components/Assistant/AssistantMarkdown.tsx create mode 100644 client/src/components/Assistant/AssistantMentionPopover.tsx create mode 100644 client/src/components/Assistant/AssistantMentionText.test.tsx create mode 100644 client/src/components/Assistant/AssistantMentionText.tsx create mode 100644 client/src/components/Assistant/AssistantMessage.tsx create mode 100644 client/src/components/Assistant/AssistantPlaceCard.tsx create mode 100644 client/src/components/Assistant/AssistantPlaceStrip.test.tsx create mode 100644 client/src/components/Assistant/AssistantPlaceStrip.tsx create mode 100644 client/src/components/Assistant/AssistantPrivacyBanner.tsx create mode 100644 client/src/components/Assistant/AssistantShareDialog.tsx create mode 100644 client/src/components/Assistant/AssistantSurface.test.tsx create mode 100644 client/src/components/Assistant/AssistantSurface.tsx create mode 100644 client/src/components/Assistant/AssistantSurfaceLazy.tsx create mode 100644 client/src/components/Assistant/AssistantThread.tsx create mode 100644 client/src/components/Assistant/AssistantToolSteps.tsx create mode 100644 client/src/components/Assistant/AssistantTripPicker.tsx create mode 100644 client/src/components/Assistant/AssistantWeatherStrip.test.tsx create mode 100644 client/src/components/Assistant/AssistantWeatherStrip.tsx create mode 100644 client/src/components/Assistant/AssistantWelcome.tsx create mode 100644 client/src/components/Assistant/AssistantWindow.test.tsx create mode 100644 client/src/components/Assistant/AssistantWindow.tsx create mode 100644 client/src/components/Assistant/assistant.css create mode 100644 client/src/components/Assistant/assistantHintRules.test.ts create mode 100644 client/src/components/Assistant/assistantHintRules.ts create mode 100644 client/src/components/Assistant/assistantHintsTime.ts create mode 100644 client/src/components/Assistant/assistantMentionModel.test.ts create mode 100644 client/src/components/Assistant/assistantMentionModel.ts create mode 100644 client/src/components/Assistant/assistantModel.test.ts create mode 100644 client/src/components/Assistant/assistantModel.ts create mode 100644 client/src/components/Assistant/assistantParts.test.ts create mode 100644 client/src/components/Assistant/assistantParts.ts create mode 100644 client/src/components/Assistant/assistantShare.test.ts create mode 100644 client/src/components/Assistant/assistantShare.ts create mode 100644 client/src/components/Assistant/assistantShowOnMap.test.ts create mode 100644 client/src/components/Assistant/assistantShowOnMap.ts create mode 100644 client/src/components/Assistant/assistantViewContext.ts create mode 100644 client/src/components/Assistant/dayAfterModel.test.ts create mode 100644 client/src/components/Assistant/dayAfterModel.ts create mode 100644 client/src/components/Assistant/providerCatalog.test.ts create mode 100644 client/src/components/Assistant/providerCatalog.ts create mode 100644 client/src/components/Assistant/useAssistantAsk.ts create mode 100644 client/src/components/Assistant/useAssistantAttach.ts create mode 100644 client/src/components/Assistant/useAssistantFocus.ts create mode 100644 client/src/components/Assistant/useAssistantHints.test.tsx create mode 100644 client/src/components/Assistant/useAssistantHints.ts create mode 100644 client/src/components/Assistant/useAssistantHotkey.ts create mode 100644 client/src/components/Assistant/useAssistantLiveStep.ts create mode 100644 client/src/components/Assistant/useAssistantMapFollow.ts create mode 100644 client/src/components/Assistant/useAssistantMentions.ts create mode 100644 client/src/components/Assistant/useAssistantPlaceDrop.test.tsx create mode 100644 client/src/components/Assistant/useAssistantPlaceDrop.ts create mode 100644 client/src/components/Assistant/useAssistantPlaceStrip.ts create mode 100644 client/src/components/Assistant/useAssistantRuntime.ts create mode 100644 client/src/components/Assistant/useAssistantShare.test.tsx create mode 100644 client/src/components/Assistant/useAssistantShare.ts create mode 100644 client/src/components/Assistant/useAssistantSuggestions.ts create mode 100644 client/src/components/Assistant/useAssistantThread.test.tsx create mode 100644 client/src/components/Assistant/useAssistantThread.ts create mode 100644 client/src/components/Assistant/useAssistantTripPicker.test.ts create mode 100644 client/src/components/Assistant/useAssistantTripPicker.ts create mode 100644 client/src/components/Assistant/useChangeHover.ts create mode 100644 client/src/components/Assistant/useFoundPlacePhoto.ts create mode 100644 client/src/components/Assistant/useTypewriterPlaceholder.ts create mode 100644 client/src/components/Collab/CollabAssistantBadge.test.tsx create mode 100644 client/src/components/Collab/CollabAssistantBadge.tsx create mode 100644 client/src/components/Map/AssistantCameraController.tsx create mode 100644 client/src/components/Map/AssistantGhostLayer.tsx create mode 100644 client/src/components/Map/AssistantMapLeaflet.test.tsx create mode 100644 client/src/components/Map/AssistantMapLeaflet.tsx create mode 100644 client/src/components/Map/assistantCamera.test.ts create mode 100644 client/src/components/Map/assistantCamera.ts create mode 100644 client/src/components/Map/assistantGhost.css create mode 100644 client/src/components/Map/assistantGhostMarker.test.ts create mode 100644 client/src/components/Map/assistantGhostMarker.ts create mode 100644 client/src/components/Map/categoryIconSvg.ts create mode 100644 client/src/components/Map/useAssistantCameraGL.test.ts create mode 100644 client/src/components/Map/useAssistantCameraGL.ts create mode 100644 client/src/components/Map/useAssistantGhostMarkersGL.test.ts create mode 100644 client/src/components/Map/useAssistantGhostMarkersGL.ts create mode 100644 client/src/components/Map/useAssistantMapGL.ts delete mode 100644 client/src/components/Planner/placeCategoryGuess.test.ts delete mode 100644 client/src/components/Planner/placeCategoryGuess.ts create mode 100644 client/src/components/Settings/ai/AiConnectPanel.test.tsx create mode 100644 client/src/components/Settings/ai/AiConnectPanel.tsx create mode 100644 client/src/components/Settings/ai/AiConnectionSection.mapFollow.test.tsx create mode 100644 client/src/components/Settings/ai/AiConnectionSection.test.tsx create mode 100644 client/src/components/Settings/ai/AiConnectionSection.tsx create mode 100644 client/src/components/Settings/ai/ChatGptPasteStep.tsx create mode 100644 client/src/components/Settings/ai/ClaudeConnectorCard.test.tsx create mode 100644 client/src/components/Settings/ai/ClaudeConnectorCard.tsx create mode 100644 client/src/components/Settings/ai/aiConnectModel.test.ts create mode 100644 client/src/components/Settings/ai/aiConnectModel.ts create mode 100644 client/src/components/Settings/ai/useAiConnect.test.ts create mode 100644 client/src/components/Settings/ai/useAiConnect.ts create mode 100644 client/src/components/Settings/ai/useAiConnections.test.ts create mode 100644 client/src/components/Settings/ai/useAiConnections.ts create mode 100644 client/src/components/Settings/ai/useClaudeConnector.ts create mode 100644 client/src/components/shared/Kbd.test.tsx create mode 100644 client/src/components/shared/Kbd.tsx create mode 100644 client/src/hooks/useDragResize.test.ts create mode 100644 client/src/hooks/useDragResize.ts create mode 100644 client/src/hooks/useKeyboardInset.test.ts create mode 100644 client/src/hooks/useKeyboardInset.ts create mode 100644 client/src/mobile/screens/admin/MAdminAiConfig.test.tsx create mode 100644 client/src/mobile/screens/admin/MAdminAiConfig.tsx create mode 100644 client/src/mobile/screens/settings/MAiConnectionSection.test.tsx create mode 100644 client/src/mobile/screens/settings/MAiConnectionSection.tsx create mode 100644 client/src/mobile/screens/trip/assistant/MAssistantScreen.tsx create mode 100644 client/src/pages/tripPlanner/useAssistantDeepLink.test.ts create mode 100644 client/src/pages/tripPlanner/useAssistantDeepLink.ts create mode 100644 client/src/pages/tripPlanner/useAssistantMapLink.test.ts create mode 100644 client/src/pages/tripPlanner/useAssistantMapLink.ts create mode 100644 client/src/repo/aiConnectionsRepo.test.ts create mode 100644 client/src/repo/aiConnectionsRepo.ts create mode 100644 client/src/repo/assistantRepo.foundPlaces.test.ts create mode 100644 client/src/repo/assistantRepo.share.test.ts create mode 100644 client/src/repo/assistantRepo.test.ts create mode 100644 client/src/repo/assistantRepo.ts create mode 100644 client/src/store/assistantHintsStore.test.ts create mode 100644 client/src/store/assistantHintsStore.ts create mode 100644 client/src/store/assistantMapModel.test.ts create mode 100644 client/src/store/assistantMapModel.ts create mode 100644 client/src/store/assistantMessages.cards.test.ts create mode 100644 client/src/store/assistantMessages.test.ts create mode 100644 client/src/store/assistantMessages.ts create mode 100644 client/src/store/assistantStore.map.test.ts create mode 100644 client/src/store/assistantStore.test.ts create mode 100644 client/src/store/assistantStore.ts create mode 100644 client/src/utils/reducedMotion.test.ts create mode 100644 client/src/utils/reducedMotion.ts create mode 100644 client/tests/unit/mobile/trip/MAssistantScreen.test.tsx create mode 100644 server/src/db/entities/AiConnections.entity.ts create mode 100644 server/src/db/entities/AssistantMessages.entity.ts create mode 100644 server/src/db/entities/AssistantThreads.entity.ts create mode 100644 server/src/db/entities/AssistantToolCalls.entity.ts create mode 100644 server/src/db/entities/AssistantUsage.entity.ts create mode 100644 server/src/db/kysely/AiConnections.table.ts create mode 100644 server/src/db/kysely/AssistantMessages.table.ts create mode 100644 server/src/db/kysely/AssistantThreads.table.ts create mode 100644 server/src/db/kysely/AssistantToolCalls.table.ts create mode 100644 server/src/db/kysely/AssistantUsage.table.ts create mode 100644 server/src/db/migrations/Migration20200101046200_a_user_can_connect_an_ai_provider.ts create mode 100644 server/src/db/migrations/Migration20200101046210_the_trip_assistant_remembers_its_conversations.ts create mode 100644 server/src/db/migrations/Migration20200101046220_a_chat_message_can_say_it_came_from_the_assistant.ts create mode 100644 server/src/db/repositories/AiConnections.repository.ts create mode 100644 server/src/db/repositories/AssistantMessages.repository.ts create mode 100644 server/src/db/repositories/AssistantThreads.repository.ts create mode 100644 server/src/db/repositories/AssistantToolCalls.repository.ts create mode 100644 server/src/db/repositories/AssistantUsage.repository.ts create mode 100644 server/src/nest/ai-connections/ai-admin.controller.ts create mode 100644 server/src/nest/ai-connections/ai-auth-flow.store.ts create mode 100644 server/src/nest/ai-connections/ai-chat-client.interface.ts create mode 100644 server/src/nest/ai-connections/ai-chat-client.service.ts create mode 100644 server/src/nest/ai-connections/ai-connection.resolver.ts create mode 100644 server/src/nest/ai-connections/ai-connections.constants.ts create mode 100644 server/src/nest/ai-connections/ai-connections.controller.ts create mode 100644 server/src/nest/ai-connections/ai-connections.dto.ts create mode 100644 server/src/nest/ai-connections/ai-connections.module.ts create mode 100644 server/src/nest/ai-connections/ai-connections.service.ts create mode 100644 server/src/nest/ai-connections/ai-connections.types.ts create mode 100644 server/src/nest/ai-connections/ai-refresh-lock.ts create mode 100644 server/src/nest/ai-connections/ai-secrets.ts create mode 100644 server/src/nest/ai-connections/ai-settings.service.ts create mode 100644 server/src/nest/ai-connections/ai-usage.service.ts create mode 100644 server/src/nest/ai-connections/chatgpt-auth.service.ts create mode 100644 server/src/nest/ai-connections/model-catalog.service.ts create mode 100644 server/src/nest/ai-connections/openrouter-auth.service.ts create mode 100644 server/src/nest/ai-connections/providers/anthropic-messages.client.ts create mode 100644 server/src/nest/ai-connections/providers/openai-chat.client.ts create mode 100644 server/src/nest/ai-connections/providers/openai-responses.client.ts create mode 100644 server/src/nest/ai-connections/providers/provider-errors.ts create mode 100644 server/src/nest/ai-connections/providers/provider-http.ts create mode 100644 server/src/nest/ai-connections/providers/sse-lines.ts create mode 100644 server/src/nest/assistant/assistant-agent.ts create mode 100644 server/src/nest/assistant/assistant-attachments.controller.ts create mode 100644 server/src/nest/assistant/assistant-booking-import.service.ts create mode 100644 server/src/nest/assistant/assistant-cards.helpers.ts create mode 100644 server/src/nest/assistant/assistant-context.ts create mode 100644 server/src/nest/assistant/assistant-found-places.service.ts create mode 100644 server/src/nest/assistant/assistant-history.ts create mode 100644 server/src/nest/assistant/assistant-redact.ts create mode 100644 server/src/nest/assistant/assistant-retention.job.ts create mode 100644 server/src/nest/assistant/assistant-share.service.ts create mode 100644 server/src/nest/assistant/assistant-stream.registry.ts create mode 100644 server/src/nest/assistant/assistant-title.ts create mode 100644 server/src/nest/assistant/assistant-tools.constants.ts create mode 100644 server/src/nest/assistant/assistant-tools.service.ts create mode 100644 server/src/nest/assistant/assistant-undo.helpers.ts create mode 100644 server/src/nest/assistant/assistant.controller.ts create mode 100644 server/src/nest/assistant/assistant.dto.ts create mode 100644 server/src/nest/assistant/assistant.module.ts create mode 100644 server/src/nest/assistant/assistant.prompt.ts create mode 100644 server/src/nest/assistant/assistant.service.ts create mode 100644 server/src/nest/assistant/tool-call-preview.helpers.ts create mode 100644 server/src/nest/assistant/ui-stream.writer.ts create mode 100644 server/src/nest/common/sse-stream.ts create mode 100644 server/src/nest/oauth/claude-connector.controller.ts create mode 100644 server/src/nest/oauth/claude-connector.service.ts create mode 100644 server/src/nest/oauth/oauth-cimd.ts create mode 100644 server/tests/e2e/ai-connections.e2e.test.ts create mode 100644 server/tests/e2e/assistant.e2e.test.ts create mode 100644 server/tests/unit/db/repositories/AiConnections.repository.test.ts create mode 100644 server/tests/unit/db/repositories/AssistantConversations.repository.test.ts create mode 100644 server/tests/unit/nest-mcp/registry-include.test.ts create mode 100644 server/tests/unit/nest/ai-connections/ai-connection.resolver.test.ts create mode 100644 server/tests/unit/nest/ai-connections/ai-connections.controller.test.ts create mode 100644 server/tests/unit/nest/ai-connections/ai-connections.helpers.test.ts create mode 100644 server/tests/unit/nest/ai-connections/ai-connections.service.test.ts create mode 100644 server/tests/unit/nest/ai-connections/chatgpt-auth.service.test.ts create mode 100644 server/tests/unit/nest/ai-connections/model-catalog.service.test.ts create mode 100644 server/tests/unit/nest/ai-connections/openrouter-auth.service.test.ts create mode 100644 server/tests/unit/nest/ai-connections/provider-adapters.test.ts create mode 100644 server/tests/unit/nest/ai-connections/sse-lines.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-agent.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-attachments.controller.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-booking-import.service.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-cards.helpers.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-context.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-found-places.service.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-history.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-redact.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-retention.job.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-share.service.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-small.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-tools.service.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant-undo.helpers.test.ts create mode 100644 server/tests/unit/nest/assistant/assistant.controller.test.ts create mode 100644 server/tests/unit/nest/assistant/tool-call-preview.helpers.test.ts create mode 100644 server/tests/unit/nest/assistant/ui-stream.writer.test.ts create mode 100644 server/tests/unit/nest/claude-connector.controller.test.ts create mode 100644 server/tests/unit/nest/oauth-cimd.test.ts create mode 100644 server/tests/unit/nest/sse-stream.test.ts create mode 100644 shared/src/ai/ai-connections.schema.spec.ts create mode 100644 shared/src/ai/ai-connections.schema.ts create mode 100644 shared/src/assistant/__fixtures__/ui-stream.v1.txt create mode 100644 shared/src/assistant/assistant-share.schema.spec.ts create mode 100644 shared/src/assistant/assistant-share.schema.ts create mode 100644 shared/src/assistant/assistant.schema.spec.ts create mode 100644 shared/src/assistant/assistant.schema.ts create mode 100644 shared/src/i18n/ar/assistant.ts create mode 100644 shared/src/i18n/az/assistant.ts create mode 100644 shared/src/i18n/br/assistant.ts create mode 100644 shared/src/i18n/ca/assistant.ts create mode 100644 shared/src/i18n/cs/assistant.ts create mode 100644 shared/src/i18n/de/assistant.ts create mode 100644 shared/src/i18n/en/assistant.ts create mode 100644 shared/src/i18n/es/assistant.ts create mode 100644 shared/src/i18n/et/assistant.ts create mode 100644 shared/src/i18n/fr/assistant.ts create mode 100644 shared/src/i18n/gr/assistant.ts create mode 100644 shared/src/i18n/hu/assistant.ts create mode 100644 shared/src/i18n/id/assistant.ts create mode 100644 shared/src/i18n/it/assistant.ts create mode 100644 shared/src/i18n/ja/assistant.ts create mode 100644 shared/src/i18n/ko/assistant.ts create mode 100644 shared/src/i18n/nl/assistant.ts create mode 100644 shared/src/i18n/pl/assistant.ts create mode 100644 shared/src/i18n/ru/assistant.ts create mode 100644 shared/src/i18n/sk/assistant.ts create mode 100644 shared/src/i18n/sv/assistant.ts create mode 100644 shared/src/i18n/th/assistant.ts create mode 100644 shared/src/i18n/tr/assistant.ts create mode 100644 shared/src/i18n/uk/assistant.ts create mode 100644 shared/src/i18n/vi/assistant.ts create mode 100644 shared/src/i18n/zh-TW/assistant.ts create mode 100644 shared/src/i18n/zh/assistant.ts create mode 100644 shared/src/place/place-kind.spec.ts create mode 100644 shared/src/place/place-kind.ts diff --git a/client/package.json b/client/package.json index de5e0283a..bd34b3cb2 100644 --- a/client/package.json +++ b/client/package.json @@ -46,6 +46,7 @@ "predev": "node scripts/patch-maplibre.mjs" }, "dependencies": { + "@assistant-ui/react": "0.15.25", "@fontsource/bebas-neue": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0", "@fontsource/geist-sans": "^5.3.0", diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index bc2ef93ff..d4fb4bdf2 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -22,6 +22,12 @@ "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": "Admin notification preference matrix", "hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"], @@ -58,6 +64,21 @@ "desktop": ["src/pages/admin/AdminUsersTab.tsx"], "mobile": ["src/mobile/screens/admin/MAdminUsersSection.tsx"] }, + { + "feature": "AI assistant chat", + "hooks": ["src/components/Assistant/useAssistantThread.ts"], + "desktop": ["src/components/Assistant/AssistantWindow.tsx"], + "mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"] + }, + { + "feature": "Assistant hints", + "hooks": ["src/components/Assistant/useAssistantHints.ts"], + "desktop": ["src/components/Assistant/AssistantHeader.tsx", "src/components/Assistant/AssistantLauncher.tsx"], + "mobile": [ + "src/mobile/screens/trip/assistant/MAssistantScreen.tsx", + "src/mobile/screens/trip/sheets/MMehrSheet.tsx" + ] + }, { "feature": "Atlas country/region action sheet", "hooks": ["src/pages/atlas/useAtlasCountryActions.ts"], @@ -652,6 +673,12 @@ "desktop": ["src/components/Settings/DisplaySettingsTab.tsx"], "mobile": ["src/mobile/screens/settings/MSettingsGeneral.tsx"] }, + { + "feature": "Settings / Integrations / AI assistant", + "hooks": ["src/components/Settings/ai/useAiConnections.ts"], + "desktop": ["src/components/Settings/ai/AiConnectionSection.tsx"], + "mobile": ["src/mobile/screens/settings/MAiConnectionSection.tsx"] + }, { "feature": "Settings / Integrations / AI parsing", "hooks": ["src/components/Settings/useLlmConnection.ts"], diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index 0666c49ce..fa4e505fb 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -729,8 +729,6 @@ "src/components/Planner/parsedItemToDraft.ts", "src/components/Planner/placeAmap.test.ts", "src/components/Planner/placeAmap.ts", - "src/components/Planner/placeCategoryGuess.test.ts", - "src/components/Planner/placeCategoryGuess.ts", "src/components/Planner/placeCoMaps.test.ts", "src/components/Planner/placeCoMaps.ts", "src/components/Planner/placeDialogParts.test.tsx", diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index addef1cc4..7fd9c1822 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -5,9 +5,9 @@ "src/components/Admin/AdminPluginsPanel.tsx": 1667, "src/components/Budget/CostsPanel.test.tsx": 2764, "src/components/Budget/CostsPanel.tsx": 1816, - "src/components/Map/MapView.tsx": 1401, + "src/components/Map/MapView.tsx": 1399, "src/components/Map/MapViewGL.test.tsx": 2920, - "src/components/Map/MapViewGL.tsx": 2293, + "src/components/Map/MapViewGL.tsx": 2291, "src/components/Map/RouteCalculator.ts": 1195, "src/components/PDF/TripPDF.tsx": 1082, "src/components/Planner/DayDetailPanel.test.tsx": 2333, diff --git a/client/src/App.tsx b/client/src/App.tsx index d1de86eba..7deb73878 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -62,6 +62,8 @@ const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage')) const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage')) const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx')) const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage')) +// The desktop assistant window: its own chunk, and nothing at all until a connection is set up. +const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost')) // The ten phone screens are chunks of their own, alongside the desktop pages // rather than inside them. Each page used to import its M screen statically and @@ -403,6 +405,12 @@ export default function App() { {!hideAuthedWidgets && } {!hideAuthedWidgets && (isPhone ? : )} + {/* The phone has its own assistant screen inside the trip. */} + {!hideAuthedWidgets && isAuthenticated && !isPhone && ( + + + + )} {/* One boundary for all route chunks, above so it stays mounted diff --git a/client/src/api/assistant.test.ts b/client/src/api/assistant.test.ts new file mode 100644 index 000000000..8099843ba --- /dev/null +++ b/client/src/api/assistant.test.ts @@ -0,0 +1,133 @@ +/** + * FE-AI-API-001 to FE-AI-API-007 + * + * The two streaming calls bypass axios, so they carry by hand what the axios + * instance would add (the cookie, the socket id) and turn a refusal before the + * stream into a typed error the chat can name. The refusal's code comes from + * the body when the server names one, and from the status otherwise. + */ +import type { AssistantChatRequest } from '@trek/shared'; +import { http, HttpResponse } from 'msw'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { server } from '../../tests/helpers/msw/server'; +import { assistantApi, AssistantRequestError } from './assistant'; +import { getSocketId } from './websocket'; + +const BODY: AssistantChatRequest = { locale: 'en', timeZone: 'Europe/Berlin', threadId: null, text: 'Hi' }; + +function streamResponse(text: string): Response { + const bytes = new TextEncoder().encode(text); + let sent = false; + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + if (sent) return { done: true, value: undefined }; + sent = true; + return { done: false, value: bytes }; + }, + cancel: async () => {}, + }), + }, + } as unknown as Response; +} + +function refusal(status: number, body: unknown): Response { + return { + ok: false, + status, + body: null, + json: async () => { + if (body === undefined) throw new SyntaxError('not json'); + return body; + }, + } as unknown as Response; +} + +async function refusedWith(res: Response) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(res); + try { + await assistantApi.chat(4, BODY, new AbortController().signal); + } catch (err) { + return err; + } + throw new Error('expected a refusal'); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('assistantApi streams', () => { + it('FE-AI-API-001: posts the message as JSON with the cookie and the socket id, and yields the chunks', async () => { + vi.mocked(getSocketId).mockReturnValueOnce('sock-1'); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(streamResponse('data: {"type":"start","messageId":"m1"}\n\ndata: [DONE]\n\n')); + const signal = new AbortController().signal; + + const chunks: unknown[] = []; + for await (const chunk of await assistantApi.chat(4, BODY, signal)) chunks.push(chunk); + + expect(chunks).toEqual([{ type: 'start', messageId: 'm1' }]); + const [url, init] = fetchSpy.mock.calls[0] ?? []; + expect(url).toBe('/api/trips/4/assistant/chat'); + expect(init).toMatchObject({ method: 'POST', credentials: 'include', signal, body: JSON.stringify(BODY) }); + expect(init?.headers).toMatchObject({ + 'content-type': 'application/json', + accept: 'text/event-stream', + 'X-Socket-Id': 'sock-1', + }); + }); + + it('FE-AI-API-002: sends decisions to the thread they belong to', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamResponse('data: [DONE]\n\n')); + const body = { locale: 'de', timeZone: 'UTC', decisions: [{ toolCallId: 'c1', approved: true }] }; + await assistantApi.decide(4, 7, body, new AbortController().signal); + expect(fetchSpy.mock.calls[0]?.[0]).toBe('/api/trips/4/assistant/threads/7/decisions'); + }); + + it('FE-AI-API-003: a refusal names the code the body gives', async () => { + const err = await refusedWith(refusal(429, { error: 'Daily limit reached', code: 'daily_limit' })); + expect(err).toBeInstanceOf(AssistantRequestError); + expect(err).toMatchObject({ code: 'daily_limit', status: 429 }); + }); + + it('FE-AI-API-004: without a known code the status decides', async () => { + expect(await refusedWith(refusal(429, { error: 'slow down' }))).toMatchObject({ code: 'too_many_requests' }); + expect(await refusedWith(refusal(409, { error: 'x', code: 'conflict' }))).toMatchObject({ code: 'busy' }); + expect(await refusedWith(refusal(502, undefined))).toMatchObject({ code: 'provider_unavailable' }); + expect(await refusedWith(refusal(500, { error: 'boom' }))).toMatchObject({ code: 'generic' }); + }); + + it('FE-AI-API-005: an accepted request without a body is a generic failure, not a silent empty answer', async () => { + const err = await refusedWith({ ok: true, status: 200, body: null } as unknown as Response); + expect(err).toMatchObject({ code: 'generic', status: 200 }); + }); +}); + +describe('assistantApi JSON calls', () => { + it('FE-AI-API-006: undo posts an empty JSON body to the escaped tool call id', async () => { + let seen: { path: string; body: unknown } | null = null; + server.use( + http.post('/api/trips/4/assistant/tool-calls/:id/undo', async ({ request, params }) => { + seen = { path: String(params.id), body: await request.json() }; + return HttpResponse.json({ undone: true }); + }) + ); + await expect(assistantApi.undo(4, 'a/b')).resolves.toEqual({ undone: true }); + expect(seen).toEqual({ path: 'a/b', body: {} }); + }); + + it('FE-AI-API-007: stop posts an empty JSON body', async () => { + let body: unknown = null; + server.use( + http.post('/api/trips/4/assistant/stop', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }) + ); + await expect(assistantApi.stop(4)).resolves.toEqual({ success: true }); + expect(body).toEqual({}); + }); +}); diff --git a/client/src/api/assistant.ts b/client/src/api/assistant.ts new file mode 100644 index 000000000..b00af85e1 --- /dev/null +++ b/client/src/api/assistant.ts @@ -0,0 +1,186 @@ +import { + ASSISTANT_ERROR_CODES, + type AiAdminConnectionResponse, + type AiAdminSettings, + type AiApiKeyConnectRequest, + type AiAuthStartResponse, + type AiChatgptFinishRequest, + type AiClaudeConnectorResponse, + type AiConnectionScope, + type AiConnectionView, + type AiInstanceConnectionRequest, + type AiModelListResponse, + type AiOpenrouterFinishRequest, + type AiPrivacyAckRequest, + type AiStatusResponse, + type AssistantAddFoundPlaceRequest, + type AssistantAddFoundPlaceResponse, + type AssistantChatRequest, + type AssistantDecisionsRequest, + type AssistantErrorCode, + type AssistantShareRequest, + type AssistantThreadListResponse, + type AssistantThreadResponse, + type AssistantUndoResponse, + type CollabMessageResponse, + type SuccessResponse, + type UiStreamChunk, +} from '@trek/shared'; +import apiClient from './client'; +import { parseUiMessageStream } from './uiMessageStream'; +import { getSocketId } from './websocket'; + +/** + * The trip assistant and the AI connections behind it, in their own file + * rather than in the `client.ts` god module. + * + * The two streaming calls (a new message, and the decisions on held changes) + * go through `fetch`, not axios: the answer is a server-sent event stream that + * runs for as long as the model works, and axios would buffer it whole behind + * its 8 s timeout. Everything else is plain JSON through `apiClient`. + */ + +/** A refusal the server answered before the stream opened, named by its `AssistantErrorCode`. */ +export class AssistantRequestError extends Error { + readonly code: AssistantErrorCode; + readonly status: number; + + constructor(code: AssistantErrorCode, status: number) { + super(`Assistant request refused: ${code}`); + this.name = 'AssistantRequestError'; + this.code = code; + this.status = status; + } +} + +const KNOWN_CODES: ReadonlySet = new Set(ASSISTANT_ERROR_CODES); + +/** + * The code of a refusal: the body's `code` when it names one, otherwise what + * the status alone can say. + */ +async function refusalCode(res: Response): Promise { + try { + const body: unknown = await res.json(); + const code = (body as { code?: unknown } | null)?.code; + if (typeof code === 'string' && KNOWN_CODES.has(code)) return code as AssistantErrorCode; + } catch { + // Not JSON (a proxy's error page); the status below still says something. + } + // A bare 429 is TREK's own limit or a proxy's; a provider's limit always names its code. + if (res.status === 429) return 'too_many_requests'; + if (res.status === 409) return 'busy'; + if (res.status === 502 || res.status === 503 || res.status === 504) return 'provider_unavailable'; + return 'generic'; +} + +async function openStream(path: string, body: unknown, signal: AbortSignal): Promise> { + // A file goes as multipart, where the browser sets the content type with its boundary. + const form = body instanceof FormData; + const headers: Record = form + ? { accept: 'text/event-stream' } + : { 'content-type': 'application/json', accept: 'text/event-stream' }; + const socketId = getSocketId(); + if (socketId) headers['X-Socket-Id'] = socketId; + const res = await fetch(`/api${path}`, { + method: 'POST', + credentials: 'include', + headers, + body: form ? body : JSON.stringify(body), + signal, + }); + if (!res.ok) throw new AssistantRequestError(await refusalCode(res), res.status); + if (!res.body) throw new AssistantRequestError('generic', res.status); + return parseUiMessageStream(res.body); +} + +const tripBase = (tripId: number) => `/trips/${tripId}/assistant`; + +/** + * For the calls that make the server go and ask the provider (listing models, + * testing a key, exchanging a sign-in code): the shared 8 s is TREK's own + * budget and too short for a provider's. + */ +const viaProvider = { timeout: 30_000 }; + +export const assistantApi = { + threads: (tripId: number): Promise => + apiClient.get(`${tripBase(tripId)}/threads`).then((r) => r.data), + thread: (tripId: number, threadId: number): Promise => + apiClient.get(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data), + deleteThread: (tripId: number, threadId: number): Promise => + apiClient.delete(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data), + undo: (tripId: number, toolCallId: string): Promise => + apiClient.post(`${tripBase(tripId)}/tool-calls/${encodeURIComponent(toolCallId)}/undo`, {}).then((r) => r.data), + // Ends this user's running turn, wherever it runs (another tab included). + stop: (tripId: number): Promise => + apiClient.post(`${tripBase(tripId)}/stop`, {}).then((r) => r.data), + // A place a search found, suggested for a day: answers with the message holding the change. + addFoundPlace: ( + tripId: number, + threadId: number, + body: AssistantAddFoundPlaceRequest + ): Promise => + apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data), + // An answer posted to the trip chat, marked as shared from the assistant; the client's socket id skips the echo. + share: ( + tripId: number, + threadId: number, + messageId: string, + body: AssistantShareRequest + ): Promise => + apiClient + .post(`${tripBase(tripId)}/threads/${threadId}/messages/${encodeURIComponent(messageId)}/share`, body) + .then((r) => r.data), + + chat: (tripId: number, body: AssistantChatRequest, signal: AbortSignal) => + openStream(`${tripBase(tripId)}/chat`, body, signal), + decide: (tripId: number, threadId: number, body: AssistantDecisionsRequest, signal: AbortSignal) => + openStream(`${tripBase(tripId)}/threads/${threadId}/decisions`, body, signal), + // A booking file: the server reads it and proposes each booking it finds as a change. + attach: (tripId: number, form: FormData, signal: AbortSignal) => + openStream(`${tripBase(tripId)}/attachments`, form, signal), +}; + +/** A user's own connection, and what the assistant runs on for them. */ +export const aiApi = { + status: (): Promise => apiClient.get('/ai/status').then((r) => r.data), + models: (): Promise => apiClient.get('/ai/models', viaProvider).then((r) => r.data), + // Saving tests the key against the provider first. + connectKey: (body: AiApiKeyConnectRequest): Promise => + apiClient.put('/ai/connection', body, viaProvider).then((r) => r.data), + setModel: (model: string): Promise => + apiClient.patch('/ai/connection/model', { model }).then((r) => r.data), + disconnect: (): Promise => apiClient.delete('/ai/connection').then((r) => r.data), + ackPrivacy: (body: AiPrivacyAckRequest): Promise => + apiClient.post('/ai/privacy-ack', body).then((r) => r.data), + + chatgptStart: (): Promise => apiClient.post('/ai/connect/chatgpt/start', {}).then((r) => r.data), + chatgptFinish: (body: AiChatgptFinishRequest): Promise => + apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data), + chatgptSignOut: (): Promise => + apiClient.post('/ai/connect/chatgpt/sign-out', {}).then((r) => r.data), + + openrouterStart: (scope: AiConnectionScope): Promise => + apiClient + .post(scope === 'instance' ? '/admin/ai/connect/openrouter/start' : '/ai/connect/openrouter/start', { scope }) + .then((r) => r.data), + openrouterFinish: (body: AiOpenrouterFinishRequest): Promise => + apiClient.post('/ai/connect/openrouter/finish', body, viaProvider).then((r) => r.data), +}; + +/** The connection the instance provides for everyone, and the assistant's admin settings. */ +export const aiAdminApi = { + settings: (): Promise => apiClient.get('/admin/ai/settings').then((r) => r.data), + saveSettings: (body: AiAdminSettings): Promise => + apiClient.put('/admin/ai/settings', body).then((r) => r.data), + connection: (): Promise => apiClient.get('/admin/ai/connection').then((r) => r.data), + saveConnection: (body: AiInstanceConnectionRequest): Promise => + apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data), + removeConnection: (): Promise => apiClient.delete('/admin/ai/connection').then((r) => r.data), + models: (): Promise => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data), +}; + +export const claudeConnectorApi = { + get: (): Promise => apiClient.get('/integrations/claude-connector').then((r) => r.data), +}; diff --git a/client/src/api/uiMessageStream.test.ts b/client/src/api/uiMessageStream.test.ts new file mode 100644 index 000000000..d8f53b396 --- /dev/null +++ b/client/src/api/uiMessageStream.test.ts @@ -0,0 +1,150 @@ +/** + * FE-AI-STREAM-001 to FE-AI-STREAM-011 + * + * The parser reads the server's assistant stream, so its contract is the + * shared fixture: the same file the server's writer is tested against. What + * the network does to that stream (cutting it at any byte, CRLF from a proxy, + * keep-alive comments) must not change what comes out. + */ +import { uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { MAX_EVENT_CHARS, parseUiMessageStream, STALL_MS } from './uiMessageStream'; + +const FIXTURE = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../../../shared/src/assistant/__fixtures__/ui-stream.v1.txt'), + 'utf8' +); + +/** A response body that hands out the given pieces one read at a time. */ +function bodyOf(pieces: (string | Uint8Array)[]) { + const encoder = new TextEncoder(); + let i = 0; + const cancel = vi.fn(async () => {}); + const body = { + getReader: () => ({ + read: async () => { + if (i >= pieces.length) return { done: true, value: undefined }; + const piece = pieces[i++]; + return { done: false, value: typeof piece === 'string' ? encoder.encode(piece) : piece }; + }, + cancel, + }), + } as unknown as ReadableStream; + return { body, cancel }; +} + +async function collect(body: ReadableStream): Promise { + const chunks: UiStreamChunk[] = []; + for await (const chunk of parseUiMessageStream(body)) chunks.push(chunk); + return chunks; +} + +/** The fixture's chunks as plain JSON, the way a reader of the file sees them. */ +function fixtureChunks(): UiStreamChunk[] { + return FIXTURE.split('\n\n') + .map((event) => event.replace(/^data: /, '').trim()) + .filter((data) => data && data !== '[DONE]') + .map((data) => uiStreamChunkSchema.parse(JSON.parse(data))); +} + +describe('parseUiMessageStream', () => { + it('FE-AI-STREAM-001: reads the shared fixture into its chunks, in order', async () => { + const chunks = await collect(bodyOf([FIXTURE]).body); + expect(chunks).toEqual(fixtureChunks()); + expect(chunks.map((c) => c.type)).toContain('tool-approval-request'); + expect(chunks[chunks.length - 1]).toEqual({ type: 'finish', finishReason: 'tool-calls' }); + }); + + it('FE-AI-STREAM-002: gives the same chunks when every byte arrives in its own read', async () => { + const bytes = new TextEncoder().encode(FIXTURE); + const pieces = Array.from(bytes, (b) => new Uint8Array([b])); + expect(await collect(bodyOf(pieces).body)).toEqual(fixtureChunks()); + }); + + it('FE-AI-STREAM-003: reads CRLF line ends, also when a CR and its LF arrive apart', async () => { + const crlf = FIXTURE.replace(/\n/g, '\r\n'); + const cut = crlf.indexOf('\r\n') + 1; + expect(await collect(bodyOf([crlf.slice(0, cut), crlf.slice(cut)]).body)).toEqual(fixtureChunks()); + }); + + it('FE-AI-STREAM-004: skips keep-alive comments and fields other than data', async () => { + const stream = [ + ': keepalive\n\n', + 'event: message\nid: 1\ndata: {"type":"start-step"}\n\n', + ': keepalive\n\n', + 'data: [DONE]\n\n', + ].join(''); + expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'start-step' }]); + }); + + it('FE-AI-STREAM-005: skips chunk types this client does not know, and torn JSON', async () => { + const stream = [ + 'data: {"type":"reasoning-delta","delta":"hm"}\n\n', + 'data: {"type":"text-delta","id":"t"\n\n', + 'data: {"type":"text-delta","id":"t","delta":"ok"}\n\n', + 'data: [DONE]\n\n', + ].join(''); + expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'ok' }]); + }); + + it('FE-AI-STREAM-006: reads an unknown error code as generic', async () => { + const stream = 'data: {"type":"error","errorText":"quota_exploded"}\n\ndata: [DONE]\n\n'; + expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'error', errorText: 'generic' }]); + }); + + it('FE-AI-STREAM-007: joins the lines of a multi-line data field', async () => { + const stream = 'data: {"type":"text-delta",\ndata: "id":"t","delta":"x"}\n\ndata: [DONE]\n\n'; + expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'x' }]); + }); + + it('FE-AI-STREAM-008: stops at [DONE] and lets go of the connection', async () => { + const { body, cancel } = bodyOf([ + 'data: {"type":"start-step"}\n\ndata: [DONE]\n\ndata: {"type":"finish-step"}\n\n', + ]); + expect(await collect(body)).toEqual([{ type: 'start-step' }]); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('FE-AI-STREAM-009: a stream that closes before [DONE] broke off, after the chunks it finished', async () => { + const { body, cancel } = bodyOf(['data: {"type":"start-step"}\n\ndata: {"type":"finish-st']); + const seen: UiStreamChunk[] = []; + const read = async () => { + for await (const chunk of parseUiMessageStream(body)) seen.push(chunk); + }; + await expect(read()).rejects.toThrow('broke off'); + expect(seen).toEqual([{ type: 'start-step' }]); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('FE-AI-STREAM-011: a stream that stays silent past the stall limit fails and lets go', async () => { + vi.useFakeTimers(); + try { + const cancel = vi.fn(async () => {}); + const body = { + getReader: () => ({ read: () => new Promise(() => {}), cancel }), + } as unknown as ReadableStream; + const failed = expect(collect(body)).rejects.toThrow('stalled'); + await vi.advanceTimersByTimeAsync(STALL_MS); + await failed; + expect(cancel).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('FE-AI-STREAM-010: refuses an event past the size limit, and a consumer leaving early cancels the read', async () => { + const huge = bodyOf([`data: ${'x'.repeat(MAX_EVENT_CHARS + 1)}`]); + await expect(collect(huge.body)).rejects.toThrow('too large'); + expect(huge.cancel).toHaveBeenCalledTimes(1); + + const early = bodyOf([FIXTURE]); + for await (const chunk of parseUiMessageStream(early.body)) { + expect(chunk.type).toBe('start'); + break; + } + expect(early.cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/api/uiMessageStream.ts b/client/src/api/uiMessageStream.ts new file mode 100644 index 000000000..1e4698934 --- /dev/null +++ b/client/src/api/uiMessageStream.ts @@ -0,0 +1,111 @@ +import { UI_STREAM_DONE, uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared'; + +/** + * Reads the assistant's server-sent events into typed chunks. + * + * The wire format is the `@trek/shared` stream contract: one `data: ` + * event per chunk, closed by `data: [DONE]`. Our own parser rather than the + * `assistant-stream` one, because that one drops `tool-approval-request`, the + * chunk a held change hangs on, and it would pull the chat runtime into every + * chunk that only needs the data layer. + * + * What it tolerates, because proxies and the network do these things: events + * split across reads at any byte, CRLF line ends, `:` keep-alive comments, + * and fields other than `data`. A chunk that does not match the contract, + * a type this client does not know among them, is skipped, so the server can + * add chunk types without breaking an older tab. An event longer than + * MAX_EVENT_CHARS ends the stream with an error instead of growing the buffer + * without bound. + * + * The server always closes with `[DONE]` and sends a keep-alive every 15 + * seconds, so a stream that ends without the marker, or stays silent for + * STALL_MS, lost its server (a restart, a dropped proxy) and fails instead of + * leaving the chat waiting for an answer that will not come. + */ + +export const MAX_EVENT_CHARS = 1024 * 1024; +export const STALL_MS = 45_000; + +/** The next read, or a failure once nothing came for STALL_MS. */ +async function readOrStall(reader: ReadableStreamDefaultReader) { + let timer: ReturnType | undefined; + const stalled = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Assistant stream stalled')), STALL_MS); + }); + try { + return await Promise.race([reader.read(), stalled]); + } finally { + clearTimeout(timer); + } +} + +export async function* parseUiMessageStream(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let finished = false; + try { + for (;;) { + const { done, value } = await readOrStall(reader); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + // A CR at the very end may be the first half of a CRLF; it waits for the next read. + const carry = !done && buffer.endsWith('\r') ? '\r' : ''; + if (carry) buffer = buffer.slice(0, -1); + buffer = buffer.replace(/\r\n?/g, '\n'); + + let boundary = buffer.indexOf('\n\n'); + while (boundary >= 0) { + const data = dataOf(buffer.slice(0, boundary)); + buffer = buffer.slice(boundary + 2); + if (data === UI_STREAM_DONE) { + finished = true; + return; + } + const chunk = data === null ? null : chunkOf(data); + if (chunk) yield chunk; + boundary = buffer.indexOf('\n\n'); + } + buffer += carry; + + if (buffer.length > MAX_EVENT_CHARS) throw new Error('Assistant stream event too large'); + // The connection closed before [DONE]: the server went away mid-answer. + if (done) { + finished = true; + throw new Error('Assistant stream broke off'); + } + } + } finally { + // Leaving early (the consumer stopped, or an error) releases the connection. + if (!finished) { + try { + await reader.cancel(); + } catch { + // The stream already failed; the error that brought us here is the one that counts. + } + } + } +} + +/** The joined `data` lines of one event, or null for an event without any (a comment). */ +function dataOf(event: string): string | null { + const lines: string[] = []; + for (const line of event.split('\n')) { + if (line === 'data' || line.startsWith('data:')) { + const value = line.slice(5); + lines.push(value.startsWith(' ') ? value.slice(1) : value); + } + } + return lines.length ? lines.join('\n') : null; +} + +function chunkOf(data: string): UiStreamChunk | null { + let json: unknown; + try { + json = JSON.parse(data); + } catch { + // A torn or foreign event; the next one is still worth reading. + return null; + } + const parsed = uiStreamChunkSchema.safeParse(json); + return parsed.success ? parsed.data : null; +} diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 94dfda115..4702cd32a 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -17,6 +17,7 @@ import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel' import { useAddonManager } from './addons/useAddonManager' import { useLlmParsingConfig } from './addons/useLlmParsingConfig' +import AiAdminConfig from './addons/AiAdminConfig' import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons' import WandererMark from '../Tours/wanderer/WandererMark' @@ -182,6 +183,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, 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 } diff --git a/client/src/components/Admin/addons/AiAdminConfig.test.tsx b/client/src/components/Admin/addons/AiAdminConfig.test.tsx new file mode 100644 index 000000000..0f4927a46 --- /dev/null +++ b/client/src/components/Admin/addons/AiAdminConfig.test.tsx @@ -0,0 +1,100 @@ +// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-004: the AI assistant's form on its addon tile. +import userEvent from '@testing-library/user-event'; +import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '../../../../tests/helpers/render'; +import { useAssistantStore } from '../../../store/assistantStore'; + +const repo = vi.hoisted(() => ({ + status: vi.fn(), + adminSettings: vi.fn(), + saveAdminSettings: vi.fn(), + adminConnection: vi.fn(), + saveAdminConnection: vi.fn(), + removeAdminConnection: vi.fn(), + adminModels: vi.fn(), +})); +vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo })); + +const { default: AiAdminConfig } = await import('./AiAdminConfig'); + +const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }; +const LIVE: AiAdminConnectionResponse = { + connection: { + scope: 'instance', + kind: 'openai', + model: 'gpt-5.5', + hint: '…9f3c', + status: 'ok', + host: 'api.openai.com', + baseUrl: null, + }, + prefill: null, +}; + +function renderConfig() { + return render( +
    + +
+ ); +} + +beforeEach(() => { + vi.clearAllMocks(); + useAssistantStore.setState({ status: null }); + repo.status.mockRejectedValue(new Error('not needed')); + repo.adminSettings.mockResolvedValue(SETTINGS); + repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next); + repo.adminConnection.mockResolvedValue({ connection: null, prefill: null }); + repo.removeAdminConnection.mockResolvedValue(undefined); + 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(); + 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(); + 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(); + 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(); + }); + + 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(); + expect(await screen.findByText('OpenAI')).toBeInTheDocument(); + expect(screen.getByText('…9f3c')).toBeInTheDocument(); + expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Change' })); + expect(screen.getByText('What would you like to chat with?')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.getByText('OpenAI')).toBeInTheDocument(); + }); + + 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(); + 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' }); + await user.click(buttons[buttons.length - 1]); + expect(await screen.findByText('No connection yet.')).toBeInTheDocument(); + expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/Admin/addons/AiAdminConfig.tsx b/client/src/components/Admin/addons/AiAdminConfig.tsx new file mode 100644 index 000000000..9e54a5a7d --- /dev/null +++ b/client/src/components/Admin/addons/AiAdminConfig.tsx @@ -0,0 +1,190 @@ +import { Check, Loader2 } from 'lucide-react'; +import React, { useId } 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 { NumericInput } from '../../shared/NumericInput'; +import { EditorField, INPUT } from '../../shared/dialogParts'; +import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig'; + +type AiAdmin = ReturnType; + +const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'; + +/** Who pays, as three rows to pick one from: the labels are too long for a segmented track in a shelf. */ +function PolicyChoice({ A }: { A: AiAdmin }) { + const { t } = useTranslation(); + return ( +
+ + {t('assistant.admin.policy')} + +
+ {POLICY_CHOICES.map((choice) => { + const on = A.policy === choice.value; + return ( + + ); + })} +
+ {A.policyHintKey && {t(A.policyHintKey)}} +
+ ); +} + +/** The connection everyone runs on, once set up: who answers, its model, and replacing or removing it. */ +function LiveConnection({ A }: { A: AiAdmin }) { + const { t } = useTranslation(); + const { live, provider, models, busy, setModel, replace, askRemove } = A.instance; + if (!live) return null; + const status = CONNECTION_STATUS[live.status]; + return ( +
+
+ +
+

+ {provider} +

+

+ {live.hint ?? live.host} +

+
+ {t(status.labelKey)} +
+ {live.status === 'ok' && ( + + setModel(String(v))} + options={models} + size="sm" + disabled={busy} + searchable={models.length > 8} + /> + + )} +
+ + +
+
+ ); +} + +function InstanceConnection({ A }: { A: AiAdmin }) { + const { t } = useTranslation(); + const { live, connection, replacing, cancelReplace, onConnected } = A.instance; + return ( +
+ + {t('assistant.admin.connection')} + + {!replacing && } + {!live && {t('assistant.admin.connectionNone')}} + {A.showPanel && ( + + )} + {replacing && ( + + )} +
+ ); +} + +/** + * 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 { + const { t } = useTranslation(); + const A = useAiAdminConfig(); + const fieldId = useId(); + + if (!A.loaded) { + return ( +
  • + +
  • + ); + } + + return ( +
  • + + {A.instancePays && } + {A.instancePays && ( + + { + if (e.key === 'Enter') e.currentTarget.blur(); + }} + /> + + )} + + A.setRetention(String(v))} + options={A.retentionOptions} + size="sm" + /> + + + +
  • + ); +} diff --git a/client/src/components/Admin/addons/useAiAdminConfig.test.ts b/client/src/components/Admin/addons/useAiAdminConfig.test.ts new file mode 100644 index 000000000..0b1768910 --- /dev/null +++ b/client/src/components/Admin/addons/useAiAdminConfig.test.ts @@ -0,0 +1,167 @@ +// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone. +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared'; +import { MASKED_SETTING_VALUE } from '@trek/shared'; +import { createElement, type ReactNode } from 'react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TranslationProvider } from '../../../i18n/TranslationContext'; +import { useAssistantStore } from '../../../store/assistantStore'; + +const repo = vi.hoisted(() => ({ + status: vi.fn(), + adminSettings: vi.fn(), + saveAdminSettings: vi.fn(), + adminConnection: vi.fn(), + saveAdminConnection: vi.fn(), + removeAdminConnection: vi.fn(), + adminModels: vi.fn(), +})); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo })); +vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); + +const { useAiAdminConfig } = await import('./useAiAdminConfig'); + +const wrapper = ({ children }: { children: ReactNode }) => + createElement( + MemoryRouter, + { initialEntries: ['/admin?tab=addons'] }, + createElement(TranslationProvider, null, children) + ); + +const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }; +const NONE: AiAdminConnectionResponse = { connection: null, prefill: null }; +const LIVE: AiAdminConnectionResponse = { + connection: { + scope: 'instance', + kind: 'anthropic', + model: 'claude-sonnet', + hint: '…a1b2', + status: 'ok', + host: 'api.anthropic.com', + baseUrl: null, + }, + prefill: null, +}; + +async function renderLoaded() { + const view = renderHook(() => useAiAdminConfig(), { wrapper }); + await waitFor(() => expect(view.result.current.loaded).toBe(true)); + return view; +} + +beforeEach(() => { + vi.clearAllMocks(); + useAssistantStore.setState({ status: null }); + repo.status.mockRejectedValue(new Error('not needed')); + repo.adminSettings.mockResolvedValue(SETTINGS); + repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next); + repo.adminConnection.mockResolvedValue(NONE); + repo.saveAdminConnection.mockResolvedValue(LIVE); + repo.removeAdminConnection.mockResolvedValue(undefined); + repo.adminModels.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]); +}); + +describe('useAiAdminConfig', () => { + it('FE-HOOK-AI-ADMIN-001: loads the settings and, without a connection, opens the setup for everyone', async () => { + const { result } = await renderLoaded(); + expect(result.current.policy).toBe('instance_or_user'); + expect(result.current.policyHintKey).toBe('assistant.admin.policy.instanceOrUserHint'); + expect(result.current.instancePays).toBe(true); + expect(result.current.showPanel).toBe(true); + expect(result.current.dailyDraft).toBe('50'); + expect(result.current.retention).toBe('90'); + expect(result.current.retentionOptions.map((o) => o.label)).toEqual(['30 days', '90 days', '365 days', 'Forever']); + expect(repo.status).toHaveBeenCalled(); + }); + + it('FE-HOOK-AI-ADMIN-002: a policy change saves at once, and a refused one is taken back', async () => { + const { result } = await renderLoaded(); + await act(async () => result.current.setPolicy('user')); + await waitFor(() => expect(result.current.policy).toBe('user')); + expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' }); + expect(result.current.instancePays).toBe(false); + + repo.saveAdminSettings.mockRejectedValueOnce(new Error('nope')); + await act(async () => result.current.setPolicy('instance')); + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + expect(result.current.policy).toBe('user'); + }); + + it('FE-HOOK-AI-ADMIN-003: the daily limit is kept within 1 to 10000 and saved when the field is left', async () => { + const { result } = await renderLoaded(); + act(() => result.current.setDailyDraft('20000')); + await act(async () => result.current.commitDailyLimit()); + expect(result.current.dailyDraft).toBe('10000'); + expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, dailyTurns: 10000 }); + + vi.clearAllMocks(); + act(() => result.current.setDailyDraft('')); + await act(async () => result.current.commitDailyLimit()); + expect(result.current.dailyDraft).toBe('10000'); + expect(repo.saveAdminSettings).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-AI-ADMIN-004: a retention period set elsewhere stays selectable', async () => { + repo.adminSettings.mockResolvedValue({ ...SETTINGS, retention: { mode: 'days', days: 120 } }); + const { result } = await renderLoaded(); + expect(result.current.retentionOptions.map((o) => o.value)).toEqual(['30', '90', '120', '365', 'forever']); + await act(async () => result.current.setRetention('forever')); + expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, retention: { mode: 'forever' } }); + }); + + it('FE-HOOK-AI-ADMIN-005: another model keeps the stored key by sending the mask', async () => { + repo.adminConnection.mockResolvedValue(LIVE); + const { result } = await renderLoaded(); + expect(result.current.showPanel).toBe(false); + await waitFor(() => expect(result.current.instance.models).toHaveLength(1)); + await act(async () => result.current.instance.setModel('claude-haiku')); + await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1)); + expect(repo.saveAdminConnection).toHaveBeenCalledWith({ + kind: 'anthropic', + apiKey: MASKED_SETTING_VALUE, + model: 'claude-haiku', + }); + }); + + it('FE-HOOK-AI-ADMIN-006: an own server without a key sends none', async () => { + repo.adminConnection.mockResolvedValue({ + connection: { + scope: 'instance', + kind: 'custom', + model: 'llama3', + hint: null, + status: 'ok', + host: 'ollama:11434', + baseUrl: 'http://ollama:11434/v1', + }, + prefill: null, + }); + const { result } = await renderLoaded(); + await act(async () => result.current.instance.setModel('qwen3:8b')); + await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1)); + expect(repo.saveAdminConnection).toHaveBeenCalledWith({ + kind: 'custom', + baseUrl: 'http://ollama:11434/v1', + model: 'qwen3:8b', + }); + }); + + it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => { + repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE); + const { result } = await renderLoaded(); + act(() => result.current.instance.replace()); + expect(result.current.showPanel).toBe(true); + act(() => result.current.instance.cancelReplace()); + expect(result.current.showPanel).toBe(false); + + act(() => result.current.instance.askRemove()); + expect(result.current.instance.confirmingRemove).toBe(true); + act(() => result.current.instance.remove()); + await waitFor(() => expect(result.current.instance.live).toBeNull()); + expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1); + expect(result.current.instance.confirmingRemove).toBe(false); + expect(result.current.showPanel).toBe(true); + }); +}); diff --git a/client/src/components/Admin/addons/useAiAdminConfig.ts b/client/src/components/Admin/addons/useAiAdminConfig.ts new file mode 100644 index 000000000..2fa2bd6d2 --- /dev/null +++ b/client/src/components/Admin/addons/useAiAdminConfig.ts @@ -0,0 +1,218 @@ +import { + MASKED_SETTING_VALUE, + type AiAdminConnectionResponse, + type AiAdminSettings, + type AiInstanceConnectionRequest, + type AiPolicy, + type AiRetention, +} from '@trek/shared'; +import { useEffect, useEffectEvent, useState } from 'react'; +import { useTranslation } from '../../../i18n'; +import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo'; +import { useAssistantStore } from '../../../store/assistantStore'; +import { providerLabel } from '../../Assistant/providerCatalog'; +import { connectErrorKey } from '../../Settings/ai/aiConnectModel'; +import { useOpenrouterReturn } from '../../Settings/ai/useAiConnect'; +import { modelOptions, useAiModels } from '../../Settings/ai/useAiConnections'; +import { useToast } from '../../shared/Toast'; + +/** Who pays, as the admin reads it ("I pay" rather than "the instance"); the first is the default. */ +export const POLICY_CHOICES: readonly { value: AiPolicy; labelKey: string; hintKey: string }[] = [ + { + value: 'instance_or_user', + labelKey: 'assistant.admin.policy.instanceOrUser', + hintKey: 'assistant.admin.policy.instanceOrUserHint', + }, + { value: 'instance', labelKey: 'assistant.admin.policy.instance', hintKey: 'assistant.admin.policy.instanceHint' }, + { value: 'user', labelKey: 'assistant.admin.policy.user', hintKey: 'assistant.admin.policy.userHint' }, +]; + +const RETENTION_DAYS = [30, 90, 365]; +const FOREVER = 'forever'; +const MAX_DAILY_TURNS = 10_000; + +/** A retention as the select's value: its days, or `forever`. */ +function retentionValue(retention: AiRetention): string { + return retention.mode === 'forever' ? FOREVER : String(retention.days); +} + +function retentionOf(value: string): AiRetention { + return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) }; +} + +/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */ +function useAdminSettings(onSaved: () => void) { + const { t } = useTranslation(); + const toast = useToast(); + const [settings, setSettings] = useState(null); + const [dailyDraft, setDailyDraft] = useState(''); + + const adopt = (next: AiAdminSettings) => { + setSettings(next); + setDailyDraft(String(next.dailyTurns)); + }; + + const save = async (patch: Partial) => { + if (!settings) return; + const before = settings; + setSettings({ ...settings, ...patch }); + try { + adopt(await aiConnectionsRepo.saveAdminSettings({ ...settings, ...patch })); + onSaved(); + } catch { + adopt(before); + toast.error(t('assistant.error.generic')); + } + }; + + /** The typed limit, kept within what the server takes, saved when the field is left. */ + const commitDailyLimit = () => { + if (!settings) return; + const typed = Number.parseInt(dailyDraft, 10); + const next = Number.isFinite(typed) ? Math.min(MAX_DAILY_TURNS, Math.max(1, typed)) : settings.dailyTurns; + setDailyDraft(String(next)); + if (next !== settings.dailyTurns) void save({ dailyTurns: next }); + }; + + const current = settings ? retentionValue(settings.retention) : ''; + // A period set some other way stays selectable instead of reading as blank. + const unlisted = current !== '' && current !== FOREVER && !RETENTION_DAYS.includes(Number(current)); + const days = unlisted ? [...RETENTION_DAYS, Number(current)].sort((a, b) => a - b) : RETENTION_DAYS; + + return { + settings, + adopt, + setPolicy: (policy: AiPolicy) => void save({ policy }), + dailyDraft, + setDailyDraft, + commitDailyLimit, + retention: current, + retentionOptions: [ + ...days.map((d) => ({ value: String(d), label: t('assistant.admin.retentionDays', { count: d }) })), + { value: FOREVER, label: t('assistant.admin.retentionForever') }, + ], + setRetention: (value: string) => void save({ retention: retentionOf(value) }), + }; +} + +/** The connection for everyone: shown, its model switched, replaced or removed. */ +function useInstanceConnection(onChanged: () => void) { + const { t } = useTranslation(); + const toast = useToast(); + const [connection, setConnection] = useState(null); + const [replacing, setReplacing] = useState(false); + const [confirmingRemove, setConfirmingRemove] = useState(false); + const [busy, setBusy] = useState(false); + const live = connection?.connection ?? null; + const provider = live ? providerLabel(live.kind, live.host) : ''; + const models = useAiModels( + live?.status === 'ok' ? () => aiConnectionsRepo.adminModels() : null, + live ? `${live.kind}@${live.host}` : '' + ); + + const act = async (action: () => Promise, doneKey: string | null) => { + setBusy(true); + try { + setConnection(await action()); + onChanged(); + if (doneKey) toast.success(t(doneKey)); + } catch (err) { + toast.error(t(connectErrorKey(err), { provider })); + } finally { + setBusy(false); + } + }; + + /** Another model on the same connection: the stored key is kept by sending the mask in its place. */ + const setModel = (model: string) => { + if (!live || live.kind === 'chatgpt') return; + const request: AiInstanceConnectionRequest = + live.kind === 'custom' + ? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) } + : { kind: live.kind, apiKey: MASKED_SETTING_VALUE, model }; + void act(() => aiConnectionsRepo.saveAdminConnection(request), 'common.saved'); + }; + + const remove = () => { + setConfirmingRemove(false); + void act(async () => { + await aiConnectionsRepo.removeAdminConnection(); + return aiConnectionsRepo.adminConnection(); + }, null); + }; + + const onConnected = () => { + setReplacing(false); + void act(() => aiConnectionsRepo.adminConnection(), null); + }; + + return { + connection, + setConnection, + live, + provider, + models: live + ? modelOptions(models, live.model, { free: t('assistant.model.free'), recommended: t('assistant.model.recommended') }) + : [], + busy, + setModel, + replacing, + replace: () => setReplacing(true), + cancelReplace: () => setReplacing(false), + onConnected, + confirmingRemove, + askRemove: () => setConfirmingRemove(true), + cancelRemove: () => setConfirmingRemove(false), + remove, + }; +} + +/** + * The AI assistant's admin settings behind both admin shells, on the addon's + * own tile: who pays, the connection everyone runs on, the daily limit while + * the instance pays, and how long chats are kept. The assistant's status is + * reloaded after every change, so the launcher appears or goes at once. + */ +export function useAiAdminConfig() { + const { t } = useTranslation(); + const toast = useToast(); + const loadStatus = useAssistantStore((s) => s.loadStatus); + const refreshStatus = () => void loadStatus(); + const form = useAdminSettings(refreshStatus); + const instance = useInstanceConnection(refreshStatus); + useOpenrouterReturn(instance.onConnected); + + const loadOnMount = useEffectEvent((isCancelled: () => boolean) => { + Promise.all([aiConnectionsRepo.adminSettings(), aiConnectionsRepo.adminConnection()]).then( + ([settings, connection]) => { + if (isCancelled()) return; + form.adopt(settings); + instance.setConnection(connection); + }, + () => { + if (!isCancelled()) toast.error(t('assistant.error.generic')); + } + ); + // The connect panel reads whether OpenRouter can return here from the status. + refreshStatus(); + }); + useEffect(() => { + let cancelled = false; + loadOnMount(() => cancelled); + return () => { + cancelled = true; + }; + }, []); + + const policy = form.settings?.policy ?? null; + return { + ...form, + instance, + loaded: form.settings !== null && instance.connection !== null, + policy, + policyHintKey: POLICY_CHOICES.find((c) => c.value === policy)?.hintKey ?? null, + /** Policy `user` charges nothing to the instance: no connection and no limit to set. */ + instancePays: policy !== null && policy !== 'user', + showPanel: instance.live === null || instance.replacing, + }; +} diff --git a/client/src/components/Assistant/AssistantAskButton.test.tsx b/client/src/components/Assistant/AssistantAskButton.test.tsx new file mode 100644 index 000000000..b094e2365 --- /dev/null +++ b/client/src/components/Assistant/AssistantAskButton.test.tsx @@ -0,0 +1,56 @@ +/** + * FE-AI-ASK-001 to FE-AI-ASK-003 + * + * "Ask" in the place inspector: there only while the assistant can run, only + * in the planner of the place's trip, and it opens the chat about the place. + */ +import type { AiStatusResponse } from '@trek/shared'; +import { Route, Routes } from 'react-router'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildPlace } from '../../../tests/helpers/factories'; +import { fireEvent, render, screen } from '../../../tests/helpers/render'; +import { chatOf, useAssistantStore } from '../../store/assistantStore'; +import AssistantAskButton from './AssistantAskButton'; + +const INITIAL = useAssistantStore.getState(); +const place = buildPlace({ id: 42, trip_id: 5, name: 'Kinkaku-ji' }); + +function renderAt(path: string) { + return render( + + } /> + } /> + , + { initialEntries: [path] } + ); +} + +afterEach(() => useAssistantStore.setState(INITIAL, true)); + +describe('AssistantAskButton', () => { + it('FE-AI-ASK-001: shows nothing until the assistant has a connection to use', () => { + renderAt('/trips/5'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('FE-AI-ASK-002: opens the assistant with the place as its context', () => { + useAssistantStore.setState({ status: { available: true } as AiStatusResponse }); + renderAt('/trips/5'); + fireEvent.click(screen.getByRole('button', { name: 'Ask: Kinkaku-ji' })); + + const state = useAssistantStore.getState(); + expect(state.window).toEqual({ open: true, minimized: false }); + expect(chatOf(state, 5).focus).toEqual({ placeIds: [42] }); + }); + + it('FE-AI-ASK-003: is not offered outside the planner of the trip the place belongs to', () => { + useAssistantStore.setState({ status: { available: true } as AiStatusResponse }); + const { unmount } = renderAt('/trips/6'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + unmount(); + + renderAt('/collections/3'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Assistant/AssistantAskButton.tsx b/client/src/components/Assistant/AssistantAskButton.tsx new file mode 100644 index 000000000..3cf5624c3 --- /dev/null +++ b/client/src/components/Assistant/AssistantAskButton.tsx @@ -0,0 +1,34 @@ +import { Sparkles } from 'lucide-react'; +import { useMatch } from 'react-router'; + +import { useTranslation } from '../../i18n'; +import type { Place } from '../../types'; +import Tooltip from '../shared/Tooltip'; +import { useAssistantAsk } from './useAssistantAsk'; + +/** + * The place inspector's "Ask": a round button in its head, beside close, that + * opens the assistant about this place. Only in the planner of the place's own + * trip; a saved place in a collection has no trip to ask about. + */ +export default function AssistantAskButton({ place }: { place: Place }) { + const { t } = useTranslation(); + const inTrip = useMatch('/trips/:id'); + const tripId = inTrip && Number(inTrip.params.id) === place.trip_id ? place.trip_id : null; + const { available, askAbout } = useAssistantAsk(tripId); + if (!available) return null; + + const label = t('assistant.context.ask'); + return ( + + + + ); +} diff --git a/client/src/components/Assistant/AssistantChangeCard.test.tsx b/client/src/components/Assistant/AssistantChangeCard.test.tsx new file mode 100644 index 000000000..76168b567 --- /dev/null +++ b/client/src/components/Assistant/AssistantChangeCard.test.tsx @@ -0,0 +1,348 @@ +/** + * FE-AI-CARD-001 to FE-AI-CARD-013 + * + * The change card: what it asks before anything is written, which decisions + * one press sends, and what the receipt offers afterwards (the map, undo + * while its window is open, and what a failed undo says). Then what ties it + * to the map: a row lights its pin and the pin its row, the days it touches + * read as a band, the phone hops to the map, and once applied the block can + * be compared before and after and taken back as a whole. + */ +import type { AssistantApplied, AssistantChangeEffect, AssistantChangePreview } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories'; +import { act, fireEvent, render, screen } from '../../../tests/helpers/render'; +import { chatOf, useAssistantStore } from '../../store/assistantStore'; +import { useTripStore } from '../../store/tripStore'; +import AssistantChangeCard from './AssistantChangeCard'; +import AssistantCompareSwitch from './AssistantCompareSwitch'; +import type { ChangeItem } from './assistantParts'; +import { type AssistantView, AssistantViewContext } from './assistantViewContext'; + +const TRIP = 4; +const INITIAL = useAssistantStore.getState(); +const TRIP_INITIAL = useTripStore.getState(); + +const PANTHEON: AssistantChangeEffect = { + kind: 'add', + dayId: 3, + fromDayId: null, + placeId: null, + assignmentId: null, + place: { name: 'Pantheon', lat: 48.02, lng: 2.0, categoryId: null }, +}; + +function preview(toolCallId: string, subject: string): AssistantChangePreview { + return { + toolCallId, + verbKey: 'addPlace', + subject, + rows: [ + { op: 'add', labelKey: 'address', before: null, after: 'Kyoto' }, + { op: 'change', labelKey: 'time', before: '09:00', after: '10:30' }, + ], + dayId: 3, + placeIds: [], + undoable: true, + }; +} + +const pending = (id: string, subject: string): ChangeItem => ({ + toolCallId: id, + preview: preview(id, subject), + applied: null, + state: 'pending', +}); + +const onTheMap = (id: string, subject: string, effect = PANTHEON): ChangeItem => ({ + ...pending(id, subject), + preview: { ...preview(id, subject), effect }, +}); + +function receipt(id: string, undoUntil: string | null): ChangeItem { + const applied: AssistantApplied = { toolCallId: id, entity: 'place', ids: [9], dayId: 3, undoUntil }; + return { toolCallId: id, preview: preview(id, 'Kinkaku-ji'), applied, state: 'applied' }; +} + +function renderCard(items: ChangeItem[], view: Partial = {}) { + const value: AssistantView = { tripId: TRIP, variant: 'window', ...view }; + return render( + + + + ); +} + +function patchChat(next: Partial>) { + useAssistantStore.setState((s) => ({ byTrip: { ...s.byTrip, [TRIP]: { ...chatOf(s, TRIP), ...next } } })); +} + +/** The planner holding this trip, with one stop on day 3. */ +function plannerHere(tripId = TRIP) { + const louvre = buildPlace({ id: 10, name: 'Louvre', lat: 48.0, lng: 2.0 }); + useTripStore.setState({ + trip: buildTrip({ id: tripId }), + days: [buildDay({ id: 3, title: 'Rome' })], + assignments: { 3: [buildAssignment({ id: 100, day_id: 3, place: louvre, assignment_time: '09:00' })] }, + places: [louvre], + }); +} + +const future = () => new Date(Date.now() + 10 * 60_000).toISOString(); + +const decide = vi.fn<(tripId: number, decisions: { toolCallId: string; approved: boolean }[]) => Promise>(); +const undo = vi.fn<(tripId: number, toolCallId: string) => Promise<'undone' | 'stale' | 'failed'>>(); +const undoBlock = + vi.fn<(tripId: number, ids: readonly string[]) => Promise<{ done: number; stale: number; failed: number }>>(); +const setCompare = vi.fn<(tripId: number, mode: 'before' | 'after') => void>(); +const requestCamera = vi.fn(); +const setMinimized = vi.fn(); +const addToast = vi.fn(); + +beforeEach(() => { + decide.mockResolvedValue(undefined); + useAssistantStore.setState({ decide, undo, undoBlock, setCompare, requestCamera, setMinimized }); + window.__addToast = addToast as unknown as typeof window.__addToast; +}); + +afterEach(() => { + useAssistantStore.setState(INITIAL, true); + useTripStore.setState(TRIP_INITIAL, true); + delete window.__addToast; + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe('AssistantChangeCard: deciding', () => { + it('FE-AI-CARD-001: shows what changes in plain words, field by field, before anything is written', () => { + renderCard([pending('c1', 'Kinkaku-ji')]); + expect(screen.getByText('Kinkaku-ji')).toBeInTheDocument(); + expect(screen.getByText('Add place')).toBeInTheDocument(); + expect(screen.getByText('Address')).toBeInTheDocument(); + expect(screen.getByText('09:00')).toBeInTheDocument(); + expect(screen.getByText('10:30')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Include this change' })).toBeNull(); + expect(screen.getByRole('status')).toHaveTextContent('1 change is waiting for you'); + }); + + it('FE-AI-CARD-002: one change applies or discards with one press', () => { + renderCard([pending('c1', 'Kinkaku-ji')]); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(decide).toHaveBeenLastCalledWith(TRIP, [{ toolCallId: 'c1', approved: true }]); + + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + expect(decide).toHaveBeenLastCalledWith(TRIP, [{ toolCallId: 'c1', approved: false }]); + }); + + it('FE-AI-CARD-003: several changes each get a switch, and the button applies the ones left on in one request', () => { + renderCard([pending('c1', 'Kinkaku-ji'), pending('c2', 'Ryoan-ji'), pending('c3', 'Nanzen-ji')]); + expect(screen.getByRole('button', { name: 'Apply 3' })).toBeEnabled(); + + const [, second] = screen.getAllByRole('button', { name: 'Include this change' }); + fireEvent.click(second as HTMLElement); + fireEvent.click(screen.getByRole('button', { name: 'Apply 2' })); + + expect(decide).toHaveBeenCalledTimes(1); + expect(decide).toHaveBeenCalledWith(TRIP, [ + { toolCallId: 'c1', approved: true }, + { toolCallId: 'c2', approved: false }, + { toolCallId: 'c3', approved: true }, + ]); + }); + + it('FE-AI-CARD-004: nothing can be decided while the assistant is still answering', () => { + patchChat({ isRunning: true }); + renderCard([pending('c1', 'Kinkaku-ji')]); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled(); + }); +}); + +describe('AssistantChangeCard: the receipt', () => { + it('FE-AI-CARD-005: an applied change shows on the map and can be undone until its window closes', () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-10-21T08:00:00.000Z')); + const onShowOnMap = vi.fn(); + renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onShowOnMap }); + + expect(screen.getByText('Applied')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Show on map' })); + expect(onShowOnMap).toHaveBeenCalledWith(expect.objectContaining({ toolCallId: 'c1', dayId: 3 })); + expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(16 * 60_000); + }); + expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull(); + }); + + it('FE-AI-CARD-006: an undone change says so, a stale one explains why it stays', async () => { + // The store marks what it took back; the card reads it from there. + undo.mockImplementationOnce(async (_tripId, toolCallId) => { + patchChat({ undone: { [toolCallId]: true } }); + return 'undone'; + }); + const first = renderCard([receipt('c1', future())]); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Undo' })); + }); + expect(undo).toHaveBeenCalledWith(TRIP, 'c1'); + expect(screen.getByText('Undone')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull(); + first.unmount(); + + undo.mockResolvedValueOnce('stale'); + renderCard([receipt('c2', future())]); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Undo' })); + }); + expect(addToast).toHaveBeenCalledWith('Changed in the meantime. Please adjust it by hand.', 'warning', undefined); + }); + + it('FE-AI-CARD-007: no map button where nothing can show it, and none of the receipt actions after a discard', () => { + renderCard([receipt('c1', null), { ...pending('c2', 'Ryoan-ji'), state: 'discarded' }]); + expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull(); + expect(screen.getByText('Discarded')).toBeInTheDocument(); + }); +}); + +describe('AssistantChangeCard: on the map', () => { + it('FE-AI-CARD-008: resting on a row lights its pin, and a lit pin lights its row', () => { + renderCard([onTheMap('c1', 'Pantheon'), pending('c2', 'Ryoan-ji')]); + fireEvent.mouseEnter(screen.getByText('Pantheon')); + expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'c1', source: 'card' }); + fireEvent.mouseLeave(screen.getByText('Pantheon')); + expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull(); + + // A change the plan does not show has no pin to light. + fireEvent.mouseEnter(screen.getByText('Ryoan-ji')); + expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull(); + + const row = screen.getByText('Pantheon').closest('[class*="py-2.5"]'); + expect(row).not.toHaveClass('bg-surface-hover'); + act(() => useAssistantStore.setState({ mapLink: { tripId: TRIP, hoveredId: 'c1', source: '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', () => { + plannerHere(); + renderCard([onTheMap('c1', 'Pantheon')]); + expect(screen.getByText('Rome')).toBeInTheDocument(); + expect(screen.queryByText('New')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Show schedule' })); + expect(screen.getByRole('button', { name: 'Hide schedule' })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Louvre')).toBeInTheDocument(); + expect(screen.getAllByText('Pantheon')).toHaveLength(2); + expect(screen.getByText('New')).toBeInTheDocument(); + expect(screen.getByText(/min travel/)).toBeInTheDocument(); + + // Hovering the stop in the schedule lights the same pin as its row. + fireEvent.mouseEnter(screen.getByText('New')); + expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('c1'); + }); + + it('FE-AI-CARD-010: no band while the planner holds another trip, or once nothing waits', () => { + plannerHere(TRIP + 1); + const first = renderCard([onTheMap('c1', 'Pantheon')]); + expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull(); + first.unmount(); + + plannerHere(); + renderCard([{ ...onTheMap('c1', 'Pantheon'), state: 'applied' }]); + expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull(); + }); + + it('FE-AI-CARD-011: on the phone the card hops to the map and frames what it would change', () => { + plannerHere(); + const onShowOnMap = vi.fn(); + const first = renderCard([onTheMap('c1', 'Pantheon')], { onShowOnMap }); + expect(screen.queryByRole('button', { name: 'View on map' })).toBeNull(); + first.unmount(); + + renderCard([onTheMap('c1', 'Pantheon')], { variant: 'fullscreen', onShowOnMap }); + fireEvent.click(screen.getByRole('button', { name: 'View on map' })); + expect(requestCamera).toHaveBeenCalledWith( + TRIP, + [ + [48.02, 2.0], + [48.0, 2.0], + ], + 'fit', + 'user' + ); + expect(setMinimized).toHaveBeenCalledWith(true); + }); +}); + +describe('AssistantChangeCard: the applied block', () => { + const compare = (blockKey: string, mode: 'before' | 'after' = 'after') => + patchChat({ compare: { blockKey, mode, snapshot: { 3: [] }, newPlaceCalls: [], addedPlaceIds: [] } }); + + it('FE-AI-CARD-012: before and after are there only for the block this session can compare, beside a map', () => { + compare('c1'); + const onShowOnMap = vi.fn(); + const first = renderCard([receipt('c1', null), receipt('c2', null)], { onShowOnMap }); + expect(screen.getByRole('button', { name: 'After' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Before' })); + expect(setCompare).toHaveBeenCalledWith(TRIP, 'before'); + first.unmount(); + + const other = renderCard([receipt('c7', null), receipt('c8', null)], { onShowOnMap }); + expect(screen.queryByRole('button', { name: 'Before' })).toBeNull(); + other.unmount(); + + renderCard([receipt('c1', null), receipt('c2', null)]); + expect(screen.queryByRole('button', { name: 'Before' })).toBeNull(); + }); + + it('FE-AI-CARD-013: undo all takes back what still can be, and says how it went', async () => { + const ids = ['c1', 'c2', 'c3']; + const items = () => [receipt('c1', future()), receipt('c2', future()), receipt('c3', null)]; + const press = async () => { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Undo all' })); + }); + }; + + undoBlock.mockResolvedValueOnce({ done: 2, stale: 0, failed: 0 }); + const first = renderCard(items()); + await press(); + expect(undoBlock).toHaveBeenCalledWith(TRIP, ids.slice(0, 2)); + expect(addToast).toHaveBeenLastCalledWith('2 changes undone', 'success', undefined); + + undoBlock.mockResolvedValueOnce({ done: 1, stale: 1, failed: 0 }); + await press(); + expect(addToast).toHaveBeenLastCalledWith('1 undone, 1 changed since', 'warning', undefined); + + undoBlock.mockResolvedValueOnce({ done: 0, stale: 0, failed: 2 }); + await press(); + expect(addToast).toHaveBeenLastCalledWith('2 changes could not be undone', 'error', undefined); + first.unmount(); + + // One left to take back is the single undo button's job. + patchChat({ undone: { c2: true } }); + renderCard(items()); + expect(screen.queryByRole('button', { name: 'Undo all' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument(); + }); +}); + +describe('AssistantCompareSwitch', () => { + it('follows whichever block is compared when it names none', () => { + patchChat({ compare: { blockKey: 'c5', mode: 'before', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] } }); + render(); + expect(screen.getByRole('button', { name: 'Before' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'After' })); + expect(setCompare).toHaveBeenCalledWith(TRIP, 'after'); + }); + + it("gives each side a thumb's room when tall", () => { + patchChat({ compare: { blockKey: 'c5', mode: 'after', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] } }); + render(); + expect(screen.getByRole('button', { name: 'Before' }).className).toContain('h-9'); + expect(screen.getByRole('button', { name: 'After' }).className).toContain('min-w-[44px]'); + }); +}); diff --git a/client/src/components/Assistant/AssistantChangeCard.tsx b/client/src/components/Assistant/AssistantChangeCard.tsx new file mode 100644 index 000000000..a6a0ee181 --- /dev/null +++ b/client/src/components/Assistant/AssistantChangeCard.tsx @@ -0,0 +1,474 @@ +import type { AssistantChangePreview, AssistantChangeRow, AssistantChangeVerbKey } from '@trek/shared'; +import { + ArrowRight, + CalendarArrowUp, + CalendarMinus, + CalendarPlus, + Check, + CircleSlash, + ListTodo, + LoaderCircle, + Luggage, + MapPin, + MapPinPlus, + PencilLine, + StickyNote, + Ticket, + TriangleAlert, + Undo2, + Wallet, +} from 'lucide-react'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; + +import { useTranslation } from '../../i18n'; +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 AssistantCompareSwitch from './AssistantCompareSwitch'; +import AssistantDayBand from './AssistantDayBand'; +import { CHANGE_LABELS, type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts'; +import { useAssistantView } from './assistantViewContext'; +import { changePoints, type DayChange } from './dayAfterModel'; +import { useChangeHover } from './useChangeHover'; + +/** + * The changes the assistant proposes in one go, as one card in plain words: + * what happens to which place, note or item, field by field. Nothing is + * written before the user applies it. Each change keeps its own decision, so + * when the card holds several, each has a switch and the button applies the + * ones left on. Once decided, the card turns into a receipt per change, with + * a way to the change on the map and, for a while, undo. + * + * Every change wears its kind: a tinted tile with the icon of what it touches + * and a badge naming the verb, in the tone of what happens (green adds, blue + * changes, amber moves, red takes away). Its fields sit as badges holding a + * label badge, so a row of them reads at a glance. + * + * While it waits, the card shows the days it touches as they would read + * afterwards, and each row lights its pin on the map (and the pin its row). + * Once applied, the head offers the plan before and after the block on the + * map, and taking the whole block back at once. + */ + +type Tone = 'success' | 'info' | 'warning' | 'danger'; + +const TILE: Record = { + success: 'bg-success-soft text-success', + info: 'bg-info-soft text-info', + warning: 'bg-warning-soft text-warning', + danger: 'bg-danger-soft text-danger', +}; + +/** What each kind of change looks like. A table, so a new verb fails to compile until it has a look. */ +const VERB_LOOK: Record = { + addPlace: { Icon: MapPinPlus, tone: 'success' }, + addPlaceToDay: { Icon: CalendarPlus, tone: 'success' }, + updatePlace: { Icon: PencilLine, tone: 'info' }, + assignPlace: { Icon: CalendarPlus, tone: 'success' }, + moveAssignment: { Icon: CalendarArrowUp, tone: 'warning' }, + removeFromDay: { Icon: CalendarMinus, tone: 'danger' }, + addDayNote: { Icon: StickyNote, tone: 'success' }, + updateDayNote: { Icon: StickyNote, tone: 'info' }, + addPackingItem: { Icon: Luggage, tone: 'success' }, + updatePackingItem: { Icon: Luggage, tone: 'info' }, + addTodo: { Icon: ListTodo, tone: 'success' }, + addBudgetItem: { Icon: Wallet, tone: 'success' }, + addReservation: { Icon: Ticket, tone: 'success' }, + importBooking: { Icon: Ticket, tone: 'success' }, +}; + +/** How a decided change reads: one quiet mark, its words in the tooltip. */ +const RECEIPT: Record, { labelKey: string; Icon: typeof Check; tone: string }> = { + applying: { labelKey: 'assistant.change.applying', Icon: LoaderCircle, tone: 'text-content-muted' }, + applied: { labelKey: 'assistant.change.applied', Icon: Check, tone: 'text-success' }, + failed: { labelKey: 'assistant.change.failed', Icon: TriangleAlert, tone: 'text-danger' }, + discarded: { labelKey: 'assistant.change.discarded', Icon: CircleSlash, tone: 'text-content-faint' }, + expired: { labelKey: 'assistant.change.expired', Icon: CircleSlash, tone: 'text-content-faint' }, +}; + +const ICON_BUTTON = + 'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-50'; + +function KindTile({ + verbKey, + size = 'md', + dimmed = false, +}: { + verbKey: AssistantChangeVerbKey | null; + size?: 'sm' | 'md'; + dimmed?: boolean; +}) { + const box = size === 'sm' ? 'h-7 w-7 rounded-md' : 'h-8 w-8 rounded-lg'; + if (!verbKey) return