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