mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
Compare commits
20 Commits
dev
...
feat/ai-assistant
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a8ce4013b | |||
| 4abab7a777 | |||
| f8dc7ad011 | |||
| 680c52dc85 | |||
| e3d40ddcf6 | |||
| 00b50e280f | |||
| f8e29dd218 | |||
| 2fbff51299 | |||
| 55c6bad295 | |||
| 858c77422c | |||
| e5f1f573c5 | |||
| b721234249 | |||
| a1f97ba548 | |||
| 77ad0a06d0 | |||
| ea7120e141 | |||
| 7e563e1170 | |||
| 3c3a228737 | |||
| b6f7488591 | |||
| 26df56d76c | |||
| 214984f398 |
@@ -46,6 +46,7 @@
|
|||||||
"predev": "node scripts/patch-maplibre.mjs"
|
"predev": "node scripts/patch-maplibre.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@assistant-ui/react": "0.15.25",
|
||||||
"@fontsource/bebas-neue": "^5.3.0",
|
"@fontsource/bebas-neue": "^5.3.0",
|
||||||
"@fontsource/eb-garamond": "^5.3.0",
|
"@fontsource/eb-garamond": "^5.3.0",
|
||||||
"@fontsource/geist-sans": "^5.3.0",
|
"@fontsource/geist-sans": "^5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"src": {
|
"src": {
|
||||||
"@typescript-eslint/no-non-null-assertion": 404,
|
"@typescript-eslint/no-non-null-assertion": 404,
|
||||||
"@typescript-eslint/no-misused-promises": 874,
|
"@typescript-eslint/no-misused-promises": 870,
|
||||||
"no-console": 88,
|
"no-console": 88,
|
||||||
"complexity": 230,
|
"complexity": 230,
|
||||||
"@typescript-eslint/no-explicit-any": 147,
|
"@typescript-eslint/no-explicit-any": 147,
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
},
|
},
|
||||||
"tests": {
|
"tests": {
|
||||||
"@typescript-eslint/no-explicit-any": 491,
|
"@typescript-eslint/no-explicit-any": 491,
|
||||||
"@typescript-eslint/no-non-null-assertion": 1946,
|
"@typescript-eslint/no-non-null-assertion": 1945,
|
||||||
"@typescript-eslint/no-misused-promises": 22,
|
"@typescript-eslint/no-misused-promises": 22,
|
||||||
"@typescript-eslint/no-unused-vars": 51,
|
"@typescript-eslint/no-unused-vars": 51,
|
||||||
"@typescript-eslint/no-floating-promises": 33,
|
"@typescript-eslint/no-floating-promises": 33,
|
||||||
|
|||||||
@@ -16,12 +16,17 @@
|
|||||||
"feature": "Addon manager",
|
"feature": "Addon manager",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
"src/components/Admin/addons/addonModel.ts",
|
"src/components/Admin/addons/addonModel.ts",
|
||||||
"src/components/Admin/addons/useAddonManager.ts",
|
"src/components/Admin/addons/useAddonManager.ts"
|
||||||
"src/components/Admin/addons/useLlmParsingConfig.ts"
|
|
||||||
],
|
],
|
||||||
"desktop": ["src/components/Admin/AddonManager.tsx"],
|
"desktop": ["src/components/Admin/AddonManager.tsx"],
|
||||||
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"feature": "Addon manager: AI tile",
|
||||||
|
"hooks": ["src/components/Admin/addons/useAiAddonTile.ts"],
|
||||||
|
"desktop": ["src/components/Admin/addons/AiAddonTile.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"feature": "Admin notification preference matrix",
|
"feature": "Admin notification preference matrix",
|
||||||
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
|
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
|
||||||
@@ -58,6 +63,21 @@
|
|||||||
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
|
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
|
||||||
"mobile": ["src/mobile/screens/admin/MAdminUsersSection.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",
|
"feature": "Atlas country/region action sheet",
|
||||||
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
|
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
|
||||||
@@ -652,6 +672,12 @@
|
|||||||
"desktop": ["src/components/Settings/DisplaySettingsTab.tsx"],
|
"desktop": ["src/components/Settings/DisplaySettingsTab.tsx"],
|
||||||
"mobile": ["src/mobile/screens/settings/MSettingsGeneral.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",
|
"feature": "Settings / Integrations / AI parsing",
|
||||||
"hooks": ["src/components/Settings/useLlmConnection.ts"],
|
"hooks": ["src/components/Settings/useLlmConnection.ts"],
|
||||||
@@ -952,7 +978,6 @@
|
|||||||
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
|
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
|
||||||
"src/mobile/screens/trip/sheets/MImportSheet.tsx": "Menu that routes to ImpFileStep and ImpListStep, with no logic of its own.",
|
"src/mobile/screens/trip/sheets/MImportSheet.tsx": "Menu that routes to ImpFileStep and ImpListStep, with no logic of its own.",
|
||||||
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
|
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
|
||||||
"src/mobile/screens/trip/sheets/MMehrSheet.tsx": "Phone dock overflow menu that only opens tabs and sheets.",
|
|
||||||
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
|
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
|
||||||
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
|
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
|
||||||
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
|
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
|
||||||
|
|||||||
@@ -729,8 +729,6 @@
|
|||||||
"src/components/Planner/parsedItemToDraft.ts",
|
"src/components/Planner/parsedItemToDraft.ts",
|
||||||
"src/components/Planner/placeAmap.test.ts",
|
"src/components/Planner/placeAmap.test.ts",
|
||||||
"src/components/Planner/placeAmap.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.test.ts",
|
||||||
"src/components/Planner/placeCoMaps.ts",
|
"src/components/Planner/placeCoMaps.ts",
|
||||||
"src/components/Planner/placeDialogParts.test.tsx",
|
"src/components/Planner/placeDialogParts.test.tsx",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"planner.ts": 53,
|
"planner.ts": 53,
|
||||||
"reservations.ts": 21,
|
"reservations.ts": 21,
|
||||||
"roadtrip.ts": 38,
|
"roadtrip.ts": 38,
|
||||||
"settings.ts": 40,
|
"settings.ts": 37,
|
||||||
"share.ts": 7,
|
"share.ts": 7,
|
||||||
"shared.ts": 2,
|
"shared.ts": 2,
|
||||||
"storage.ts": 16,
|
"storage.ts": 16,
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
"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.test.tsx": 2920,
|
||||||
"src/components/Map/MapViewGL.tsx": 2293,
|
"src/components/Map/MapViewGL.tsx": 2291,
|
||||||
"src/components/Map/RouteCalculator.ts": 1195,
|
"src/components/Map/RouteCalculator.ts": 1195,
|
||||||
"src/components/PDF/TripPDF.tsx": 1082,
|
"src/components/PDF/TripPDF.tsx": 1082,
|
||||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||||
|
|||||||
@@ -148,7 +148,6 @@
|
|||||||
"tests/integration/api/client.test.ts": 1,
|
"tests/integration/api/client.test.ts": 1,
|
||||||
"tests/integration/hooks/useRouteCalculation.test.ts": 1,
|
"tests/integration/hooks/useRouteCalculation.test.ts": 1,
|
||||||
"tests/unit/i18n/i18nKeysScript.test.ts": 3,
|
"tests/unit/i18n/i18nKeysScript.test.ts": 3,
|
||||||
"tests/unit/mobile/admin/MAdminAddonManager.test.tsx": 2,
|
|
||||||
"tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1,
|
"tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1,
|
||||||
"tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1,
|
"tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1,
|
||||||
"tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1,
|
"tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1,
|
||||||
|
|||||||
@@ -156,7 +156,7 @@
|
|||||||
"mobile/components/MToastHost.tsx": 2,
|
"mobile/components/MToastHost.tsx": 2,
|
||||||
"mobile/components/MUserPickerList.tsx": 1,
|
"mobile/components/MUserPickerList.tsx": 1,
|
||||||
"mobile/screens/admin/MAdmin.tsx": 9,
|
"mobile/screens/admin/MAdmin.tsx": 9,
|
||||||
"mobile/screens/admin/MAdminAddonManager.tsx": 26,
|
"mobile/screens/admin/MAdminAddonManager.tsx": 13,
|
||||||
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
|
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
|
||||||
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
|
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
|
||||||
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
|
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
|
|||||||
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
|
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
|
||||||
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
|
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
|
||||||
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
|
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
|
// 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
|
// rather than inside them. Each page used to import its M screen statically and
|
||||||
@@ -403,6 +405,12 @@ export default function App() {
|
|||||||
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
||||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||||
|
{/* The phone has its own assistant screen inside the trip. */}
|
||||||
|
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
|
||||||
|
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
|
||||||
|
<Suspense fallback={null}><AssistantHost /></Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)}
|
||||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||||
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
||||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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<string> = 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<AssistantErrorCode> {
|
||||||
|
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<AsyncGenerator<UiStreamChunk>> {
|
||||||
|
// A file goes as multipart, where the browser sets the content type with its boundary.
|
||||||
|
const form = body instanceof FormData;
|
||||||
|
const headers: Record<string, string> = 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<AssistantThreadListResponse> =>
|
||||||
|
apiClient.get(`${tripBase(tripId)}/threads`).then((r) => r.data),
|
||||||
|
thread: (tripId: number, threadId: number): Promise<AssistantThreadResponse> =>
|
||||||
|
apiClient.get(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
|
||||||
|
deleteThread: (tripId: number, threadId: number): Promise<SuccessResponse> =>
|
||||||
|
apiClient.delete(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
|
||||||
|
undo: (tripId: number, toolCallId: string): Promise<AssistantUndoResponse> =>
|
||||||
|
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<SuccessResponse> =>
|
||||||
|
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<AssistantAddFoundPlaceResponse> =>
|
||||||
|
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<CollabMessageResponse> =>
|
||||||
|
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<AiStatusResponse> => apiClient.get('/ai/status').then((r) => r.data),
|
||||||
|
models: (): Promise<AiModelListResponse> => apiClient.get('/ai/models', viaProvider).then((r) => r.data),
|
||||||
|
// Saving tests the key against the provider first.
|
||||||
|
connectKey: (body: AiApiKeyConnectRequest): Promise<AiConnectionView> =>
|
||||||
|
apiClient.put('/ai/connection', body, viaProvider).then((r) => r.data),
|
||||||
|
setModel: (model: string): Promise<AiConnectionView> =>
|
||||||
|
apiClient.patch('/ai/connection/model', { model }).then((r) => r.data),
|
||||||
|
disconnect: (): Promise<SuccessResponse> => apiClient.delete('/ai/connection').then((r) => r.data),
|
||||||
|
ackPrivacy: (body: AiPrivacyAckRequest): Promise<SuccessResponse> =>
|
||||||
|
apiClient.post('/ai/privacy-ack', body).then((r) => r.data),
|
||||||
|
|
||||||
|
chatgptStart: (scope: AiConnectionScope = 'user'): Promise<AiAuthStartResponse> =>
|
||||||
|
apiClient
|
||||||
|
.post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {})
|
||||||
|
.then((r) => r.data),
|
||||||
|
chatgptFinish: (body: AiChatgptFinishRequest): Promise<AiConnectionView> =>
|
||||||
|
apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data),
|
||||||
|
chatgptSignOut: (): Promise<SuccessResponse> =>
|
||||||
|
apiClient.post('/ai/connect/chatgpt/sign-out', {}).then((r) => r.data),
|
||||||
|
|
||||||
|
openrouterStart: (scope: AiConnectionScope): Promise<AiAuthStartResponse> =>
|
||||||
|
apiClient
|
||||||
|
.post(scope === 'instance' ? '/admin/ai/connect/openrouter/start' : '/ai/connect/openrouter/start', { scope })
|
||||||
|
.then((r) => r.data),
|
||||||
|
openrouterFinish: (body: AiOpenrouterFinishRequest): Promise<AiConnectionView> =>
|
||||||
|
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<AiAdminSettings> => apiClient.get('/admin/ai/settings').then((r) => r.data),
|
||||||
|
saveSettings: (body: AiAdminSettings): Promise<AiAdminSettings> =>
|
||||||
|
apiClient.put('/admin/ai/settings', body).then((r) => r.data),
|
||||||
|
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
|
||||||
|
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
|
||||||
|
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
|
||||||
|
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
||||||
|
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const claudeConnectorApi = {
|
||||||
|
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -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<Uint8Array>;
|
||||||
|
return { body, cancel };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(body: ReadableStream<Uint8Array>): Promise<UiStreamChunk[]> {
|
||||||
|
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<Uint8Array>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: <json>`
|
||||||
|
* 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<Uint8Array>) {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const stalled = new Promise<never>((_, 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<Uint8Array>): AsyncGenerator<UiStreamChunk> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { delay, http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../../../tests/helpers/msw/server';
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
@@ -24,23 +24,20 @@ function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
|
|||||||
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
|
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function llmAddon(config: Record<string, unknown> = {}) {
|
function llmAddon(config: Record<string, unknown> = {}, enabled = true) {
|
||||||
return buildAddon({
|
return buildAddon({
|
||||||
id: 'llm_parsing',
|
id: 'llm_parsing',
|
||||||
name: 'AI Parsing',
|
name: 'AI Parsing',
|
||||||
description: 'Extract bookings from files',
|
description: 'Extract bookings from files',
|
||||||
icon: 'Sparkles',
|
icon: 'Sparkles',
|
||||||
type: 'integration',
|
type: 'integration',
|
||||||
enabled: true,
|
enabled,
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
function aiAddon(id: string, name: string, enabled: boolean) {
|
||||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
return buildAddon({ id, name, icon: id === 'mcp' ? 'Terminal' : 'Sparkles', type: 'integration', enabled });
|
||||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
|
||||||
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every switch is labelled with the thing it controls, top-level tile or shelf
|
/** Every switch is labelled with the thing it controls, top-level tile or shelf
|
||||||
@@ -576,286 +573,55 @@ describe('AddonManager', () => {
|
|||||||
expect(links.getAttribute('title')).not.toBe('No shared links yet');
|
expect(links.getAttribute('title')).not.toBe('No shared links yet');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
|
it('FE-ADMIN-ADDON-020: the AI addons head the integration column as one tile with a switch each', async () => {
|
||||||
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
|
|
||||||
render(<AddonManager />);
|
|
||||||
|
|
||||||
await screen.findByText('AI Parsing');
|
|
||||||
// The catalog key now exists, so the tile shows the translation rather than falling
|
|
||||||
// back to the English description the server sent.
|
|
||||||
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
|
|
||||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
|
|
||||||
render(<AddonManager />);
|
|
||||||
|
|
||||||
const label = await screen.findByText('Provider');
|
|
||||||
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
|
|
||||||
const tile = label.closest('article');
|
|
||||||
expect(tile).not.toBeNull();
|
|
||||||
expect(tile!.textContent).toContain('AI Parsing');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const urls: (string | null)[] = [];
|
|
||||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
|
||||||
render(<AddonManager />);
|
|
||||||
|
|
||||||
await screen.findByText('Installed on the server');
|
|
||||||
await screen.findByRole('button', { name: 'llama3:8b' });
|
|
||||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
|
||||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
|
||||||
|
|
||||||
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
|
||||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
|
||||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
let calls = 0;
|
|
||||||
server.use(
|
server.use(
|
||||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
addonsRoute([
|
||||||
http.get('/api/admin/llm/local/models', () => {
|
aiAddon('mcp', 'MCP', false),
|
||||||
calls += 1;
|
llmAddon({}, false),
|
||||||
return calls === 1
|
aiAddon('ai_assistant', 'AI Assistant', false),
|
||||||
? HttpResponse.json({ error: 'down' }, { status: 500 })
|
buildAddon({ id: 'airtrail', name: 'AirTrail', type: 'integration', icon: 'Plane' }),
|
||||||
: HttpResponse.json({ models: [] });
|
]),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
render(<AddonManager />);
|
render(<AddonManager />);
|
||||||
|
|
||||||
// The server's reason is shown, not axios' status line (#2649)
|
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
|
||||||
await screen.findByText('down');
|
const switches = within(tile).getAllByRole('button').map((b) => b.getAttribute('aria-label'));
|
||||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
// In the tile's order, whatever order the server sent them in.
|
||||||
|
expect(switches).toEqual(['AI Assistant', 'AI Parsing', 'MCP']);
|
||||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
expect(within(tile).getByText('0/3')).toBeInTheDocument();
|
||||||
await screen.findByText('No models installed yet — pull one below.');
|
// Their own icons, keyed by id: the icon column of an install still says Sparkles or Terminal.
|
||||||
expect(calls).toBe(2);
|
expect(tile.querySelector('svg.lucide-messages-square')).toBeInTheDocument();
|
||||||
|
expect(tile.querySelector('svg.lucide-scan-text')).toBeInTheDocument();
|
||||||
|
expect(tile.querySelector('svg.lucide-cable')).toBeInTheDocument();
|
||||||
|
// Nothing to set up while neither the assistant nor parsing is on.
|
||||||
|
expect(within(tile).queryByRole('button', { name: 'Set up AI' })).toBeNull();
|
||||||
|
// AirTrail is still its own tile in the same column.
|
||||||
|
expect(screen.getByText('AirTrail').closest('article')).not.toBe(tile);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
|
it('FE-ADMIN-ADDON-021: with parsing on, the tile offers the settings dialog, which closes again', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const urls: (string | null)[] = [];
|
server.use(
|
||||||
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
|
addonsRoute([llmAddon({}), aiAddon('ai_assistant', 'AI Assistant', false), aiAddon('mcp', 'MCP', true)]),
|
||||||
|
http.get('/api/admin/ai/settings', () =>
|
||||||
|
HttpResponse.json({ policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }),
|
||||||
|
),
|
||||||
|
http.get('/api/admin/ai/connection', () => HttpResponse.json({ connection: null, prefill: null })),
|
||||||
|
http.get('/api/ai/status', () => HttpResponse.json({ error: 'not needed' }, { status: 404 })),
|
||||||
|
);
|
||||||
render(<AddonManager />);
|
render(<AddonManager />);
|
||||||
|
|
||||||
await screen.findByText('Installed on the server');
|
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
|
||||||
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
expect(within(tile).getByText('2/3')).toBeInTheDocument();
|
||||||
|
await user.click(within(tile).getByRole('button', { name: 'Set up AI' }));
|
||||||
// A hand-typed base URL is used for the next lookup on blur
|
const dialog = await screen.findByRole('dialog');
|
||||||
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
|
expect(within(dialog).getByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
||||||
await user.tab();
|
expect(within(dialog).queryByRole('button', { name: 'AI Assistant' })).toBeNull();
|
||||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
const closes = within(dialog).getAllByRole('button', { name: 'Close' });
|
||||||
|
await user.click(closes[closes.length - 1]);
|
||||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
|
||||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
|
||||||
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
|
||||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
|
||||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
|
||||||
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
let pulled: unknown = null;
|
|
||||||
let modelCalls = 0;
|
|
||||||
server.use(
|
|
||||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
|
||||||
http.get('/api/admin/llm/local/models', () => {
|
|
||||||
modelCalls += 1;
|
|
||||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
|
||||||
}),
|
|
||||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
|
||||||
pulled = await request.json();
|
|
||||||
await delay(150);
|
|
||||||
return new HttpResponse(
|
|
||||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
|
||||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
render(<><ToastContainer /><AddonManager /></>);
|
|
||||||
|
|
||||||
await screen.findByText('No models installed yet — pull one below.');
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
|
||||||
|
|
||||||
await screen.findByText('Pulling…');
|
|
||||||
expect(screen.getByText('starting…')).toBeInTheDocument();
|
|
||||||
|
|
||||||
await screen.findByText('Model pulled');
|
|
||||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
|
||||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const bodies: unknown[] = [];
|
|
||||||
server.use(
|
|
||||||
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
|
|
||||||
modelsRoute([]),
|
|
||||||
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
|
|
||||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
|
||||||
bodies.push(await request.json());
|
|
||||||
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
render(<><ToastContainer /><AddonManager /></>);
|
|
||||||
|
|
||||||
await screen.findByText('No models installed yet — pull one below.');
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
|
||||||
await screen.findByText('no disk space');
|
|
||||||
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
||||||
await screen.findByText('Saved');
|
|
||||||
expect(bodies[0]).toEqual({
|
|
||||||
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
||||||
await screen.findByText('Failed to save');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const bodies: unknown[] = [];
|
|
||||||
server.use(
|
|
||||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
|
||||||
modelsRoute([]),
|
|
||||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
|
||||||
bodies.push(await request.json());
|
|
||||||
return HttpResponse.json({ success: true });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
render(<><ToastContainer /><AddonManager /></>);
|
|
||||||
|
|
||||||
await screen.findByText('Installed on the server');
|
|
||||||
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
|
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
|
|
||||||
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
|
||||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
|
||||||
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
||||||
await screen.findByText('Saved');
|
|
||||||
// The model is trimmed before it is stored, the key is sent verbatim
|
|
||||||
expect(bodies[0]).toEqual({
|
|
||||||
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', vision: 'auto' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const bodies: unknown[] = [];
|
|
||||||
server.use(
|
|
||||||
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
|
|
||||||
modelsRoute([]),
|
|
||||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
|
||||||
bodies.push(await request.json());
|
|
||||||
return HttpResponse.json({ success: true });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
render(<><ToastContainer /><AddonManager /></>);
|
|
||||||
|
|
||||||
await screen.findByText('Installed on the server');
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
|
||||||
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
|
|
||||||
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
||||||
await screen.findByText('Saved');
|
|
||||||
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
|
|
||||||
expect(bodies[0]).toEqual({
|
|
||||||
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-038: whether the model reads images is auto, on or off, kept as stored and saved as chosen', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const bodies: unknown[] = [];
|
|
||||||
server.use(
|
|
||||||
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'off' })]),
|
|
||||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
|
||||||
bodies.push(await request.json());
|
|
||||||
return HttpResponse.json({ success: true });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
render(<><ToastContainer /><AddonManager /></>);
|
|
||||||
|
|
||||||
// Stored off stays off; a cloud provider is told what Automatic means for it.
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'No' }));
|
|
||||||
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
||||||
await screen.findByText('Saved');
|
|
||||||
expect(bodies[0]).toMatchObject({ config: { provider: 'openai', vision: 'on' } });
|
|
||||||
expect(bodies[0]).not.toHaveProperty('config.multimodal');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => {
|
|
||||||
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
|
|
||||||
render(<AddonManager />);
|
|
||||||
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
server.use(
|
|
||||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
|
||||||
modelsRoute([]),
|
|
||||||
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
|
|
||||||
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
|
|
||||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
render(<><ToastContainer /><AddonManager /></>);
|
|
||||||
|
|
||||||
await screen.findByText('No models installed yet — pull one below.');
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
|
||||||
|
|
||||||
await screen.findByText('manifest not found');
|
|
||||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
|
||||||
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const urls: (string | null)[] = [];
|
|
||||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
|
|
||||||
render(<AddonManager />);
|
|
||||||
|
|
||||||
await screen.findByText('Provider');
|
|
||||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
|
|
||||||
await user.tab();
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
|
|
||||||
expect(urls).toHaveLength(0);
|
|
||||||
});
|
|
||||||
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
|
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
|
||||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||||
render(
|
render(
|
||||||
@@ -901,6 +667,10 @@ describe('AddonManager', () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Folded like Collab's and Documents' options until the bar is opened.
|
||||||
|
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
|
||||||
|
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
|
||||||
|
await unfoldShelves();
|
||||||
const toggle = await screen.findByRole('button', { name: 'wanderer' });
|
const toggle = await screen.findByRole('button', { name: 'wanderer' });
|
||||||
expect(isOn(toggle)).toBe(false);
|
expect(isOn(toggle)).toBe(false);
|
||||||
await userEvent.click(toggle);
|
await userEvent.click(toggle);
|
||||||
@@ -921,6 +691,7 @@ describe('AddonManager', () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await unfoldShelves();
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
|
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
|
||||||
|
|
||||||
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
|
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useId, type ComponentType } from 'react'
|
import type { ComponentType } from 'react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useIsDark } from '../../hooks/useIsDark'
|
import { useIsDark } from '../../hooks/useIsDark'
|
||||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
|
||||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
|
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import DawarichIcon from '../shared/DawarichIcon'
|
import DawarichIcon from '../shared/DawarichIcon'
|
||||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||||
@@ -12,11 +10,10 @@ import AddonTile from './AddonTile'
|
|||||||
import AddonSubRow from './AddonSubRow'
|
import AddonSubRow from './AddonSubRow'
|
||||||
import { Tooltip } from '../shared/Tooltip'
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
import { fs } from '../shared/DialogShell'
|
import { fs } from '../shared/DialogShell'
|
||||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
|
||||||
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
|
|
||||||
import { useAddonManager } from './addons/useAddonManager'
|
import { useAddonManager } from './addons/useAddonManager'
|
||||||
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
|
import AiAddonTile from './addons/AiAddonTile'
|
||||||
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
|
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
|
||||||
import WandererMark from '../Tours/wanderer/WandererMark'
|
import WandererMark from '../Tours/wanderer/WandererMark'
|
||||||
|
|
||||||
@@ -74,7 +71,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dark = useIsDark()
|
const dark = useIsDark()
|
||||||
const {
|
const {
|
||||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||||
toursWanderer,
|
toursWanderer,
|
||||||
toggleToursWanderer,
|
toggleToursWanderer,
|
||||||
mapSwitches,
|
mapSwitches,
|
||||||
@@ -154,33 +151,29 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// An array, not a fragment: the tile counts its rows to fold them behind the
|
||||||
|
// "N options" bar the way Collab's and Documents' are.
|
||||||
if (addon.id === 'tours' && addon.enabled) {
|
if (addon.id === 'tours' && addon.enabled) {
|
||||||
return (
|
return [
|
||||||
<>
|
<AddonSubRow
|
||||||
|
key="wanderer"
|
||||||
|
icon={<WandererMark size={14} />}
|
||||||
|
title={t('admin.toursWanderer.title')}
|
||||||
|
description={t('admin.toursWanderer.subtitle')}
|
||||||
|
enabled={toursWanderer}
|
||||||
|
onToggle={() => void toggleToursWanderer()}
|
||||||
|
/>,
|
||||||
|
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||||
<AddonSubRow
|
<AddonSubRow
|
||||||
icon={<WandererMark size={14} />}
|
key={key}
|
||||||
title={t('admin.toursWanderer.title')}
|
icon={<Icon size={14} />}
|
||||||
description={t('admin.toursWanderer.subtitle')}
|
title={t(titleKey)}
|
||||||
enabled={toursWanderer}
|
description={t(subtitleKey)}
|
||||||
onToggle={() => void toggleToursWanderer()}
|
enabled={enabled}
|
||||||
|
onToggle={() => void toggle()}
|
||||||
/>
|
/>
|
||||||
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
)),
|
||||||
<AddonSubRow
|
]
|
||||||
key={key}
|
|
||||||
icon={<Icon size={14} />}
|
|
||||||
title={t(titleKey)}
|
|
||||||
description={t(subtitleKey)}
|
|
||||||
enabled={enabled}
|
|
||||||
onToggle={() => void toggle()}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
|
||||||
// just as a form instead of toggle rows.
|
|
||||||
if (addon.id === 'llm_parsing' && addon.enabled) {
|
|
||||||
return <LlmParsingConfig addon={addon} />
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -209,9 +202,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
{ key: 'trip', addons: tripAddons },
|
{ key: 'trip', addons: tripAddons },
|
||||||
{ key: 'global', addons: globalAddons },
|
{ key: 'global', addons: globalAddons },
|
||||||
{ key: 'integration', addons: integrationAddons },
|
{ key: 'integration', addons: integrationAddons },
|
||||||
].filter(g => g.addons.length > 0)
|
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
|
||||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
||||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length
|
||||||
|
|
||||||
const subtitle = (
|
const subtitle = (
|
||||||
<span className="inline-flex flex-wrap items-center gap-1">
|
<span className="inline-flex flex-wrap items-center gap-1">
|
||||||
@@ -242,8 +235,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
const meta = TYPE_META[g.key]
|
const meta = TYPE_META[g.key]
|
||||||
return (
|
return (
|
||||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
|
||||||
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
|
<div className="divide-y divide-edge-faint">
|
||||||
|
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
|
||||||
|
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||||
|
{g.addons.map(tile)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -282,140 +279,3 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
|
||||||
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
|
||||||
* overrides per-user config (see server llmConfig.ts). The API key is masked on
|
|
||||||
* read; an unchanged mask is treated as a no-op by the server. For the local
|
|
||||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
|
||||||
*/
|
|
||||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const {
|
|
||||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
|
|
||||||
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
|
|
||||||
} = useLlmParsingConfig(addon)
|
|
||||||
const fieldId = useId()
|
|
||||||
|
|
||||||
const providerOptions = [
|
|
||||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
|
||||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
|
||||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
|
||||||
]
|
|
||||||
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
|
|
||||||
|
|
||||||
/* Lives in the tile's shelf like the collab toggles, so it is one column of
|
|
||||||
eyebrow fields: the band this used to be had a whole page width. */
|
|
||||||
return (
|
|
||||||
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
|
|
||||||
<SettingsHint>
|
|
||||||
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
|
|
||||||
</SettingsHint>
|
|
||||||
|
|
||||||
<EditorField label="Provider">
|
|
||||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
|
||||||
</EditorField>
|
|
||||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
|
||||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
|
|
||||||
</EditorField>
|
|
||||||
<EditorField
|
|
||||||
label="API key"
|
|
||||||
htmlFor={`${fieldId}-key`}
|
|
||||||
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
|
|
||||||
>
|
|
||||||
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
|
||||||
</EditorField>
|
|
||||||
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
|
|
||||||
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
|
||||||
</EditorField>
|
|
||||||
|
|
||||||
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
|
|
||||||
<Segmented<LlmVision>
|
|
||||||
label={t('settings.aiParsing.multimodal')}
|
|
||||||
value={vision}
|
|
||||||
onChange={v => setVision(asLlmVision(v))}
|
|
||||||
options={visionOptions}
|
|
||||||
fill
|
|
||||||
/>
|
|
||||||
</EditorField>
|
|
||||||
|
|
||||||
{/* Local model management (Ollama) */}
|
|
||||||
{provider === 'local' && (
|
|
||||||
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
|
|
||||||
<button type="button" onClick={loadModels} disabled={loadingModels}
|
|
||||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
|
|
||||||
style={fs(11.5, 'body')}>
|
|
||||||
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
|
|
||||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
|
|
||||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
|
||||||
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
|
|
||||||
)}
|
|
||||||
{installed.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{installed.map(name => (
|
|
||||||
<button type="button"
|
|
||||||
key={name}
|
|
||||||
title={name}
|
|
||||||
onClick={() => setModel(name)}
|
|
||||||
aria-pressed={model === name}
|
|
||||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
|
||||||
style={fs(11.5)}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
|
||||||
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
|
|
||||||
{RECOMMENDED_MODELS.map(m => {
|
|
||||||
const installedHere = isInstalled(m.id)
|
|
||||||
const isPulling = pulling === m.id
|
|
||||||
const active = model === m.id
|
|
||||||
return (
|
|
||||||
<div key={m.id} className="min-w-0" title={m.note}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
|
|
||||||
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
|
|
||||||
{installedHere ? (
|
|
||||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
|
|
||||||
{active ? 'Selected' : 'Use'}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
|
|
||||||
{isPulling ? 'Pulling…' : 'Pull'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isPulling && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
|
||||||
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { ChevronRight, Settings2, Sparkles } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { StatusPill } from '../../Settings/settingsKit';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import AddonSubRow from '../AddonSubRow';
|
||||||
|
import { type Addon, getAddonLabel } from './addonModel';
|
||||||
|
import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons';
|
||||||
|
import AiSettingsDialog from './AiAdminConfig';
|
||||||
|
import { useAiAddonTile } from './useAiAddonTile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AI addons as one tile at the head of the integration column: the
|
||||||
|
* assistant, AI parsing and MCP as switches in its shelf, and one button to
|
||||||
|
* the dialog that holds their settings, so a page with all three on stays as
|
||||||
|
* short as one with none.
|
||||||
|
*/
|
||||||
|
export default function AiAddonTile({
|
||||||
|
addons,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
addons: Addon[];
|
||||||
|
onToggle: (addon: Addon) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tile = useAiAddonTile(addons);
|
||||||
|
const anyOn = tile.enabledCount > 0;
|
||||||
|
return (
|
||||||
|
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] transition-colors ${
|
||||||
|
anyOn
|
||||||
|
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
|
||||||
|
: 'bg-surface-tertiary text-content-faint'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pt-px">
|
||||||
|
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||||
|
{t('admin.addons.ai.name')}
|
||||||
|
</h4>
|
||||||
|
<p
|
||||||
|
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${anyOn ? 'text-content-muted' : 'text-content-faint'}`}
|
||||||
|
style={fs(11.5)}
|
||||||
|
>
|
||||||
|
{t('admin.addons.ai.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-none pt-1">
|
||||||
|
<StatusPill tone={anyOn ? 'success' : 'neutral'}>
|
||||||
|
{tile.enabledCount}/{addons.length}
|
||||||
|
</StatusPill>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||||
|
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
|
||||||
|
{addons.map((addon) => {
|
||||||
|
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
|
||||||
|
const label = getAddonLabel(t, addon);
|
||||||
|
return (
|
||||||
|
<AddonSubRow
|
||||||
|
key={addon.id}
|
||||||
|
icon={<Icon size={14} />}
|
||||||
|
title={label.name}
|
||||||
|
description={label.description}
|
||||||
|
enabled={addon.enabled}
|
||||||
|
onToggle={() => onToggle(addon)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{tile.canSetUp && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={tile.openDialog}
|
||||||
|
className="flex min-h-[44px] w-full items-center gap-2.5 border-t border-edge-faint px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
|
||||||
|
<Settings2 size={14} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||||
|
{t('admin.addons.ai.setup')}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile.
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||||
|
import { delay, http, HttpResponse } from 'msw';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { server } from '../../../../tests/helpers/msw/server';
|
||||||
|
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||||
|
import { useAssistantStore } from '../../../store/assistantStore';
|
||||||
|
import { ToastContainer } from '../../shared/Toast';
|
||||||
|
import type { Addon } from './addonModel';
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({
|
||||||
|
status: vi.fn(),
|
||||||
|
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: AiSettingsDialog } = 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 addon(id: string, enabled: boolean, config?: Record<string, unknown>): Addon {
|
||||||
|
return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSISTANT = addon('ai_assistant', true);
|
||||||
|
const parsing = (config: Record<string, unknown> = {}) => addon('llm_parsing', true, config);
|
||||||
|
|
||||||
|
function renderDialog(addons: Addon[] = [ASSISTANT]) {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<ToastContainer />
|
||||||
|
<AiSettingsDialog addons={addons} onClose={onClose} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return { onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dialog opened on AI parsing's tab. */
|
||||||
|
async function renderParsing(config: Record<string, unknown> = {}) {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog([ASSISTANT, parsing(config)]);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'AI Parsing' }));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||||
|
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||||
|
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||||
|
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRoute(bodies: unknown[], fail = false) {
|
||||||
|
return http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||||
|
bodies.push(await request.json());
|
||||||
|
return fail ? HttpResponse.error() : HttpResponse.json({ success: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
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('the connection and the assistant', () => {
|
||||||
|
it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
|
||||||
|
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
|
||||||
|
// The admin may share their own ChatGPT plan with everyone, so its tile is offered too.
|
||||||
|
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
||||||
|
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
|
||||||
|
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
|
||||||
|
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
|
||||||
|
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
||||||
|
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
repo.adminConnection.mockResolvedValue(LIVE);
|
||||||
|
renderDialog();
|
||||||
|
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 });
|
||||||
|
renderDialog();
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Remove connection' }));
|
||||||
|
expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument();
|
||||||
|
const buttons = screen.getAllByRole('button', { name: 'Remove connection' });
|
||||||
|
await user.click(buttons[buttons.length - 1]);
|
||||||
|
expect(await screen.findByText('No connection yet.')).toBeInTheDocument();
|
||||||
|
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]);
|
||||||
|
expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument();
|
||||||
|
const closes = screen.getAllByRole('button', { name: 'Close' });
|
||||||
|
await user.click(closes[closes.length - 1]);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AI parsing', () => {
|
||||||
|
it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => {
|
||||||
|
repo.adminConnection.mockResolvedValue(LIVE);
|
||||||
|
server.use(modelsRoute([]));
|
||||||
|
const user = await renderParsing({ vision: 'auto' });
|
||||||
|
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('gpt-5.5')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Use a different one' }));
|
||||||
|
expect(screen.getByLabelText('Model')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
|
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => {
|
||||||
|
const user = await renderParsing();
|
||||||
|
expect(
|
||||||
|
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// The link in the hint, not the tab of the same name above it.
|
||||||
|
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
|
||||||
|
await user.click(link as HTMLElement);
|
||||||
|
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => {
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
repo.adminConnection.mockResolvedValue(LIVE);
|
||||||
|
server.use(modelsRoute([]), saveRoute(bodies));
|
||||||
|
const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' });
|
||||||
|
expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('claude-x')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Use the shared connection' }));
|
||||||
|
await screen.findByText('AI settings saved');
|
||||||
|
expect(bodies[0]).toEqual({ config: { vision: 'on' } });
|
||||||
|
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => {
|
||||||
|
const urls: (string | null)[] = [];
|
||||||
|
server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||||
|
const user = await renderParsing({ provider: 'local', model: 'old' });
|
||||||
|
await screen.findByText('Installed on the server');
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'llama3:8b' }));
|
||||||
|
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||||
|
expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b');
|
||||||
|
// qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull.
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||||
|
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
||||||
|
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/llm/local/models', () => {
|
||||||
|
calls += 1;
|
||||||
|
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||||
|
await screen.findByText('down');
|
||||||
|
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||||
|
await screen.findByText('No models installed yet. Pull one below.');
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => {
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
server.use(modelsRoute([]), saveRoute(bodies));
|
||||||
|
const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' });
|
||||||
|
await screen.findByText('Installed on the server');
|
||||||
|
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
||||||
|
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue('');
|
||||||
|
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('radio', { name: 'Anthropic' }));
|
||||||
|
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||||
|
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||||
|
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||||
|
await user.clear(screen.getByLabelText('Model'));
|
||||||
|
await user.type(screen.getByLabelText('Model'), ' claude-haiku ');
|
||||||
|
await user.type(screen.getByLabelText('API key'), 'sk-ant-live');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
await screen.findByText('AI settings saved');
|
||||||
|
// The stale local address must not ride along to Anthropic, and the model is trimmed.
|
||||||
|
expect(bodies[0]).toEqual({
|
||||||
|
config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => {
|
||||||
|
const urls: (string | null)[] = [];
|
||||||
|
server.use(modelsRoute([], urls));
|
||||||
|
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||||
|
await screen.findByText('Installed on the server');
|
||||||
|
await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1');
|
||||||
|
await user.tab();
|
||||||
|
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||||
|
const before = urls.length;
|
||||||
|
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
||||||
|
await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1');
|
||||||
|
await user.tab();
|
||||||
|
expect(urls).toHaveLength(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => {
|
||||||
|
let pulled: unknown = null;
|
||||||
|
let modelCalls = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/llm/local/models', () => {
|
||||||
|
modelCalls += 1;
|
||||||
|
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||||
|
}),
|
||||||
|
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||||
|
pulled = await request.json();
|
||||||
|
await delay(150);
|
||||||
|
return new HttpResponse(
|
||||||
|
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||||
|
{ headers: { 'Content-Type': 'application/x-ndjson' } }
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||||
|
await screen.findByText('No models installed yet. Pull one below.');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||||
|
expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0);
|
||||||
|
await screen.findByText('Model pulled');
|
||||||
|
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||||
|
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => {
|
||||||
|
server.use(
|
||||||
|
modelsRoute([]),
|
||||||
|
http.post(
|
||||||
|
'/api/admin/llm/local/pull',
|
||||||
|
() =>
|
||||||
|
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
|
||||||
|
headers: { 'Content-Type': 'application/x-ndjson' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||||
|
await screen.findByText('No models installed yet. Pull one below.');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||||
|
await screen.findByText('manifest not found');
|
||||||
|
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => {
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
server.use(modelsRoute([]), saveRoute(bodies));
|
||||||
|
const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' });
|
||||||
|
await screen.findByText('Installed on the server');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
await screen.findByText('AI settings saved');
|
||||||
|
expect(bodies[0]).toEqual({
|
||||||
|
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
||||||
|
});
|
||||||
|
server.use(saveRoute(bodies, true));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
await screen.findByText('Could not save AI settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => {
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
server.use(saveRoute(bodies));
|
||||||
|
const user = await renderParsing({
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4.1-mini',
|
||||||
|
baseUrl: '',
|
||||||
|
apiKey: 'sk',
|
||||||
|
vision: 'off',
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
||||||
|
await screen.findByText('AI settings saved');
|
||||||
|
expect(bodies[0]).toEqual({
|
||||||
|
config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { Check, Loader2, Sparkles } from 'lucide-react';
|
||||||
|
import React, { useId, useState } from 'react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||||
|
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
|
||||||
|
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||||
|
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||||
|
import CustomSelect from '../../shared/CustomSelect';
|
||||||
|
import {
|
||||||
|
DialogButton,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogShell,
|
||||||
|
DialogTile,
|
||||||
|
FooterSpacer,
|
||||||
|
NEUTRAL_TINT,
|
||||||
|
fs,
|
||||||
|
} from '../../shared/DialogShell';
|
||||||
|
import { NumericInput } from '../../shared/NumericInput';
|
||||||
|
import { EditorField, INPUT, Segmented } from '../../shared/dialogParts';
|
||||||
|
import ParsingSettings from './ParsingSettings';
|
||||||
|
import type { Addon } from './addonModel';
|
||||||
|
import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig';
|
||||||
|
|
||||||
|
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className={EYEBROW} style={fs(9.5)}>
|
||||||
|
{t('assistant.admin.policy')}
|
||||||
|
</span>
|
||||||
|
<div role="radiogroup" aria-label={t('assistant.admin.policy')} className="flex flex-col gap-1.5">
|
||||||
|
{POLICY_CHOICES.map((choice) => {
|
||||||
|
const on = A.policy === choice.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={choice.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={on}
|
||||||
|
onClick={() => A.setPolicy(choice.value)}
|
||||||
|
className={`flex items-center gap-2 rounded-[10px] border px-3 py-2 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`min-w-0 flex-1 font-medium ${on ? 'text-content' : 'text-content-muted'}`}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
{t(choice.labelKey)}
|
||||||
|
</span>
|
||||||
|
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-content" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{A.policyHintKey && <SettingsHint>{t(A.policyHintKey)}</SettingsHint>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-3 rounded-[12px] border border-edge-faint bg-surface-card p-3 ${busy ? 'opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<ProviderMark kind={live.kind} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||||
|
{provider}
|
||||||
|
</p>
|
||||||
|
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
|
||||||
|
{live.hint ?? live.host}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill tone={status.tone}>{t(status.labelKey)}</StatusPill>
|
||||||
|
</div>
|
||||||
|
{live.status === 'ok' && (
|
||||||
|
<EditorField label={t('assistant.connect.model')} hint={t('assistant.connect.modelHint')}>
|
||||||
|
<CustomSelect
|
||||||
|
ariaLabel={t('assistant.connect.model')}
|
||||||
|
value={live.model}
|
||||||
|
onChange={(v) => setModel(String(v))}
|
||||||
|
options={models}
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
searchable={models.length > 8}
|
||||||
|
/>
|
||||||
|
</EditorField>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
|
||||||
|
<button type="button" onClick={replace} disabled={busy} className={SETTINGS_BUTTON}>
|
||||||
|
{t('common.change')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={askRemove} disabled={busy} className={SETTINGS_BUTTON_DANGER}>
|
||||||
|
{t('assistant.admin.connectionRemove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstanceConnection({ A }: { A: AiAdmin }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { live, connection, replacing, cancelReplace, onConnected } = A.instance;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<span className={EYEBROW} style={fs(9.5)}>
|
||||||
|
{t('assistant.admin.connection')}
|
||||||
|
</span>
|
||||||
|
{!replacing && <LiveConnection A={A} />}
|
||||||
|
{!live && <SettingsHint>{t('assistant.admin.connectionNone')}</SettingsHint>}
|
||||||
|
{A.showPanel && (
|
||||||
|
<AiConnectPanel scope="instance" onConnected={onConnected} prefill={connection?.prefill ?? null} />
|
||||||
|
)}
|
||||||
|
{replacing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelReplace}
|
||||||
|
className={`${SETTINGS_BUTTON} self-start`}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
|
||||||
|
function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fieldId = useId();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{A.instancePays && (
|
||||||
|
<EditorField
|
||||||
|
label={t('assistant.admin.dailyLimit')}
|
||||||
|
htmlFor={`${fieldId}-daily`}
|
||||||
|
hint={t('assistant.admin.dailyLimitHint')}
|
||||||
|
>
|
||||||
|
<NumericInput
|
||||||
|
id={`${fieldId}-daily`}
|
||||||
|
mode="integer"
|
||||||
|
className={`${INPUT} tabular-nums`}
|
||||||
|
value={A.dailyDraft}
|
||||||
|
onValueChange={A.setDailyDraft}
|
||||||
|
onBlur={A.commitDailyLimit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') e.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EditorField>
|
||||||
|
)}
|
||||||
|
<EditorField label={t('assistant.admin.retention')}>
|
||||||
|
<CustomSelect
|
||||||
|
ariaLabel={t('assistant.admin.retention')}
|
||||||
|
value={A.retention}
|
||||||
|
onChange={(v) => A.setRetention(String(v))}
|
||||||
|
options={A.retentionOptions}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</EditorField>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AiTab = 'connection' | 'assistant' | 'parsing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AI settings behind the AI tile's button, in one dialog so the addon page
|
||||||
|
* stays short: the connection the assistant and AI parsing share (and who pays
|
||||||
|
* for it), the assistant's limits, and how bookings are read. A tab shows only
|
||||||
|
* while its addon is on. Each change saves at once.
|
||||||
|
*/
|
||||||
|
export default function AiSettingsDialog({
|
||||||
|
addons,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
addons: Addon[];
|
||||||
|
onClose: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const labelId = useId();
|
||||||
|
const A = useAiAdminConfig();
|
||||||
|
const assistantOn = addons.some((a) => a.id === 'ai_assistant' && a.enabled);
|
||||||
|
const parsing = addons.find((a) => a.id === 'llm_parsing' && a.enabled) ?? null;
|
||||||
|
const tabs: { value: AiTab; label: string }[] = [
|
||||||
|
{ value: 'connection', label: t('admin.addons.ai.tab.connection') },
|
||||||
|
...(assistantOn ? [{ value: 'assistant' as const, label: t('admin.addons.catalog.ai_assistant.name') }] : []),
|
||||||
|
...(parsing ? [{ value: 'parsing' as const, label: t('admin.addons.catalog.llm_parsing.name') }] : []),
|
||||||
|
];
|
||||||
|
const [tab, setTab] = useState<AiTab>('connection');
|
||||||
|
const shown = tabs.some((x) => x.value === tab) ? tab : 'connection';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogShell
|
||||||
|
onClose={onClose}
|
||||||
|
labelledBy={labelId}
|
||||||
|
width="detail"
|
||||||
|
align="top"
|
||||||
|
blocked={A.instance.confirmingRemove}
|
||||||
|
header={
|
||||||
|
<DialogHeader
|
||||||
|
tile={
|
||||||
|
<DialogTile>
|
||||||
|
<Sparkles size={20} strokeWidth={2} />
|
||||||
|
</DialogTile>
|
||||||
|
}
|
||||||
|
tint={NEUTRAL_TINT}
|
||||||
|
labelId={labelId}
|
||||||
|
eyebrow={t('admin.addons.ai.name')}
|
||||||
|
title={t('admin.addons.ai.setup')}
|
||||||
|
sub={t('admin.addons.ai.dialogSub')}
|
||||||
|
subWraps
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<DialogFooter>
|
||||||
|
<FooterSpacer />
|
||||||
|
<DialogButton variant="primary" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</DialogButton>
|
||||||
|
</DialogFooter>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tabs.length > 1 && (
|
||||||
|
<Segmented<AiTab> label={t('admin.addons.ai.setup')} value={shown} onChange={setTab} options={tabs} fill />
|
||||||
|
)}
|
||||||
|
{!A.loaded ? (
|
||||||
|
<div className="grid place-items-center py-10">
|
||||||
|
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{shown === 'connection' && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<PolicyChoice A={A} />
|
||||||
|
{A.instancePays && <InstanceConnection A={A} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shown === 'assistant' && <AssistantLimits A={A} />}
|
||||||
|
{shown === 'parsing' && parsing && (
|
||||||
|
<ParsingSettings addon={parsing} A={A} onOpenConnection={() => setTab('connection')} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={A.instance.confirmingRemove}
|
||||||
|
onClose={A.instance.cancelRemove}
|
||||||
|
onConfirm={A.instance.remove}
|
||||||
|
title={t('assistant.admin.connectionRemove')}
|
||||||
|
message={t('assistant.connect.disconnectConfirm', { provider: A.instance.provider })}
|
||||||
|
confirmLabel={t('assistant.admin.connectionRemove')}
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared';
|
||||||
|
import { Check, RefreshCw } from 'lucide-react';
|
||||||
|
import React, { useId } from 'react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||||
|
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||||
|
import { DialogSection, fs } from '../../shared/DialogShell';
|
||||||
|
import { EditorField, INPUT, PANEL, Segmented } from '../../shared/dialogParts';
|
||||||
|
import { type Addon, ANTHROPIC_HINT, MASKED, RECOMMENDED_MODELS } from './addonModel';
|
||||||
|
import type { useAiAdminConfig } from './useAiAdminConfig';
|
||||||
|
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
||||||
|
|
||||||
|
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||||
|
type Parsing = ReturnType<typeof useLlmParsingConfig>;
|
||||||
|
type MarkKind = Parameters<typeof ProviderMark>[0]['kind'];
|
||||||
|
|
||||||
|
/** Parsing's own providers, with the mark the assistant shows for each. */
|
||||||
|
const OWN_PROVIDERS = [
|
||||||
|
{ value: 'local', labelKey: 'settings.aiParsing.providerLocal', mark: 'custom' },
|
||||||
|
{ value: 'openai', labelKey: 'settings.aiParsing.providerOpenai', mark: 'openai' },
|
||||||
|
{ value: 'anthropic', labelKey: 'settings.aiParsing.providerAnthropic', mark: 'anthropic' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const markOf = (provider: string): MarkKind => OWN_PROVIDERS.find((p) => p.value === provider)?.mark ?? 'custom';
|
||||||
|
|
||||||
|
/** The connection parsing reads with, as one line: a mark, who answers, the model and where it comes from. */
|
||||||
|
function ReadsWith({ mark, title, model, tag }: { mark: MarkKind; title: string; model: string; tag: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||||
|
<ProviderMark kind={mark} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
|
||||||
|
{model}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill>{tag}</StatusPill>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which connection parsing reads with now, and the way to the other one. */
|
||||||
|
function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onOpenConnection: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const live = A.instancePays ? A.instance.live : null;
|
||||||
|
if (P.ownConnection) {
|
||||||
|
const own = OWN_PROVIDERS.find((p) => p.value === P.savedProvider);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<ReadsWith
|
||||||
|
mark={markOf(P.savedProvider)}
|
||||||
|
title={own ? t(own.labelKey) : P.savedProvider}
|
||||||
|
model={P.savedModel}
|
||||||
|
tag={t('admin.addons.ai.parsing.own')}
|
||||||
|
/>
|
||||||
|
<SettingsHint>{t('admin.addons.ai.parsing.ownHint')}</SettingsHint>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void P.switchToShared()}
|
||||||
|
disabled={P.saving}
|
||||||
|
className={`${SETTINGS_BUTTON} self-start`}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
{t('admin.addons.ai.parsing.useShared')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{live ? (
|
||||||
|
<ReadsWith
|
||||||
|
mark={live.kind}
|
||||||
|
title={A.instance.provider}
|
||||||
|
model={live.model}
|
||||||
|
tag={t('admin.addons.ai.parsing.sharedTag')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SettingsHint>
|
||||||
|
{t(A.policy === 'user' ? 'admin.addons.ai.parsing.perUser' : 'admin.addons.ai.parsing.none')}
|
||||||
|
</SettingsHint>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
|
||||||
|
{!live && A.policy !== 'user' && (
|
||||||
|
<button type="button" onClick={onOpenConnection} className={SETTINGS_BUTTON_PRIMARY}>
|
||||||
|
{t('admin.addons.ai.tab.connection')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!P.formOpen && (
|
||||||
|
<button type="button" onClick={P.startOwn} className={SETTINGS_BUTTON}>
|
||||||
|
{t('admin.addons.ai.parsing.setupOwn')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The models on the local server to pick from, and the recommended ones to pull. */
|
||||||
|
function LocalModels({ P }: { P: Parsing }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={PANEL}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
|
||||||
|
style={fs(9.5)}
|
||||||
|
>
|
||||||
|
{t('admin.addons.ai.parsing.installed')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void P.loadModels()}
|
||||||
|
disabled={P.loadingModels}
|
||||||
|
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-card hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||||
|
style={fs(11.5, 'body')}
|
||||||
|
>
|
||||||
|
<RefreshCw size={11} strokeWidth={2.2} className={P.loadingModels ? 'animate-spin' : undefined} />
|
||||||
|
{t('admin.addons.ai.parsing.refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{P.modelsErr && (
|
||||||
|
<p className="m-0 break-words text-danger" style={fs(11.5)}>
|
||||||
|
{P.modelsErr}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!P.modelsErr && P.installed.length === 0 && !P.loadingModels && (
|
||||||
|
<SettingsHint>{t('admin.addons.ai.parsing.noModels')}</SettingsHint>
|
||||||
|
)}
|
||||||
|
{P.installed.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{P.installed.map((name) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={name}
|
||||||
|
title={name}
|
||||||
|
onClick={() => P.setModel(name)}
|
||||||
|
aria-pressed={P.model === name}
|
||||||
|
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${P.model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||||
|
style={fs(11.5)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||||
|
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
|
||||||
|
{t('admin.addons.ai.parsing.pullTitle')}
|
||||||
|
</span>
|
||||||
|
{RECOMMENDED_MODELS.map((m) => {
|
||||||
|
const here = P.isInstalled(m.id);
|
||||||
|
const chosen = P.model === m.id;
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="min-w-0" title={m.note}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
{m.recommended && <StatusPill tone="success">{t('assistant.model.recommended')}</StatusPill>}
|
||||||
|
{here ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => P.setModel(m.id)}
|
||||||
|
disabled={chosen}
|
||||||
|
className={SETTINGS_BUTTON}
|
||||||
|
style={fs(12, 'body')}
|
||||||
|
>
|
||||||
|
{chosen ? <Check size={13} strokeWidth={2.4} /> : null}
|
||||||
|
{t(chosen ? 'admin.addons.ai.parsing.selected' : 'admin.addons.ai.parsing.use')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void P.pull(m.id)}
|
||||||
|
disabled={!!P.pulling}
|
||||||
|
className={SETTINGS_BUTTON_PRIMARY}
|
||||||
|
style={fs(12, 'body')}
|
||||||
|
>
|
||||||
|
{t(P.pulling === m.id ? 'admin.addons.ai.parsing.pulling' : 'admin.addons.ai.parsing.pull')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{P.pulling === m.id && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent transition-[width] duration-200"
|
||||||
|
style={{ width: `${P.pullPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||||
|
{P.pullStatus}
|
||||||
|
{P.pullPct ? ` ${P.pullPct}%` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A connection of parsing's own: the provider as tiles, its address, key and model. */
|
||||||
|
function OwnConnectionForm({ P }: { P: Parsing }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fieldId = useId();
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-3 ${P.saving ? 'opacity-60' : ''}`}>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={t('settings.aiParsing.provider')}
|
||||||
|
className="grid grid-cols-3 gap-2 max-sm:grid-cols-1"
|
||||||
|
>
|
||||||
|
{OWN_PROVIDERS.map((p) => {
|
||||||
|
const on = P.provider === p.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={on}
|
||||||
|
onClick={() => P.setProvider(p.value)}
|
||||||
|
className={`flex min-w-0 items-center gap-2 rounded-[12px] border p-2.5 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
|
||||||
|
>
|
||||||
|
<ProviderMark kind={p.mark} size="sm" />
|
||||||
|
<span
|
||||||
|
className={`min-w-0 flex-1 truncate font-medium ${on ? 'text-content' : 'text-content-muted'}`}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
{t(p.labelKey)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<EditorField
|
||||||
|
label={t('settings.aiParsing.baseUrl')}
|
||||||
|
htmlFor={`${fieldId}-url`}
|
||||||
|
hint={t('settings.aiParsing.baseUrlHint')}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={`${fieldId}-url`}
|
||||||
|
type="url"
|
||||||
|
autoComplete="off"
|
||||||
|
className={INPUT}
|
||||||
|
value={P.baseUrl}
|
||||||
|
onChange={(e) => P.setBaseUrl(e.target.value)}
|
||||||
|
onBlur={() => void P.loadModels()}
|
||||||
|
placeholder={P.baseUrlPlaceholder}
|
||||||
|
/>
|
||||||
|
</EditorField>
|
||||||
|
<EditorField
|
||||||
|
label={t('settings.aiParsing.apiKey')}
|
||||||
|
htmlFor={`${fieldId}-key`}
|
||||||
|
hint={P.provider === 'anthropic' ? ANTHROPIC_HINT : t('settings.aiParsing.apiKeyHint')}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={`${fieldId}-key`}
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
className={INPUT}
|
||||||
|
value={P.apiKey}
|
||||||
|
onChange={(e) => P.setApiKey(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
P.apiKey === MASKED ? MASKED : P.provider === 'local' ? t('admin.addons.ai.parsing.apiKeyOptional') : ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</EditorField>
|
||||||
|
<EditorField label={t('settings.aiParsing.model')} htmlFor={`${fieldId}-model`}>
|
||||||
|
<input
|
||||||
|
id={`${fieldId}-model`}
|
||||||
|
autoComplete="off"
|
||||||
|
className={`${INPUT} font-geist`}
|
||||||
|
value={P.model}
|
||||||
|
onChange={(e) => P.setModel(e.target.value)}
|
||||||
|
/>
|
||||||
|
</EditorField>
|
||||||
|
{P.provider === 'local' && <LocalModels P={P} />}
|
||||||
|
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
|
||||||
|
{!P.ownConnection && (
|
||||||
|
<button type="button" onClick={P.cancelOwn} disabled={P.saving} className={SETTINGS_BUTTON}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void P.save()}
|
||||||
|
disabled={P.saving || !P.model.trim()}
|
||||||
|
className={SETTINGS_BUTTON_PRIMARY}
|
||||||
|
>
|
||||||
|
{t(P.saving ? 'common.saving' : 'common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI parsing in the AI dialog: which connection reads the bookings (the shared
|
||||||
|
* one, or one of its own an older install kept), a connection of its own when
|
||||||
|
* wanted, and whether the model reads images. Each part saves on its own.
|
||||||
|
*/
|
||||||
|
export default function ParsingSettings({
|
||||||
|
addon,
|
||||||
|
A,
|
||||||
|
onOpenConnection,
|
||||||
|
}: {
|
||||||
|
addon: Addon;
|
||||||
|
A: AiAdmin;
|
||||||
|
onOpenConnection: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const P = useLlmParsingConfig(addon);
|
||||||
|
const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) }));
|
||||||
|
const local =
|
||||||
|
(P.ownConnection ? P.savedProvider : A.instance.live?.kind === 'custom' ? 'local' : 'cloud') === 'local';
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<DialogSection label={t('admin.addons.ai.parsing.readsWith')}>
|
||||||
|
<ParsingSource A={A} P={P} onOpenConnection={onOpenConnection} />
|
||||||
|
</DialogSection>
|
||||||
|
{P.formOpen && (
|
||||||
|
<DialogSection label={t('admin.addons.ai.parsing.own')}>
|
||||||
|
<OwnConnectionForm P={P} />
|
||||||
|
</DialogSection>
|
||||||
|
)}
|
||||||
|
<DialogSection label={t('settings.aiParsing.multimodal')}>
|
||||||
|
<Segmented<LlmVision>
|
||||||
|
label={t('settings.aiParsing.multimodal')}
|
||||||
|
value={P.vision}
|
||||||
|
onChange={(v) => P.setVision(asLlmVision(v))}
|
||||||
|
options={visionOptions}
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<SettingsHint>
|
||||||
|
{t(local ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
|
||||||
|
</SettingsHint>
|
||||||
|
</div>
|
||||||
|
</DialogSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Cable, type LucideIcon, MessagesSquare, ScanText } from 'lucide-react';
|
||||||
|
import type { Addon } from './addonModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three addons that talk to a language model, shown as one AI tile in the
|
||||||
|
* integration column: the assistant in the trip, AI parsing (booking import)
|
||||||
|
* and MCP (TREK inside other AI apps). Their ids, rows and settings stay their
|
||||||
|
* own; only the page groups them. Assistant and parsing share one connection.
|
||||||
|
*/
|
||||||
|
const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
|
||||||
|
export type AiAddonId = (typeof AI_ADDON_IDS)[number];
|
||||||
|
|
||||||
|
/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */
|
||||||
|
export const AI_ADDON_ICONS: Record<AiAddonId, LucideIcon> = {
|
||||||
|
ai_assistant: MessagesSquare,
|
||||||
|
llm_parsing: ScanText,
|
||||||
|
mcp: Cable,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAiAddon(addon: Addon): boolean {
|
||||||
|
return (AI_ADDON_IDS as readonly string[]).includes(addon.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The AI addons this install has, in the tile's order. */
|
||||||
|
export function aiAddonsOf(addons: Addon[]): Addon[] {
|
||||||
|
return AI_ADDON_IDS.flatMap((id) => addons.find((a) => a.id === id) ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the shared connection is used at all: the assistant or AI parsing is on. */
|
||||||
|
export function usesConnection(addons: Addon[]): boolean {
|
||||||
|
return addons.some((a) => (a.id === 'ai_assistant' || a.id === 'llm_parsing') && a.enabled);
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ const ADDONS = [
|
|||||||
addon('llm_parsing', 'integration', false),
|
addon('llm_parsing', 'integration', false),
|
||||||
addon('immich', 'photo_provider', false),
|
addon('immich', 'photo_provider', false),
|
||||||
addon('gdrive', 'document_provider'),
|
addon('gdrive', 'document_provider'),
|
||||||
|
addon('airtrail', 'integration', false),
|
||||||
|
addon('mcp', 'integration'),
|
||||||
];
|
];
|
||||||
|
|
||||||
let loadAddons: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
let loadAddons: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||||
@@ -60,14 +62,16 @@ function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' |
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('useAddonManager', () => {
|
describe('useAddonManager', () => {
|
||||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
|
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration, AI and the two shelves', async () => {
|
||||||
const { result, rerender } = await loaded();
|
const { result, rerender } = await loaded();
|
||||||
rerender();
|
rerender();
|
||||||
expect(adminApi.addons).toHaveBeenCalledTimes(1);
|
expect(adminApi.addons).toHaveBeenCalledTimes(1);
|
||||||
const ids = (list: { id?: string; key?: string }[]) => list.map((a) => a.id ?? a.key);
|
const ids = (list: { id?: string; key?: string }[]) => list.map((a) => a.id ?? a.key);
|
||||||
expect(ids(result.current.tripAddons)).toEqual(['todo', 'documents']);
|
expect(ids(result.current.tripAddons)).toEqual(['todo', 'documents']);
|
||||||
expect(ids(result.current.globalAddons)).toEqual(['journey']);
|
expect(ids(result.current.globalAddons)).toEqual(['journey']);
|
||||||
expect(ids(result.current.integrationAddons)).toEqual(['llm_parsing']);
|
// The AI addons leave the integration list for their own tile, in its order.
|
||||||
|
expect(ids(result.current.integrationAddons)).toEqual(['airtrail']);
|
||||||
|
expect(ids(result.current.aiAddons)).toEqual(['llm_parsing', 'mcp']);
|
||||||
expect(result.current.providerOptions).toEqual([
|
expect(result.current.providerOptions).toEqual([
|
||||||
expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }),
|
expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useTranslation } from '../../../i18n';
|
|||||||
import { useAddonStore } from '../../../store/addonStore';
|
import { useAddonStore } from '../../../store/addonStore';
|
||||||
import { useToast } from '../../shared/Toast';
|
import { useToast } from '../../shared/Toast';
|
||||||
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
||||||
|
import { aiAddonsOf, isAiAddon } from './aiAddons';
|
||||||
|
|
||||||
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
|
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
|
||||||
function loadToursFlag(
|
function loadToursFlag(
|
||||||
@@ -139,7 +140,9 @@ export function useAddonManager() {
|
|||||||
|
|
||||||
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
|
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
|
||||||
const globalAddons = addons.filter((a) => a.type === 'global');
|
const globalAddons = addons.filter((a) => a.type === 'global');
|
||||||
const integrationAddons = addons.filter((a) => a.type === 'integration');
|
// The AI addons leave the list to sit together on one tile at its head.
|
||||||
|
const integrationAddons = addons.filter((a) => a.type === 'integration' && !isAiAddon(a));
|
||||||
|
const aiAddons = aiAddonsOf(addons);
|
||||||
const providerOptions = addons.filter((a) => a.type === 'photo_provider').map(asOption);
|
const providerOptions = addons.filter((a) => a.type === 'photo_provider').map(asOption);
|
||||||
// No credential form under these, unlike the photo providers: a document connection
|
// No credential form under these, unlike the photo providers: a document connection
|
||||||
// belongs to a trip and is entered there. The admin only decides whether a provider
|
// belongs to a trip and is entered there. The admin only decides whether a provider
|
||||||
@@ -153,6 +156,7 @@ export function useAddonManager() {
|
|||||||
tripAddons,
|
tripAddons,
|
||||||
globalAddons,
|
globalAddons,
|
||||||
integrationAddons,
|
integrationAddons,
|
||||||
|
aiAddons,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
documentProviderOptions,
|
documentProviderOptions,
|
||||||
toursWanderer,
|
toursWanderer,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import type { Addon } from './addonModel';
|
||||||
|
import { usesConnection } from './aiAddons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AI tile behind both admin shells: its three switches, how many are on,
|
||||||
|
* and the settings dialog. The dialog opens by itself when OpenRouter's
|
||||||
|
* sign-in returns here (`?ai=openrouter`), since the connection form that
|
||||||
|
* finishes it lives inside.
|
||||||
|
*/
|
||||||
|
export function useAiAddonTile(aiAddons: Addon[]) {
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
|
||||||
|
const canSetUp = usesConnection(aiAddons);
|
||||||
|
return {
|
||||||
|
enabledCount: aiAddons.filter((a) => a.enabled).length,
|
||||||
|
canSetUp,
|
||||||
|
dialogOpen: open && canSetUp,
|
||||||
|
openDialog: () => setOpen(true),
|
||||||
|
closeDialog: () => setOpen(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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<AiAdminSettings | null>(null);
|
||||||
|
const [dailyDraft, setDailyDraft] = useState('');
|
||||||
|
|
||||||
|
const adopt = (next: AiAdminSettings) => {
|
||||||
|
setSettings(next);
|
||||||
|
setDailyDraft(String(next.dailyTurns));
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (patch: Partial<AiAdminSettings>) => {
|
||||||
|
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<AiAdminConnectionResponse | null>(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<AiAdminConnectionResponse>, 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@ import { adminApi } from '../../../api/client';
|
|||||||
import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel';
|
import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel';
|
||||||
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
||||||
|
|
||||||
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-011
|
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-013
|
||||||
|
|
||||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
||||||
|
|
||||||
|
/** A local connection of parsing's own, so the form is open and lists the server's models. */
|
||||||
|
const OWN_LOCAL = { provider: 'local', model: 'm' };
|
||||||
|
|
||||||
function llm(config?: Record<string, unknown>): Addon {
|
function llm(config?: Record<string, unknown>): Addon {
|
||||||
return {
|
return {
|
||||||
@@ -45,7 +49,7 @@ describe('useLlmParsingConfig', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => {
|
it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => {
|
||||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||||
expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL);
|
expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL);
|
||||||
expect(result.current.isInstalled('qwen3.5:4b')).toBe(true);
|
expect(result.current.isInstalled('qwen3.5:4b')).toBe(true);
|
||||||
@@ -54,7 +58,7 @@ describe('useLlmParsingConfig', () => {
|
|||||||
|
|
||||||
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
|
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
|
useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt', baseUrl: ' http://box:11434/v1 ' }))
|
||||||
);
|
);
|
||||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||||
await act(() => result.current.loadModels());
|
await act(() => result.current.loadModels());
|
||||||
@@ -69,13 +73,13 @@ describe('useLlmParsingConfig', () => {
|
|||||||
|
|
||||||
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
|
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
|
||||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(new Error('ECONNREFUSED'));
|
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||||
await waitFor(() => expect(result.current.modelsErr).toBe('ECONNREFUSED'));
|
await waitFor(() => expect(result.current.modelsErr).toBe('ECONNREFUSED'));
|
||||||
expect(result.current.installed).toEqual([]);
|
expect(result.current.installed).toEqual([]);
|
||||||
expect(result.current.loadingModels).toBe(false);
|
expect(result.current.loadingModels).toBe(false);
|
||||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue('nope');
|
vi.mocked(adminApi.llmLocalModels).mockRejectedValue('nope');
|
||||||
await act(() => result.current.loadModels());
|
await act(() => result.current.loadModels());
|
||||||
expect(result.current.modelsErr).toBe('Could not reach the local LLM server');
|
expect(result.current.modelsErr).toBe('admin.addons.ai.parsing.unreachable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
|
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
|
||||||
@@ -83,7 +87,7 @@ describe('useLlmParsingConfig', () => {
|
|||||||
response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } },
|
response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } },
|
||||||
});
|
});
|
||||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused);
|
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused);
|
||||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||||
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
|
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,14 +98,14 @@ describe('useLlmParsingConfig', () => {
|
|||||||
progress = onProgress;
|
progress = onProgress;
|
||||||
return new Promise<void>((r) => (finish = r));
|
return new Promise<void>((r) => (finish = r));
|
||||||
});
|
});
|
||||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||||
let pending!: Promise<void>;
|
let pending!: Promise<void>;
|
||||||
act(() => {
|
act(() => {
|
||||||
pending = result.current.pull('qwen3.5:4b');
|
pending = result.current.pull('qwen3.5:4b');
|
||||||
});
|
});
|
||||||
expect(result.current.pulling).toBe('qwen3.5:4b');
|
expect(result.current.pulling).toBe('qwen3.5:4b');
|
||||||
expect(result.current.pullStatus).toBe('starting…');
|
expect(result.current.pullStatus).toBe('admin.addons.ai.parsing.pulling');
|
||||||
act(() => progress({ status: 'downloading', total: 200, completed: 50 }));
|
act(() => progress({ status: 'downloading', total: 200, completed: 50 }));
|
||||||
expect(result.current.pullStatus).toBe('downloading');
|
expect(result.current.pullStatus).toBe('downloading');
|
||||||
expect(result.current.pullPct).toBe(25);
|
expect(result.current.pullPct).toBe(25);
|
||||||
@@ -109,7 +113,7 @@ describe('useLlmParsingConfig', () => {
|
|||||||
finish();
|
finish();
|
||||||
await pending;
|
await pending;
|
||||||
});
|
});
|
||||||
expect(toast.success).toHaveBeenCalledWith('Model pulled');
|
expect(toast.success).toHaveBeenCalledWith('admin.addons.ai.parsing.pulled');
|
||||||
expect(result.current.model).toBe('qwen3.5:4b');
|
expect(result.current.model).toBe('qwen3.5:4b');
|
||||||
expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2);
|
expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2);
|
||||||
expect(result.current.pulling).toBeNull();
|
expect(result.current.pulling).toBeNull();
|
||||||
@@ -136,7 +140,7 @@ describe('useLlmParsingConfig', () => {
|
|||||||
expect(toast.error).toHaveBeenCalledWith('manifest unknown');
|
expect(toast.error).toHaveBeenCalledWith('manifest unknown');
|
||||||
vi.mocked(adminApi.llmLocalPull).mockRejectedValue('nope');
|
vi.mocked(adminApi.llmLocalPull).mockRejectedValue('nope');
|
||||||
await act(() => result.current.pull('x'));
|
await act(() => result.current.pull('x'));
|
||||||
expect(toast.error).toHaveBeenLastCalledWith('Pull failed');
|
expect(toast.error).toHaveBeenLastCalledWith('admin.addons.ai.parsing.pullFailed');
|
||||||
expect(result.current.pulling).toBeNull();
|
expect(result.current.pulling).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,18 +152,17 @@ describe('useLlmParsingConfig', () => {
|
|||||||
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', {
|
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', {
|
||||||
config: { provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o/v1', apiKey: MASKED, vision: 'auto' },
|
config: { provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o/v1', apiKey: MASKED, vision: 'auto' },
|
||||||
});
|
});
|
||||||
expect(toast.success).toHaveBeenCalledWith('Saved');
|
expect(toast.success).toHaveBeenCalledWith('settings.aiParsing.toast.saved');
|
||||||
expect(result.current.saving).toBe(false);
|
expect(result.current.saving).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
|
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
|
||||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
|
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
|
||||||
act(() => {
|
await act(async () => result.current.setVision('off'));
|
||||||
result.current.setProvider('anthropic');
|
act(() => result.current.setProvider('anthropic'));
|
||||||
result.current.setVision('off');
|
|
||||||
});
|
|
||||||
await act(() => result.current.save());
|
await act(() => result.current.save());
|
||||||
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1]).toEqual({
|
const calls = vi.mocked(adminApi.updateAddon).mock.calls;
|
||||||
|
expect(calls[calls.length - 1]?.[1]).toEqual({
|
||||||
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
|
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -212,7 +215,35 @@ describe('useLlmParsingConfig', () => {
|
|||||||
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
|
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
|
||||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
|
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
|
||||||
await act(() => result.current.save());
|
await act(() => result.current.save());
|
||||||
expect(toast.error).toHaveBeenCalledWith('Failed to save');
|
expect(toast.error).toHaveBeenCalledWith('settings.aiParsing.toast.saveError');
|
||||||
expect(result.current.saving).toBe(false);
|
expect(result.current.saving).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-HOOK-LLMCFG-012: without its own connection the form stays shut and asks the server nothing until opened', async () => {
|
||||||
|
const { result } = renderHook(() => useLlmParsingConfig(llm({ vision: 'on' })));
|
||||||
|
expect(result.current.ownConnection).toBe(false);
|
||||||
|
expect(result.current.formOpen).toBe(false);
|
||||||
|
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||||
|
act(() => result.current.startOwn());
|
||||||
|
expect(result.current.formOpen).toBe(true);
|
||||||
|
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
|
||||||
|
// The vision setting alone is saved without a connection.
|
||||||
|
await act(async () => result.current.setVision('off'));
|
||||||
|
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('llm_parsing', { config: { vision: 'off' } });
|
||||||
|
act(() => result.current.cancelOwn());
|
||||||
|
expect(result.current.formOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-HOOK-LLMCFG-013: going back to the shared connection drops the own one and keeps the vision setting', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'claude-x', apiKey: MASKED, vision: 'on' }))
|
||||||
|
);
|
||||||
|
expect(result.current.ownConnection).toBe(true);
|
||||||
|
expect(result.current.savedModel).toBe('claude-x');
|
||||||
|
await act(() => result.current.switchToShared());
|
||||||
|
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { config: { vision: 'on' } });
|
||||||
|
expect(result.current.ownConnection).toBe(false);
|
||||||
|
expect(result.current.formOpen).toBe(false);
|
||||||
|
expect(result.current.model).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,33 +2,118 @@ import { type LlmVision, asLlmVision } from '@trek/shared';
|
|||||||
import { useEffect, useEffectEvent, useState } from 'react';
|
import { useEffect, useEffectEvent, useState } from 'react';
|
||||||
|
|
||||||
import { adminApi } from '../../../api/client';
|
import { adminApi } from '../../../api/client';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
import { getApiErrorMessage } from '../../../types';
|
import { getApiErrorMessage } from '../../../types';
|
||||||
import { useToast } from '../../shared/Toast';
|
import { useToast } from '../../shared/Toast';
|
||||||
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
|
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
|
||||||
|
|
||||||
/**
|
/** What AI parsing keeps in its addon config: a connection of its own (or none) and the vision setting. */
|
||||||
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
|
interface SavedParsing {
|
||||||
* whole instance and overrides per-user config (see server llmConfig.ts). The API key is
|
provider: string;
|
||||||
* masked on read; an unchanged mask is treated as a no-op by the server. For the local
|
model: string;
|
||||||
* provider it also lists the installed Ollama models and can pull the recommended ones.
|
baseUrl: string;
|
||||||
*/
|
apiKey: string;
|
||||||
export function useLlmParsingConfig(addon: Addon) {
|
vision: LlmVision;
|
||||||
const toast = useToast();
|
}
|
||||||
const cfg = addon.config ?? {};
|
|
||||||
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
|
|
||||||
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
|
|
||||||
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
|
|
||||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
|
|
||||||
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision));
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
// Local-provider model management.
|
function savedOf(cfg: Record<string, unknown>): SavedParsing {
|
||||||
|
return {
|
||||||
|
provider: typeof cfg.provider === 'string' ? cfg.provider : '',
|
||||||
|
model: typeof cfg.model === 'string' ? cfg.model : '',
|
||||||
|
baseUrl: typeof cfg.baseUrl === 'string' ? cfg.baseUrl : '',
|
||||||
|
apiKey: typeof cfg.apiKey === 'string' ? cfg.apiKey : '',
|
||||||
|
vision: asLlmVision(cfg.vision),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The models a local (Ollama) server has, and pulling the recommended ones.
|
||||||
|
* The list loads while the form shows the local provider: a provider change or
|
||||||
|
* opening the form loads it, the address field's own blur reloads it.
|
||||||
|
*/
|
||||||
|
function useLocalModels(baseUrl: string, isLocal: boolean, formOpen: boolean, onPulled: (id: string) => void) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
const [installed, setInstalled] = useState<string[]>([]);
|
const [installed, setInstalled] = useState<string[]>([]);
|
||||||
const [modelsErr, setModelsErr] = useState('');
|
const [modelsErr, setModelsErr] = useState('');
|
||||||
const [loadingModels, setLoadingModels] = useState(false);
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
const [pulling, setPulling] = useState<string | null>(null);
|
const [pulling, setPulling] = useState<string | null>(null);
|
||||||
const [pullPct, setPullPct] = useState(0);
|
const [pullPct, setPullPct] = useState(0);
|
||||||
const [pullStatus, setPullStatus] = useState('');
|
const [pullStatus, setPullStatus] = useState('');
|
||||||
|
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
|
||||||
|
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
|
||||||
|
|
||||||
|
const loadModels = async () => {
|
||||||
|
if (!isLocal) return;
|
||||||
|
setLoadingModels(true);
|
||||||
|
setModelsErr('');
|
||||||
|
try {
|
||||||
|
const res = await adminApi.llmLocalModels(effectiveUrl);
|
||||||
|
setInstalled(res.models.map((m) => m.name));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// The server says why (unreachable, key refused); axios' own status line does not.
|
||||||
|
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
|
||||||
|
setInstalled([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingModels(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadModelsForProvider = useEffectEvent(() => {
|
||||||
|
if (formOpen && isLocal) void loadModels();
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
loadModelsForProvider();
|
||||||
|
}, [isLocal, formOpen]);
|
||||||
|
|
||||||
|
const pull = async (id: string) => {
|
||||||
|
if (pulling) return;
|
||||||
|
setPulling(id);
|
||||||
|
setPullPct(0);
|
||||||
|
setPullStatus(t('admin.addons.ai.parsing.pulling'));
|
||||||
|
try {
|
||||||
|
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
||||||
|
if (p.error) throw new Error(p.error);
|
||||||
|
if (p.status) setPullStatus(p.status);
|
||||||
|
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
|
||||||
|
});
|
||||||
|
toast.success(t('admin.addons.ai.parsing.pulled'));
|
||||||
|
onPulled(id);
|
||||||
|
await loadModels();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
|
||||||
|
} finally {
|
||||||
|
setPulling(null);
|
||||||
|
setPullPct(0);
|
||||||
|
setPullStatus('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI parsing's admin settings behind both admin shells. Parsing reads with the
|
||||||
|
* AI connection the assistant runs on unless the instance keeps one of its own
|
||||||
|
* here; an install set up before the two shared one keeps its own until the
|
||||||
|
* admin switches it over. The API key is masked on read and an unchanged mask
|
||||||
|
* keeps the stored key. For a local server it lists the installed Ollama models
|
||||||
|
* and pulls the recommended ones.
|
||||||
|
*/
|
||||||
|
export function useLlmParsingConfig(addon: Addon) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const [saved, setSaved] = useState<SavedParsing>(() => savedOf(addon.config ?? {}));
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [provider, setProviderState] = useState<string>(saved.provider || 'local');
|
||||||
|
const [model, setModel] = useState<string>(saved.model);
|
||||||
|
const [baseUrl, setBaseUrl] = useState<string>(saved.baseUrl);
|
||||||
|
const [apiKey, setApiKey] = useState<string>(saved.apiKey);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
/** A connection of its own, which wins over the shared one. */
|
||||||
|
const ownConnection = !!(saved.provider && saved.model);
|
||||||
|
const formOpen = ownConnection || editing;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A different provider never inherits the last one's endpoint: an Ollama address
|
* A different provider never inherits the last one's endpoint: an Ollama address
|
||||||
@@ -37,83 +122,58 @@ export function useLlmParsingConfig(addon: Addon) {
|
|||||||
* keep an address the admin typed for it (#2655); empty means the provider default.
|
* keep an address the admin typed for it (#2655); empty means the provider default.
|
||||||
*/
|
*/
|
||||||
const setProvider = (next: string) => {
|
const setProvider = (next: string) => {
|
||||||
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
|
if (next !== provider) setBaseUrl(next === saved.provider ? saved.baseUrl : '');
|
||||||
setProviderState(next);
|
setProviderState(next);
|
||||||
};
|
};
|
||||||
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
|
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
|
||||||
|
const local = useLocalModels(baseUrl, provider === 'local', formOpen, setModel);
|
||||||
|
|
||||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
|
/** Writes the config; the stored key stays as long as the mask is sent in its place. */
|
||||||
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
|
const write = async (next: SavedParsing, config: Record<string, unknown>): Promise<boolean> => {
|
||||||
|
|
||||||
const loadModels = async () => {
|
|
||||||
if (provider !== 'local') return;
|
|
||||||
setLoadingModels(true);
|
|
||||||
setModelsErr('');
|
|
||||||
try {
|
|
||||||
const res = await adminApi.llmLocalModels(effectiveUrl);
|
|
||||||
setInstalled(res.models.map((m) => m.name));
|
|
||||||
} catch (e: unknown) {
|
|
||||||
// The server says why (unreachable, key refused); axios' own status line does not.
|
|
||||||
setModelsErr(getApiErrorMessage(e, 'Could not reach the local LLM server'));
|
|
||||||
setInstalled([]);
|
|
||||||
} finally {
|
|
||||||
setLoadingModels(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load installed models when the local provider is active. Only a provider change
|
|
||||||
// triggers it; the base URL is read as it stands then (its own blur reloads).
|
|
||||||
const loadModelsForProvider = useEffectEvent(() => {
|
|
||||||
if (provider === 'local') void loadModels();
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
loadModelsForProvider();
|
|
||||||
}, [provider]);
|
|
||||||
|
|
||||||
const pull = async (id: string) => {
|
|
||||||
if (pulling) return;
|
|
||||||
setPulling(id);
|
|
||||||
setPullPct(0);
|
|
||||||
setPullStatus('starting…');
|
|
||||||
try {
|
|
||||||
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
|
||||||
if (p.error) throw new Error(p.error);
|
|
||||||
if (p.status) setPullStatus(p.status);
|
|
||||||
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
|
|
||||||
});
|
|
||||||
toast.success('Model pulled');
|
|
||||||
setModel(id);
|
|
||||||
await loadModels();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
toast.error(e instanceof Error ? e.message : 'Pull failed');
|
|
||||||
} finally {
|
|
||||||
setPulling(null);
|
|
||||||
setPullPct(0);
|
|
||||||
setPullStatus('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
// Send the masked sentinel unchanged so the server keeps the stored key.
|
await adminApi.updateAddon(addon.id, { config });
|
||||||
await adminApi.updateAddon(addon.id, {
|
setSaved(next);
|
||||||
config: {
|
toast.success(t('settings.aiParsing.toast.saved'));
|
||||||
provider,
|
return true;
|
||||||
model: model.trim(),
|
|
||||||
baseUrl: baseUrl.trim(),
|
|
||||||
apiKey,
|
|
||||||
vision,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toast.success('Saved');
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save');
|
toast.error(t('settings.aiParsing.toast.saveError'));
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ownConfig = (s: SavedParsing) => ({
|
||||||
|
provider: s.provider,
|
||||||
|
model: s.model,
|
||||||
|
baseUrl: s.baseUrl,
|
||||||
|
apiKey: s.apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const next: SavedParsing = { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, vision: saved.vision };
|
||||||
|
if (await write(next, { ...ownConfig(next), vision: next.vision })) setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Back on the shared connection: the own one and its key are dropped, the vision setting stays. */
|
||||||
|
const switchToShared = async () => {
|
||||||
|
const next: SavedParsing = { provider: '', model: '', baseUrl: '', apiKey: '', vision: saved.vision };
|
||||||
|
if (await write(next, { vision: next.vision })) {
|
||||||
|
setEditing(false);
|
||||||
|
setProviderState('local');
|
||||||
|
setModel('');
|
||||||
|
setBaseUrl('');
|
||||||
|
setApiKey('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Saved at once, with the connection as it was saved: a draft in the form is not sent along. */
|
||||||
|
const setVision = (value: LlmVision) => {
|
||||||
|
const next = { ...saved, vision: value };
|
||||||
|
void write(next, ownConnection ? { ...ownConfig(saved), vision: value } : { vision: value });
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
setProvider,
|
setProvider,
|
||||||
@@ -124,18 +184,17 @@ export function useLlmParsingConfig(addon: Addon) {
|
|||||||
baseUrlPlaceholder,
|
baseUrlPlaceholder,
|
||||||
apiKey,
|
apiKey,
|
||||||
setApiKey,
|
setApiKey,
|
||||||
vision,
|
vision: saved.vision,
|
||||||
setVision,
|
setVision,
|
||||||
saving,
|
saving,
|
||||||
installed,
|
ownConnection,
|
||||||
modelsErr,
|
savedProvider: saved.provider,
|
||||||
loadingModels,
|
savedModel: saved.model,
|
||||||
pulling,
|
formOpen,
|
||||||
pullPct,
|
startOwn: () => setEditing(true),
|
||||||
pullStatus,
|
cancelOwn: () => setEditing(false),
|
||||||
isInstalled,
|
switchToShared,
|
||||||
loadModels,
|
...local,
|
||||||
pull,
|
|
||||||
save,
|
save,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/trips/:id" element={<AssistantAskButton place={place} />} />
|
||||||
|
<Route path="*" element={<AssistantAskButton place={place} />} />
|
||||||
|
</Routes>,
|
||||||
|
{ 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<Tooltip label={label}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => askAbout(place.id)}
|
||||||
|
aria-label={`${label}: ${place.name}`}
|
||||||
|
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content"
|
||||||
|
>
|
||||||
|
<Sparkles size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
/**
|
||||||
|
* 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 { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
|
||||||
|
// The toasts a component raises, read as (message, type) the way the toast host takes them.
|
||||||
|
vi.mock('../shared/Toast', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../shared/Toast')>()),
|
||||||
|
useToast: () => ({
|
||||||
|
success: (message: string) => addToast(message, 'success', undefined),
|
||||||
|
error: (message: string) => addToast(message, 'error', undefined),
|
||||||
|
warning: (message: string) => addToast(message, 'warning', undefined),
|
||||||
|
info: (message: string) => addToast(message, 'info', undefined),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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<AssistantView> = {}) {
|
||||||
|
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
|
||||||
|
return render(
|
||||||
|
<AssistantViewContext.Provider value={value}>
|
||||||
|
<AssistantChangeCard items={items} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchChat(next: Partial<ReturnType<typeof chatOf>>) {
|
||||||
|
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<void>>();
|
||||||
|
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();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
decide.mockResolvedValue(undefined);
|
||||||
|
useAssistantStore.setState({ decide, undo, undoBlock, setCompare, requestCamera, setMinimized });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useAssistantStore.setState(INITIAL, true);
|
||||||
|
useTripStore.setState(TRIP_INITIAL, true);
|
||||||
|
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();
|
||||||
|
// The faint line says when, old to new; the address is for the planner, not the card.
|
||||||
|
expect(screen.getByText('09:00')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('10:30')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Kyoto')).toBeNull();
|
||||||
|
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.getByRole('img', { name: '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.getByRole('img', { name: '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.getByRole('img', { name: '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*="rounded-[12px]"]');
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
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 head says whether the days still fit, without drawing the days again', () => {
|
||||||
|
plannerHere();
|
||||||
|
renderCard([onTheMap('c1', 'Pantheon')]);
|
||||||
|
expect(screen.getByText(/Fits the time|to check/)).toBeInTheDocument();
|
||||||
|
// The day list of the planner shows the suggestion; the card keeps to one line per change.
|
||||||
|
expect(screen.getAllByText('Pantheon')).toHaveLength(1);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-CARD-010: no check while the planner holds another trip, or once nothing waits', () => {
|
||||||
|
plannerHere(TRIP + 1);
|
||||||
|
const first = renderCard([onTheMap('c1', 'Pantheon')]);
|
||||||
|
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
plannerHere();
|
||||||
|
renderCard([{ ...onTheMap('c1', 'Pantheon'), state: 'applied' }]);
|
||||||
|
expect(screen.queryByText(/Fits the time|to check/)).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(<AssistantCompareSwitch tripId={TRIP} />);
|
||||||
|
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(<AssistantCompareSwitch tripId={TRIP} tall />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Before' }).className).toContain('h-9');
|
||||||
|
expect(screen.getByRole('button', { name: 'After' }).className).toContain('min-w-[44px]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
import type { AssistantApplied, AssistantChangePreview, AssistantChangeVerbKey } from '@trek/shared';
|
||||||
|
import {
|
||||||
|
CalendarArrowUp,
|
||||||
|
CalendarMinus,
|
||||||
|
CalendarPlus,
|
||||||
|
Check,
|
||||||
|
CircleSlash,
|
||||||
|
ListTodo,
|
||||||
|
LoaderCircle,
|
||||||
|
Luggage,
|
||||||
|
MapPin,
|
||||||
|
MapPinPlus,
|
||||||
|
NotebookPen,
|
||||||
|
PencilLine,
|
||||||
|
Plus,
|
||||||
|
StickyNote,
|
||||||
|
Ticket,
|
||||||
|
Trash2,
|
||||||
|
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 { useToast } from '../shared/Toast';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import AssistantChangeFields from './AssistantChangeFields';
|
||||||
|
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||||
|
import AssistantFitCheck from './AssistantFitCheck';
|
||||||
|
import AssistantWeekOverview from './AssistantWeekOverview';
|
||||||
|
import { type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
import { changePoints, type DayChange } from './dayAfterModel';
|
||||||
|
import { useChangeHover } from './useChangeHover';
|
||||||
|
import { useWeekDays } from './useWeekDays';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The changes the assistant proposes in one go, as one card in plain words:
|
||||||
|
* 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<Tone, string> = {
|
||||||
|
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<AssistantChangeVerbKey, { Icon: typeof Check; tone: Tone }> = {
|
||||||
|
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' },
|
||||||
|
updateBudgetItem: { Icon: Wallet, tone: 'info' },
|
||||||
|
addReservation: { Icon: Ticket, tone: 'success' },
|
||||||
|
importBooking: { Icon: Ticket, tone: 'success' },
|
||||||
|
rememberNote: { Icon: NotebookPen, tone: 'info' },
|
||||||
|
genericCreate: { Icon: Plus, tone: 'success' },
|
||||||
|
genericUpdate: { Icon: PencilLine, tone: 'info' },
|
||||||
|
genericRemove: { Icon: Trash2, tone: 'danger' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** How a decided change reads: one quiet mark, its words in the tooltip. */
|
||||||
|
const RECEIPT: Record<Exclude<ChangeState, 'pending'>, { 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 <span className={`trek-skeleton flex-shrink-0 ${box}`} aria-hidden="true" />;
|
||||||
|
const { Icon, tone } = VERB_LOOK[verbKey];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`flex flex-shrink-0 items-center justify-center ${box} ${dimmed ? 'bg-surface-secondary text-content-faint' : TILE[tone]}`}
|
||||||
|
>
|
||||||
|
<Icon size={size === 'sm' ? 14 : 16} strokeWidth={2.1} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The key of a tool's own action name, for the tools TREK names one for. */
|
||||||
|
const toolKey = (tool: string) => `assistant.tool.${tool}`;
|
||||||
|
|
||||||
|
/** What the change does: the tool's own action where TREK names it, else add, change or remove. */
|
||||||
|
function verbLabel(preview: AssistantChangePreview, t: (key: string) => string): string {
|
||||||
|
const own = preview.tool ? toolKey(preview.tool) : null;
|
||||||
|
const named = own ? t(own) : null;
|
||||||
|
return named && named !== own ? named : t(`assistant.change.verb.${preview.verbKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeHead({ preview, struck = false }: { preview: AssistantChangePreview | null; struck?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!preview) return <span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />;
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<bdi className={`truncate text-body font-semibold leading-tight text-content ${struck ? 'line-through' : ''}`}>
|
||||||
|
{preview.subject}
|
||||||
|
</bdi>
|
||||||
|
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
|
||||||
|
<span className="truncate">{verbLabel(preview, t)}</span>
|
||||||
|
{preview.domain && (
|
||||||
|
// Where in TREK a change outside the trip plan lands: the vacation planner, the journal.
|
||||||
|
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
|
||||||
|
{t(`assistant.domain.${preview.domain}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The time now, ticking once more when the next undo window closes. */
|
||||||
|
function useUndoClock(items: ChangeItem[]): number {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const next = Math.min(...items.map((i) => undoLeftMs(i.applied, now)).filter((ms) => ms > 0));
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(next)) return;
|
||||||
|
const timer = window.setTimeout(() => setNow(Date.now()), next + 50);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [next]);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an applied change can still be taken back now. */
|
||||||
|
function undoableNow(item: ChangeItem, undone: Readonly<Record<string, true>>, now: number): boolean {
|
||||||
|
return (
|
||||||
|
item.state === 'applied' &&
|
||||||
|
!undone[item.toolCallId] &&
|
||||||
|
!!item.preview?.undoable &&
|
||||||
|
undoLeftMs(item.applied, now) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The waiting changes the user left on that touch the day plan. */
|
||||||
|
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
|
||||||
|
return items.flatMap((i) =>
|
||||||
|
i.state === 'pending' && i.preview?.effect && !excluded.has(i.toolCallId)
|
||||||
|
? [{ toolCallId: i.toolCallId, effect: i.preview.effect, subject: i.preview.subject }]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an applied change can be shown as on the map; a kept note, a todo or an expense has no place there. */
|
||||||
|
const ON_MAP: ReadonlySet<AssistantApplied['entity']> = new Set(['place', 'assignment', 'day_note', 'reservation']);
|
||||||
|
|
||||||
|
function Receipt({
|
||||||
|
item,
|
||||||
|
undone,
|
||||||
|
canUndo,
|
||||||
|
onUndo,
|
||||||
|
}: {
|
||||||
|
item: ChangeItem;
|
||||||
|
undone: boolean;
|
||||||
|
canUndo: boolean;
|
||||||
|
onUndo: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { onShowOnMap } = useAssistantView();
|
||||||
|
if (item.state === 'pending') return null;
|
||||||
|
const receipt = undone ? RECEIPT.discarded : RECEIPT[item.state];
|
||||||
|
const { Icon } = receipt;
|
||||||
|
const applied = item.state === 'applied' && !undone ? item.applied : null;
|
||||||
|
const mappable = applied !== null && ON_MAP.has(applied.entity);
|
||||||
|
const settled = item.state === 'discarded' || item.state === 'expired' || undone;
|
||||||
|
const statusLabel = t(undone ? 'assistant.change.undone' : receipt.labelKey);
|
||||||
|
return (
|
||||||
|
<div className="trek-assistant-enter flex items-center gap-2.5 py-1.5">
|
||||||
|
<KindTile verbKey={item.preview?.verbKey ?? null} size="sm" dimmed={settled} />
|
||||||
|
<div className={`min-w-0 flex-1 ${settled ? 'opacity-60' : ''}`}>
|
||||||
|
<ChangeHead preview={item.preview} struck={settled} />
|
||||||
|
</div>
|
||||||
|
{applied && mappable && onShowOnMap && (
|
||||||
|
<Tooltip label={t('assistant.change.showOnMap')} placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onShowOnMap(applied)}
|
||||||
|
aria-label={t('assistant.change.showOnMap')}
|
||||||
|
className={ICON_BUTTON}
|
||||||
|
>
|
||||||
|
<MapPin size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{canUndo && (
|
||||||
|
<Tooltip label={t('assistant.change.undo')} placement="top">
|
||||||
|
<button type="button" onClick={onUndo} aria-label={t('assistant.change.undo')} className={ICON_BUTTON}>
|
||||||
|
<Undo2 size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label={statusLabel} placement="top">
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={statusLabel}
|
||||||
|
className={`flex h-7 w-5 items-center justify-center ${receipt.tone}`}
|
||||||
|
>
|
||||||
|
<Icon size={15} strokeWidth={2.4} className={item.state === 'applying' ? 'animate-spin' : ''} />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A change waiting for its decision, as a row of the planner's day list: the
|
||||||
|
* round choice in front (only when there is more than one to choose from),
|
||||||
|
* what it touches in bold, and a faint line saying what happens and when.
|
||||||
|
* Resting on it lights its pin on the map.
|
||||||
|
*/
|
||||||
|
function PendingRow({
|
||||||
|
item,
|
||||||
|
excluded,
|
||||||
|
withChoice,
|
||||||
|
disabled,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
item: ChangeItem;
|
||||||
|
excluded: boolean;
|
||||||
|
withChoice: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tripId } = useAssistantView();
|
||||||
|
const { litFromMap, handlers } = useChangeHover(tripId, item.preview?.effect ? item.toolCallId : null);
|
||||||
|
const preview = item.preview;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...handlers}
|
||||||
|
className={`-mx-1.5 flex items-center gap-2.5 rounded-[12px] px-1.5 py-2 transition-[opacity,background-color] ${
|
||||||
|
excluded ? 'opacity-50' : ''
|
||||||
|
} ${litFromMap ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
|
||||||
|
>
|
||||||
|
{withChoice && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={!excluded}
|
||||||
|
aria-label={t('assistant.change.include')}
|
||||||
|
className={`grid h-[18px] w-[18px] flex-none place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 ${
|
||||||
|
excluded ? 'border-[1.5px] border-edge bg-surface-card' : 'bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!excluded && <Check size={11} strokeWidth={3} aria-hidden="true" className="text-accent-text" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<KindTile verbKey={preview?.verbKey ?? null} size="sm" dimmed={excluded} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{preview ? (
|
||||||
|
<>
|
||||||
|
<bdi className="block truncate text-body font-semibold leading-tight text-content">{preview.subject}</bdi>
|
||||||
|
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
|
||||||
|
<span className="flex-shrink-0">{verbLabel(preview, t)}</span>
|
||||||
|
{preview.domain && (
|
||||||
|
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
|
||||||
|
{t(`assistant.domain.${preview.domain}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<AssistantChangeFields rows={preview.rows} />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
|
||||||
|
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="mb-1 flex min-h-7 items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content-secondary">
|
||||||
|
{t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||||
|
</span>
|
||||||
|
{actions}
|
||||||
|
<span className="text-caption tabular-nums text-content-faint">{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On the phone the map hides behind the chat: this shrinks the chat and frames what the card would change. */
|
||||||
|
function ViewOnMapButton({ changes }: { changes: readonly DayChange[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tripId } = useAssistantView();
|
||||||
|
const requestCamera = useAssistantStore((s) => s.requestCamera);
|
||||||
|
const setMinimized = useAssistantStore((s) => s.setMinimized);
|
||||||
|
const show = () => {
|
||||||
|
const { assignments, places } = useTripStore.getState();
|
||||||
|
requestCamera(tripId, changePoints(changes, assignments, places), 'fit', 'user');
|
||||||
|
setMinimized(true);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Tooltip label={t('assistant.change.viewOnMap')} placement="top">
|
||||||
|
<button type="button" onClick={show} aria-label={t('assistant.change.viewOnMap')} className={ICON_BUTTON}>
|
||||||
|
<MapPin size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes back every change of the block that still can be, the last one first, and says how it went. */
|
||||||
|
function UndoAllButton({ toolCallIds }: { toolCallIds: readonly string[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const { tripId } = useAssistantView();
|
||||||
|
const undoBlock = useAssistantStore((s) => s.undoBlock);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const run = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const { done, stale, failed } = await undoBlock(tripId, toolCallIds);
|
||||||
|
if (failed > 0) toast.error(t('assistant.change.undoAllFailed', { count: failed }));
|
||||||
|
else if (stale > 0) toast.warning(t('assistant.change.undoAllPartial', { done, stale }));
|
||||||
|
else toast.success(t('assistant.change.undoneAll', { count: done }));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Tooltip label={t('assistant.change.undoAll')} placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void run()}
|
||||||
|
aria-label={t('assistant.change.undoAll')}
|
||||||
|
className={ICON_BUTTON}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Undo2 size={14} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the head offers: on a waiting card the way to the map on the phone, on a receipt the block as a whole. */
|
||||||
|
function HeadActions({
|
||||||
|
open,
|
||||||
|
items,
|
||||||
|
changes,
|
||||||
|
undoable,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
items: readonly ChangeItem[];
|
||||||
|
changes: readonly DayChange[];
|
||||||
|
undoable: readonly string[];
|
||||||
|
}) {
|
||||||
|
const { tripId, variant, onShowOnMap } = useAssistantView();
|
||||||
|
const here = useTripStore((s) => s.trip?.id === tripId);
|
||||||
|
if (open) {
|
||||||
|
const toMap = variant === 'fullscreen' && !!onShowOnMap && here && changes.length > 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AssistantFitCheck changes={changes} />
|
||||||
|
{toMap && <ViewOnMapButton changes={changes} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{onShowOnMap && <AssistantCompareSwitch tripId={tripId} blockKey={blockKeyOf(items.map((i) => i.toolCallId))} />}
|
||||||
|
{undoable.length >= 2 && <UndoAllButton toolCallIds={undoable} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssistantChangeCard({ items }: { items: ChangeItem[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const { tripId } = useAssistantView();
|
||||||
|
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
|
||||||
|
const undone = useAssistantStore((s) => chatOf(s, tripId).undone);
|
||||||
|
const decide = useAssistantStore((s) => s.decide);
|
||||||
|
const undo = useAssistantStore((s) => s.undo);
|
||||||
|
const [excluded, setExcluded] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
|
const now = useUndoClock(items);
|
||||||
|
|
||||||
|
const pending = items.filter((i) => i.state === 'pending');
|
||||||
|
const chosen = pending.filter((i) => !excluded.has(i.toolCallId)).length;
|
||||||
|
const changes = useMemo(() => dayChangesOf(items, excluded), [items, excluded]);
|
||||||
|
const undoable = items.filter((i) => undoableNow(i, undone, now)).map((i) => i.toolCallId);
|
||||||
|
|
||||||
|
const week = useWeekDays(pending);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
// A plan over a week reads from its grid; the single changes wait behind one line.
|
||||||
|
const folded = week.length > 0 && !showAll;
|
||||||
|
|
||||||
|
const toggleDay = (ids: readonly string[], include: boolean) =>
|
||||||
|
setExcluded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of ids) {
|
||||||
|
if (include) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = (toolCallId: string) =>
|
||||||
|
setExcluded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (!next.delete(toolCallId)) next.add(toolCallId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (applyChosen: boolean) =>
|
||||||
|
decide(
|
||||||
|
tripId,
|
||||||
|
pending.map((i) => ({ toolCallId: i.toolCallId, approved: applyChosen && !excluded.has(i.toolCallId) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// An undone change reads as undone from the store; only what stays needs a word.
|
||||||
|
const runUndo = async (toolCallId: string) => {
|
||||||
|
const outcome = await undo(tripId, toolCallId);
|
||||||
|
if (outcome === 'stale') toast.warning(t('assistant.change.undoStale'));
|
||||||
|
else if (outcome === 'failed') toast.error(t('assistant.change.undoFailed'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = pending.length > 0;
|
||||||
|
return (
|
||||||
|
<section className="trek-popover-enter rounded-xl border border-edge-faint bg-surface-card px-3 pb-2 pt-2.5 shadow-card">
|
||||||
|
<CardHead
|
||||||
|
open={open}
|
||||||
|
count={items.length}
|
||||||
|
actions={<HeadActions open={open} items={items} changes={changes} undoable={undoable} />}
|
||||||
|
/>
|
||||||
|
{open && week.length > 0 && (
|
||||||
|
<AssistantWeekOverview days={week} excluded={excluded} disabled={isRunning} onToggleDay={toggleDay} />
|
||||||
|
)}
|
||||||
|
{folded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAll(true)}
|
||||||
|
className="mb-1 w-full rounded-lg px-2 py-1.5 text-center text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
||||||
|
>
|
||||||
|
{t('assistant.week.showAll', { count: pending.length })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
{items.map((item) =>
|
||||||
|
item.state === 'pending' ? (
|
||||||
|
folded ? null : (
|
||||||
|
<PendingRow
|
||||||
|
key={item.toolCallId}
|
||||||
|
item={item}
|
||||||
|
excluded={excluded.has(item.toolCallId)}
|
||||||
|
withChoice={pending.length > 1}
|
||||||
|
disabled={isRunning}
|
||||||
|
onToggle={() => toggle(item.toolCallId)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Receipt
|
||||||
|
key={item.toolCallId}
|
||||||
|
item={item}
|
||||||
|
undone={!!undone[item.toolCallId]}
|
||||||
|
canUndo={undoable.includes(item.toolCallId)}
|
||||||
|
onUndo={() => void runUndo(item.toolCallId)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<p className="sr-only" role="status">
|
||||||
|
{t('assistant.change.pending', { count: pending.length })}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-edge-faint pt-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isRunning}
|
||||||
|
onClick={() => void submit(false)}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-body text-content-secondary hover:bg-surface-hover hover:text-content disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('assistant.change.discard')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isRunning || chosen === 0}
|
||||||
|
onClick={() => void submit(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-body font-medium text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check size={14} aria-hidden="true" />
|
||||||
|
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CalendarClock,
|
||||||
|
CalendarDays,
|
||||||
|
Clock,
|
||||||
|
Hash,
|
||||||
|
ListOrdered,
|
||||||
|
LogIn,
|
||||||
|
LogOut,
|
||||||
|
type LucideIcon,
|
||||||
|
Tag,
|
||||||
|
Wallet,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The few facts that tell a change apart at a glance, on the faint line under
|
||||||
|
* its name the way the planner's day list shows a place: when, what kind,
|
||||||
|
* what it costs, a new order. A value that changes reads old to new. The rest
|
||||||
|
* of what the change sets is not listed: the planner shows it once applied.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The fields worth a place on the line, each with its glyph, in this order. */
|
||||||
|
const KEY_FIELDS: Partial<Record<AssistantChangeLabelKey, LucideIcon>> = {
|
||||||
|
day: CalendarDays,
|
||||||
|
time: Clock,
|
||||||
|
endTime: Clock,
|
||||||
|
checkIn: LogIn,
|
||||||
|
checkOut: LogOut,
|
||||||
|
dueDate: CalendarClock,
|
||||||
|
category: Tag,
|
||||||
|
price: Wallet,
|
||||||
|
quantity: Hash,
|
||||||
|
order: ListOrdered,
|
||||||
|
};
|
||||||
|
const KEY_ORDER = Object.keys(KEY_FIELDS) as AssistantChangeLabelKey[];
|
||||||
|
const MAX_FACTS = 3;
|
||||||
|
|
||||||
|
/** "#3" is how a day comes from the server; it reads as "Day 3". */
|
||||||
|
function shown(row: AssistantChangeRow, value: string | null | undefined, dayWord: string): string {
|
||||||
|
if (row.labelKey === 'day' && value && /^#\d+$/.test(value)) return `${dayWord} ${value.slice(1)}`;
|
||||||
|
return value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssistantChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dayWord = t('assistant.week.day');
|
||||||
|
const facts = rows
|
||||||
|
.filter((row) => row.labelKey in KEY_FIELDS && row.after)
|
||||||
|
.sort((a, b) => KEY_ORDER.indexOf(a.labelKey) - KEY_ORDER.indexOf(b.labelKey))
|
||||||
|
.slice(0, MAX_FACTS);
|
||||||
|
if (facts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 items-center gap-2.5">
|
||||||
|
{facts.map((row, i) => {
|
||||||
|
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
|
||||||
|
const changed = row.op !== 'add' && !!row.before;
|
||||||
|
return (
|
||||||
|
<span key={`${row.labelKey}-${i}`} className="inline-flex min-w-0 items-center gap-1">
|
||||||
|
<Icon size={11} aria-hidden="true" className="flex-shrink-0" />
|
||||||
|
{changed && (
|
||||||
|
<>
|
||||||
|
<bdi className="truncate line-through">{shown(row, row.before, dayWord)}</bdi>
|
||||||
|
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 rtl:-scale-x-100" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<bdi className="truncate text-content-muted">{shown(row, row.after, dayWord)}</bdi>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Before" and "after" of the block of changes applied last, as a small
|
||||||
|
* segmented switch: "before" lays the days it touched back the way they were
|
||||||
|
* on the map, "after" shows the plan as it is. Only there while this session
|
||||||
|
* holds the plan from before the block ran; a thread opened after a reload has
|
||||||
|
* no switch. Without a `blockKey` it follows whichever block is compared.
|
||||||
|
* `tall` gives each side a thumb's room, for the phone, where a tooltip
|
||||||
|
* would never show.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MODES = [
|
||||||
|
{ mode: 'before', labelKey: 'assistant.change.before' },
|
||||||
|
{ mode: 'after', labelKey: 'assistant.change.after' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface AssistantCompareSwitchProps {
|
||||||
|
tripId: number;
|
||||||
|
blockKey?: string;
|
||||||
|
tall?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssistantCompareSwitch({ tripId, blockKey, tall = false }: AssistantCompareSwitchProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const current = useAssistantStore((s) => {
|
||||||
|
const compare = chatOf(s, tripId).compare;
|
||||||
|
return compare && (blockKey === undefined || compare.blockKey === blockKey) ? compare.mode : null;
|
||||||
|
});
|
||||||
|
const setCompare = useAssistantStore((s) => s.setCompare);
|
||||||
|
if (!current) return null;
|
||||||
|
const label = t('assistant.change.compare');
|
||||||
|
const group = (
|
||||||
|
<div role="group" aria-label={label} className="inline-flex flex-shrink-0 rounded-lg bg-surface-secondary p-0.5">
|
||||||
|
{MODES.map(({ mode, labelKey }) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={current === mode}
|
||||||
|
onClick={() => setCompare(tripId, mode)}
|
||||||
|
className={`rounded-md text-caption font-medium transition-colors ${
|
||||||
|
tall ? 'h-9 min-w-[44px] px-3' : 'px-2 py-0.5'
|
||||||
|
} ${current === mode ? 'bg-surface-card text-content shadow-card' : 'text-content-muted hover:text-content'}`}
|
||||||
|
>
|
||||||
|
{t(labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (tall) return group;
|
||||||
|
return (
|
||||||
|
<Tooltip label={label} placement="top">
|
||||||
|
{group}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-MENTION-001 to FE-AI-MENTION-007
|
||||||
|
*
|
||||||
|
* `@` in the composer on the real @assistant-ui runtime: the list of the
|
||||||
|
* trip's days, places and bookings opens only at the start of a word, Enter
|
||||||
|
* picks from it instead of sending, Escape closes it and keeps the text, and
|
||||||
|
* a pick leaves `@label` in the text, with no chip above it: the text is what
|
||||||
|
* the question names.
|
||||||
|
*/
|
||||||
|
import type { AiStatusResponse } from '@trek/shared';
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { buildDay, buildPlace, buildReservation, buildTrip } from '../../../tests/helpers/factories';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import AssistantSurface from './AssistantSurface';
|
||||||
|
|
||||||
|
const TRIP = 4;
|
||||||
|
const INITIAL = useAssistantStore.getState();
|
||||||
|
const INITIAL_TRIP = useTripStore.getState();
|
||||||
|
|
||||||
|
const STATUS: AiStatusResponse = {
|
||||||
|
available: true,
|
||||||
|
policy: 'instance',
|
||||||
|
canConnectOwn: false,
|
||||||
|
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
|
||||||
|
own: null,
|
||||||
|
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
|
||||||
|
quota: null,
|
||||||
|
privacyAcked: true,
|
||||||
|
retention: { mode: 'days', days: 90 },
|
||||||
|
openrouterOAuth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = vi.fn(async () => {});
|
||||||
|
|
||||||
|
function seed(tripInPlanner = TRIP) {
|
||||||
|
useAssistantStore.setState({
|
||||||
|
status: STATUS,
|
||||||
|
send,
|
||||||
|
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [] } },
|
||||||
|
});
|
||||||
|
useTripStore.setState({
|
||||||
|
trip: buildTrip({ id: tripInPlanner, title: 'Paris' }),
|
||||||
|
days: [buildDay({ id: 11, title: 'Arrival', date: '2026-11-02' }), buildDay({ id: 12, title: null, date: null })],
|
||||||
|
places: [buildPlace({ id: 21, name: 'Louvre' }), buildPlace({ id: 22, name: 'Bouillon Pigalle' })],
|
||||||
|
reservations: [buildReservation({ id: 31, title: 'Dinner at Le Train Bleu', day_id: 11 })],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function input(): Promise<HTMLTextAreaElement> {
|
||||||
|
render(<AssistantSurface tripId={TRIP} tripTitle="Paris" variant="window" />);
|
||||||
|
return (await screen.findByRole('textbox', { name: 'Ask anything about Paris' })) as HTMLTextAreaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentions = () => chatOf(useAssistantStore.getState(), TRIP).mentions;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// jsdom lays nothing out and has no element scrolling; the viewport only needs the call to exist.
|
||||||
|
Element.prototype.scrollTo = vi.fn() as unknown as typeof Element.prototype.scrollTo;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => send.mockClear());
|
||||||
|
afterEach(() => {
|
||||||
|
useAssistantStore.setState(INITIAL, true);
|
||||||
|
useTripStore.setState(INITIAL_TRIP, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AssistantComposer mentions', () => {
|
||||||
|
it('FE-AI-MENTION-001: typing after @ lists matches, and Enter picks one instead of sending', async () => {
|
||||||
|
seed();
|
||||||
|
const field = await input();
|
||||||
|
expect(screen.getByText('Type @ to mention days, places and bookings.')).toBeInTheDocument();
|
||||||
|
fireEvent.change(field, { target: { value: 'Is @lou' } });
|
||||||
|
|
||||||
|
expect(await screen.findByRole('option', { name: 'Louvre' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('option', { name: 'Bouillon Pigalle' })).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.keyDown(field, { key: 'Enter' });
|
||||||
|
await waitFor(() => expect(field.value).toBe('Is @Louvre '));
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
// A browser reports where the caret went after the pick; jsdom does not on its own.
|
||||||
|
fireEvent.select(field);
|
||||||
|
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
|
||||||
|
expect(mentions()).toEqual([{ kind: 'place', id: 21, label: 'Louvre' }]);
|
||||||
|
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Is @Louvre ');
|
||||||
|
// The pick shows in the text only, never as a chip above it.
|
||||||
|
expect(screen.queryByRole('button', { name: 'Remove context' })).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.change(field, { target: { value: 'Is @Louvre open late?' } });
|
||||||
|
fireEvent.keyDown(field, { key: 'Enter' });
|
||||||
|
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Is @Louvre open late?'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-002: a bare @ offers the kinds, and a kind opens its entries', async () => {
|
||||||
|
seed();
|
||||||
|
const field = await input();
|
||||||
|
fireEvent.change(field, { target: { value: '@' } });
|
||||||
|
|
||||||
|
expect(await screen.findByRole('option', { name: 'Days' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('option', { name: 'Places' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('option', { name: 'Bookings' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: 'Days' }));
|
||||||
|
expect(await screen.findByRole('option', { name: /Arrival/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('option', { name: 'Day 2' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// The heading leads back to the kinds.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Days' }));
|
||||||
|
expect(await screen.findByRole('option', { name: 'Places' })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: 'Days' }));
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('option', { name: /Arrival/ }));
|
||||||
|
await waitFor(() => expect(field.value).toBe('@Arrival '));
|
||||||
|
expect(mentions()).toEqual([{ kind: 'day', id: 11, label: 'Arrival' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-003: Escape closes the list and keeps what was typed', async () => {
|
||||||
|
seed();
|
||||||
|
const field = await input();
|
||||||
|
fireEvent.change(field, { target: { value: 'near @bou' } });
|
||||||
|
expect(await screen.findByRole('listbox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(field, { key: 'Escape' });
|
||||||
|
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
|
||||||
|
expect(field.value).toBe('near @bou');
|
||||||
|
expect(mentions()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-004: @ inside a word, such as an email address, opens nothing', async () => {
|
||||||
|
seed();
|
||||||
|
const field = await input();
|
||||||
|
fireEvent.change(field, { target: { value: 'mail@lou' } });
|
||||||
|
expect(screen.queryByRole('listbox')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-005: says so when nothing matches', async () => {
|
||||||
|
seed();
|
||||||
|
const field = await input();
|
||||||
|
fireEvent.change(field, { target: { value: '@zzz' } });
|
||||||
|
expect(await screen.findByText('Nothing found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-006: a mention is taken back by deleting its @label, with no chip row to keep in step', async () => {
|
||||||
|
seed();
|
||||||
|
const field = await input();
|
||||||
|
fireEvent.change(field, { target: { value: 'Dinner after @Din' } });
|
||||||
|
fireEvent.click(await screen.findByRole('option', { name: /Dinner at Le Train Bleu/ }));
|
||||||
|
await waitFor(() => expect(field.value).toBe('Dinner after @Dinner at Le Train Bleu '));
|
||||||
|
expect(screen.queryByRole('button', { name: 'Remove context' })).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.change(field, { target: { value: 'Dinner after ' } });
|
||||||
|
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Dinner after ');
|
||||||
|
expect(screen.queryByText('Dinner at Le Train Bleu')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-007: for a trip the planner does not show, @ stays plain text', async () => {
|
||||||
|
seed(99);
|
||||||
|
const field = await input();
|
||||||
|
fireEvent.change(field, { target: { value: '@lou' } });
|
||||||
|
expect(screen.queryByRole('listbox')).toBeNull();
|
||||||
|
expect(screen.queryByText('Type @ to mention days, places and bookings.')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { AuiIf, ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react';
|
||||||
|
import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-react';
|
||||||
|
import { type ReactNode, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import PlaceAvatar from '../shared/PlaceAvatar';
|
||||||
|
import Tooltip from '../shared/Tooltip';
|
||||||
|
import AssistantMentionPopover from './AssistantMentionPopover';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
import { useAssistantAttach } from './useAssistantAttach';
|
||||||
|
import { useAssistantFocus } from './useAssistantFocus';
|
||||||
|
import { useAssistantMentions } from './useAssistantMentions';
|
||||||
|
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||||
|
import { useTypewriterPlaceholder } from './useTypewriterPlaceholder';
|
||||||
|
|
||||||
|
/** Send and Stop share one spot; on the phone it is a full thumb's size. */
|
||||||
|
const ACTION = {
|
||||||
|
window: 'h-9 w-9',
|
||||||
|
fullscreen: 'h-11 w-11',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the user writes. One rounded shell with the text and, in the same
|
||||||
|
* spot, Send or Stop. Enter sends, Shift+Enter breaks the line, Escape stops
|
||||||
|
* a running answer. The one chip above the text is the focus the question is
|
||||||
|
* about, and can be removed. `@` at the start of a word opens the trip's days,
|
||||||
|
* places and bookings to name; a pick stays as `@label` in the text, which is
|
||||||
|
* what the question names, and shows as a chip only in the sent bubble. While
|
||||||
|
* that list is open, Enter picks and Escape closes it. While the field is empty,
|
||||||
|
* its placeholder writes out things worth asking about this trip, one after
|
||||||
|
* another.
|
||||||
|
*
|
||||||
|
* The text typed so far lives in the assistant store too, so it survives the
|
||||||
|
* window closing or the phone hopping to the map and back.
|
||||||
|
*/
|
||||||
|
export default function AssistantComposer({ tripId, tripTitle }: { tripId: number; tripTitle: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { variant } = useAssistantView();
|
||||||
|
const aui = useAui();
|
||||||
|
const setDraft = useAssistantStore((s) => s.setDraft);
|
||||||
|
const { label, kind, place, clear } = useAssistantFocus(tripId);
|
||||||
|
const mentions = useAssistantMentions(tripId);
|
||||||
|
const { suggestions } = useAssistantSuggestions(tripId);
|
||||||
|
const empty = useAuiState((s) => s.composer.isEmpty);
|
||||||
|
const { take, busy, accept } = useAssistantAttach(tripId);
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
|
||||||
|
const placeholder = useTypewriterPlaceholder(
|
||||||
|
suggestions.map((s) => s.prompt),
|
||||||
|
fallback,
|
||||||
|
empty
|
||||||
|
);
|
||||||
|
|
||||||
|
// Takes back what was typed before the chat was last shown.
|
||||||
|
useEffect(() => {
|
||||||
|
const draft = chatOf(useAssistantStore.getState(), tripId).draft;
|
||||||
|
if (draft) aui.composer.setText(draft);
|
||||||
|
}, [aui, tripId]);
|
||||||
|
|
||||||
|
const FocusIcon = kind === 'place' ? MapPin : CalendarDays;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposerPrimitive.Root className="relative rounded-2xl border border-edge-faint bg-surface-card shadow-card transition-colors focus-within:border-edge">
|
||||||
|
<ComposerPrimitive.TriggerPopoverRoot>
|
||||||
|
<AssistantMentionPopover adapter={mentions.adapter} onInserted={mentions.onInserted} />
|
||||||
|
{label && (
|
||||||
|
<div className="flex flex-wrap gap-1 px-2 pt-2">
|
||||||
|
<ContextChip
|
||||||
|
label={label}
|
||||||
|
icon={place ? <PlaceAvatar place={place} size={18} /> : <FocusIcon size={12} aria-hidden="true" />}
|
||||||
|
round={!!place}
|
||||||
|
onRemove={clear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 p-1.5">
|
||||||
|
<Tooltip label={t('assistant.attach.button')} placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
aria-label={t('assistant.attach.button')}
|
||||||
|
className={`flex ${ACTION[variant]} flex-shrink-0 items-center justify-center rounded-full text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40`}
|
||||||
|
>
|
||||||
|
<Paperclip size={17} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
take(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ComposerPrimitive.Input
|
||||||
|
// The desktop window opens ready to type; on the phone the keyboard waits for a tap.
|
||||||
|
autoFocus={variant === 'window'}
|
||||||
|
dir="auto"
|
||||||
|
minRows={1}
|
||||||
|
maxRows={8}
|
||||||
|
submitMode="enter"
|
||||||
|
addAttachmentOnPaste={false}
|
||||||
|
enterKeyHint="send"
|
||||||
|
aria-label={fallback}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => setDraft(tripId, e.target.value)}
|
||||||
|
onPaste={(e) => {
|
||||||
|
if (e.clipboardData.files.length > 0 && take(e.clipboardData.files)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
className="max-h-48 min-h-[36px] flex-1 resize-none bg-transparent px-1.5 py-1.5 text-body text-content outline-none placeholder:text-content-faint disabled:cursor-not-allowed dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
<AuiIf condition={(s) => s.thread.isRunning}>
|
||||||
|
<ComposerPrimitive.Cancel
|
||||||
|
aria-label={t('assistant.composer.stop')}
|
||||||
|
className={`flex ${ACTION[variant]} flex-shrink-0 items-center justify-center rounded-full bg-accent text-accent-text transition-transform hover:scale-105 hover:bg-accent-hover active:scale-95`}
|
||||||
|
>
|
||||||
|
<Square size={12} fill="currentColor" aria-hidden="true" />
|
||||||
|
</ComposerPrimitive.Cancel>
|
||||||
|
</AuiIf>
|
||||||
|
<AuiIf condition={(s) => !s.thread.isRunning}>
|
||||||
|
<SendButton size={ACTION[variant]} />
|
||||||
|
</AuiIf>
|
||||||
|
</div>
|
||||||
|
</ComposerPrimitive.TriggerPopoverRoot>
|
||||||
|
</ComposerPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One thing the question is about, above the text, with the cross that takes it back. */
|
||||||
|
function ContextChip({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
round,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
/** A place's picture fills the chip's round end, so the chip starts closer to it. */
|
||||||
|
round: boolean;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full bg-surface-secondary py-0.5 pe-1 text-caption text-content-secondary ${round ? 'ps-0.5' : 'ps-2'}`}
|
||||||
|
>
|
||||||
|
<span className="flex flex-shrink-0">{icon}</span>
|
||||||
|
<bdi className="truncate">{label}</bdi>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label={t('assistant.context.remove')}
|
||||||
|
className="rounded-full p-0.5 text-content-faint hover:bg-surface-hover hover:text-content"
|
||||||
|
>
|
||||||
|
<X size={12} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendButton({ size }: { size: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Sending waits while a proposed change is undecided; the tooltip says so.
|
||||||
|
const blocked = useAuiState((s) => s.thread.isSendDisabled);
|
||||||
|
const button = (
|
||||||
|
<ComposerPrimitive.Send
|
||||||
|
aria-label={t('assistant.composer.send')}
|
||||||
|
className={`flex ${size} flex-shrink-0 items-center justify-center rounded-full bg-accent text-accent-text shadow-card transition-[transform,opacity,box-shadow] duration-150 hover:scale-105 hover:bg-accent-hover hover:shadow-dropdown active:scale-95 disabled:scale-100 disabled:opacity-30 disabled:shadow-none`}
|
||||||
|
>
|
||||||
|
<ArrowUp size={17} strokeWidth={2.4} aria-hidden="true" />
|
||||||
|
</ComposerPrimitive.Send>
|
||||||
|
);
|
||||||
|
// A disabled button gets no pointer events, so the tooltip that says why hangs on a wrapper.
|
||||||
|
return blocked ? (
|
||||||
|
<Tooltip label={t('assistant.composer.decideFirst')} placement="top">
|
||||||
|
<span className="inline-flex">{button}</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
button
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { AssistantChangeEffect, AssistantMessage } from '@trek/shared';
|
||||||
|
import { ArrowRightLeft, Minus, Sparkles } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { changeEffects } from '../../store/assistantMapModel';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
|
||||||
|
/** What a waiting change does to one day, as a row of the preview. */
|
||||||
|
interface PreviewRow {
|
||||||
|
toolCallId: string;
|
||||||
|
kind: 'add' | 'in' | 'remove' | 'out';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The changes no one has decided on yet, by their call. */
|
||||||
|
function waitingCalls(messages: readonly AssistantMessage[]): Set<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const m of messages) {
|
||||||
|
for (const p of m.parts) {
|
||||||
|
if (p.type === 'tool-call' && p.approval && !p.approval.resolution && p.approval.approved === undefined) {
|
||||||
|
ids.add(p.toolCallId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowOf(
|
||||||
|
toolCallId: string,
|
||||||
|
effect: AssistantChangeEffect,
|
||||||
|
dayId: number,
|
||||||
|
nameOf: (id: number | null) => string | null
|
||||||
|
) {
|
||||||
|
const name = effect.place?.name ?? nameOf(effect.placeId);
|
||||||
|
if (!name) return null;
|
||||||
|
if (effect.dayId === dayId) {
|
||||||
|
const kind = effect.kind === 'remove' ? 'remove' : effect.kind === 'move' ? 'in' : 'add';
|
||||||
|
return { toolCallId, kind, name } satisfies PreviewRow;
|
||||||
|
}
|
||||||
|
if (effect.kind === 'move' && effect.fromDayId === dayId)
|
||||||
|
return { toolCallId, kind: 'out', name } satisfies PreviewRow;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOOK: Record<PreviewRow['kind'], { icon: typeof Sparkles; row: string; name: string; badgeKey: string }> = {
|
||||||
|
add: {
|
||||||
|
icon: Sparkles,
|
||||||
|
row: 'border-dashed border-accent bg-accent-subtle',
|
||||||
|
name: 'text-content',
|
||||||
|
badgeKey: 'assistant.preview.suggested',
|
||||||
|
},
|
||||||
|
in: {
|
||||||
|
icon: ArrowRightLeft,
|
||||||
|
row: 'border-dashed border-accent bg-accent-subtle',
|
||||||
|
name: 'text-content',
|
||||||
|
badgeKey: 'assistant.preview.movedHere',
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
icon: Minus,
|
||||||
|
row: 'border-dashed border-edge',
|
||||||
|
name: 'text-content-faint line-through',
|
||||||
|
badgeKey: 'assistant.preview.removed',
|
||||||
|
},
|
||||||
|
out: {
|
||||||
|
icon: ArrowRightLeft,
|
||||||
|
row: 'border-dashed border-edge',
|
||||||
|
name: 'text-content-faint line-through',
|
||||||
|
badgeKey: 'assistant.preview.movedAway',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The assistant's waiting changes inside the day they touch, in the planner's
|
||||||
|
* own day list: a place it would add as a dashed row, one it would take off
|
||||||
|
* struck through, a move on both of its days. Resting on a row lights the
|
||||||
|
* change's pin and card; a click brings the chat forward to decide. Nothing
|
||||||
|
* here writes; the change card stays the one place a change is applied.
|
||||||
|
*/
|
||||||
|
export default function AssistantDayPreview({ tripId, dayId }: { tripId: number; dayId: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
|
||||||
|
const hovered = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
|
||||||
|
const { setMapHover, open } = useAssistantStore(useShallow((s) => ({ setMapHover: s.setMapHover, open: s.open })));
|
||||||
|
const places = useTripStore((s) => s.places);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const waiting = waitingCalls(messages);
|
||||||
|
if (waiting.size === 0) return [];
|
||||||
|
const nameOf = (id: number | null) => (id === null ? null : (places.find((p) => p.id === id)?.name ?? null));
|
||||||
|
const out: PreviewRow[] = [];
|
||||||
|
for (const [toolCallId, effect] of changeEffects(messages)) {
|
||||||
|
if (!waiting.has(toolCallId)) continue;
|
||||||
|
const row = rowOf(toolCallId, effect, dayId, nameOf);
|
||||||
|
if (row) out.push(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [messages, places, dayId]);
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-1 px-3 pb-2 pt-1" aria-label={t('assistant.preview.title')}>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const look = LOOK[row.kind];
|
||||||
|
const Icon = look.icon;
|
||||||
|
const lit = hovered === row.toolCallId;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${row.toolCallId}-${row.kind}`}
|
||||||
|
className="trek-assistant-enter"
|
||||||
|
style={{ animationDelay: `${i * 50}ms` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={open}
|
||||||
|
onMouseEnter={() => setMapHover(tripId, row.toolCallId, 'card')}
|
||||||
|
onMouseLeave={() => setMapHover(tripId, null, 'card')}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-xl border px-2.5 py-1.5 text-start transition-all ${look.row} ${
|
||||||
|
lit ? 'shadow-md ring-2 ring-accent' : 'hover:shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-lg bg-surface-card text-content-muted shadow-sm">
|
||||||
|
<Icon size={12} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<bdi className={`min-w-0 flex-1 truncate text-caption font-medium ${look.name}`}>{row.name}</bdi>
|
||||||
|
<span className="flex-shrink-0 rounded-full bg-surface-card px-2 py-0.5 text-caption text-content-muted shadow-sm">
|
||||||
|
{t(look.badgeKey)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-ERR-001 to FE-AI-ERR-008
|
||||||
|
*
|
||||||
|
* The error line: one sentence, at most one thing to do, and that thing
|
||||||
|
* does what it says.
|
||||||
|
*/
|
||||||
|
import type { AiStatusResponse, AssistantErrorCode } from '@trek/shared';
|
||||||
|
import { Route, Routes } from 'react-router';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { setForcedOffline } from '../../sync/networkMode';
|
||||||
|
import AssistantError from './AssistantError';
|
||||||
|
|
||||||
|
const TRIP = 4;
|
||||||
|
const INITIAL = useAssistantStore.getState();
|
||||||
|
|
||||||
|
const STATUS: AiStatusResponse = {
|
||||||
|
available: true,
|
||||||
|
policy: 'instance_or_user',
|
||||||
|
canConnectOwn: true,
|
||||||
|
effective: { source: 'user', kind: 'anthropic', model: 'claude', host: 'api.anthropic.com', paidBy: 'user' },
|
||||||
|
own: null,
|
||||||
|
instance: null,
|
||||||
|
quota: null,
|
||||||
|
privacyAcked: true,
|
||||||
|
retention: { mode: 'days', days: 90 },
|
||||||
|
openrouterOAuth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry = vi.fn(async () => {});
|
||||||
|
const continueHere = vi.fn(async () => {});
|
||||||
|
const send = vi.fn(async () => {});
|
||||||
|
|
||||||
|
function withError(error: AssistantErrorCode | null, status: AiStatusResponse = STATUS) {
|
||||||
|
useAssistantStore.setState({
|
||||||
|
status,
|
||||||
|
retry,
|
||||||
|
continueHere,
|
||||||
|
send,
|
||||||
|
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), error } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLine() {
|
||||||
|
return render(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/trips/4" element={<AssistantError tripId={TRIP} />} />
|
||||||
|
<Route path="/settings" element={<p>settings page</p>} />
|
||||||
|
</Routes>,
|
||||||
|
{ initialEntries: ['/trips/4'] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setForcedOffline(false));
|
||||||
|
afterEach(() => {
|
||||||
|
useAssistantStore.setState(INITIAL, true);
|
||||||
|
setForcedOffline(false);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AssistantError', () => {
|
||||||
|
it('FE-AI-ERR-001: shows nothing while all is well', () => {
|
||||||
|
withError(null);
|
||||||
|
const { container } = renderLine();
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-ERR-002: names the provider and retries a provider that was down', () => {
|
||||||
|
withError('provider_unavailable');
|
||||||
|
renderLine();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Anthropic isn’t responding right now.');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||||
|
expect(retry).toHaveBeenCalledWith(TRIP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-ERR-003: a busy chat continues here, and a long run goes on with "Continue"', () => {
|
||||||
|
withError('busy');
|
||||||
|
const { unmount } = renderLine();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Continue here' }));
|
||||||
|
expect(continueHere).toHaveBeenCalledWith(TRIP);
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
withError('max_steps');
|
||||||
|
renderLine();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||||
|
expect(send).toHaveBeenCalledWith(TRIP, 'Continue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-ERR-004: an expired sign-in leads straight to the settings card', () => {
|
||||||
|
withError('needs_reauth');
|
||||||
|
renderLine();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Sign in again' }));
|
||||||
|
expect(screen.getByText('settings page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-ERR-006: an own key that stopped working says so before anything is sent', () => {
|
||||||
|
withError(null, {
|
||||||
|
...STATUS,
|
||||||
|
own: {
|
||||||
|
scope: 'user',
|
||||||
|
kind: 'anthropic',
|
||||||
|
model: 'claude',
|
||||||
|
hint: '…a1b2',
|
||||||
|
status: 'error',
|
||||||
|
host: 'api.anthropic.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderLine();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Replace key' }));
|
||||||
|
expect(screen.getByText('settings page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-ERR-007: a provider limit names the provider, without guessing at its plan', () => {
|
||||||
|
withError('rate_limited');
|
||||||
|
renderLine();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Limit reached at Anthropic. Try again later.');
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-ERR-008: waiting changes ask for a decision before a place can be suggested', () => {
|
||||||
|
withError('decide_first');
|
||||||
|
renderLine();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Decide on the open suggestions first.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-ERR-005: offline wins over any other error and offers nothing to press', () => {
|
||||||
|
withError('provider_unavailable');
|
||||||
|
setForcedOffline(true);
|
||||||
|
renderLine();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Offline. The assistant needs internet.');
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { CircleAlert, WifiOff } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { type AssistantErrorActionKind, connectionErrorOf, errorView } from './assistantModel';
|
||||||
|
import { AI_SETTINGS_PATH } from './assistantParts';
|
||||||
|
import { CHATGPT_USAGE_URL, providerLabel } from './providerCatalog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the assistant cannot go on, as one line above the composer with at
|
||||||
|
* most one thing to do about it: try again, continue, sign in again, and so
|
||||||
|
* on. Offline needs no button; the line says why the composer is off.
|
||||||
|
*/
|
||||||
|
export default function AssistantError({ tripId }: { tripId: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { offline } = useNetworkMode();
|
||||||
|
const error = useAssistantStore((s) => chatOf(s, tripId).error);
|
||||||
|
const status = useAssistantStore((s) => s.status);
|
||||||
|
const retry = useAssistantStore((s) => s.retry);
|
||||||
|
const continueHere = useAssistantStore((s) => s.continueHere);
|
||||||
|
const send = useAssistantStore((s) => s.send);
|
||||||
|
|
||||||
|
const code = offline ? 'offline' : (error ?? connectionErrorOf(status));
|
||||||
|
if (!code) return null;
|
||||||
|
|
||||||
|
const effective = status?.effective;
|
||||||
|
const provider = effective ? providerLabel(effective.kind, effective.host) : '';
|
||||||
|
const view = errorView(code, { canConnectOwn: status?.canConnectOwn ?? false, provider });
|
||||||
|
|
||||||
|
const run = (kind: AssistantErrorActionKind) => {
|
||||||
|
if (kind === 'retry') void retry(tripId);
|
||||||
|
else if (kind === 'continueHere') void continueHere(tripId);
|
||||||
|
// The model asked whether to go on; the answer is the button's own word.
|
||||||
|
else if (kind === 'continue') void send(tripId, t('assistant.errorAction.continue'));
|
||||||
|
else if (kind === 'openSettings') void navigate(AI_SETTINGS_PATH);
|
||||||
|
else window.open(CHATGPT_USAGE_URL, '_blank', 'noopener,noreferrer');
|
||||||
|
};
|
||||||
|
|
||||||
|
const { action } = view;
|
||||||
|
const Icon = code === 'offline' ? WifiOff : CircleAlert;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="trek-assistant-enter flex items-center gap-2.5 rounded-lg bg-surface-secondary px-3 py-2 text-caption text-content-secondary"
|
||||||
|
>
|
||||||
|
<Icon size={14} aria-hidden="true" className="flex-shrink-0 text-warning" />
|
||||||
|
<span className="min-w-0 flex-1">{t(view.messageKey, { provider })}</span>
|
||||||
|
{action && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => run(action.kind)}
|
||||||
|
className="flex-shrink-0 rounded-md px-2 py-1 text-caption font-medium text-accent-on hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
{t(action.labelKey)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { CircleCheck, TriangleAlert } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
import { buildDayAfter, changedDays, type DayChange, type TimelineStop } from './dayAfterModel';
|
||||||
|
|
||||||
|
/** Lines the tooltip names; the rest are only counted on the pill. */
|
||||||
|
const MAX_LINES = 3;
|
||||||
|
|
||||||
|
/** What does not fit on a stop of the day: arriving after the next thing starts, or a way too tight. */
|
||||||
|
function problemOf(stop: TimelineStop, t: (key: string, params?: Record<string, string | number>) => string) {
|
||||||
|
const lead = stop.lead;
|
||||||
|
if (lead?.overlapsWith) return t('assistant.change.timeline.overlap', { place: lead.overlapsWith });
|
||||||
|
if (lead?.shortBy != null) return `${stop.name}: ${t('assistant.change.timeline.tight', { count: lead.shortBy })}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the days a waiting card changes still work out in time, as one
|
||||||
|
* small pill in the card's head: "fits" in green, or how many things to look
|
||||||
|
* at in amber, with what they are on hover. The days themselves show the
|
||||||
|
* suggestion in the planner's day list, so the card does not draw them again.
|
||||||
|
* Shown only while the planner holds this trip, whose day plans it reads.
|
||||||
|
*/
|
||||||
|
export default function AssistantFitCheck({ changes }: { changes: readonly DayChange[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tripId } = useAssistantView();
|
||||||
|
const plan = useTripStore(
|
||||||
|
useShallow((s) => ({ here: s.trip?.id === tripId, days: s.days, assignments: s.assignments, places: s.places }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const check = useMemo(() => {
|
||||||
|
if (!plan.here || changes.length === 0) return null;
|
||||||
|
const stops = changedDays(changes).flatMap((dayId) => {
|
||||||
|
const day = plan.days.find((d) => d.id === dayId);
|
||||||
|
if (!day) return [];
|
||||||
|
return buildDayAfter({
|
||||||
|
dayId,
|
||||||
|
assignments: plan.assignments,
|
||||||
|
places: plan.places,
|
||||||
|
changes,
|
||||||
|
defaultMode: day.default_transport_mode,
|
||||||
|
}).stops;
|
||||||
|
});
|
||||||
|
const measured = stops.some((stop) => stop.lead?.travelMinutes != null);
|
||||||
|
const problems = stops.map((stop) => problemOf(stop, t)).filter((line): line is string => line !== null);
|
||||||
|
return measured || problems.length > 0 ? problems : null;
|
||||||
|
}, [plan, changes, t]);
|
||||||
|
|
||||||
|
if (!check) return null;
|
||||||
|
const ok = check.length === 0;
|
||||||
|
const pill = (
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-6 flex-shrink-0 items-center gap-1 rounded-full px-2 text-caption font-medium ${
|
||||||
|
ok ? 'bg-success-soft text-success' : 'bg-warning-soft text-warning'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ok ? <CircleCheck size={12} aria-hidden="true" /> : <TriangleAlert size={12} aria-hidden="true" />}
|
||||||
|
{ok ? t('assistant.check.ok') : t('assistant.check.issues', { count: check.length })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (ok) return pill;
|
||||||
|
return (
|
||||||
|
<Tooltip label={check.slice(0, MAX_LINES).join('\n')} placement="top">
|
||||||
|
<span tabIndex={0} className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||||
|
{pill}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { AssistantMessage } from '@trek/shared';
|
||||||
|
import { CornerDownRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useCanDo } from '../../store/permissionsStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { followUpsFor } from './assistantFollowUpRules';
|
||||||
|
import { hasUndecided } from './assistantParts';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
|
||||||
|
/** Each chip comes in a moment after the one before it. */
|
||||||
|
const STAGGER_MS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next questions under the latest answer, one tap each. Only under the
|
||||||
|
* last answer, once it is written, and not while changes wait for a decision:
|
||||||
|
* then the cards are what to answer.
|
||||||
|
*/
|
||||||
|
export default function AssistantFollowUps({ message }: { message: AssistantMessage }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tripId } = useAssistantView();
|
||||||
|
const can = useCanDo();
|
||||||
|
const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null));
|
||||||
|
const send = useAssistantStore((s) => s.send);
|
||||||
|
const shown = useAssistantStore((s) => {
|
||||||
|
const chat = chatOf(s, tripId);
|
||||||
|
const last = chat.messages[chat.messages.length - 1];
|
||||||
|
return !chat.isRunning && !chat.error && last?.id === message.id && !hasUndecided(chat.messages);
|
||||||
|
});
|
||||||
|
if (!shown) return null;
|
||||||
|
const chips = followUpsFor(message, !!trip && can('place_edit', trip));
|
||||||
|
if (chips.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label={t('assistant.followUp.title')} className="flex flex-wrap gap-1.5 pt-0.5">
|
||||||
|
{chips.map((chip, i) => {
|
||||||
|
const text = t(chip.labelKey);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={chip.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void send(tripId, text)}
|
||||||
|
className="trek-assistant-enter group/chip inline-flex max-w-full items-center gap-1.5 rounded-full bg-surface-card px-3 py-1.5 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md hover:ring-edge active:translate-y-0"
|
||||||
|
style={{ animationDelay: `${i * STAGGER_MS}ms` }}
|
||||||
|
>
|
||||||
|
<CornerDownRight
|
||||||
|
size={12}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex-shrink-0 text-content-faint transition-colors group-hover/chip:text-content rtl:-scale-x-100"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{text}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
|
||||||
|
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { PopoverItem } from '../Packing/PackingPopover';
|
||||||
|
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||||
|
import { ContextMenu, useContextMenu } from '../shared/ContextMenu';
|
||||||
|
import { NEUTRAL_TINT } from '../shared/DialogShell';
|
||||||
|
import Tooltip from '../shared/Tooltip';
|
||||||
|
import { PopoverListPanel } from '../shared/dialogParts';
|
||||||
|
import { usePopoverList } from '../shared/usePopoverList';
|
||||||
|
import AssistantMemoryDialog from './AssistantMemoryDialog';
|
||||||
|
import { AI_SETTINGS_PATH } from './assistantParts';
|
||||||
|
import { useAssistantHintsSetting } from './useAssistantHints';
|
||||||
|
import { useAssistantLiveStep } from './useAssistantLiveStep';
|
||||||
|
import type { useAssistantThread } from './useAssistantThread';
|
||||||
|
import { useTripMemory } from './useTripMemory';
|
||||||
|
|
||||||
|
// The round, raised buttons of TREK's dialog heads.
|
||||||
|
const ICON_BUTTON =
|
||||||
|
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content';
|
||||||
|
|
||||||
|
interface AssistantHeaderProps {
|
||||||
|
tripId: number;
|
||||||
|
thread: ReturnType<typeof useAssistantThread>;
|
||||||
|
tripTitle: string;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Moves the window: spread on the title, which is the part of the header to grab. */
|
||||||
|
dragProps: HTMLAttributes<HTMLElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window's head, in the look of TREK's dialog heads: a tinted band with
|
||||||
|
* a small line naming the assistant over the conversation's name; the band is where the window is
|
||||||
|
* grabbed to move it. On the end side the recent chats (a new chat starts at
|
||||||
|
* the top of that list), more and close, as round raised buttons. Closing
|
||||||
|
* keeps the conversation, and an answer being written goes on. The model is not picked here;
|
||||||
|
* that lives in the settings.
|
||||||
|
*/
|
||||||
|
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const live = useAssistantLiveStep(tripId);
|
||||||
|
const memory = useTripMemory(tripId);
|
||||||
|
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const menu = useContextMenu();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const threads = thread.threads ?? [];
|
||||||
|
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
|
||||||
|
const { open: historyOpen, triggerRef: historyTrigger, listId: historyListId } = history;
|
||||||
|
const hints = useAssistantHintsSetting();
|
||||||
|
|
||||||
|
const openHistory = () => {
|
||||||
|
if (!history.open) thread.refreshThreads();
|
||||||
|
history.toggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (run: () => void) => {
|
||||||
|
history.close();
|
||||||
|
run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMenu = (e: MouseEvent) =>
|
||||||
|
menu.open(
|
||||||
|
e,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: t('assistant.history.new'),
|
||||||
|
icon: Plus,
|
||||||
|
disabled: thread.threadId === null && !thread.isRunning,
|
||||||
|
onClick: thread.newThread,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn'),
|
||||||
|
icon: hints.on ? LightbulbOff : Lightbulb,
|
||||||
|
onClick: hints.toggle,
|
||||||
|
},
|
||||||
|
{ label: t('assistant.window.settings'), icon: Settings, onClick: () => void navigate(AI_SETTINGS_PATH) },
|
||||||
|
{
|
||||||
|
label: t('assistant.window.deleteThread'),
|
||||||
|
icon: Trash2,
|
||||||
|
danger: true,
|
||||||
|
disabled: thread.threadId === null || thread.isRunning,
|
||||||
|
onClick: () => setConfirming(true),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="flex flex-shrink-0 items-center gap-2 border-b border-edge-faint py-2.5 pe-2.5 ps-4"
|
||||||
|
style={{ background: NEUTRAL_TINT }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...dragProps}
|
||||||
|
className="flex min-w-0 flex-1 cursor-grab touch-none select-none items-center active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{thread.isRunning ? (
|
||||||
|
// While it works, the line over the title says what it does right now.
|
||||||
|
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
|
||||||
|
/>
|
||||||
|
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="m-0 truncate font-geist text-caption font-bold uppercase tracking-[.08em] text-content-muted">
|
||||||
|
{t('assistant.launcher.tooltip')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h2 className="m-0 truncate text-body font-bold tracking-[-0.01em] text-content">
|
||||||
|
<bdi>{thread.title ?? tripTitle}</bdi>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Buttons are no place to start a drag from. */}
|
||||||
|
<div
|
||||||
|
className="flex flex-shrink-0 items-center gap-1"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMemoryOpen(true)}
|
||||||
|
aria-label={t('assistant.memory.title')}
|
||||||
|
className={`relative ${ICON_BUTTON}`}
|
||||||
|
>
|
||||||
|
<NotebookPen size={13} aria-hidden="true" />
|
||||||
|
{memory.has && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -end-0.5 -top-0.5 h-2 w-2 rounded-full bg-accent ring-2 ring-[color:var(--bg-elevated)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('assistant.history.open')}>
|
||||||
|
<button
|
||||||
|
ref={historyTrigger}
|
||||||
|
type="button"
|
||||||
|
onClick={openHistory}
|
||||||
|
aria-label={t('assistant.history.open')}
|
||||||
|
aria-expanded={historyOpen}
|
||||||
|
aria-controls={historyOpen ? historyListId : undefined}
|
||||||
|
className={ICON_BUTTON}
|
||||||
|
>
|
||||||
|
<History size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('assistant.window.menu')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openMenu}
|
||||||
|
aria-label={t('assistant.window.menu')}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className={ICON_BUTTON}
|
||||||
|
>
|
||||||
|
<Ellipsis size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('common.close')}>
|
||||||
|
<button type="button" onClick={onClose} aria-label={t('common.close')} className={ICON_BUTTON}>
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PopoverListPanel list={history} label={t('assistant.history.open')} className="w-72">
|
||||||
|
<PopoverItem
|
||||||
|
icon={<Plus size={14} />}
|
||||||
|
label={t('assistant.history.new')}
|
||||||
|
onClick={() => pick(thread.newThread)}
|
||||||
|
/>
|
||||||
|
{threads.length === 0 ? (
|
||||||
|
<p className="m-0 px-2.5 py-2 text-caption text-content-muted">{t('assistant.history.empty')}</p>
|
||||||
|
) : (
|
||||||
|
threads.map((th) => (
|
||||||
|
<PopoverItem
|
||||||
|
key={th.id}
|
||||||
|
icon={null}
|
||||||
|
label={th.title}
|
||||||
|
active={th.id === thread.threadId}
|
||||||
|
onClick={() => pick(() => thread.openThread(th.id))}
|
||||||
|
trailing={
|
||||||
|
<span className="flex-shrink-0 text-caption text-content-faint">
|
||||||
|
{new Date(th.lastMessageAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' })}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{thread.retentionNote && (
|
||||||
|
<p className="m-0 border-t border-edge-faint px-2.5 pb-1 pt-2 text-caption text-content-faint">
|
||||||
|
{thread.retentionNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</PopoverListPanel>
|
||||||
|
|
||||||
|
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||||
|
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirming}
|
||||||
|
onClose={() => setConfirming(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
void thread.removeThread();
|
||||||
|
}}
|
||||||
|
title={t('assistant.window.deleteThread')}
|
||||||
|
message={t('assistant.window.deleteConfirm')}
|
||||||
|
confirmLabel={t('common.delete')}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-HINTLIST-001 to FE-AI-HINTLIST-006
|
||||||
|
*
|
||||||
|
* The hint list in the empty chat: three rows at most with the rest behind
|
||||||
|
* "n more", a row that asks, an X that waves one away, rows shown counted as
|
||||||
|
* seen, and rows that wait while offline.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import AssistantHints from './AssistantHints';
|
||||||
|
import type { AssistantHintsView, ReadyHint } from './useAssistantHints';
|
||||||
|
|
||||||
|
const network = vi.hoisted(() => ({ offline: false }));
|
||||||
|
vi.mock('../../hooks/useNetworkMode', () => ({
|
||||||
|
useNetworkMode: () => ({ offline: network.offline, forced: false, setForced: () => undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function hint(id: string, level: ReadyHint['level'] = 'info'): ReadyHint {
|
||||||
|
const rule = level === 'warn' ? 'overlap' : 'gap';
|
||||||
|
return { id, rule, level, text: `Line ${id}`, prompt: `Ask ${id}`, focus: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function view(hints: ReadyHint[]): AssistantHintsView {
|
||||||
|
return {
|
||||||
|
hints,
|
||||||
|
unseenWarn: 0,
|
||||||
|
markSeen: vi.fn(),
|
||||||
|
markWarnsSeen: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
ask: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
network.offline = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AssistantHints', () => {
|
||||||
|
it('FE-AI-HINTLIST-001: shows nothing without hints, and three rows with the rest behind "n more"', () => {
|
||||||
|
const { container, unmount } = render(<AssistantHints view={view([])} phone={false} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
const five = view(['a', 'b', 'c', 'd', 'e'].map((id) => hint(id)));
|
||||||
|
render(<AssistantHints view={five} phone={false} />);
|
||||||
|
expect(screen.getByRole('region', { name: 'Worth a look' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
||||||
|
expect(five.markSeen).toHaveBeenLastCalledWith(['a', 'b', 'c']);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
|
||||||
|
expect(screen.getAllByRole('listitem')).toHaveLength(5);
|
||||||
|
expect(five.markSeen).toHaveBeenLastCalledWith(['a', 'b', 'c', 'd', 'e']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINTLIST-002: a row asks its question, and the X waves the hint away', () => {
|
||||||
|
const one = view([hint('a', 'warn')]);
|
||||||
|
render(<AssistantHints view={one} phone={false} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Line a' }));
|
||||||
|
expect(one.ask).toHaveBeenCalledWith(one.hints[0]);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Hide this hint' }));
|
||||||
|
expect(one.dismiss).toHaveBeenCalledWith('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINTLIST-003: on the phone the X is always there, with room for a thumb', () => {
|
||||||
|
render(<AssistantHints view={view([hint('a')])} phone />);
|
||||||
|
const dismiss = screen.getByRole('button', { name: 'Hide this hint' });
|
||||||
|
expect(dismiss.className).toContain('h-10');
|
||||||
|
expect(dismiss.className).not.toContain('opacity-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINTLIST-006: in the window the X hides until hover only on a screen that can hover', () => {
|
||||||
|
render(<AssistantHints view={view([hint('a')])} phone={false} />);
|
||||||
|
const dismiss = screen.getByRole('button', { name: 'Hide this hint' });
|
||||||
|
expect(dismiss.className).toContain('[@media(hover:hover)]:opacity-0');
|
||||||
|
expect(dismiss.className.split(' ')).not.toContain('opacity-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINTLIST-004: offline the rows stay readable but do not ask', () => {
|
||||||
|
network.offline = true;
|
||||||
|
const one = view([hint('a')]);
|
||||||
|
render(<AssistantHints view={one} phone={false} />);
|
||||||
|
const row = screen.getByRole('button', { name: 'Line a' });
|
||||||
|
expect(row).toBeDisabled();
|
||||||
|
fireEvent.click(row);
|
||||||
|
expect(one.ask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { BedDouble, Clock, type LucideIcon, MapPin, Ticket, TriangleAlert, X } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import Tooltip from '../shared/Tooltip';
|
||||||
|
import type { HintRule } from './assistantHintRules';
|
||||||
|
import type { AssistantHintsView, ReadyHint } from './useAssistantHints';
|
||||||
|
|
||||||
|
/** Rows shown before "n more", so the suggestions under them stay in view. */
|
||||||
|
export const MAX_VISIBLE_HINTS = 3;
|
||||||
|
|
||||||
|
/** Warnings all carry the one triangle; a note says what it is about. */
|
||||||
|
const NOTE_ICONS: Partial<Record<HintRule, LucideIcon>> = {
|
||||||
|
gap: Clock,
|
||||||
|
longLeg: MapPin,
|
||||||
|
noStay: BedDouble,
|
||||||
|
unplacedBooking: Ticket,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan hints in the empty chat: a short, quiet list, at most three rows
|
||||||
|
* with the rest behind "n more". A row is the button: it puts its day or
|
||||||
|
* places in focus and asks its question. Each can be waved away. On the
|
||||||
|
* desktop the X shows on hover or focus, on the phone always, with room for
|
||||||
|
* a thumb. Shown means seen, so the launcher stops pointing at them.
|
||||||
|
*/
|
||||||
|
export default function AssistantHints({ view, phone }: { view: AssistantHintsView; phone: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { offline } = useNetworkMode();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const shown = expanded ? view.hints : view.hints.slice(0, MAX_VISIBLE_HINTS);
|
||||||
|
const rest = view.hints.length - shown.length;
|
||||||
|
const shownIds = shown.map((h) => h.id).join('\n');
|
||||||
|
const { markSeen } = view;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shownIds) markSeen(shownIds.split('\n'));
|
||||||
|
}, [shownIds, markSeen]);
|
||||||
|
|
||||||
|
if (view.hints.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section aria-label={t('assistant.hint.title')} className="flex w-full max-w-sm flex-col gap-1.5">
|
||||||
|
<h3 className="m-0 px-1 text-caption font-medium text-content-muted">{t('assistant.hint.title')}</h3>
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||||
|
{shown.map((hint) => (
|
||||||
|
<HintRow key={hint.id} hint={hint} phone={phone} disabled={offline} view={view} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{rest > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
className={`self-start rounded-lg px-1 text-caption text-content-muted hover:text-content ${phone ? 'min-h-[44px]' : 'py-1'}`}
|
||||||
|
>
|
||||||
|
{t('assistant.hint.more', { count: rest })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HintRow({
|
||||||
|
hint,
|
||||||
|
phone,
|
||||||
|
disabled,
|
||||||
|
view,
|
||||||
|
}: {
|
||||||
|
hint: ReadyHint;
|
||||||
|
phone: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
view: AssistantHintsView;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const warn = hint.level === 'warn';
|
||||||
|
const Icon = warn ? TriangleAlert : (NOTE_ICONS[hint.rule] ?? Clock);
|
||||||
|
const dismissLabel = t('assistant.hint.dismiss');
|
||||||
|
return (
|
||||||
|
<li className="group relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => view.ask(hint)}
|
||||||
|
className={`flex w-full items-start gap-2 rounded-xl border border-edge-faint bg-surface-card py-2 ps-3 text-start text-body text-content transition-colors hover:border-edge hover:bg-surface-hover disabled:cursor-default disabled:opacity-60 disabled:hover:border-edge-faint disabled:hover:bg-surface-card ${
|
||||||
|
phone ? 'min-h-[44px] pe-11' : 'pe-9'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={14}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`mt-[3px] flex-shrink-0 ${warn ? 'text-content-secondary' : 'text-content-muted'}`}
|
||||||
|
/>
|
||||||
|
<span dir="auto" className="line-clamp-2 min-w-0 flex-1">
|
||||||
|
{hint.text}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* Hidden until hover only where there is hover: a touch screen at window size shows it always. */}
|
||||||
|
<Tooltip label={dismissLabel}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => view.dismiss(hint.id)}
|
||||||
|
aria-label={dismissLabel}
|
||||||
|
className={`absolute grid place-items-center rounded-lg text-content-faint transition-opacity hover:bg-surface-hover hover:text-content ${
|
||||||
|
phone
|
||||||
|
? 'end-0.5 top-0.5 h-10 w-10'
|
||||||
|
: 'end-1 top-1 h-7 w-7 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:hover)]:opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<X size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-HOST-001 to FE-AI-HOST-005
|
||||||
|
*
|
||||||
|
* The desktop host follows the trip on screen and sends "Show on map" and a
|
||||||
|
* picked day to the planner through its links.
|
||||||
|
*/
|
||||||
|
import type { AssistantApplied } from '@trek/shared';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { act, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import type { Trip } from '../../types';
|
||||||
|
import AssistantHost from './AssistantHost';
|
||||||
|
|
||||||
|
interface WindowProps {
|
||||||
|
trip: { id: number; title: string } | null;
|
||||||
|
onShowOnMap?: (applied: AssistantApplied) => void;
|
||||||
|
onFocusDay?: (dayId: number) => void;
|
||||||
|
}
|
||||||
|
const seen: { props: WindowProps | null } = { props: null };
|
||||||
|
|
||||||
|
vi.mock('./AssistantWindow', () => ({
|
||||||
|
default: (props: WindowProps) => {
|
||||||
|
seen.props = props;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function Location() {
|
||||||
|
const { pathname, search } = useLocation();
|
||||||
|
return <output data-testid="location">{`${pathname}${search}`}</output>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAt(path: string) {
|
||||||
|
return render(
|
||||||
|
<>
|
||||||
|
<AssistantHost />
|
||||||
|
<Location />
|
||||||
|
</>,
|
||||||
|
{ initialEntries: [path] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
|
||||||
|
toolCallId: 'c1',
|
||||||
|
entity,
|
||||||
|
ids,
|
||||||
|
dayId,
|
||||||
|
undoUntil: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const INITIAL = useTripStore.getState();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useTripStore.setState(INITIAL, true);
|
||||||
|
seen.props = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AssistantHost', () => {
|
||||||
|
it('FE-AI-HOST-001: outside a trip the window gets no trip and asks which one', () => {
|
||||||
|
renderAt('/dashboard');
|
||||||
|
expect(seen.props?.trip).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HOST-002: inside a trip it is about that trip, named once the planner has it', () => {
|
||||||
|
renderAt('/trips/5/files');
|
||||||
|
expect(seen.props?.trip).toEqual({ id: 5, title: '' });
|
||||||
|
|
||||||
|
act(() => useTripStore.setState({ trip: { id: 5, title: 'Kyoto' } as Trip }));
|
||||||
|
expect(seen.props?.trip).toEqual({ id: 5, title: 'Kyoto' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HOST-003: "Show on map" opens the planner on the place, or on the day', () => {
|
||||||
|
renderAt('/trips/5/files');
|
||||||
|
act(() => seen.props?.onShowOnMap?.(applied('place', [7], 2)));
|
||||||
|
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?place=7');
|
||||||
|
|
||||||
|
act(() => seen.props?.onShowOnMap?.(applied('day_note', [9], 3)));
|
||||||
|
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HOST-005: a day picked in the chat selects it in the planner', () => {
|
||||||
|
renderAt('/trips/5');
|
||||||
|
act(() => seen.props?.onFocusDay?.(12));
|
||||||
|
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HOST-004: a route that only looks like a trip is none', () => {
|
||||||
|
renderAt('/trips/new');
|
||||||
|
expect(seen.props?.trip).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { AssistantApplied } from '@trek/shared';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useLocation, useMatch, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import AssistantWindow from './AssistantWindow';
|
||||||
|
import { showOnMapSearch } from './assistantShowOnMap';
|
||||||
|
|
||||||
|
/** The trip a route under /trips/:id shows, or null anywhere else. */
|
||||||
|
function useRouteTripId(): number | null {
|
||||||
|
const match = useMatch('/trips/:id/*');
|
||||||
|
const id = Number(match?.params.id);
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The desktop assistant, mounted once above the routes so a conversation
|
||||||
|
* keeps going from page to page. Inside a trip it is about that trip; "Show
|
||||||
|
* on map" opens the planner on the change through its links (`?place=`,
|
||||||
|
* `?day=`), the same way a notification opens a place, and a day picked in
|
||||||
|
* the chat (its weather) selects that day the same way.
|
||||||
|
*/
|
||||||
|
export default function AssistantHost() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const tripId = useRouteTripId();
|
||||||
|
// The title is there once the planner has loaded the trip; the window waits for nothing else.
|
||||||
|
const title = useTripStore((s) => (s.trip && s.trip.id === tripId ? s.trip.title : ''));
|
||||||
|
const trip = useMemo(() => (tripId === null ? null : { id: tripId, title }), [tripId, title]);
|
||||||
|
|
||||||
|
const openPlanner = useCallback(
|
||||||
|
(search: string) => {
|
||||||
|
if (tripId === null) return;
|
||||||
|
const planner = `/trips/${tripId}`;
|
||||||
|
void navigate({ pathname: planner, search }, { replace: pathname === planner });
|
||||||
|
},
|
||||||
|
[navigate, pathname, tripId]
|
||||||
|
);
|
||||||
|
const showOnMap = useCallback((applied: AssistantApplied) => openPlanner(showOnMapSearch(applied)), [openPlanner]);
|
||||||
|
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
|
||||||
|
|
||||||
|
return <AssistantWindow trip={trip} onShowOnMap={showOnMap} onFocusDay={focusDay} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-LAUNCH-001 to FE-AI-LAUNCH-006
|
||||||
|
*
|
||||||
|
* The way in: hidden until the assistant can run, a button that opens and
|
||||||
|
* closes the window, a dot for warnings in the plan not seen yet, and
|
||||||
|
* ⌘K / Ctrl+K, which leaves a dialog alone.
|
||||||
|
*/
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import type { AiStatusResponse } from '@trek/shared';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import { resetAllStores } from '../../../tests/helpers/store';
|
||||||
|
import { useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import AssistantLauncher from './AssistantLauncher';
|
||||||
|
import { isAssistantHotkey, useAssistantHotkey } from './useAssistantHotkey';
|
||||||
|
|
||||||
|
const INITIAL = useAssistantStore.getState();
|
||||||
|
|
||||||
|
const STATUS = { available: true } as AiStatusResponse;
|
||||||
|
|
||||||
|
function press(init: KeyboardEventInit): KeyboardEvent {
|
||||||
|
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => useAssistantStore.setState(INITIAL, true));
|
||||||
|
|
||||||
|
describe('AssistantLauncher', () => {
|
||||||
|
it('FE-AI-LAUNCH-001: shows nothing until the assistant has a connection to use', () => {
|
||||||
|
useAssistantStore.setState({ status: { ...STATUS, available: false } });
|
||||||
|
const { container } = render(<AssistantLauncher variant="navbar" />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-LAUNCH-002: the navbar button and the floating one open and close the window', () => {
|
||||||
|
useAssistantStore.setState({ status: STATUS });
|
||||||
|
const { unmount } = render(<AssistantLauncher variant="navbar" />);
|
||||||
|
const button = screen.getByRole('button', { name: 'Assistant' });
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||||
|
expect(button).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(useAssistantStore.getState().window.open).toBe(false);
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<AssistantLauncher variant="floating" />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||||
|
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-LAUNCH-006: the floating button carries a dot for a warning not seen yet, until the chat is opened', () => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
resetAllStores();
|
||||||
|
const places = [
|
||||||
|
buildPlace({ id: 50, place_time: '10:00', end_time: '12:00' }),
|
||||||
|
buildPlace({ id: 51, place_time: '11:00', end_time: '12:30' }),
|
||||||
|
];
|
||||||
|
const stops = places.map((place, i) =>
|
||||||
|
buildAssignment({ id: 60 + i, day_id: 1, place_id: place.id, order_index: i, place })
|
||||||
|
);
|
||||||
|
useTripStore.setState({
|
||||||
|
trip: buildTrip({ id: 3, start_date: '2099-06-01', end_date: '2099-06-01' }),
|
||||||
|
days: [buildDay({ id: 1, trip_id: 3, date: '2099-06-01' })],
|
||||||
|
places,
|
||||||
|
assignments: { '1': stops },
|
||||||
|
reservations: [],
|
||||||
|
dayNotes: {},
|
||||||
|
});
|
||||||
|
useAssistantStore.setState({ status: STATUS });
|
||||||
|
|
||||||
|
const { unmount } = render(<AssistantLauncher variant="navbar" />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<AssistantLauncher variant="floating" tripId={3} />);
|
||||||
|
const button = screen.getByRole('button', { name: 'Assistant, 1 hint' });
|
||||||
|
// The dot stands out from the accent button it sits on.
|
||||||
|
expect(button.querySelector('.bg-warning')).not.toBeNull();
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||||
|
expect(screen.getByRole('button', { name: 'Assistant' }).querySelector('.bg-warning')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the shortcut', () => {
|
||||||
|
it('FE-AI-LAUNCH-003: is ⌘K or Ctrl+K, without Alt or Shift', () => {
|
||||||
|
const key = (init: KeyboardEventInit) => isAssistantHotkey(new KeyboardEvent('keydown', init));
|
||||||
|
expect(key({ key: 'k', metaKey: true })).toBe(true);
|
||||||
|
expect(key({ key: 'K', ctrlKey: true })).toBe(true);
|
||||||
|
expect(key({ key: 'k' })).toBe(false);
|
||||||
|
expect(key({ key: 'k', ctrlKey: true, altKey: true })).toBe(false);
|
||||||
|
expect(key({ key: 'k', metaKey: true, shiftKey: true })).toBe(false);
|
||||||
|
expect(key({ key: 'j', metaKey: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-LAUNCH-004: toggles and takes the key, only while enabled', () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
const { rerender } = renderHook(({ enabled }) => useAssistantHotkey(enabled, onToggle), {
|
||||||
|
initialProps: { enabled: false },
|
||||||
|
});
|
||||||
|
expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(false);
|
||||||
|
expect(onToggle).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender({ enabled: true });
|
||||||
|
expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(true);
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-LAUNCH-005: leaves the key alone while a dialog is open or something else took it', () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
renderHook(() => useAssistantHotkey(true, onToggle));
|
||||||
|
const backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'trek-modal-backdrop';
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
press({ key: 'k', metaKey: true });
|
||||||
|
backdrop.remove();
|
||||||
|
|
||||||
|
const taken = new KeyboardEvent('keydown', { key: 'k', metaKey: true, cancelable: true });
|
||||||
|
taken.preventDefault();
|
||||||
|
window.dispatchEvent(taken);
|
||||||
|
|
||||||
|
expect(onToggle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import Tooltip from '../shared/Tooltip';
|
||||||
|
import { useAssistantHints } from './useAssistantHints';
|
||||||
|
import { useAssistantShortcutKeys } from './useAssistantHotkey';
|
||||||
|
|
||||||
|
/** Where the floating button sits, from the window's end and bottom edges. The window grows out of it. */
|
||||||
|
export const LAUNCHER_INSET = 24;
|
||||||
|
export const LAUNCHER_SIZE = 48;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The way into the assistant: a button in the navbar, and in the planner a
|
||||||
|
* round one floating at the bottom end of the screen. Both open and close the
|
||||||
|
* window; outside a trip it opens on the trip picker. Nothing shows until the
|
||||||
|
* assistant has a connection this user can use: no setup prompt in its place.
|
||||||
|
* The floating one carries a dot while the trip has a warning the user has
|
||||||
|
* not been shown; opening the chat counts them as seen.
|
||||||
|
*/
|
||||||
|
export default function AssistantLauncher({
|
||||||
|
variant,
|
||||||
|
tripId = null,
|
||||||
|
focusOnMount = false,
|
||||||
|
}: {
|
||||||
|
variant: 'navbar' | 'floating';
|
||||||
|
/** The trip the floating button stands in, for its hint dot. */
|
||||||
|
tripId?: number | null;
|
||||||
|
/** Takes the focus when it appears, as when the window it stands for was just closed. */
|
||||||
|
focusOnMount?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const available = useAssistantStore((s) => s.status?.available === true);
|
||||||
|
const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
|
||||||
|
const toggle = useAssistantStore((s) => s.toggle);
|
||||||
|
// An answer still being written in a closed window: the floating button breathes until it is opened.
|
||||||
|
const busy = useAssistantStore((s) =>
|
||||||
|
!s.window.open || s.window.minimized ? Object.values(s.byTrip).some((c) => c?.isRunning) : false
|
||||||
|
);
|
||||||
|
const keys = useAssistantShortcutKeys();
|
||||||
|
const hints = useAssistantHints(variant === 'floating' ? tripId : null, { withStays: false });
|
||||||
|
const floating = useRef<HTMLButtonElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusOnMount) floating.current?.focus();
|
||||||
|
}, [focusOnMount]);
|
||||||
|
if (!available) return null;
|
||||||
|
|
||||||
|
const label = t('assistant.launcher.tooltip');
|
||||||
|
if (variant === 'navbar') {
|
||||||
|
return (
|
||||||
|
<Tooltip label={label} shortcut={keys}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={open}
|
||||||
|
className={`relative hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg p-2 transition-colors sm:flex ${
|
||||||
|
open ? 'bg-surface-selected text-content' : 'text-content-muted hover:bg-surface-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointed = hints.unseenWarn > 0 ? t('assistant.launcher.withHints', { count: hints.unseenWarn }) : label;
|
||||||
|
const openChat = () => {
|
||||||
|
if (!open) hints.markWarnsSeen();
|
||||||
|
toggle();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Tooltip label={pointed} shortcut={keys} placement="top">
|
||||||
|
<button
|
||||||
|
ref={floating}
|
||||||
|
type="button"
|
||||||
|
onClick={openChat}
|
||||||
|
aria-label={pointed}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
className="trek-assistant-pop fixed z-[var(--z-nav)] flex items-center justify-center rounded-full bg-accent text-accent-text shadow-elevated transition-transform hover:scale-105 active:scale-95"
|
||||||
|
style={{
|
||||||
|
insetInlineEnd: LAUNCHER_INSET,
|
||||||
|
bottom: LAUNCHER_INSET,
|
||||||
|
width: LAUNCHER_SIZE,
|
||||||
|
height: LAUNCHER_SIZE,
|
||||||
|
transformOrigin: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="trek-assistant-busy absolute inset-0 rounded-full border-2 border-current opacity-40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Sparkles size={20} aria-hidden="true" />
|
||||||
|
{hints.unseenWarn > 0 && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute end-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-warning ring-2 ring-[var(--bg-elevated)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-LIVE-001 to FE-AI-LIVE-004
|
||||||
|
*
|
||||||
|
* The running step's line: what it does, and once the stream says where it
|
||||||
|
* looks, the place or the query, with a pin when the map can show the area.
|
||||||
|
*/
|
||||||
|
import type { AssistantActivity, AssistantMessage } from '@trek/shared';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import AssistantToolSteps from './AssistantToolSteps';
|
||||||
|
import { AssistantViewContext } from './assistantViewContext';
|
||||||
|
|
||||||
|
const TRIP = 4;
|
||||||
|
const INITIAL = useAssistantStore.getState();
|
||||||
|
|
||||||
|
const running: AssistantMessage = {
|
||||||
|
id: 'msg-2',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'complete',
|
||||||
|
createdAt: '2026-10-21T08:00:00.000Z',
|
||||||
|
parts: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'search_nearby_places', args: {} }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function seed(activity: AssistantActivity | null) {
|
||||||
|
useAssistantStore.setState({
|
||||||
|
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [running], live: { activity } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSteps() {
|
||||||
|
return render(
|
||||||
|
<AssistantViewContext.Provider value={{ tripId: TRIP, variant: 'window' }}>
|
||||||
|
<AssistantToolSteps steps={[{ toolCallId: 'call-1', toolName: 'search_nearby_places', running: true }]} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => useAssistantStore.setState(INITIAL, true));
|
||||||
|
|
||||||
|
describe('AssistantLiveStep', () => {
|
||||||
|
it('FE-AI-LIVE-001: names the place the running step searches near', () => {
|
||||||
|
seed({
|
||||||
|
toolCallId: 'call-1',
|
||||||
|
label: 'Trastevere',
|
||||||
|
area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
|
||||||
|
});
|
||||||
|
const { container } = renderSteps();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Searching near Trastevere');
|
||||||
|
expect(container.querySelector('svg')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-LIVE-002: without a place it keeps the step name, and no pin', () => {
|
||||||
|
seed({ toolCallId: 'call-1', label: null, area: null });
|
||||||
|
const { container } = renderSteps();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Searching nearby');
|
||||||
|
expect(container.querySelector('svg')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FE-AI-LIVE-003: a place another step named is not this step's", () => {
|
||||||
|
seed({ toolCallId: 'call-0', label: 'Trastevere', area: null });
|
||||||
|
renderSteps();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Searching nearby');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-LIVE-004: with "map follows the assistant" off the place stays, the pin goes', () => {
|
||||||
|
const settings = useSettingsStore.getState().settings;
|
||||||
|
useSettingsStore.setState({ settings: { ...settings, assistant_map_follow: false } });
|
||||||
|
seed({
|
||||||
|
toolCallId: 'call-1',
|
||||||
|
label: 'Trastevere',
|
||||||
|
area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
|
||||||
|
});
|
||||||
|
const { container } = renderSteps();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Searching near Trastevere');
|
||||||
|
expect(container.querySelector('svg')).toBeNull();
|
||||||
|
useSettingsStore.setState({ settings });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MapPin } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { toolStepKey } from './assistantModel';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
import { useAssistantLiveStep } from './useAssistantLiveStep';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The running step as one quiet line: what it does, shimmering, and once it
|
||||||
|
* names a place, where ("Searching near Trastevere"). A small pin says the
|
||||||
|
* map is looking there too, shown only while the map follows the assistant.
|
||||||
|
*/
|
||||||
|
export default function AssistantLiveStep({ toolCallId, toolName }: { toolCallId: string; toolName: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tripId } = useAssistantView();
|
||||||
|
const live = useAssistantLiveStep(tripId);
|
||||||
|
const here = live?.toolCallId === toolCallId ? live : null;
|
||||||
|
return (
|
||||||
|
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption">
|
||||||
|
{here?.hasArea && <MapPin size={12} aria-hidden="true" className="flex-shrink-0 text-content-faint" />}
|
||||||
|
<span className="trek-assistant-shimmer min-w-0 truncate">{here ? here.text : t(toolStepKey(toolName))}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-MD-001 to FE-AI-MD-005
|
||||||
|
*
|
||||||
|
* An answer is untrusted text: nothing in it may load by itself, raw HTML
|
||||||
|
* never becomes markup, and a link always shows where it goes.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import AssistantMarkdown from './AssistantMarkdown';
|
||||||
|
|
||||||
|
describe('AssistantMarkdown', () => {
|
||||||
|
it('FE-AI-MD-001: renders Markdown as text, lists and emphasis', () => {
|
||||||
|
const { container } = render(<AssistantMarkdown text={'**Kyoto** first\n\n- Kinkaku-ji\n- Ryoan-ji'} />);
|
||||||
|
expect(container.querySelector('strong')).toHaveTextContent('Kyoto');
|
||||||
|
expect(screen.getAllByRole('listitem')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MD-002: draws no image, in Markdown or as raw HTML', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AssistantMarkdown text={'\n\n<img src="https://evil.example/q.gif">'} />
|
||||||
|
);
|
||||||
|
expect(container.querySelector('img')).toBeNull();
|
||||||
|
expect(container.querySelector('picture, video, audio, iframe')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MD-003: leaves no javascript: link to click', () => {
|
||||||
|
const { container } = render(<AssistantMarkdown text={'[open](javascript:alert(1))'} />);
|
||||||
|
const link = container.querySelector('a');
|
||||||
|
expect(link?.getAttribute('href') ?? '').not.toMatch(/^javascript:/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MD-004: an outside link opens in its own tab and names its host', () => {
|
||||||
|
render(<AssistantMarkdown text={'[Tickets](https://www.kinkaku-ji.or.jp/en/)'} />);
|
||||||
|
const link = screen.getByRole('link', { name: /Tickets/ });
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||||
|
expect(link).toHaveTextContent('(www.kinkaku-ji.or.jp)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MD-005: a link into TREK stays in the app, and the writing caret is marked', () => {
|
||||||
|
const { container } = render(<AssistantMarkdown text={'See [day 2](/trips/4)'} writing />);
|
||||||
|
const link = screen.getByRole('link', { name: 'day 2' });
|
||||||
|
expect(link).toHaveAttribute('href', '/trips/4');
|
||||||
|
expect(link).not.toHaveAttribute('target');
|
||||||
|
expect(container.firstElementChild).toHaveAttribute('data-writing', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import Markdown, { type Components } from 'react-markdown';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
import { sanitizedMarkdownPlugins } from '../shared/markdownSanitize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An answer's text, as Markdown. The model writes it, and what the model reads
|
||||||
|
* (trip notes, place descriptions, search results) can carry someone else's
|
||||||
|
* words, so it renders like untrusted input: raw HTML is skipped and
|
||||||
|
* sanitized, and nothing that loads on its own (an image is a tracking pixel
|
||||||
|
* that leaks a request to any host) is drawn at all.
|
||||||
|
*
|
||||||
|
* A link shows where it goes: an outside one names its host beside the text
|
||||||
|
* and opens in its own tab, a link into TREK stays in the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DISALLOWED = ['img', 'picture', 'source', 'video', 'audio', 'iframe', 'object', 'embed'];
|
||||||
|
|
||||||
|
function hostOf(href: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(href).host || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const components: Components = {
|
||||||
|
a: ({ href, children }) => {
|
||||||
|
if (href?.startsWith('/') && !href.startsWith('//')) return <Link to={href}>{children}</Link>;
|
||||||
|
const host = href ? hostOf(href) : null;
|
||||||
|
const text = typeof children === 'string' ? children : null;
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer nofollow">
|
||||||
|
{children}
|
||||||
|
{host && text !== host && text !== href && <span className="text-content-muted"> ({host})</span>}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function AssistantMarkdown({ text, writing = false }: { text: string; writing?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="auto"
|
||||||
|
data-writing={writing || undefined}
|
||||||
|
className="trek-assistant-md break-words text-body text-content"
|
||||||
|
>
|
||||||
|
<Markdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={sanitizedMarkdownPlugins}
|
||||||
|
disallowedElements={DISALLOWED}
|
||||||
|
skipHtml
|
||||||
|
components={components}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An answer re-renders with every streamed word; a block whose text did not change keeps its parse.
|
||||||
|
export default memo(AssistantMarkdown);
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { NotebookPen, Plus } from 'lucide-react';
|
||||||
|
import { useId, useState } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import {
|
||||||
|
DialogButton,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogSection,
|
||||||
|
DialogShell,
|
||||||
|
DialogTile,
|
||||||
|
FooterSpacer,
|
||||||
|
NEUTRAL_TINT,
|
||||||
|
} from '../shared/DialogShell';
|
||||||
|
import { TEXTAREA } from '../shared/dialogParts';
|
||||||
|
import { MAX_MEMORY_CHARS, useTripMemory } from './useTripMemory';
|
||||||
|
|
||||||
|
/** What travellers most often want minded, one tap to add each. */
|
||||||
|
const QUICK = ['vegetarian', 'kids', 'budget', 'slow', 'accessible', 'noMuseums'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trip's notes for the assistant: a few lines it follows in every
|
||||||
|
* suggestion on this trip, written once instead of in every question. The
|
||||||
|
* common ones are a tap away; the rest is free text.
|
||||||
|
*/
|
||||||
|
export default function AssistantMemoryDialog({ tripId, onClose }: { tripId: number; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const labelId = useId();
|
||||||
|
const memory = useTripMemory(tripId);
|
||||||
|
const [draft, setDraft] = useState(memory.text);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const add = (line: string) =>
|
||||||
|
setDraft((prev) => (prev.includes(line) ? prev : `${prev.trimEnd()}${prev.trim() ? '\n' : ''}${line}`));
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
const done = await memory.save(draft);
|
||||||
|
setSaving(false);
|
||||||
|
if (done) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogShell
|
||||||
|
onClose={onClose}
|
||||||
|
labelledBy={labelId}
|
||||||
|
width="narrow"
|
||||||
|
discardGuard={draft}
|
||||||
|
header={
|
||||||
|
<DialogHeader
|
||||||
|
tile={
|
||||||
|
<DialogTile>
|
||||||
|
<NotebookPen size={20} strokeWidth={2} />
|
||||||
|
</DialogTile>
|
||||||
|
}
|
||||||
|
tint={NEUTRAL_TINT}
|
||||||
|
labelId={labelId}
|
||||||
|
eyebrow={t('assistant.launcher.tooltip')}
|
||||||
|
title={t('assistant.memory.title')}
|
||||||
|
sub={t('assistant.memory.sub')}
|
||||||
|
subWraps
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<DialogFooter>
|
||||||
|
<span className="text-caption tabular-nums text-content-faint">
|
||||||
|
{draft.length}/{MAX_MEMORY_CHARS}
|
||||||
|
</span>
|
||||||
|
<FooterSpacer />
|
||||||
|
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||||
|
<DialogButton variant="primary" disabled={saving} onClick={() => void save()}>
|
||||||
|
{t(saving ? 'common.saving' : 'common.save')}
|
||||||
|
</DialogButton>
|
||||||
|
</DialogFooter>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DialogSection label={t('assistant.memory.quick')}>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{QUICK.map((key, i) => {
|
||||||
|
const line = t(`assistant.memory.quick.${key}`);
|
||||||
|
const taken = draft.includes(line);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
disabled={taken}
|
||||||
|
onClick={() => add(line)}
|
||||||
|
className="trek-assistant-enter inline-flex items-center gap-1 rounded-full bg-surface-card px-2.5 py-1 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md disabled:translate-y-0 disabled:opacity-40 disabled:shadow-none"
|
||||||
|
style={{ animationDelay: `${i * 40}ms` }}
|
||||||
|
>
|
||||||
|
<Plus size={11} aria-hidden="true" />
|
||||||
|
{line}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogSection>
|
||||||
|
<DialogSection label={t('assistant.memory.notes')}>
|
||||||
|
<textarea
|
||||||
|
aria-label={t('assistant.memory.notes')}
|
||||||
|
rows={6}
|
||||||
|
maxLength={MAX_MEMORY_CHARS}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder={t('assistant.memory.placeholder')}
|
||||||
|
className={TEXTAREA}
|
||||||
|
/>
|
||||||
|
</DialogSection>
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
ComposerPrimitive,
|
||||||
|
type TriggerAdapter,
|
||||||
|
type TriggerItem,
|
||||||
|
unstable_useTriggerPopoverScopeContext as useTriggerScope,
|
||||||
|
} from '@assistant-ui/react';
|
||||||
|
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Ticket } from 'lucide-react';
|
||||||
|
import { type MouseEvent, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { MENTION_FORMATTER, MENTION_KINDS, type MentionKind } from './assistantMentionModel';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
|
||||||
|
/** The icon of a day, place or booking in the `@` list. */
|
||||||
|
function MentionKindIcon({ kind, size }: { kind: string; size: number }) {
|
||||||
|
const props = { size, 'aria-hidden': true, className: 'flex-shrink-0' } as const;
|
||||||
|
if (kind === 'day') return <CalendarDays {...props} />;
|
||||||
|
if (kind === 'reservation') return <Ticket {...props} />;
|
||||||
|
return <MapPin {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row takes the click without the focus, so the caret stays in the text and typing goes on. */
|
||||||
|
const keepFocus = (e: MouseEvent) => e.preventDefault();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list `@` opens over the composer: first the kinds (days, places,
|
||||||
|
* bookings), and once letters follow the `@`, the entries that match across
|
||||||
|
* all of them. The focus never leaves the text; arrows walk the list, Enter
|
||||||
|
* or Tab picks, Escape closes it and keeps what was typed.
|
||||||
|
*/
|
||||||
|
export default function AssistantMentionPopover({
|
||||||
|
adapter,
|
||||||
|
onInserted,
|
||||||
|
}: {
|
||||||
|
/** Undefined while the trip's lists are not at hand; `@` then opens nothing. */
|
||||||
|
adapter: TriggerAdapter | undefined;
|
||||||
|
onInserted: (item: TriggerItem) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { variant } = useAssistantView();
|
||||||
|
const phone = variant === 'fullscreen';
|
||||||
|
return (
|
||||||
|
<ComposerPrimitive.TriggerPopover
|
||||||
|
char="@"
|
||||||
|
adapter={adapter}
|
||||||
|
aria-label={t('assistant.mention.hint')}
|
||||||
|
className={`absolute bottom-full end-0 start-0 z-30 mb-1.5 overflow-y-auto rounded-xl border border-edge-faint bg-surface-card p-1 shadow-dropdown ${phone ? 'max-h-[40vh]' : 'max-h-56'}`}
|
||||||
|
>
|
||||||
|
<ComposerPrimitive.TriggerPopover.Directive formatter={MENTION_FORMATTER} onInserted={onInserted} />
|
||||||
|
<MentionList row={phone ? 'min-h-[44px]' : 'min-h-[32px]'} />
|
||||||
|
</ComposerPrimitive.TriggerPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW =
|
||||||
|
'flex w-full items-center gap-2 rounded-lg px-2 text-start text-body text-content transition-colors hover:bg-surface-hover data-[highlighted]:bg-surface-hover';
|
||||||
|
|
||||||
|
function MentionList({ row }: { row: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const scope = useTriggerScope();
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// The highlighted row stays in view while the arrows walk past the list's edge.
|
||||||
|
useEffect(() => {
|
||||||
|
const hot = listRef.current?.querySelector<HTMLElement>('[data-highlighted]');
|
||||||
|
hot?.scrollIntoView?.({ block: 'nearest' });
|
||||||
|
}, [scope.highlightedIndex, scope.items, scope.activeCategoryId]);
|
||||||
|
|
||||||
|
if (!scope.open) return null;
|
||||||
|
const listing = scope.isSearchMode || scope.activeCategoryId !== null;
|
||||||
|
const active = scope.activeCategoryId ? MENTION_KINDS[scope.activeCategoryId as MentionKind] : undefined;
|
||||||
|
const heading = active ? t(active.labelKey) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={listRef}>
|
||||||
|
{heading && (
|
||||||
|
<ComposerPrimitive.TriggerPopoverBack
|
||||||
|
onMouseDown={keepFocus}
|
||||||
|
className="flex w-full items-center gap-1 rounded-lg px-1.5 py-1 text-caption font-semibold text-content-muted hover:text-content"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={13} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||||
|
{heading}
|
||||||
|
</ComposerPrimitive.TriggerPopoverBack>
|
||||||
|
)}
|
||||||
|
<ComposerPrimitive.TriggerPopoverCategories aria-label={t('assistant.mention.hint')}>
|
||||||
|
{(categories) =>
|
||||||
|
categories.map((c) => (
|
||||||
|
<ComposerPrimitive.TriggerPopoverCategoryItem
|
||||||
|
key={c.id}
|
||||||
|
categoryId={c.id}
|
||||||
|
onMouseDown={keepFocus}
|
||||||
|
className={`${ROW} ${row}`}
|
||||||
|
>
|
||||||
|
<span className="flex text-content-muted">
|
||||||
|
<MentionKindIcon kind={c.id} size={14} />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{c.label}</span>
|
||||||
|
<ChevronRight
|
||||||
|
size={13}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex-shrink-0 text-content-faint rtl:-scale-x-100"
|
||||||
|
/>
|
||||||
|
</ComposerPrimitive.TriggerPopoverCategoryItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ComposerPrimitive.TriggerPopoverCategories>
|
||||||
|
<ComposerPrimitive.TriggerPopoverItems aria-label={heading ?? t('assistant.mention.hint')}>
|
||||||
|
{(items) =>
|
||||||
|
items.map((item, index) => (
|
||||||
|
<ComposerPrimitive.TriggerPopoverItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
onMouseDown={keepFocus}
|
||||||
|
className={`${ROW} ${row}`}
|
||||||
|
>
|
||||||
|
<span className="flex text-content-muted">
|
||||||
|
<MentionKindIcon kind={item.type} size={14} />
|
||||||
|
</span>
|
||||||
|
<bdi className="min-w-0 flex-1 truncate">{item.label}</bdi>
|
||||||
|
{item.description && (
|
||||||
|
<span className="flex-shrink-0 text-caption text-content-faint">{item.description}</span>
|
||||||
|
)}
|
||||||
|
</ComposerPrimitive.TriggerPopoverItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ComposerPrimitive.TriggerPopoverItems>
|
||||||
|
{listing && scope.items.length === 0 && (
|
||||||
|
<p className="m-0 px-2 py-1.5 text-caption text-content-muted">{t('assistant.mention.none')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-MENTION-TEXT-001 to FE-AI-MENTION-TEXT-002
|
||||||
|
*
|
||||||
|
* A sent question with the days, places and bookings it named set off as
|
||||||
|
* chips, and every other `@` left as text.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import AssistantMentionText from './AssistantMentionText';
|
||||||
|
|
||||||
|
describe('AssistantMentionText', () => {
|
||||||
|
it('FE-AI-MENTION-TEXT-001: sets off each named @label and keeps the words around it', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<p>
|
||||||
|
<AssistantMentionText
|
||||||
|
text="Is @Louvre open on @Arrival?"
|
||||||
|
items={[
|
||||||
|
{ kind: 'place', id: 21, label: 'Louvre' },
|
||||||
|
{ kind: 'day', id: 11, label: 'Arrival' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
expect(container.querySelector('p')?.textContent).toBe('Is @Louvre open on @Arrival?');
|
||||||
|
expect(screen.getByText('@Louvre').tagName).toBe('BDI');
|
||||||
|
expect(screen.getByText('@Arrival').tagName).toBe('BDI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-TEXT-002: an @ that names nothing stays plain text', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<p>
|
||||||
|
<AssistantMentionText
|
||||||
|
text="Write to me @ home or @Louvre2"
|
||||||
|
items={[{ kind: 'place', id: 21, label: 'Louvre' }]}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
expect(container.querySelector('p')?.textContent).toBe('Write to me @ home or @Louvre2');
|
||||||
|
expect(container.querySelector('bdi')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { AssistantMentions } from '@trek/shared';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { splitMentions } from './assistantMentionModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sent question as the user wrote it, with every `@label` it named a day,
|
||||||
|
* place or booking with set off as a quiet inline chip. The labels come from
|
||||||
|
* the question's own mentions part, so a stray `@` in the text stays text.
|
||||||
|
*/
|
||||||
|
export default function AssistantMentionText({ text, items }: { text: string; items: AssistantMentions['items'] }) {
|
||||||
|
const segments = useMemo(
|
||||||
|
() =>
|
||||||
|
splitMentions(
|
||||||
|
text,
|
||||||
|
items.map((i) => i.label)
|
||||||
|
),
|
||||||
|
[text, items]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segments.map((s) =>
|
||||||
|
s.mention ? (
|
||||||
|
<bdi key={s.at} className="rounded-md bg-surface-card px-1 font-medium text-content">
|
||||||
|
{s.text}
|
||||||
|
</bdi>
|
||||||
|
) : (
|
||||||
|
<span key={s.at}>{s.text}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
|
||||||
|
import type { AssistantMessage as StoredMessage } from '@trek/shared';
|
||||||
|
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import AssistantChangeCard from './AssistantChangeCard';
|
||||||
|
import AssistantFollowUps from './AssistantFollowUps';
|
||||||
|
import AssistantMarkdown from './AssistantMarkdown';
|
||||||
|
import AssistantMentionText from './AssistantMentionText';
|
||||||
|
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||||
|
import AssistantShareDialog from './AssistantShareDialog';
|
||||||
|
import AssistantToolSteps from './AssistantToolSteps';
|
||||||
|
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||||
|
import { messageBlocks } from './assistantParts';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
import { useAssistantShare } from './useAssistantShare';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One message of the chat. The user's question sits on the end side in a
|
||||||
|
* quiet accent bubble; the answer runs as plain text across the column, with
|
||||||
|
* its looked-up steps, the places and weather they found, and the proposed
|
||||||
|
* changes in between, and a copy and a share button that show on hover
|
||||||
|
* (always on the latest answer, and always on the phone, which has no hover).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BAR_BUTTON =
|
||||||
|
'rounded-md p-1 text-content-faint hover:bg-surface-hover hover:text-content-secondary disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent';
|
||||||
|
|
||||||
|
/** Posts the answer to the trip chat after a preview; a tick once it went. */
|
||||||
|
function ShareButton({ share }: { share: ReturnType<typeof useAssistantShare> }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (share.shared) {
|
||||||
|
return (
|
||||||
|
<Tooltip label={t('assistant.share.shared')}>
|
||||||
|
<span className="inline-flex p-1 text-content-faint">
|
||||||
|
<Check size={14} aria-hidden="true" />
|
||||||
|
<span className="sr-only">{t('assistant.share.shared')}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const label = share.offline ? t('assistant.error.offline') : t('assistant.share.button');
|
||||||
|
return (
|
||||||
|
<Tooltip label={label}>
|
||||||
|
{/* The wrapper carries the tooltip while the button is off. */}
|
||||||
|
<span className="inline-flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={share.open}
|
||||||
|
disabled={share.offline || share.sending}
|
||||||
|
aria-label={t('assistant.share.button')}
|
||||||
|
className={BAR_BUTTON}
|
||||||
|
>
|
||||||
|
{share.sending ? (
|
||||||
|
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Share2 size={14} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file the user handed over, as a chip with what it is and how big. */
|
||||||
|
function AttachmentChip({ name, size }: { name: string; size: number }) {
|
||||||
|
const kb = Math.max(1, Math.round(size / 1024));
|
||||||
|
return (
|
||||||
|
<span className="inline-flex max-w-full items-center gap-2.5 rounded-xl border border-edge-faint bg-surface-card py-2 pe-3.5 ps-2 shadow-card">
|
||||||
|
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-content-secondary">
|
||||||
|
<FileText size={16} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<bdi className="truncate text-body font-medium text-content">{name}</bdi>
|
||||||
|
<span className="text-caption tabular-nums text-content-muted">
|
||||||
|
{kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Question({ message }: { message: StoredMessage }) {
|
||||||
|
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
|
||||||
|
const files = message.parts.flatMap((p) => (p.type === 'attachment' ? [p] : []));
|
||||||
|
const mentions = message.parts.flatMap((p) => (p.type === 'data-trek-mentions' ? p.data.items : []));
|
||||||
|
return (
|
||||||
|
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col items-end gap-1.5 py-1.5">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<AttachmentChip key={`${f.name}-${i}`} name={f.name} size={f.size} />
|
||||||
|
))}
|
||||||
|
{text && (
|
||||||
|
<p
|
||||||
|
dir="auto"
|
||||||
|
className="m-0 max-w-[85%] whitespace-pre-wrap break-words rounded-xl bg-accent-subtle px-3.5 py-2 text-body text-content"
|
||||||
|
>
|
||||||
|
{mentions.length > 0 ? <AssistantMentionText text={text} items={mentions} /> : text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</MessagePrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Answer({ message }: { message: StoredMessage }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tripId, variant } = useAssistantView();
|
||||||
|
const writing = useAssistantStore((s) => {
|
||||||
|
const chat = chatOf(s, tripId);
|
||||||
|
return chat.isRunning && chat.messages[chat.messages.length - 1]?.id === message.id;
|
||||||
|
});
|
||||||
|
const blocks = useMemo(() => messageBlocks(message, writing), [message, writing]);
|
||||||
|
const share = useAssistantShare(tripId, message, writing);
|
||||||
|
const last = blocks[blocks.length - 1];
|
||||||
|
const hasText = blocks.some((b) => b.kind === 'text');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
||||||
|
{blocks.map((block) => {
|
||||||
|
if (block.kind === 'text') {
|
||||||
|
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
|
||||||
|
}
|
||||||
|
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
|
||||||
|
if (block.kind === 'places') {
|
||||||
|
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||||
|
}
|
||||||
|
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||||
|
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||||
|
})}
|
||||||
|
{/* Nothing below says the answer goes on: no words being written, no step running. */}
|
||||||
|
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
||||||
|
<p role="status" className="m-0 text-caption">
|
||||||
|
<span className="trek-assistant-shimmer">{t('assistant.step.working')}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hasText && !writing && (
|
||||||
|
// The bar mounts on hover for older answers; the row keeps its height so nothing jumps.
|
||||||
|
<div className="-mt-1 h-6">
|
||||||
|
<ActionBarPrimitive.Root autohide={variant === 'fullscreen' ? 'never' : 'not-last'} className="-ms-1 flex">
|
||||||
|
<ActionBarPrimitive.Copy aria-label={t('common.copy')} className={BAR_BUTTON}>
|
||||||
|
<AuiIf condition={(s) => s.message.isCopied}>
|
||||||
|
<Check size={14} aria-hidden="true" />
|
||||||
|
</AuiIf>
|
||||||
|
<AuiIf condition={(s) => !s.message.isCopied}>
|
||||||
|
<Copy size={14} aria-hidden="true" />
|
||||||
|
</AuiIf>
|
||||||
|
</ActionBarPrimitive.Copy>
|
||||||
|
{share.canShare && <ShareButton share={share} />}
|
||||||
|
</ActionBarPrimitive.Root>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{share.canShare && <AssistantShareDialog share={share} />}
|
||||||
|
<AssistantFollowUps message={message} />
|
||||||
|
</MessagePrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssistantMessage({ message }: { message: StoredMessage }) {
|
||||||
|
return message.role === 'user' ? <Question message={message} /> : <Answer message={message} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import type { AssistantFoundPlace } from '@trek/shared';
|
||||||
|
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
|
||||||
|
import { createElement, type ReactNode, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||||
|
import { PopoverListPanel } from '../shared/dialogParts';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import { usePopoverList } from '../shared/usePopoverList';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||||
|
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
|
||||||
|
|
||||||
|
type Strip = ReturnType<typeof useAssistantPlaceStrip>;
|
||||||
|
|
||||||
|
const ACTION =
|
||||||
|
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
|
||||||
|
|
||||||
|
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
|
||||||
|
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { days, focusDay, disabled, blockedKey, adding } = strip;
|
||||||
|
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
|
||||||
|
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
|
||||||
|
});
|
||||||
|
const { open, toggle, close, triggerRef, listId } = list;
|
||||||
|
const height = tall ? 'h-10' : 'h-7';
|
||||||
|
const busy = adding === place.ref;
|
||||||
|
|
||||||
|
const pick = (dayId: number) => {
|
||||||
|
close();
|
||||||
|
void strip.suggest(place, dayId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttons = (
|
||||||
|
<span className="inline-flex min-w-0 gap-px">
|
||||||
|
{focusDay ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => void strip.suggest(place, focusDay.id)}
|
||||||
|
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
|
||||||
|
className={`${ACTION} ${height} rounded-e-none px-2`}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus size={12} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={toggle}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? listId : undefined}
|
||||||
|
aria-label={t('assistant.place.pickDay')}
|
||||||
|
className={`${ACTION} ${height} rounded-s-none px-1.5`}
|
||||||
|
>
|
||||||
|
<ChevronDown size={12} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={toggle}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? listId : undefined}
|
||||||
|
className={`${ACTION} ${height} px-2`}
|
||||||
|
>
|
||||||
|
<Plus size={12} aria-hidden="true" />
|
||||||
|
<span className="truncate">{t('assistant.place.pickDay')}</span>
|
||||||
|
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blockedKey ? (
|
||||||
|
<Tooltip label={t(blockedKey)} placement="top">
|
||||||
|
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
|
||||||
|
<span className="inline-flex min-w-0">{buttons}</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
buttons
|
||||||
|
)}
|
||||||
|
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
|
||||||
|
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{days.map((day) => {
|
||||||
|
const current = day.id === focusDay?.id;
|
||||||
|
return (
|
||||||
|
<Tooltip key={day.id} label={day.label} placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pick(day.id)}
|
||||||
|
aria-label={day.label}
|
||||||
|
aria-pressed={current}
|
||||||
|
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-body font-semibold tabular-nums">{day.n}</span>
|
||||||
|
{day.weekday && (
|
||||||
|
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
|
||||||
|
{day.weekday}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PopoverListPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Category = ReturnType<Strip['categoryOf']>;
|
||||||
|
|
||||||
|
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
|
||||||
|
function PlaceMedia({
|
||||||
|
place,
|
||||||
|
photo,
|
||||||
|
category,
|
||||||
|
}: {
|
||||||
|
place: AssistantFoundPlace;
|
||||||
|
photo: string | null;
|
||||||
|
category: Category;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const icon = category ? getCategoryIcon(category.icon) : MapPin;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
|
||||||
|
// Without a picture the category's own colour washes the space, data from the trip.
|
||||||
|
style={
|
||||||
|
photo || !category
|
||||||
|
? undefined
|
||||||
|
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{photo ? (
|
||||||
|
<img
|
||||||
|
src={photo}
|
||||||
|
alt=""
|
||||||
|
decoding="async"
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="grid h-full w-full place-items-center">
|
||||||
|
{createElement(icon, {
|
||||||
|
size: 30,
|
||||||
|
'aria-hidden': true,
|
||||||
|
style: { color: category?.color ?? 'var(--text-faint)' },
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{category && photo && (
|
||||||
|
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
|
||||||
|
{createElement(icon, {
|
||||||
|
size: 11,
|
||||||
|
'aria-hidden': true,
|
||||||
|
className: 'flex-shrink-0',
|
||||||
|
style: { color: category.color },
|
||||||
|
})}
|
||||||
|
<bdi className="truncate">{category.name}</bdi>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{place.openNow !== null && (
|
||||||
|
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
|
||||||
|
/>
|
||||||
|
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card's one way on: "On map" for a place the trip has, "Suggested" once it is, else "+ Day 3". */
|
||||||
|
function PlaceAction({
|
||||||
|
place,
|
||||||
|
strip,
|
||||||
|
suggested,
|
||||||
|
tall,
|
||||||
|
}: {
|
||||||
|
place: AssistantFoundPlace;
|
||||||
|
strip: Strip;
|
||||||
|
suggested: boolean;
|
||||||
|
tall: boolean;
|
||||||
|
}): ReactNode {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (place.inTripPlaceId !== null) {
|
||||||
|
return (
|
||||||
|
<Tooltip label={t('assistant.place.inTrip')} placement="top">
|
||||||
|
<button type="button" onClick={() => strip.show(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
|
||||||
|
<MapPin size={12} aria-hidden="true" />
|
||||||
|
{t('assistant.place.showOnMap')}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (suggested) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-7 items-center gap-1 text-caption text-content-muted">
|
||||||
|
<Check size={12} aria-hidden="true" className="text-success" />
|
||||||
|
{t('assistant.place.suggested')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One place a search found, as a card: its picture from TREK's photo proxy
|
||||||
|
* with the category it most likely belongs in on it (or that category's icon
|
||||||
|
* on its colour), its name, address and rating, whether it is open now while
|
||||||
|
* the trip runs, how far it is from the last stop of the day in focus, and one
|
||||||
|
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
|
||||||
|
* the card shows it on the map, and resting the pointer on it pins it there
|
||||||
|
* for a moment.
|
||||||
|
*/
|
||||||
|
export default function AssistantPlaceCard({
|
||||||
|
place,
|
||||||
|
strip,
|
||||||
|
suggested,
|
||||||
|
}: {
|
||||||
|
place: AssistantFoundPlace;
|
||||||
|
strip: Strip;
|
||||||
|
suggested: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { variant, density } = useAssistantView();
|
||||||
|
const ref = useRef<HTMLLIElement>(null);
|
||||||
|
const photo = useFoundPlacePhoto(place, ref);
|
||||||
|
const category = strip.categoryOf(place);
|
||||||
|
const distance = strip.distanceOf(place);
|
||||||
|
const tall = variant === 'fullscreen';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={`group/card flex flex-shrink-0 snap-start flex-col overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm transition-shadow hover:shadow-md ${
|
||||||
|
density === 'compact' ? 'w-[188px]' : 'w-[220px]'
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => strip.hover(place)}
|
||||||
|
onMouseLeave={() => strip.hover(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => strip.show(place)}
|
||||||
|
onFocus={() => strip.hover(place)}
|
||||||
|
onBlur={() => strip.hover(null)}
|
||||||
|
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<PlaceMedia place={place} photo={photo} category={category} />
|
||||||
|
<span className="flex min-w-0 flex-col gap-1 px-3 pt-2.5">
|
||||||
|
<bdi className="line-clamp-2 text-body font-semibold leading-snug text-content">{place.name}</bdi>
|
||||||
|
{category && !photo && <bdi className="truncate text-caption text-content-muted">{category.name}</bdi>}
|
||||||
|
{place.address && <bdi className="truncate text-caption text-content-muted">{place.address}</bdi>}
|
||||||
|
{(place.rating !== null || distance) && (
|
||||||
|
<span className="flex min-w-0 items-center gap-2 text-caption text-content-faint">
|
||||||
|
{place.rating !== null && (
|
||||||
|
<span className="inline-flex flex-shrink-0 items-center gap-0.5 font-medium text-content-muted">
|
||||||
|
<Star size={11} aria-hidden="true" className="fill-current text-warning" />
|
||||||
|
{place.rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{distance && <span className="truncate">{t('assistant.place.fromPrev', { distance })}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
|
||||||
|
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-PLACES-001 to FE-AI-PLACES-008
|
||||||
|
*
|
||||||
|
* The strip of places a search found: what a card says, that its picture only
|
||||||
|
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
|
||||||
|
* picked one, why it waits while a change is undecided, and the map's part.
|
||||||
|
*/
|
||||||
|
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildAssignment,
|
||||||
|
buildCategory,
|
||||||
|
buildDay,
|
||||||
|
buildPlace,
|
||||||
|
buildTrip,
|
||||||
|
buildUser,
|
||||||
|
} from '../../../tests/helpers/factories';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { fetchPhoto } from '../../services/photoService';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { setForcedOffline } from '../../sync/networkMode';
|
||||||
|
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||||
|
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||||
|
|
||||||
|
vi.mock('../../services/photoService', () => ({
|
||||||
|
fetchPhoto: vi.fn(),
|
||||||
|
getCached: vi.fn(() => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TRIP = 4;
|
||||||
|
const ASSISTANT = useAssistantStore.getState();
|
||||||
|
const TRIPS = useTripStore.getState();
|
||||||
|
const AUTH = useAuthStore.getState();
|
||||||
|
const RealObserver = globalThis.IntersectionObserver;
|
||||||
|
|
||||||
|
const DAY_ONE = buildDay({ id: 31, date: '2026-11-02' });
|
||||||
|
const DAY_TWO = buildDay({ id: 32, date: '2026-11-03' });
|
||||||
|
|
||||||
|
function place(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
|
||||||
|
return {
|
||||||
|
ref,
|
||||||
|
name: `Trattoria ${ref}`,
|
||||||
|
address: null,
|
||||||
|
lat: 41.889,
|
||||||
|
lng: 12.47,
|
||||||
|
kind: 'food',
|
||||||
|
rating: null,
|
||||||
|
openNow: null,
|
||||||
|
googlePlaceId: null,
|
||||||
|
osmId: null,
|
||||||
|
photoKey: null,
|
||||||
|
inTripPlaceId: null,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
|
||||||
|
id: 'msg-5',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'complete',
|
||||||
|
createdAt: '2026-10-21T08:00:00.000Z',
|
||||||
|
parts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addFoundPlace = vi.fn(async () => {});
|
||||||
|
const requestCamera = vi.fn();
|
||||||
|
const setPreviewPin = vi.fn();
|
||||||
|
const onRevealMap = vi.fn();
|
||||||
|
|
||||||
|
function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
|
||||||
|
useAuthStore.setState({ user: buildUser({ id: 1 }), placesPhotosEnabled: true });
|
||||||
|
useTripStore.setState({
|
||||||
|
trip: buildTrip({ id: TRIP, user_id: 1 }),
|
||||||
|
days: [DAY_ONE, DAY_TWO],
|
||||||
|
assignments: {
|
||||||
|
'31': [buildAssignment({ order_index: 0, place: buildPlace({ lat: 41.9, lng: 12.47 }) })],
|
||||||
|
},
|
||||||
|
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
|
||||||
|
});
|
||||||
|
useAssistantStore.setState({
|
||||||
|
addFoundPlace,
|
||||||
|
requestCamera,
|
||||||
|
setPreviewPin,
|
||||||
|
byTrip: {
|
||||||
|
[TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7, focus: { dayId: focusDayId }, messages },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStrip(found: AssistantPlaces, message = answer(), view: Partial<AssistantView> = {}) {
|
||||||
|
const value: AssistantView = { tripId: TRIP, variant: 'window', density: 'compact', onRevealMap, ...view };
|
||||||
|
return render(
|
||||||
|
<AssistantViewContext.Provider value={value}>
|
||||||
|
<AssistantPlaceStrip found={found} message={message} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setForcedOffline(false));
|
||||||
|
afterEach(() => {
|
||||||
|
useAssistantStore.setState(ASSISTANT, true);
|
||||||
|
useTripStore.setState(TRIPS, true);
|
||||||
|
useAuthStore.setState(AUTH, true);
|
||||||
|
globalThis.IntersectionObserver = RealObserver;
|
||||||
|
setForcedOffline(false);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AssistantPlaceStrip', () => {
|
||||||
|
it('FE-AI-PLACES-001: a card names the place, its likely category, whether it is open and how far it is', () => {
|
||||||
|
seed();
|
||||||
|
renderStrip({ toolCallId: 's1', places: [place('s1:0', { openNow: true })], more: 0 });
|
||||||
|
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Restaurant')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Open now')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1.2 km from the previous stop')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PLACES-002: shows every card it was sent and counts what the server left out on a tile', () => {
|
||||||
|
seed();
|
||||||
|
const list = Array.from({ length: 8 }, (_, i) => place(`s1:${i}`));
|
||||||
|
renderStrip({ toolCallId: 's1', places: list, more: 4 });
|
||||||
|
expect(screen.getAllByRole('button', { name: /^Add to/ })).toHaveLength(8);
|
||||||
|
expect(screen.getByText('4 more')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PLACES-009: a place known only by its position shows its category, never a photo of the area', () => {
|
||||||
|
seed();
|
||||||
|
renderStrip({ toolCallId: 's1', places: [place('s1:0', { photoKey: 'coords:41.889000,12.470000' })], more: 0 });
|
||||||
|
expect(fetchPhoto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PLACES-003: a picture only ever comes from the photo proxy', async () => {
|
||||||
|
seed();
|
||||||
|
globalThis.IntersectionObserver = class {
|
||||||
|
private readonly callback: IntersectionObserverCallback;
|
||||||
|
constructor(callback: IntersectionObserverCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
observe() {
|
||||||
|
this.callback([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
|
||||||
|
}
|
||||||
|
disconnect() {}
|
||||||
|
unobserve() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} as unknown as typeof IntersectionObserver;
|
||||||
|
vi.mocked(fetchPhoto).mockImplementation((key, _id, _lat, _lng, _name, callback) => {
|
||||||
|
const photoUrl = key === 'good' ? '/api/maps/place-photo/good/bytes' : 'https://evil.example/x.jpg';
|
||||||
|
callback?.({ photoUrl, thumbDataUrl: null });
|
||||||
|
});
|
||||||
|
const { container } = renderStrip({
|
||||||
|
toolCallId: 's1',
|
||||||
|
places: [place('s1:0', { photoKey: 'good' }), place('s1:1', { photoKey: 'bad', lat: 40 })],
|
||||||
|
more: 0,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1));
|
||||||
|
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/maps/place-photo/good/bytes');
|
||||||
|
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
|
||||||
|
seed();
|
||||||
|
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
|
||||||
|
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
|
||||||
|
seed({ focusDayId: 0 });
|
||||||
|
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
|
||||||
|
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
|
||||||
|
const waiting = answer([
|
||||||
|
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
|
||||||
|
]);
|
||||||
|
seed({ messages: [waiting] });
|
||||||
|
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||||
|
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
seed();
|
||||||
|
setForcedOffline(true);
|
||||||
|
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||||
|
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
|
||||||
|
seed();
|
||||||
|
const suggested = answer([
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'f1',
|
||||||
|
toolName: 'add_found_place',
|
||||||
|
args: { lat: 41.889, lng: 12.47 },
|
||||||
|
approval: { id: 'f1', approved: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
renderStrip(
|
||||||
|
{ toolCallId: 's1', places: [place('s1:0'), place('s1:1', { lat: 40, inTripPlaceId: 8 })], more: 0 },
|
||||||
|
suggested,
|
||||||
|
{ variant: 'fullscreen' }
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Suggested')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'On map' }));
|
||||||
|
expect(onRevealMap).toHaveBeenCalled();
|
||||||
|
expect(requestCamera).toHaveBeenCalledWith(TRIP, [[40, 12.47]], 'pan', 'user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PLACES-008: resting on a card pins it on the map until the pointer leaves', () => {
|
||||||
|
seed();
|
||||||
|
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||||
|
const card = screen.getByText('Trattoria s1:0').closest('li');
|
||||||
|
if (!card) throw new Error('no card');
|
||||||
|
fireEvent.mouseEnter(card);
|
||||||
|
expect(setPreviewPin).toHaveBeenLastCalledWith({
|
||||||
|
tripId: TRIP,
|
||||||
|
ref: 's1:0',
|
||||||
|
lat: 41.889,
|
||||||
|
lng: 12.47,
|
||||||
|
name: 'Trattoria s1:0',
|
||||||
|
});
|
||||||
|
fireEvent.mouseLeave(card);
|
||||||
|
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type { AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import AssistantPlaceCard from './AssistantPlaceCard';
|
||||||
|
import { suggestedFromStrip } from './assistantParts';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
import { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||||
|
|
||||||
|
/** Cards the strip shows, every one the server sends; what it left out is counted on the tile at its end. */
|
||||||
|
const SHOWN = 60;
|
||||||
|
|
||||||
|
// In the strip's head, never over a card: a press that ends a pixel off still lands on the arrow.
|
||||||
|
const ARROW =
|
||||||
|
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content disabled:cursor-default disabled:opacity-40 disabled:hover:text-content-secondary';
|
||||||
|
|
||||||
|
/** How far the strip has scrolled from either end, with the reading direction in mind. */
|
||||||
|
function edgesOf(el: HTMLElement): { start: boolean; end: boolean } {
|
||||||
|
// A right-to-left strip scrolls into negative numbers; the distance from the start is the same either way.
|
||||||
|
const from = Math.abs(el.scrollLeft);
|
||||||
|
return { start: from > 4, end: from + el.clientWidth < el.scrollWidth - 4 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The places a search found, as one row of cards to swipe or scroll through:
|
||||||
|
* the next card peeks in, so the row says it goes on, and the edges fade
|
||||||
|
* where there is more. On the desktop a head over the row names it and holds
|
||||||
|
* two arrows that move it by whole cards, off while that way is the end. Every card the step found, then a
|
||||||
|
* tile counting what the server left out.
|
||||||
|
*/
|
||||||
|
export default function AssistantPlaceStrip({ found, message }: { found: AssistantPlaces; message: AssistantMessage }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { variant } = useAssistantView();
|
||||||
|
const strip = useAssistantPlaceStrip();
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
const [fade, setFade] = useState({ start: false, end: false });
|
||||||
|
const rest = Math.max(0, found.places.length - SHOWN) + found.more;
|
||||||
|
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const next = edgesOf(el);
|
||||||
|
setFade((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
measure();
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [measure, found]);
|
||||||
|
|
||||||
|
// The pin a card left on the map goes with the strip.
|
||||||
|
const { hover } = strip;
|
||||||
|
useEffect(() => () => hover(null), [hover]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls by as many whole cards as the view holds, towards the start (-1)
|
||||||
|
* or the end (1), so the row lands on a card edge instead of fighting its
|
||||||
|
* snap points half way; smoothly, unless motion is turned down.
|
||||||
|
*/
|
||||||
|
const page = (towards: -1 | 1) => {
|
||||||
|
const el = listRef.current;
|
||||||
|
const card = el?.firstElementChild as HTMLElement | null;
|
||||||
|
if (!el || !card) return;
|
||||||
|
const gap = Number.parseFloat(getComputedStyle(el).columnGap) || 0;
|
||||||
|
const step = card.offsetWidth + gap;
|
||||||
|
const cards = Math.max(1, Math.floor((el.clientWidth - 32) / step));
|
||||||
|
const rtl = getComputedStyle(el).direction === 'rtl';
|
||||||
|
// Smooth through the strip's own scroll-behavior, which reduced motion turns off.
|
||||||
|
el.scrollBy((rtl ? -towards : towards) * cards * step, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = found.places.length + found.more;
|
||||||
|
return (
|
||||||
|
<section aria-label={t('assistant.place.strip')} className="-mx-4">
|
||||||
|
{variant === 'window' && (fade.start || fade.end) && (
|
||||||
|
// The cards are reachable by Tab; the arrows are for the pointer only.
|
||||||
|
<div className="mb-1.5 flex items-center gap-2 px-4">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-muted">
|
||||||
|
{t('assistant.place.strip')}
|
||||||
|
<span className="ms-1.5 tabular-nums text-content-faint">{total}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
disabled={!fade.start}
|
||||||
|
onClick={() => page(-1)}
|
||||||
|
className={ARROW}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} className="rtl:-scale-x-100" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
disabled={!fade.end}
|
||||||
|
onClick={() => page(1)}
|
||||||
|
className={ARROW}
|
||||||
|
>
|
||||||
|
<ChevronRight size={15} className="rtl:-scale-x-100" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
onScroll={measure}
|
||||||
|
data-fade-start={fade.start || undefined}
|
||||||
|
data-fade-end={fade.end || undefined}
|
||||||
|
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5 motion-safe:scroll-smooth"
|
||||||
|
>
|
||||||
|
{found.places.slice(0, SHOWN).map((place) => (
|
||||||
|
<AssistantPlaceCard
|
||||||
|
key={place.ref}
|
||||||
|
place={place}
|
||||||
|
strip={strip}
|
||||||
|
suggested={suggestedFromStrip(message, place)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{rest > 0 && (
|
||||||
|
<li className="flex w-24 flex-shrink-0 snap-start items-center justify-center rounded-2xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
|
||||||
|
{t('assistant.place.more', { count: rest })}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-POWER-001 to FE-AI-POWER-010
|
||||||
|
*
|
||||||
|
* The parts that tie the chat into the planner: the next questions under an
|
||||||
|
* answer, the waiting changes inside the day list, the week grid of a plan
|
||||||
|
* over several days, the context menu rows, the trip notes and the part of the
|
||||||
|
* map a question carries.
|
||||||
|
*/
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import type { AssistantChangePreview, AssistantMessage } from '@trek/shared';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { mapViewOf } from '../Map/assistantViewport';
|
||||||
|
import AssistantDayPreview from './AssistantDayPreview';
|
||||||
|
import { followUpsFor } from './assistantFollowUpRules';
|
||||||
|
import AssistantFollowUps from './AssistantFollowUps';
|
||||||
|
import type { ChangeItem } from './assistantParts';
|
||||||
|
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||||
|
import AssistantWeekOverview from './AssistantWeekOverview';
|
||||||
|
import { useAssistantMenu } from './useAssistantMenu';
|
||||||
|
import { useTripMemory } from './useTripMemory';
|
||||||
|
import { useWeekDays } from './useWeekDays';
|
||||||
|
|
||||||
|
const TRIP = 9;
|
||||||
|
const ASSISTANT = useAssistantStore.getState();
|
||||||
|
const TRIPS = useTripStore.getState();
|
||||||
|
const SETTINGS = useSettingsStore.getState();
|
||||||
|
const AUTH = useAuthStore.getState();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useAssistantStore.setState(ASSISTANT, true);
|
||||||
|
useTripStore.setState(TRIPS, true);
|
||||||
|
useSettingsStore.setState(SETTINGS, true);
|
||||||
|
useAuthStore.setState(AUTH, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const view: AssistantView = { tripId: TRIP, variant: 'window' };
|
||||||
|
const inView = (node: ReactNode) => <AssistantViewContext.Provider value={view}>{node}</AssistantViewContext.Provider>;
|
||||||
|
|
||||||
|
function answer(parts: AssistantMessage['parts'], id = 'a1'): AssistantMessage {
|
||||||
|
return { id, role: 'assistant', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z', parts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(
|
||||||
|
toolCallId: string,
|
||||||
|
dayId: number,
|
||||||
|
over: Partial<AssistantChangePreview> = {}
|
||||||
|
): AssistantChangePreview {
|
||||||
|
return {
|
||||||
|
toolCallId,
|
||||||
|
verbKey: 'addPlace',
|
||||||
|
subject: 'Place',
|
||||||
|
rows: [],
|
||||||
|
dayId,
|
||||||
|
placeIds: [],
|
||||||
|
undoable: true,
|
||||||
|
effect: { kind: 'add', dayId, fromDayId: null, placeId: null, assignmentId: null, place: null },
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function waiting(toolCallId: string, data: AssistantChangePreview): AssistantMessage['parts'] {
|
||||||
|
return [
|
||||||
|
{ type: 'tool-call', toolCallId, toolName: 'create_place', args: {}, approval: { id: toolCallId } },
|
||||||
|
{ type: 'data-trek-change', data },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedChat(messages: AssistantMessage[], extra: Record<string, unknown> = {}) {
|
||||||
|
useAssistantStore.setState({
|
||||||
|
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages, ...extra } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the next questions', () => {
|
||||||
|
it('FE-AI-POWER-001: an answer offers what fits what it holds, changes only to someone who may make them', () => {
|
||||||
|
const ids = (parts: AssistantMessage['parts'], canEdit = true) =>
|
||||||
|
followUpsFor(answer(parts), canEdit).map((f) => f.id);
|
||||||
|
expect(ids([{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } }])).toContain('cheaper');
|
||||||
|
expect(ids([{ type: 'data-trek-change', data: preview('c', 1) }])).toContain('nextDay');
|
||||||
|
expect(ids([{ type: 'data-trek-change', data: preview('c', 1) }], false)).toEqual([]);
|
||||||
|
expect(ids([{ type: 'text', text: 'Hi' }])).toEqual(['more', 'next']);
|
||||||
|
expect(ids([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-POWER-002: under the last finished answer a tap sends the question; not while it runs', () => {
|
||||||
|
const send = vi.fn(async () => {});
|
||||||
|
const last = answer([{ type: 'text', text: 'Done.' }]);
|
||||||
|
seedChat([last]);
|
||||||
|
useAssistantStore.setState({ send });
|
||||||
|
const { rerender } = render(inView(<AssistantFollowUps message={last} />));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Tell me more' }));
|
||||||
|
expect(send).toHaveBeenCalledWith(TRIP, 'Tell me more');
|
||||||
|
|
||||||
|
seedChat([last], { isRunning: true });
|
||||||
|
rerender(inView(<AssistantFollowUps message={last} />));
|
||||||
|
expect(screen.queryByRole('button', { name: 'Tell me more' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the day list preview', () => {
|
||||||
|
it('FE-AI-POWER-003: a waiting add shows dashed on its day, a removal struck, and resting on it lights the change', () => {
|
||||||
|
useTripStore.setState({ places: [buildPlace({ id: 5, name: 'Louvre' })] });
|
||||||
|
const add = preview('c1', 3, {
|
||||||
|
effect: {
|
||||||
|
kind: 'add',
|
||||||
|
dayId: 3,
|
||||||
|
fromDayId: null,
|
||||||
|
placeId: null,
|
||||||
|
assignmentId: null,
|
||||||
|
place: { name: 'Pantheon', lat: 1, lng: 2, categoryId: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const remove = preview('c2', 3, {
|
||||||
|
effect: { kind: 'remove', dayId: 3, fromDayId: null, placeId: 5, assignmentId: 50, place: null },
|
||||||
|
});
|
||||||
|
seedChat([answer([...waiting('c1', add), ...waiting('c2', remove)])]);
|
||||||
|
render(<AssistantDayPreview tripId={TRIP} dayId={3} />);
|
||||||
|
expect(screen.getByText('Pantheon')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Louvre')).toHaveClass('line-through');
|
||||||
|
fireEvent.mouseEnter(screen.getByText('Pantheon'));
|
||||||
|
expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('c1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-POWER-004: nothing shows on another day, or once the change was decided', () => {
|
||||||
|
const add = preview('c1', 3, {
|
||||||
|
effect: {
|
||||||
|
kind: 'add',
|
||||||
|
dayId: 3,
|
||||||
|
fromDayId: null,
|
||||||
|
placeId: null,
|
||||||
|
assignmentId: null,
|
||||||
|
place: { name: 'Pantheon', lat: 1, lng: 2, categoryId: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
seedChat([answer(waiting('c1', add))]);
|
||||||
|
const { container } = render(<AssistantDayPreview tripId={TRIP} dayId={4} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the week grid', () => {
|
||||||
|
const item = (id: string, dayId: number): ChangeItem => ({
|
||||||
|
toolCallId: id,
|
||||||
|
preview: preview(id, dayId),
|
||||||
|
applied: null,
|
||||||
|
state: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-POWER-005: from three days on the changes group by day, in trip order', () => {
|
||||||
|
useTripStore.setState({
|
||||||
|
trip: buildTrip({ id: TRIP }),
|
||||||
|
days: [
|
||||||
|
buildDay({ id: 1, date: '2026-11-02' }),
|
||||||
|
buildDay({ id: 2, date: '2026-11-03' }),
|
||||||
|
buildDay({ id: 3, date: '2026-11-04' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => inView(children);
|
||||||
|
const three = renderHook(() => useWeekDays([item('a', 3), item('b', 1), item('c', 2), item('d', 1)]), { wrapper });
|
||||||
|
expect(three.result.current.map((d) => [d.n, d.ids])).toEqual([
|
||||||
|
[1, ['b', 'd']],
|
||||||
|
[2, ['c']],
|
||||||
|
[3, ['a']],
|
||||||
|
]);
|
||||||
|
const two = renderHook(() => useWeekDays([item('a', 1), item('b', 2)]), { wrapper });
|
||||||
|
expect(two.result.current).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-POWER-006: a tap leaves a whole day out or takes it back in', () => {
|
||||||
|
const onToggleDay = vi.fn();
|
||||||
|
const days = [
|
||||||
|
{ id: 1, n: 1, weekday: 'Mon', ids: ['a', 'b'] },
|
||||||
|
{ id: 2, n: 2, weekday: 'Tue', ids: ['c'] },
|
||||||
|
];
|
||||||
|
render(<AssistantWeekOverview days={days} excluded={new Set(['c'])} disabled={false} onToggleDay={onToggleDay} />);
|
||||||
|
const [mon, tue] = screen.getAllByRole('button');
|
||||||
|
expect(mon).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(tue).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
fireEvent.click(mon);
|
||||||
|
expect(onToggleDay).toHaveBeenCalledWith(['a', 'b'], false);
|
||||||
|
fireEvent.click(tue);
|
||||||
|
expect(onToggleDay).toHaveBeenLastCalledWith(['c'], true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the context menu rows', () => {
|
||||||
|
it('FE-AI-POWER-007: none while the assistant cannot run; else they open the chat on the place and ask', () => {
|
||||||
|
const send = vi.fn(async () => {});
|
||||||
|
const openWith = vi.fn();
|
||||||
|
useAssistantStore.setState({ status: null, send, openWith });
|
||||||
|
const louvre = buildPlace({ id: 5, name: 'Louvre' });
|
||||||
|
const translated = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
|
||||||
|
const off = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
|
||||||
|
expect(off.result.current.place(louvre)).toEqual([]);
|
||||||
|
|
||||||
|
useAssistantStore.setState({ status: { available: true } as never });
|
||||||
|
const on = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
|
||||||
|
const rows = on.result.current.place(louvre);
|
||||||
|
const alternative = rows.find((r) => 'label' in r && r.label === 'Find an alternative');
|
||||||
|
act(() => (alternative && 'onClick' in alternative ? alternative.onClick() : undefined));
|
||||||
|
expect(openWith).toHaveBeenCalledWith(TRIP, { placeIds: [5] });
|
||||||
|
expect(send).toHaveBeenCalledWith(TRIP, expect.stringContaining('Louvre'));
|
||||||
|
expect(on.result.current.day(3).length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the trip notes', () => {
|
||||||
|
it('FE-AI-POWER-008: notes are kept per trip, and saving empty ones drops the entry', async () => {
|
||||||
|
useAuthStore.setState({ user: buildUser({ id: 1 }) });
|
||||||
|
const updateSetting = vi.fn(async () => {});
|
||||||
|
useSettingsStore.setState({
|
||||||
|
settings: { ...SETTINGS.settings, assistant_memory: { '2': 'Other' } },
|
||||||
|
updateSetting,
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useTripMemory(TRIP));
|
||||||
|
expect(result.current.has).toBe(false);
|
||||||
|
await act(async () => void (await result.current.save(' Vegetarian ')));
|
||||||
|
expect(updateSetting).toHaveBeenCalledWith('assistant_memory', { '2': 'Other', [TRIP]: 'Vegetarian' });
|
||||||
|
await act(async () => void (await result.current.save(' ')));
|
||||||
|
expect(updateSetting).toHaveBeenLastCalledWith('assistant_memory', { '2': 'Other' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the map a question carries', () => {
|
||||||
|
it('FE-AI-POWER-009: edges stay on the globe and are rounded to about ten metres', () => {
|
||||||
|
expect(mapViewOf({ south: 35.123456, west: -190, north: 35.2, east: 139.987654 })).toEqual({
|
||||||
|
south: 35.1235,
|
||||||
|
west: -180,
|
||||||
|
north: 35.2,
|
||||||
|
east: 139.9877,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-POWER-010: a question goes out with the map of its own trip in its focus', async () => {
|
||||||
|
const send = vi.fn();
|
||||||
|
useAssistantStore.setState({ mapView: { tripId: TRIP, view: { south: 1, west: 2, north: 3, east: 4 } } });
|
||||||
|
seedChat([]);
|
||||||
|
// The request body is built inside the store; read it from the repo call.
|
||||||
|
const { assistantRepo } = await import('../../repo/assistantRepo');
|
||||||
|
const spy = vi.spyOn(assistantRepo, 'send').mockImplementation(async (_trip, body) => {
|
||||||
|
send(body);
|
||||||
|
throw new Error('stop');
|
||||||
|
});
|
||||||
|
await useAssistantStore.getState().send(TRIP, 'What is here?');
|
||||||
|
expect(send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ focus: { view: { south: 1, west: 2, north: 3, east: 4 } } })
|
||||||
|
);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { ChevronDown, ShieldCheck } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useToast } from '../shared/Toast';
|
||||||
|
import { providerLabel } from './providerCatalog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who reads along, said once. A user who connected their own provider agreed
|
||||||
|
* to this while connecting; on the admin's connection the chat says it here,
|
||||||
|
* above the composer, until the user has read it. A new provider set up by
|
||||||
|
* the admin asks again, because the answer is stored per provider and host.
|
||||||
|
*/
|
||||||
|
export default function AssistantPrivacyBanner() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const effective = useAssistantStore((s) => s.status?.effective ?? null);
|
||||||
|
const acked = useAssistantStore((s) => s.status?.privacyAcked ?? true);
|
||||||
|
const ackPrivacy = useAssistantStore((s) => s.ackPrivacy);
|
||||||
|
const [details, setDetails] = useState(false);
|
||||||
|
|
||||||
|
if (!effective || effective.source !== 'instance' || acked) return null;
|
||||||
|
const provider = providerLabel(effective.kind, effective.host);
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
try {
|
||||||
|
await ackPrivacy();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('assistant.error.generic'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="trek-assistant-enter rounded-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 text-caption text-content-secondary">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<ShieldCheck size={14} aria-hidden="true" className="mt-0.5 flex-shrink-0 text-content-muted" />
|
||||||
|
<p className="m-0 min-w-0 flex-1">{t('assistant.privacy.banner', { provider })}</p>
|
||||||
|
</div>
|
||||||
|
{details && (
|
||||||
|
<ul className="m-0 mt-2 list-disc space-y-1 ps-9">
|
||||||
|
<li>{t('assistant.privacy.shared')}</li>
|
||||||
|
<li>{t('assistant.privacy.notShared')}</li>
|
||||||
|
<li>{t('assistant.privacy.provider', { provider })}</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center justify-end gap-1">
|
||||||
|
{!details && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetails(true)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-caption text-content-muted hover:bg-surface-hover hover:text-content"
|
||||||
|
>
|
||||||
|
{t('assistant.privacy.what')}
|
||||||
|
<ChevronDown size={12} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void confirm()}
|
||||||
|
className="rounded-md px-2 py-1 text-caption font-medium text-accent-on hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
{t('assistant.privacy.ok')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||||
|
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||||
|
import { SHARE_MAX_CHARS } from './assistantShare';
|
||||||
|
import type { useAssistantShare } from './useAssistantShare';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The question before an answer goes to the trip chat: who will see it, the
|
||||||
|
* text exactly as it will arrive, and whether the applied changes go along.
|
||||||
|
* Nothing can be edited here; what goes out is what the preview shows. One
|
||||||
|
* dialog for the window and the phone, which lays it out as a sheet.
|
||||||
|
*/
|
||||||
|
export default function AssistantShareDialog({ share }: { share: ReturnType<typeof useAssistantShare> }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { preview } = share;
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={share.previewing}
|
||||||
|
onClose={share.close}
|
||||||
|
onConfirm={() => void share.send()}
|
||||||
|
title={t('assistant.share.title')}
|
||||||
|
message={t('assistant.share.who')}
|
||||||
|
confirmLabel={t('assistant.share.send')}
|
||||||
|
danger={false}
|
||||||
|
>
|
||||||
|
{/* Spaced on its own: the phone's sheet sets the message flush against what follows. */}
|
||||||
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
|
<p
|
||||||
|
dir="auto"
|
||||||
|
className="m-0 max-h-[40vh] overflow-y-auto whitespace-pre-wrap break-words rounded-xl border border-edge-faint bg-surface-secondary p-3 text-body text-content"
|
||||||
|
>
|
||||||
|
{preview.text}
|
||||||
|
</p>
|
||||||
|
{preview.hasChanges && (
|
||||||
|
<label className="flex items-center justify-between gap-3 text-body text-content">
|
||||||
|
<span>{t('assistant.share.includeChanges')}</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
on={share.includeChanges}
|
||||||
|
onToggle={share.toggleChanges}
|
||||||
|
label={t('assistant.share.includeChanges')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{preview.truncated && (
|
||||||
|
<p className="m-0 text-caption text-content-muted">
|
||||||
|
{t('assistant.share.truncated', { count: SHARE_MAX_CHARS })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-SURF-001 to FE-AI-SURF-006
|
||||||
|
*
|
||||||
|
* The chat on the real @assistant-ui runtime, fed from the assistant store:
|
||||||
|
* the empty chat with its suggestions, stored messages as the user sees
|
||||||
|
* them, sending from the composer, and the composer holding back while a
|
||||||
|
* change waits for a decision.
|
||||||
|
*/
|
||||||
|
import type { AiStatusResponse, AssistantMessage } from '@trek/shared';
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import AssistantSurface from './AssistantSurface';
|
||||||
|
|
||||||
|
const TRIP = 4;
|
||||||
|
const INITIAL = useAssistantStore.getState();
|
||||||
|
const AT = '2026-10-21T08:00:00.000Z';
|
||||||
|
|
||||||
|
const STATUS: AiStatusResponse = {
|
||||||
|
available: true,
|
||||||
|
policy: 'instance',
|
||||||
|
canConnectOwn: false,
|
||||||
|
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
|
||||||
|
own: null,
|
||||||
|
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
|
||||||
|
quota: null,
|
||||||
|
privacyAcked: true,
|
||||||
|
retention: { mode: 'days', days: 90 },
|
||||||
|
openrouterOAuth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = vi.fn(async () => {});
|
||||||
|
|
||||||
|
function seed(messages: AssistantMessage[], status: AiStatusResponse = STATUS) {
|
||||||
|
useAssistantStore.setState({
|
||||||
|
status,
|
||||||
|
send,
|
||||||
|
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChat() {
|
||||||
|
return render(<AssistantSurface tripId={TRIP} tripTitle="Kyoto" variant="window" />);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// jsdom lays nothing out and has no element scrolling; the viewport only needs the call to exist.
|
||||||
|
Element.prototype.scrollTo = vi.fn() as unknown as typeof Element.prototype.scrollTo;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => send.mockClear());
|
||||||
|
afterEach(() => useAssistantStore.setState(INITIAL, true));
|
||||||
|
|
||||||
|
describe('AssistantSurface', () => {
|
||||||
|
it('FE-AI-SURF-001: the empty chat asks what to plan and offers questions that change nothing for an unknown trip', async () => {
|
||||||
|
seed([]);
|
||||||
|
renderChat();
|
||||||
|
expect(await screen.findByText('What are we planning?')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Make a packing list for this trip' })).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Which days are still empty?' }));
|
||||||
|
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Which days are still empty?'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-SURF-002: shows the question and the answer, with the steps folded away', async () => {
|
||||||
|
seed([
|
||||||
|
{ id: 'q1', role: 'user', status: 'complete', createdAt: AT, parts: [{ type: 'text', text: 'Plan Friday' }] },
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'complete',
|
||||||
|
createdAt: AT,
|
||||||
|
parts: [
|
||||||
|
{ type: 'tool-call', toolCallId: 'r1', toolName: 'get_trip_summary', args: {}, result: { ok: true } },
|
||||||
|
{ type: 'text', text: 'Here is a **plan**.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
renderChat();
|
||||||
|
expect(await screen.findByText('Plan Friday')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('plan')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '1 step' })).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(screen.queryByText('What are we planning?')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-SURF-003: Enter sends what was typed, with the trip in the placeholder', async () => {
|
||||||
|
seed([]);
|
||||||
|
renderChat();
|
||||||
|
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
|
||||||
|
fireEvent.change(input, { target: { value: 'Where should we eat?' } });
|
||||||
|
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Where should we eat?');
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Where should we eat?'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-SURF-004: a change waiting for a decision holds the send button back', async () => {
|
||||||
|
seed([
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
createdAt: AT,
|
||||||
|
parts: [{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_todo', args: {}, approval: { id: 'w1' } }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
renderChat();
|
||||||
|
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
|
||||||
|
fireEvent.change(input, { target: { value: 'Something else' } });
|
||||||
|
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-SURF-005: on the admin connection the chat says once where messages go', async () => {
|
||||||
|
seed([], { ...STATUS, privacyAcked: false });
|
||||||
|
renderChat();
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Your messages and the trip data the assistant reads go to OpenAI.')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'What gets shared?' }));
|
||||||
|
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-SURF-006: an answer shows what its search found and the weather it looked up', async () => {
|
||||||
|
seed([
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'complete',
|
||||||
|
createdAt: AT,
|
||||||
|
parts: [
|
||||||
|
{ type: 'tool-call', toolCallId: 's1', toolName: 'search_nearby_places', args: {}, result: { ok: true } },
|
||||||
|
{
|
||||||
|
type: 'data-trek-places',
|
||||||
|
data: {
|
||||||
|
toolCallId: 's1',
|
||||||
|
more: 0,
|
||||||
|
places: [
|
||||||
|
{
|
||||||
|
ref: 's1:0',
|
||||||
|
name: 'Nishiki Market',
|
||||||
|
address: null,
|
||||||
|
lat: 35.005,
|
||||||
|
lng: 135.765,
|
||||||
|
kind: 'food',
|
||||||
|
rating: null,
|
||||||
|
openNow: null,
|
||||||
|
googlePlaceId: null,
|
||||||
|
osmId: null,
|
||||||
|
photoKey: null,
|
||||||
|
inTripPlaceId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'data-trek-weather',
|
||||||
|
data: {
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayId: null,
|
||||||
|
date: '2026-11-02',
|
||||||
|
main: 'Rain',
|
||||||
|
tempMax: 14,
|
||||||
|
tempMin: 9,
|
||||||
|
rainPct: 70,
|
||||||
|
kind: 'forecast',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'text', text: 'Rain on Monday, so the market is a good pick.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
renderChat();
|
||||||
|
expect(await screen.findByRole('region', { name: 'Places found' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Nishiki Market')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('list', { name: 'Weather for 1 day' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('14°')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import './assistant.css';
|
||||||
|
|
||||||
|
import { AssistantRuntimeProvider } from '@assistant-ui/react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import AssistantThread from './AssistantThread';
|
||||||
|
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||||
|
import { useAssistantRuntime } from './useAssistantRuntime';
|
||||||
|
|
||||||
|
export interface AssistantSurfaceProps extends AssistantView {
|
||||||
|
/** Named in the composer's placeholder. */
|
||||||
|
tripTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat for one trip, the same on the desktop window and the phone screen.
|
||||||
|
* Its own chunk: this is the only module tree that pulls in @assistant-ui, so
|
||||||
|
* nobody downloads the chat runtime before opening the assistant.
|
||||||
|
*/
|
||||||
|
export default function AssistantSurface({
|
||||||
|
tripId,
|
||||||
|
tripTitle,
|
||||||
|
variant,
|
||||||
|
density,
|
||||||
|
onShowOnMap,
|
||||||
|
onFocusDay,
|
||||||
|
onRevealMap,
|
||||||
|
}: AssistantSurfaceProps) {
|
||||||
|
const runtime = useAssistantRuntime(tripId);
|
||||||
|
// The phone is always compact; a window is what its width says.
|
||||||
|
const view = useMemo(
|
||||||
|
() => ({
|
||||||
|
tripId,
|
||||||
|
variant,
|
||||||
|
density: variant === 'fullscreen' ? ('compact' as const) : (density ?? 'roomy'),
|
||||||
|
onShowOnMap,
|
||||||
|
onFocusDay,
|
||||||
|
onRevealMap,
|
||||||
|
}),
|
||||||
|
[tripId, variant, density, onShowOnMap, onFocusDay, onRevealMap]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
<AssistantViewContext.Provider value={view}>
|
||||||
|
<AssistantThread tripTitle={tripTitle} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
</AssistantRuntimeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { lazyWithRetry } from '../../utils/lazyWithRetry';
|
||||||
|
import ErrorBoundary from '../shared/ErrorBoundary';
|
||||||
|
import { Spinner } from '../shared/Spinner';
|
||||||
|
import type { AssistantSurfaceProps } from './AssistantSurface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat, loaded when it is first shown: the window and the phone screen
|
||||||
|
* mount this, and the chat runtime arrives in one chunk of its own (kept out
|
||||||
|
* of the offline precache, the assistant needs the network anyway).
|
||||||
|
*/
|
||||||
|
const AssistantSurface = lazyWithRetry(() => import('./AssistantSurface'));
|
||||||
|
|
||||||
|
export default function AssistantSurfaceLazy(props: AssistantSurfaceProps) {
|
||||||
|
const waiting = (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<Spinner className="h-5 w-5 border-2 border-edge border-t-[color:var(--text-primary)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
// Boundary outside the Suspense, as in VideoPlayerLazy: a rejected chunk flies past Suspense.
|
||||||
|
<ErrorBoundary
|
||||||
|
boundaryId="assistant"
|
||||||
|
level="panel"
|
||||||
|
variant={props.variant === 'fullscreen' ? 'mobile' : 'desktop'}
|
||||||
|
resetKeys={[props.tripId]}
|
||||||
|
>
|
||||||
|
<Suspense fallback={waiting}>
|
||||||
|
<AssistantSurface key={props.tripId} {...props} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
|
||||||
|
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import AssistantComposer from './AssistantComposer';
|
||||||
|
import AssistantError from './AssistantError';
|
||||||
|
import AssistantMessage from './AssistantMessage';
|
||||||
|
import AssistantPrivacyBanner from './AssistantPrivacyBanner';
|
||||||
|
import AssistantWelcome, { AssistantSuggestions } from './AssistantWelcome';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
import { useAssistantAttach } from './useAssistantAttach';
|
||||||
|
import { useFollowSelectedDay } from './useAssistantFocus';
|
||||||
|
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
|
||||||
|
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat itself: the messages, and under them the line that says what went
|
||||||
|
* wrong, the privacy note and the composer, held at the bottom while the
|
||||||
|
* messages scroll. A new question moves up to the top of the view, so its
|
||||||
|
* answer reads from its first line instead of chasing the newest one. A
|
||||||
|
* booking file or a place of the trip can be dropped anywhere on it.
|
||||||
|
*/
|
||||||
|
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tripId, variant } = useAssistantView();
|
||||||
|
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
|
||||||
|
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
|
||||||
|
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||||
|
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
|
||||||
|
useFollowSelectedDay(tripId);
|
||||||
|
|
||||||
|
const phone = variant === 'fullscreen';
|
||||||
|
const attach = useAssistantAttach(tripId);
|
||||||
|
const placeDrop = useAssistantPlaceDrop(tripId);
|
||||||
|
const DropIcon = placeDrop.dragging ? MapPin : FileUp;
|
||||||
|
// `@` reaches only the trip the planner shows; for another one the hint would promise nothing.
|
||||||
|
const mentionable = useTripStore((s) => s.trip?.id === tripId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThreadPrimitive.Root
|
||||||
|
className="relative flex min-h-0 flex-1 flex-col"
|
||||||
|
{...joinDropZones(attach.dropZone, placeDrop.dropZone)}
|
||||||
|
>
|
||||||
|
{(attach.dragging || placeDrop.dragging) && (
|
||||||
|
<div className="pointer-events-none absolute inset-2 z-20 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-edge bg-surface-elevated text-center backdrop-blur-sm">
|
||||||
|
<DropIcon size={22} aria-hidden="true" className="text-content-secondary" />
|
||||||
|
<span className="max-w-[16rem] text-body font-medium text-content">
|
||||||
|
{t(placeDrop.dragging ? 'assistant.attach.dropPlace' : 'assistant.attach.drop')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||||
|
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||||
|
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
|
||||||
|
{mentionable && (
|
||||||
|
<p className="m-0 pb-1 text-center text-caption text-content-faint">{t('assistant.mention.hint')}</p>
|
||||||
|
)}
|
||||||
|
</AuiIf>
|
||||||
|
<div role="log" aria-label={t('assistant.window.log')} aria-busy={isRunning} className="pt-2">
|
||||||
|
<ThreadPrimitive.Messages>
|
||||||
|
{({ message }) => {
|
||||||
|
const stored = byId.get(message.id);
|
||||||
|
return stored ? <AssistantMessage message={stored} /> : null;
|
||||||
|
}}
|
||||||
|
</ThreadPrimitive.Messages>
|
||||||
|
</div>
|
||||||
|
</ThreadPrimitive.Viewport>
|
||||||
|
{/* Outside the scroller, so it needs no backdrop of its own: on the glass window a second
|
||||||
|
surface behind the composer showed as a box. */}
|
||||||
|
<div className="relative flex flex-shrink-0 flex-col gap-2 px-4 pb-3 pt-2">
|
||||||
|
<ThreadPrimitive.ScrollToBottom
|
||||||
|
aria-label={t('assistant.thread.scrollDown')}
|
||||||
|
className="absolute -top-10 start-1/2 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-edge-faint bg-surface-card text-content-secondary shadow-dropdown hover:text-content disabled:hidden rtl:translate-x-1/2"
|
||||||
|
>
|
||||||
|
<ArrowDown size={15} aria-hidden="true" />
|
||||||
|
</ThreadPrimitive.ScrollToBottom>
|
||||||
|
{phone && (
|
||||||
|
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||||
|
<AssistantSuggestions suggestions={suggestions} layout="row" />
|
||||||
|
</AuiIf>
|
||||||
|
)}
|
||||||
|
<AssistantError tripId={tripId} />
|
||||||
|
<AssistantPrivacyBanner />
|
||||||
|
<AssistantComposer tripId={tripId} tripTitle={tripTitle} />
|
||||||
|
</div>
|
||||||
|
</ThreadPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import AssistantLiveStep from './AssistantLiveStep';
|
||||||
|
import { toolStepKey } from './assistantModel';
|
||||||
|
import type { ToolStep } from './assistantParts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the assistant looked up on the way to its answer. While a step runs it
|
||||||
|
* says what it does ("Reading your plan", "Searching near Trastevere") and
|
||||||
|
* shimmers; once the run is done the steps fold into one quiet line that
|
||||||
|
* opens to the list. Tool names never show: they are the model's words, not
|
||||||
|
* the user's.
|
||||||
|
*/
|
||||||
|
export default function AssistantToolSteps({ steps }: { steps: ToolStep[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const running = steps.filter((s) => s.running).pop();
|
||||||
|
|
||||||
|
if (running) return <AssistantLiveStep toolCallId={running.toolCallId} toolName={running.toolName} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-caption text-content-muted">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="-ms-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-caption text-content-muted hover:bg-surface-hover hover:text-content-secondary"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={13}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`transition-transform duration-150 rtl:-scale-x-100 ${open ? 'rotate-90' : ''}`}
|
||||||
|
/>
|
||||||
|
{t('assistant.step.collapsed', { count: steps.length })}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ul className="m-0 mt-1 list-none space-y-0.5 border-s border-edge-faint ps-3">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<li key={step.toolCallId}>{t(toolStepKey(step.toolName))}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ChevronRight, Luggage } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import EmptyState from '../shared/EmptyState';
|
||||||
|
import { Spinner } from '../shared/Spinner';
|
||||||
|
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outside a trip the assistant first asks which one: the chat, its history
|
||||||
|
* and its changes all belong to one trip. The running and the next trip lead.
|
||||||
|
*/
|
||||||
|
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const trips = useAssistantTripPicker();
|
||||||
|
|
||||||
|
if (trips === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<Spinner className="h-5 w-5 border-2 border-edge border-t-[color:var(--text-primary)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (trips.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center px-6">
|
||||||
|
<EmptyState scene="idle" size={72} compact surface="var(--bg-elevated)" title={t('assistant.picker.empty')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
|
||||||
|
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
|
||||||
|
{trips.map((trip) => (
|
||||||
|
<li key={trip.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(trip)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-surface-secondary text-content-muted">
|
||||||
|
<Luggage size={15} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<bdi className="min-w-0 flex-1 truncate text-body text-content">{trip.title}</bdi>
|
||||||
|
{trip.badge && (
|
||||||
|
<span className="flex-shrink-0 rounded-full bg-accent-subtle px-2 py-px text-caption text-accent-on">
|
||||||
|
{t(trip.badge === 'now' ? 'assistant.picker.now' : 'assistant.picker.next')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex-shrink-0 text-content-faint rtl:-scale-x-100"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-WEATHER-001 to FE-AI-WEATHER-005
|
||||||
|
*
|
||||||
|
* The weather row of an answer: temperatures in the user's unit, a drop only
|
||||||
|
* where rain is likely, the day in focus framed, and a trip day that selects
|
||||||
|
* itself in the planner.
|
||||||
|
*/
|
||||||
|
import type { AssistantWeatherDay } from '@trek/shared';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||||
|
import { inUnit } from './assistantModel';
|
||||||
|
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||||
|
|
||||||
|
const TRIP = 4;
|
||||||
|
const ASSISTANT = useAssistantStore.getState();
|
||||||
|
const SETTINGS = useSettingsStore.getState();
|
||||||
|
|
||||||
|
function day(date: string, extra: Partial<AssistantWeatherDay> = {}): AssistantWeatherDay {
|
||||||
|
return { dayId: null, date, main: 'Clear', tempMax: 21, tempMin: 12, rainPct: null, kind: 'forecast', ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStrip(days: AssistantWeatherDay[], view: Partial<AssistantView> = {}) {
|
||||||
|
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
|
||||||
|
return render(
|
||||||
|
<AssistantViewContext.Provider value={value}>
|
||||||
|
<AssistantWeatherStrip days={days} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useAssistantStore.setState(ASSISTANT, true);
|
||||||
|
useSettingsStore.setState(SETTINGS, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AssistantWeatherStrip', () => {
|
||||||
|
it('FE-AI-WEATHER-001: shows °C in the user unit, without a negative zero', () => {
|
||||||
|
expect(inUnit(21, false)).toBe(21);
|
||||||
|
expect(inUnit(21, true)).toBe(70);
|
||||||
|
expect(inUnit(-0.4, false)).toBe(0);
|
||||||
|
|
||||||
|
useSettingsStore.setState({ settings: { ...SETTINGS.settings, temperature_unit: 'fahrenheit' } });
|
||||||
|
renderStrip([day('2026-11-02')]);
|
||||||
|
expect(screen.getByText('70°')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('54°')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WEATHER-002: a drop shows from 30 % rain on, and a climate figure reads as approximate', () => {
|
||||||
|
renderStrip([
|
||||||
|
day('2026-11-02', { rainPct: 29 }),
|
||||||
|
day('2026-11-03', { rainPct: 60 }),
|
||||||
|
day('2026-11-04', { kind: 'climate', tempMax: 18 }),
|
||||||
|
]);
|
||||||
|
expect(screen.queryByText('29%')).toBeNull();
|
||||||
|
expect(screen.getByText('60%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('≈18°')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('list', { name: 'Weather for 3 days' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WEATHER-003: a trip day selects itself in the planner, and the day in focus is marked', () => {
|
||||||
|
const onFocusDay = vi.fn();
|
||||||
|
useAssistantStore.setState({
|
||||||
|
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), focus: { dayId: 31 } } },
|
||||||
|
});
|
||||||
|
renderStrip([day('2026-11-02', { dayId: 31 }), day('2026-11-03', { dayId: 32 })], { onFocusDay });
|
||||||
|
const [first, second] = screen.getAllByRole('button');
|
||||||
|
expect(first).toHaveAttribute('aria-current', 'true');
|
||||||
|
expect(second).not.toHaveAttribute('aria-current');
|
||||||
|
if (second) fireEvent.click(second);
|
||||||
|
expect(onFocusDay).toHaveBeenCalledWith(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WEATHER-004: without a planner or a trip day a chip is only text', () => {
|
||||||
|
renderStrip([day('2026-11-02', { dayId: 31 })]);
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
renderStrip([day('2026-11-03')], { onFocusDay: vi.fn() });
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WEATHER-005: a chip grows with three-digit or approximate figures instead of spilling over', () => {
|
||||||
|
useSettingsStore.setState({ settings: { ...SETTINGS.settings, temperature_unit: 'fahrenheit' } });
|
||||||
|
renderStrip([day('2026-07-02', { kind: 'climate', tempMax: 39, tempMin: 29 })]);
|
||||||
|
expect(screen.getByText('≈102°')).toBeInTheDocument();
|
||||||
|
const chip = screen.getByRole('listitem');
|
||||||
|
expect(chip.className).toContain('min-w-14');
|
||||||
|
expect(chip.className.split(' ')).not.toContain('w-14');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { AssistantWeatherDay } from '@trek/shared';
|
||||||
|
import { Droplet } from 'lucide-react';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { weatherIconFor } from '../Weather/weatherIcons';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import { inUnit } from './assistantModel';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
|
||||||
|
/** Rain this likely or more gets its drop. */
|
||||||
|
const RAIN_SHOWN_PCT = 30;
|
||||||
|
|
||||||
|
/** The weekday and the day of the month of a `YYYY-MM-DD` date, read as a calendar date wherever the user is. */
|
||||||
|
function dateParts(iso: string, locale: string): { weekday: string; day: string } {
|
||||||
|
const [y, m, d] = iso.split('-').map(Number);
|
||||||
|
const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
|
||||||
|
return { weekday: date.toLocaleDateString(locale, { weekday: 'short' }), day: String(date.getDate()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeatherChip({
|
||||||
|
day,
|
||||||
|
fahrenheit,
|
||||||
|
focused,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
day: AssistantWeatherDay;
|
||||||
|
fahrenheit: boolean;
|
||||||
|
focused: boolean;
|
||||||
|
onPick: ((dayId: number) => void) | null;
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const icon = weatherIconFor(day.main);
|
||||||
|
const { weekday, day: dayOfMonth } = dateParts(day.date, locale);
|
||||||
|
// A climate figure is an average for the date, not a forecast; it says so with "≈".
|
||||||
|
const approx = day.kind === 'climate' ? '≈' : '';
|
||||||
|
const max = `${approx}${inUnit(day.tempMax, fahrenheit)}°`;
|
||||||
|
const min = day.tempMin === null ? null : `${inUnit(day.tempMin, fahrenheit)}°`;
|
||||||
|
const rainy = day.rainPct !== null && day.rainPct >= RAIN_SHOWN_PCT;
|
||||||
|
const dayId = day.dayId;
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<span className="text-caption text-content-muted">
|
||||||
|
{weekday} {dayOfMonth}
|
||||||
|
</span>
|
||||||
|
{createElement(icon, { size: 16, strokeWidth: 1.8, 'aria-hidden': true, className: 'text-content-secondary' })}
|
||||||
|
<span className="flex items-baseline gap-1 tabular-nums">
|
||||||
|
<span className="text-body font-semibold text-content">{max}</span>
|
||||||
|
{min && <span className="text-caption text-content-faint">{min}</span>}
|
||||||
|
</span>
|
||||||
|
{rainy && (
|
||||||
|
<Tooltip label={t('assistant.weather.rain', { pct: day.rainPct ?? 0 })} placement="top">
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-caption tabular-nums text-content-muted">
|
||||||
|
<Droplet size={10} aria-hidden="true" />
|
||||||
|
{day.rainPct}%
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const look = `flex min-w-14 flex-shrink-0 snap-start flex-col items-center gap-0.5 rounded-lg border px-1 py-1.5 ${
|
||||||
|
focused ? 'border-edge bg-surface-card' : 'border-transparent bg-surface-secondary'
|
||||||
|
}`;
|
||||||
|
const label = `${weekday} ${dayOfMonth}, ${max}${min ? ` / ${min}` : ''}`;
|
||||||
|
|
||||||
|
if (onPick && dayId !== null) {
|
||||||
|
return (
|
||||||
|
<li className="flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(dayId)}
|
||||||
|
aria-label={label}
|
||||||
|
aria-current={focused || undefined}
|
||||||
|
className={`${look} transition-colors hover:bg-surface-hover`}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li aria-label={label} className={look}>
|
||||||
|
{body}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The weather the assistant looked up, one small chip per day: weekday and
|
||||||
|
* date, the sky, the high in full and the low in quiet, a drop from 30 % rain
|
||||||
|
* on. The day in focus is framed; a chip with a trip day selects that day in
|
||||||
|
* the planner. More than a week scrolls sideways.
|
||||||
|
*/
|
||||||
|
export default function AssistantWeatherStrip({ days }: { days: AssistantWeatherDay[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tripId, onFocusDay } = useAssistantView();
|
||||||
|
const fahrenheit = useSettingsStore((s) => s.settings.temperature_unit) === 'fahrenheit';
|
||||||
|
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
aria-label={t('assistant.weather.strip', { count: days.length })}
|
||||||
|
className="trek-assistant-strip -mx-4 flex snap-x scroll-px-4 gap-1.5 overflow-x-auto px-4"
|
||||||
|
>
|
||||||
|
{days.map((day) => (
|
||||||
|
<WeatherChip
|
||||||
|
key={day.date}
|
||||||
|
day={day}
|
||||||
|
fahrenheit={fahrenheit}
|
||||||
|
focused={day.dayId !== null && day.dayId === focusDayId}
|
||||||
|
onPick={onFocusDay ?? null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import type { WeekDay } from './useWeekDays';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plan over several days as one row of small tiles: a day per tile, its
|
||||||
|
* number of changes in the corner, taken in or left out as a whole with one
|
||||||
|
* tap. The single changes
|
||||||
|
* stay below it, folded away until asked for.
|
||||||
|
*/
|
||||||
|
export default function AssistantWeekOverview({
|
||||||
|
days,
|
||||||
|
excluded,
|
||||||
|
disabled,
|
||||||
|
onToggleDay,
|
||||||
|
}: {
|
||||||
|
days: readonly WeekDay[];
|
||||||
|
excluded: ReadonlySet<string>;
|
||||||
|
disabled: boolean;
|
||||||
|
onToggleDay: (ids: readonly string[], include: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t('assistant.week.title')}
|
||||||
|
className="mb-2 grid gap-2 pt-1"
|
||||||
|
style={{ gridTemplateColumns: `repeat(${Math.min(days.length, 7)}, minmax(0, 1fr))` }}
|
||||||
|
>
|
||||||
|
{days.map((day, i) => {
|
||||||
|
const on = day.ids.filter((id) => !excluded.has(id)).length;
|
||||||
|
const taken = on > 0;
|
||||||
|
const count = taken ? on : day.ids.length;
|
||||||
|
const name = `${day.weekday ?? t('assistant.week.day')} ${day.n}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.id}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={taken}
|
||||||
|
aria-label={`${name}, ${t('assistant.week.changes', { count })}`}
|
||||||
|
title={t('assistant.week.changes', { count })}
|
||||||
|
onClick={() => onToggleDay(day.ids, !taken)}
|
||||||
|
className={`trek-assistant-enter relative flex min-w-0 flex-col items-center rounded-lg py-1.5 outline-none transition-all focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-default ${
|
||||||
|
taken
|
||||||
|
? 'bg-accent text-accent-text shadow-sm hover:-translate-y-px hover:shadow-md'
|
||||||
|
: 'bg-surface-secondary text-content-faint hover:text-content-muted'
|
||||||
|
}`}
|
||||||
|
style={{ animationDelay: `${i * 40}ms` }}
|
||||||
|
>
|
||||||
|
<span className={`text-caption leading-tight ${taken ? 'opacity-75' : ''}`}>
|
||||||
|
{day.weekday ?? t('assistant.week.day')}
|
||||||
|
</span>
|
||||||
|
<span className={`text-body font-semibold tabular-nums leading-tight ${taken ? '' : 'line-through'}`}>
|
||||||
|
{day.n}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`absolute -end-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full px-1 text-caption font-semibold tabular-nums leading-none ring-2 ring-surface-card ${
|
||||||
|
taken ? 'bg-surface-card text-content' : 'bg-surface-tertiary text-content-faint'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { ThreadPrimitive } from '@assistant-ui/react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import EmptyState from '../shared/EmptyState';
|
||||||
|
import AssistantHints, { MAX_VISIBLE_HINTS } from './AssistantHints';
|
||||||
|
import { useAssistantView } from './assistantViewContext';
|
||||||
|
import { useAssistantHints } from './useAssistantHints';
|
||||||
|
import type { ReadySuggestion } from './useAssistantSuggestions';
|
||||||
|
|
||||||
|
/** Suggestions left under a full list of hints, so both fit the smallest window. */
|
||||||
|
const SUGGESTIONS_UNDER_HINTS = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The empty chat: the mascot, one question, and a few things to ask that fit
|
||||||
|
* where the trip stands. A suggestion sends itself; there is nothing to edit
|
||||||
|
* first. On the phone the suggestions sit by the composer instead, where the
|
||||||
|
* thumb is, so this shows them only when `withSuggestions` asks for it.
|
||||||
|
* Between the two sit the plan hints, when the trip has any.
|
||||||
|
*/
|
||||||
|
export default function AssistantWelcome({
|
||||||
|
suggestions,
|
||||||
|
canEdit,
|
||||||
|
withSuggestions,
|
||||||
|
}: {
|
||||||
|
suggestions: ReadySuggestion[];
|
||||||
|
canEdit: boolean;
|
||||||
|
withSuggestions: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tripId, variant } = useAssistantView();
|
||||||
|
const hints = useAssistantHints(tripId, { withStays: true });
|
||||||
|
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
|
||||||
|
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
|
||||||
|
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={t('assistant.empty.title')} />
|
||||||
|
{!canEdit && (
|
||||||
|
<p className="m-0 max-w-xs text-center text-caption text-content-muted">{t('assistant.empty.readOnly')}</p>
|
||||||
|
)}
|
||||||
|
<AssistantHints view={hints} phone={variant === 'fullscreen'} />
|
||||||
|
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssistantSuggestions({
|
||||||
|
suggestions,
|
||||||
|
layout,
|
||||||
|
}: {
|
||||||
|
suggestions: ReadySuggestion[];
|
||||||
|
/** 'stack' under the mascot, 'row' as a strip of chips over the composer. */
|
||||||
|
layout: 'stack' | 'row';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
layout === 'stack'
|
||||||
|
? 'trek-stagger flex w-full max-w-sm flex-col gap-1.5'
|
||||||
|
: 'trek-stagger -mx-1 flex gap-2 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<ThreadPrimitive.Suggestion
|
||||||
|
key={s.id}
|
||||||
|
prompt={s.prompt}
|
||||||
|
send
|
||||||
|
dir="auto"
|
||||||
|
className={
|
||||||
|
layout === 'stack'
|
||||||
|
? 'rounded-xl border border-edge-faint bg-surface-card px-3.5 py-2.5 text-start text-body text-content transition-colors hover:border-edge hover:bg-surface-hover'
|
||||||
|
: 'min-h-[44px] flex-shrink-0 whitespace-nowrap rounded-full border border-edge-faint bg-surface-card px-4 text-body text-content'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.prompt}
|
||||||
|
</ThreadPrimitive.Suggestion>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-WIN-001 to FE-AI-WIN-010
|
||||||
|
*
|
||||||
|
* The desktop window around the chat: when it shows at all, the button it
|
||||||
|
* opens from, the trip picker outside a trip, closing that keeps the chat, and
|
||||||
|
* Escape, which stops an answer before it closes anything (and leaves the
|
||||||
|
* header's menu to itself), and where the focus goes on the way. The geometry is
|
||||||
|
* useDragResize's and has its own tests; here it is a fixed rect.
|
||||||
|
*/
|
||||||
|
import type { AiStatusResponse } from '@trek/shared';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { tripRepo } from '../../repo/tripRepo';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import type { Trip } from '../../types';
|
||||||
|
import AssistantWindow from './AssistantWindow';
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useDragResize', () => ({
|
||||||
|
useDragResize: () => ({
|
||||||
|
rect: { x: 600, y: 120, width: 400, height: 600 },
|
||||||
|
active: false,
|
||||||
|
handleProps: {},
|
||||||
|
edgeProps: () => ({}),
|
||||||
|
onKeyDown: () => {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./AssistantSurfaceLazy', () => ({
|
||||||
|
default: ({
|
||||||
|
tripId,
|
||||||
|
tripTitle,
|
||||||
|
density,
|
||||||
|
onFocusDay,
|
||||||
|
}: {
|
||||||
|
tripId: number;
|
||||||
|
tripTitle: string;
|
||||||
|
density?: string;
|
||||||
|
onFocusDay?: (dayId: number) => void;
|
||||||
|
}) => (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
chat {tripId} {tripTitle}
|
||||||
|
</p>
|
||||||
|
<output data-testid="density">{density}</output>
|
||||||
|
{onFocusDay && (
|
||||||
|
<button type="button" onClick={() => onFocusDay(12)}>
|
||||||
|
pick day
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<textarea aria-label="composer" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TRIP = { id: 4, title: 'Kyoto' };
|
||||||
|
const INITIAL = useAssistantStore.getState();
|
||||||
|
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
|
||||||
|
|
||||||
|
const loadStatus = vi.fn(async () => {});
|
||||||
|
const resume = vi.fn(async () => {});
|
||||||
|
const cancel = vi.fn();
|
||||||
|
|
||||||
|
function seed(window: { open: boolean; minimized: boolean }, isRunning = false) {
|
||||||
|
// A chat the test before unmounted stops itself on the way out; that call is not this test's.
|
||||||
|
cancel.mockClear();
|
||||||
|
useAssistantStore.setState({
|
||||||
|
status: STATUS,
|
||||||
|
window,
|
||||||
|
loadStatus,
|
||||||
|
resume,
|
||||||
|
cancel,
|
||||||
|
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(tripRepo, 'list').mockResolvedValue({
|
||||||
|
trips: [{ id: 9, title: 'Lisbon', start_date: '2026-10-01', end_date: '2026-12-01' } as Trip],
|
||||||
|
archivedTrips: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useAssistantStore.setState(INITIAL, true);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AssistantWindow', () => {
|
||||||
|
it('FE-AI-WIN-001: shows nothing at all without a connection, but asks for the status', () => {
|
||||||
|
seed({ open: true, minimized: false });
|
||||||
|
useAssistantStore.setState({ status: { ...STATUS, available: false } });
|
||||||
|
const { container } = render(<AssistantWindow trip={TRIP} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
expect(loadStatus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WIN-002: closed, the planner shows the round button, and it opens the chat on this trip', () => {
|
||||||
|
seed({ open: false, minimized: false });
|
||||||
|
render(<AssistantWindow trip={TRIP} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Assistant' })).toHaveTextContent('chat 4 Kyoto');
|
||||||
|
expect(resume).toHaveBeenCalledWith(TRIP.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WIN-003: closed outside a trip, nothing floats', () => {
|
||||||
|
seed({ open: false, minimized: false });
|
||||||
|
const { container } = render(<AssistantWindow trip={null} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
|
||||||
|
seed({ open: true, minimized: false });
|
||||||
|
render(<AssistantWindow trip={null} />);
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
|
||||||
|
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WIN-005: closed while writing, the chat stays mounted and the round button breathes until it is opened', () => {
|
||||||
|
seed({ open: true, minimized: true }, true);
|
||||||
|
render(<AssistantWindow trip={TRIP} />);
|
||||||
|
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
|
||||||
|
const button = screen.getByRole('button', { name: 'Assistant' });
|
||||||
|
expect(button).toHaveAttribute('aria-busy', 'true');
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(useAssistantStore.getState().window.minimized).toBe(false);
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WIN-006: Escape stops a running answer first and only then closes', () => {
|
||||||
|
seed({ open: true, minimized: false }, true);
|
||||||
|
render(<AssistantWindow trip={TRIP} />);
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Assistant' });
|
||||||
|
fireEvent.keyDown(dialog, { key: 'Escape' });
|
||||||
|
expect(cancel).toHaveBeenCalledWith(TRIP.id);
|
||||||
|
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
useAssistantStore.setState({
|
||||||
|
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning: false } },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(dialog, { key: 'Escape' });
|
||||||
|
expect(useAssistantStore.getState().window.open).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WIN-007: the head has one close button, which hides the window and keeps the chat', () => {
|
||||||
|
seed({ open: true, minimized: false });
|
||||||
|
render(<AssistantWindow trip={TRIP} />);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Minimize' })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||||
|
expect(useAssistantStore.getState().window.minimized).toBe(true);
|
||||||
|
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WIN-008: closing hands the focus to the round button, opening hands it back to the composer', async () => {
|
||||||
|
seed({ open: true, minimized: false });
|
||||||
|
render(<AssistantWindow trip={TRIP} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||||
|
const button = await screen.findByRole('button', { name: 'Assistant' });
|
||||||
|
expect(button).toHaveFocus();
|
||||||
|
fireEvent.click(button);
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('composer')).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FE-AI-WIN-009: Escape in the header's menu closes the menu, not the window", async () => {
|
||||||
|
seed({ open: true, minimized: false });
|
||||||
|
render(<AssistantWindow trip={TRIP} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'More options' }));
|
||||||
|
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
|
||||||
|
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
|
||||||
|
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
|
||||||
|
seed({ open: true, minimized: false });
|
||||||
|
const onFocusDay = vi.fn();
|
||||||
|
const { unmount } = render(<AssistantWindow trip={TRIP} onFocusDay={onFocusDay} />);
|
||||||
|
expect(useAssistantStore.getState().windowRect).toEqual({ x: 600, y: 120, width: 400, height: 600 });
|
||||||
|
expect(screen.getByTestId('density')).toHaveTextContent('compact');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'pick day' }));
|
||||||
|
expect(onFocusDay).toHaveBeenCalledWith(12);
|
||||||
|
unmount();
|
||||||
|
expect(useAssistantStore.getState().windowRect).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import './assistant.css';
|
||||||
|
|
||||||
|
import type { AssistantApplied } from '@trek/shared';
|
||||||
|
import { Sparkles, X } from 'lucide-react';
|
||||||
|
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { type ResizeEdge, type WindowRect, type WindowSize, useDragResize } from '../../hooks/useDragResize';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import AssistantHeader from './AssistantHeader';
|
||||||
|
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
|
||||||
|
import AssistantSurfaceLazy from './AssistantSurfaceLazy';
|
||||||
|
import AssistantTripPicker from './AssistantTripPicker';
|
||||||
|
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||||
|
import { useAssistantThread } from './useAssistantThread';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The assistant on the desktop: a glass window floating over the app, moved
|
||||||
|
* by its head, resized from any edge or corner, kept inside the screen. It
|
||||||
|
* remembers where it was. A double click on the head switches between the
|
||||||
|
* compact window and a large one; let go near a corner and it settles there;
|
||||||
|
* Alt+arrows move it and Alt+Shift+arrows resize it from the keyboard.
|
||||||
|
*
|
||||||
|
* The head's close button only hides it: the conversation goes on behind it,
|
||||||
|
* and the planner shows the round button it opens from again, breathing while
|
||||||
|
* an answer is still being written. Escape stops an answer first, then
|
||||||
|
* closes, and the focus goes back to where it was.
|
||||||
|
*
|
||||||
|
* Mounted once for the whole app; it shows nothing until the assistant has a
|
||||||
|
* connection this user can use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface WindowTrip {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantWindowProps {
|
||||||
|
/** The trip on screen; null outside a trip, where the window asks which one first. */
|
||||||
|
trip: WindowTrip | null;
|
||||||
|
/** Shows an applied change in the planner on screen. */
|
||||||
|
onShowOnMap?: (applied: AssistantApplied) => void;
|
||||||
|
/** Selects a day in the planner on screen. */
|
||||||
|
onFocusDay?: (dayId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrower than this, the chat lays its cards out compact. */
|
||||||
|
const ROOMY_WIDTH = 480;
|
||||||
|
|
||||||
|
/** Where the window's place and size are kept between visits. */
|
||||||
|
const WINDOW_STORAGE = 'trek.assistantWindow';
|
||||||
|
const MIN_SIZE: WindowSize = { width: 340, height: 420 };
|
||||||
|
const DEFAULT_SIZE: WindowSize = { width: 400, height: 600 };
|
||||||
|
/** Never closer to the screen's edge than this. */
|
||||||
|
const MARGIN = 16;
|
||||||
|
/** The default distance from the edges, and how near a corner a released window snaps into it. */
|
||||||
|
const GAP = 24;
|
||||||
|
const expandedSize = (viewport: WindowSize): WindowSize => ({
|
||||||
|
width: Math.min(720, viewport.width - 2 * MARGIN),
|
||||||
|
height: Math.round(viewport.height * 0.8),
|
||||||
|
});
|
||||||
|
|
||||||
|
const EDGES: { edge: ResizeEdge; className: string; cursor: 'ns' | 'ew' | 'corner-start' | 'corner-end' }[] = [
|
||||||
|
{ edge: 'n', className: 'inset-x-3 -top-1 h-2', cursor: 'ns' },
|
||||||
|
{ edge: 's', className: 'inset-x-3 -bottom-1 h-2', cursor: 'ns' },
|
||||||
|
{ edge: 'start', className: 'inset-y-3 -start-1 w-2', cursor: 'ew' },
|
||||||
|
{ edge: 'end', className: 'inset-y-3 -end-1 w-2', cursor: 'ew' },
|
||||||
|
{ edge: 'n-start', className: '-start-1 -top-1 h-4 w-4', cursor: 'corner-start' },
|
||||||
|
{ edge: 's-end', className: '-bottom-1 -end-1 h-4 w-4', cursor: 'corner-start' },
|
||||||
|
{ edge: 'n-end', className: '-end-1 -top-1 h-4 w-4', cursor: 'corner-end' },
|
||||||
|
{ edge: 's-start', className: '-bottom-1 -start-1 h-4 w-4', cursor: 'corner-end' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function edgeCursor(kind: (typeof EDGES)[number]['cursor'], rtl: boolean): string {
|
||||||
|
if (kind === 'ns') return 'ns-resize';
|
||||||
|
if (kind === 'ew') return 'ew-resize';
|
||||||
|
// The top-start corner is the top left in a left-to-right page and the top right in Arabic.
|
||||||
|
return (kind === 'corner-start') !== rtl ? 'nwse-resize' : 'nesw-resize';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fixed navbar's height (`--nav-h`, a calc() and 0 where there is none), measured. */
|
||||||
|
function navbarHeight(): number {
|
||||||
|
if (typeof document === 'undefined') return 0;
|
||||||
|
const probe = document.createElement('div');
|
||||||
|
probe.style.cssText = 'position:fixed;visibility:hidden;height:var(--nav-h, 0px)';
|
||||||
|
document.body.appendChild(probe);
|
||||||
|
const height = probe.offsetHeight;
|
||||||
|
probe.remove();
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the floating button's middle sits inside the window, so the window grows out of it. */
|
||||||
|
function originFrom(rect: WindowRect, rtl: boolean): string {
|
||||||
|
const fromStart = window.innerWidth - LAUNCHER_INSET - LAUNCHER_SIZE / 2 - rect.x;
|
||||||
|
const x = rtl ? rect.width - fromStart : fromStart;
|
||||||
|
const y = window.innerHeight - LAUNCHER_INSET - LAUNCHER_SIZE / 2 - rect.y;
|
||||||
|
return `${Math.round(x)}px ${Math.round(y)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatWindow({
|
||||||
|
trip,
|
||||||
|
hidden,
|
||||||
|
onShowOnMap,
|
||||||
|
onFocusDay,
|
||||||
|
}: {
|
||||||
|
trip: WindowTrip;
|
||||||
|
hidden: boolean;
|
||||||
|
onShowOnMap?: (applied: AssistantApplied) => void;
|
||||||
|
onFocusDay?: (dayId: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const thread = useAssistantThread(trip.id);
|
||||||
|
const close = useAssistantStore((s) => s.close);
|
||||||
|
const setMinimized = useAssistantStore((s) => s.setMinimized);
|
||||||
|
const setWindowRect = useAssistantStore((s) => s.setWindowRect);
|
||||||
|
// The navbar sits on the same layer and later in the page: the head never goes under it.
|
||||||
|
const [topInset] = useState(navbarHeight);
|
||||||
|
const frame = useDragResize({
|
||||||
|
storageKey: WINDOW_STORAGE,
|
||||||
|
minSize: MIN_SIZE,
|
||||||
|
defaultSize: DEFAULT_SIZE,
|
||||||
|
expandedSize,
|
||||||
|
margin: MARGIN,
|
||||||
|
gap: GAP,
|
||||||
|
topInset,
|
||||||
|
});
|
||||||
|
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||||
|
const [origin] = useState(() => originFrom(frame.rect, rtl));
|
||||||
|
const returnFocus = useRef<Element | null>(typeof document !== 'undefined' ? document.activeElement : null);
|
||||||
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
const wasHidden = useRef(hidden);
|
||||||
|
const { x, y, width, height } = frame.rect;
|
||||||
|
|
||||||
|
// The map frames its view around the window: it learns where the window
|
||||||
|
// settled after a move or a resize, not every step of the drag.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hidden) setWindowRect(null);
|
||||||
|
else if (!frame.active) setWindowRect({ x, y, width, height });
|
||||||
|
}, [hidden, frame.active, x, y, width, height, setWindowRect]);
|
||||||
|
useEffect(() => () => setWindowRect(null), [setWindowRect]);
|
||||||
|
|
||||||
|
// Back to whatever had the focus before the window opened.
|
||||||
|
useEffect(() => {
|
||||||
|
const target = returnFocus.current;
|
||||||
|
return () => {
|
||||||
|
if (target instanceof HTMLElement && target.isConnected) target.focus();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restored from the pill: back into the composer, where the user left off.
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasHidden.current && !hidden) sectionRef.current?.querySelector('textarea')?.focus();
|
||||||
|
wasHidden.current = hidden;
|
||||||
|
}, [hidden]);
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
||||||
|
// Keys from a portal (the header's menu) bubble here through React; they are not the window's.
|
||||||
|
if (!e.currentTarget.contains(e.target as Node)) return;
|
||||||
|
frame.onKeyDown(e);
|
||||||
|
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (thread.isRunning) thread.stop();
|
||||||
|
else close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
insetInlineStart: frame.rect.x,
|
||||||
|
top: frame.rect.y,
|
||||||
|
width: frame.rect.width,
|
||||||
|
height: frame.rect.height,
|
||||||
|
transformOrigin: origin,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={sectionRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t('assistant.window.title')}
|
||||||
|
// Hidden, not unmounted, while minimized: the answer being written keeps coming in.
|
||||||
|
hidden={hidden}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
style={style}
|
||||||
|
className={`trek-assistant-pop fixed z-[var(--z-nav)] ${hidden ? 'hidden' : 'flex'} flex-col overflow-visible rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150 ${
|
||||||
|
frame.active ? 'select-none' : 'trek-assistant-settle'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{EDGES.map(({ edge, className, cursor }) => (
|
||||||
|
<div
|
||||||
|
key={edge}
|
||||||
|
aria-hidden="true"
|
||||||
|
{...frame.edgeProps(edge)}
|
||||||
|
className={`absolute z-10 touch-none ${className}`}
|
||||||
|
style={{ cursor: edgeCursor(cursor, rtl) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
|
||||||
|
<AssistantHeader
|
||||||
|
tripId={trip.id}
|
||||||
|
thread={thread}
|
||||||
|
tripTitle={trip.title}
|
||||||
|
dragProps={frame.handleProps}
|
||||||
|
// Closing only hides the window: an answer being written keeps coming in,
|
||||||
|
// and the floating button says so until it is opened again.
|
||||||
|
onClose={() => setMinimized(true)}
|
||||||
|
/>
|
||||||
|
<AssistantSurfaceLazy
|
||||||
|
tripId={trip.id}
|
||||||
|
tripTitle={trip.title}
|
||||||
|
variant="window"
|
||||||
|
density={width < ROOMY_WIDTH ? 'compact' : 'roomy'}
|
||||||
|
onShowOnMap={onShowOnMap}
|
||||||
|
onFocusDay={onFocusDay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: AssistantWindowProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const available = useAssistantStore((s) => s.status?.available === true);
|
||||||
|
const open = useAssistantStore((s) => s.window.open);
|
||||||
|
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||||
|
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||||
|
const toggle = useAssistantStore((s) => s.toggle);
|
||||||
|
const [picked, setPicked] = useState<WindowTrip | null>(null);
|
||||||
|
const [wasOpen, setWasOpen] = useState(open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStatus();
|
||||||
|
}, [loadStatus]);
|
||||||
|
useAssistantHotkey(available, toggle);
|
||||||
|
|
||||||
|
// A trip picked outside the planner is only for this opening; the next one asks again.
|
||||||
|
if (open !== wasOpen) {
|
||||||
|
setWasOpen(open);
|
||||||
|
if (!open) setPicked(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = trip ?? picked;
|
||||||
|
const showPicked = useCallback(() => {
|
||||||
|
if (picked) void navigate(`/trips/${picked.id}`);
|
||||||
|
}, [navigate, picked]);
|
||||||
|
|
||||||
|
if (!available) return null;
|
||||||
|
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
|
||||||
|
if (!current) return <PickerWindow onPick={setPicked} />;
|
||||||
|
|
||||||
|
// A change in a trip the planner does not show is shown by opening that trip.
|
||||||
|
const onScreen = !!trip && current.id === trip.id;
|
||||||
|
const showOnMap = onScreen ? onShowOnMap : showPicked;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChatWindow
|
||||||
|
trip={current}
|
||||||
|
hidden={minimized}
|
||||||
|
onShowOnMap={showOnMap}
|
||||||
|
// Only the planner on screen has days to select.
|
||||||
|
onFocusDay={onScreen ? onFocusDay : undefined}
|
||||||
|
/>
|
||||||
|
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PickerWindow({ onPick }: { onPick: (trip: WindowTrip) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const close = useAssistantStore((s) => s.close);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="assistant-picker-title"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape' && !e.defaultPrevented) close();
|
||||||
|
}}
|
||||||
|
className="trek-assistant-pop fixed z-[var(--z-nav)] flex max-h-[min(32rem,calc(100vh-6rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150"
|
||||||
|
style={{ insetInlineEnd: LAUNCHER_INSET, bottom: LAUNCHER_INSET, transformOrigin: 'bottom' }}
|
||||||
|
>
|
||||||
|
<header className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-edge-faint pe-1.5 ps-3">
|
||||||
|
<Sparkles size={14} aria-hidden="true" className="flex-shrink-0 text-content-muted" />
|
||||||
|
<h2 id="assistant-picker-title" className="m-0 flex-1 truncate text-body font-semibold text-content">
|
||||||
|
{t('assistant.picker.title')}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-content-muted hover:bg-surface-hover hover:text-content"
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<AssistantTripPicker onPick={({ id, title }) => onPick({ id, title })} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
/* The assistant's motion. Calm on purpose: things arrive, nothing bounces.
|
||||||
|
Every rule here has a still form under reduced motion, from the OS setting
|
||||||
|
or from the user's own switch (data-reduce-motion on <html>). */
|
||||||
|
|
||||||
|
/* The window grows out of the button that opened it. The origin is set inline,
|
||||||
|
because it is wherever the button sits relative to the window. */
|
||||||
|
@keyframes trek-assistant-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.trek-assistant-pop {
|
||||||
|
animation: trek-assistant-pop 240ms var(--ease-drawer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settling after a drag: the window glides into a corner or its other size,
|
||||||
|
but never while a pointer is moving it. */
|
||||||
|
.trek-assistant-settle {
|
||||||
|
transition-property: inset-inline-start, top, width, height;
|
||||||
|
transition-duration: 220ms;
|
||||||
|
transition-timing-function: var(--ease-drawer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A new message rises in. */
|
||||||
|
.trek-assistant-enter {
|
||||||
|
animation: trek-fade-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A reading step names what it does while it runs: the words shimmer. */
|
||||||
|
.trek-assistant-shimmer {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--text-muted) 0%,
|
||||||
|
var(--text-muted) 40%,
|
||||||
|
var(--text-primary) 50%,
|
||||||
|
var(--text-muted) 60%,
|
||||||
|
var(--text-muted) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
animation: trek-shimmer 1.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The caret at the end of an answer that is still being written: after the
|
||||||
|
last word, so in the last item of a closing list. */
|
||||||
|
@keyframes trek-assistant-caret {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
|
||||||
|
.trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1em;
|
||||||
|
margin-inline-start: 2px;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
animation: trek-assistant-caret 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown in an answer: the planner's text scale, tight rhythm, no images. */
|
||||||
|
.trek-assistant-md > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.trek-assistant-md > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.trek-assistant-md p,
|
||||||
|
.trek-assistant-md ul,
|
||||||
|
.trek-assistant-md ol,
|
||||||
|
.trek-assistant-md pre,
|
||||||
|
.trek-assistant-md blockquote,
|
||||||
|
.trek-assistant-md table {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.trek-assistant-md ul {
|
||||||
|
list-style: disc;
|
||||||
|
padding-inline-start: 1.25em;
|
||||||
|
}
|
||||||
|
.trek-assistant-md ol {
|
||||||
|
list-style: decimal;
|
||||||
|
padding-inline-start: 1.25em;
|
||||||
|
}
|
||||||
|
.trek-assistant-md li + li {
|
||||||
|
margin-top: 0.15em;
|
||||||
|
}
|
||||||
|
.trek-assistant-md h1,
|
||||||
|
.trek-assistant-md h2,
|
||||||
|
.trek-assistant-md h3,
|
||||||
|
.trek-assistant-md h4 {
|
||||||
|
margin: 0.75em 0 0.25em;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.trek-assistant-md strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.trek-assistant-md a {
|
||||||
|
color: var(--accent-on);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.trek-assistant-md code {
|
||||||
|
padding: 0.05em 0.3em;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
.trek-assistant-md pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0.6em 0.75em;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
.trek-assistant-md pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
.trek-assistant-md blockquote {
|
||||||
|
padding-inline-start: 0.75em;
|
||||||
|
border-inline-start: 2px solid var(--border-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.trek-assistant-md table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.trek-assistant-md th,
|
||||||
|
.trek-assistant-md td {
|
||||||
|
padding: 0.25em 0.6em;
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
.trek-assistant-md hr {
|
||||||
|
margin: 0.75em 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A closed window still writing an answer: a ring breathes around the floating button. */
|
||||||
|
@keyframes trek-assistant-busy {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.18);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.trek-assistant-busy {
|
||||||
|
animation: trek-assistant-busy 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dot beside what the assistant does right now: a soft beat, a halo going out. */
|
||||||
|
@keyframes trek-assistant-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 6px transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.trek-assistant-pulse {
|
||||||
|
animation: trek-assistant-pulse 1.4s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.trek-assistant-pop,
|
||||||
|
.trek-assistant-enter {
|
||||||
|
animation: trek-backdrop-enter 120ms ease-out;
|
||||||
|
}
|
||||||
|
.trek-assistant-settle {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.trek-assistant-busy,
|
||||||
|
.trek-assistant-pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.trek-assistant-shimmer {
|
||||||
|
animation: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
|
||||||
|
.trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[data-reduce-motion] .trek-assistant-pop,
|
||||||
|
[data-reduce-motion] .trek-assistant-enter {
|
||||||
|
animation: trek-backdrop-enter 120ms ease-out;
|
||||||
|
}
|
||||||
|
[data-reduce-motion] .trek-assistant-settle {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
[data-reduce-motion] .trek-assistant-busy,
|
||||||
|
[data-reduce-motion] .trek-assistant-pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
[data-reduce-motion] .trek-assistant-shimmer {
|
||||||
|
animation: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
[data-reduce-motion] .trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
|
||||||
|
[data-reduce-motion] .trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A row of cards that scrolls sideways (found places, the weather): no bar,
|
||||||
|
and the edges fade where there is more to scroll to. */
|
||||||
|
.trek-assistant-strip {
|
||||||
|
--strip-fade-start: 0px;
|
||||||
|
--strip-fade-end: 0px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
black var(--strip-fade-start),
|
||||||
|
black calc(100% - var(--strip-fade-end)),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
black var(--strip-fade-start),
|
||||||
|
black calc(100% - var(--strip-fade-end)),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.trek-assistant-strip::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[dir='rtl'] .trek-assistant-strip {
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to left,
|
||||||
|
transparent,
|
||||||
|
black var(--strip-fade-start),
|
||||||
|
black calc(100% - var(--strip-fade-end)),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to left,
|
||||||
|
transparent,
|
||||||
|
black var(--strip-fade-start),
|
||||||
|
black calc(100% - var(--strip-fade-end)),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.trek-assistant-strip[data-fade-start] {
|
||||||
|
--strip-fade-start: 24px;
|
||||||
|
}
|
||||||
|
.trek-assistant-strip[data-fade-end] {
|
||||||
|
--strip-fade-end: 24px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { AssistantMessage } from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next questions an answer suggests, as one tap each: read from what the
|
||||||
|
* answer holds, not asked from the model, so they cost nothing and come at
|
||||||
|
* once. Places found ask to narrow them, a plan asks to go on, a forecast asks
|
||||||
|
* for the other weather. Three at most; the first kind the answer holds wins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FollowUp {
|
||||||
|
id: string;
|
||||||
|
labelKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACES: readonly FollowUp[] = [
|
||||||
|
{ id: 'cheaper', labelKey: 'assistant.followUp.cheaper' },
|
||||||
|
{ id: 'closer', labelKey: 'assistant.followUp.closer' },
|
||||||
|
{ id: 'openNow', labelKey: 'assistant.followUp.openNow' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLAN: readonly FollowUp[] = [
|
||||||
|
{ id: 'nextDay', labelKey: 'assistant.followUp.nextDay' },
|
||||||
|
{ id: 'relaxed', labelKey: 'assistant.followUp.relaxed' },
|
||||||
|
{ id: 'dinners', labelKey: 'assistant.followUp.dinners' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEATHER: readonly FollowUp[] = [
|
||||||
|
{ id: 'rain', labelKey: 'assistant.followUp.rain' },
|
||||||
|
{ id: 'sun', labelKey: 'assistant.followUp.sun' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GENERAL: readonly FollowUp[] = [
|
||||||
|
{ id: 'more', labelKey: 'assistant.followUp.more' },
|
||||||
|
{ id: 'next', labelKey: 'assistant.followUp.next' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** What an answer offers to ask next; changes are offered only to someone who may make them. */
|
||||||
|
export function followUpsFor(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] {
|
||||||
|
const types = new Set(message.parts.map((part) => part.type));
|
||||||
|
if (canEdit && (types.has('data-trek-change') || types.has('data-trek-applied'))) return PLAN;
|
||||||
|
if (types.has('data-trek-places')) return PLACES;
|
||||||
|
if (types.has('data-trek-weather')) return WEATHER;
|
||||||
|
return types.has('text') ? GENERAL : [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-HINT-001 to FE-AI-HINT-019
|
||||||
|
*
|
||||||
|
* The plan hints, worked out from the trip alone: each rule on both sides of
|
||||||
|
* its threshold, the data that silences it, and the order, ids and clock
|
||||||
|
* helpers they rest on.
|
||||||
|
*/
|
||||||
|
import type { PlaceOpeningHours } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
||||||
|
import type { Accommodation, Assignment, Day, Place, Reservation } from '../../types';
|
||||||
|
import { formatClockTime } from '../../utils/formatters';
|
||||||
|
import { type AssistantHint, GAP_MIN_MINUTES, LONG_LEG_KM, tripHints, type TripHintsInput } from './assistantHintRules';
|
||||||
|
import {
|
||||||
|
bookingSpan,
|
||||||
|
clockMinutes,
|
||||||
|
clockText,
|
||||||
|
noteMinutes,
|
||||||
|
spansOverlap,
|
||||||
|
stopSpan,
|
||||||
|
weekdayMondayFirst,
|
||||||
|
} from './assistantHintsTime';
|
||||||
|
|
||||||
|
const TODAY = '2026-06-10';
|
||||||
|
/** A Friday. */
|
||||||
|
const FRIDAY = '2026-06-12';
|
||||||
|
/** One kilometre north, in degrees of latitude on the sphere haversineKm uses. */
|
||||||
|
const KM = 1 / ((2 * Math.PI * 6371) / 360);
|
||||||
|
|
||||||
|
function day(id: number, date: string | null = FRIDAY, extra: Partial<Day> = {}): Day {
|
||||||
|
return buildDay({ id, date, ...extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StopOptions {
|
||||||
|
time?: string;
|
||||||
|
end?: string;
|
||||||
|
duration?: number;
|
||||||
|
lat?: number;
|
||||||
|
place?: Partial<Place>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(id: number, dayId: number, opts: StopOptions = {}, extra: Partial<Assignment> = {}): Assignment {
|
||||||
|
const place = buildPlace({
|
||||||
|
id: 1000 + id,
|
||||||
|
name: `Stop ${id}`,
|
||||||
|
place_time: opts.time ?? null,
|
||||||
|
end_time: opts.end ?? null,
|
||||||
|
duration_minutes: opts.duration ?? null,
|
||||||
|
lat: opts.lat ?? 0,
|
||||||
|
lng: 0,
|
||||||
|
...opts.place,
|
||||||
|
});
|
||||||
|
return buildAssignment({ id, day_id: dayId, place_id: place.id, order_index: id, place, ...extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(over: Partial<TripHintsInput> & { stops?: Assignment[] }): TripHintsInput {
|
||||||
|
const { stops = [], ...rest } = over;
|
||||||
|
const assignments: TripHintsInput['assignments'] = {};
|
||||||
|
for (const a of stops) (assignments[String(a.day_id)] ??= []).push(a);
|
||||||
|
return {
|
||||||
|
today: TODAY,
|
||||||
|
days: [day(1)],
|
||||||
|
assignments,
|
||||||
|
places: stops.map((a) => a.place as Place),
|
||||||
|
reservations: [],
|
||||||
|
dayNotes: {},
|
||||||
|
accommodations: [],
|
||||||
|
distanceUnit: 'metric',
|
||||||
|
is12h: false,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function booking(over: Partial<Reservation>): Reservation {
|
||||||
|
return buildReservation({ day_id: 1, ...over });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stay(start: number, end: number): Accommodation {
|
||||||
|
return { id: start * 100 + end, trip_id: 1, start_day_id: start, end_day_id: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hours(closedOn: number | null, open = '09:00', close = '17:00'): string {
|
||||||
|
const week: PlaceOpeningHours = Array.from({ length: 7 }, (_, i) =>
|
||||||
|
i === closedOn ? { closed: true } : { closed: false, open, close }
|
||||||
|
);
|
||||||
|
return JSON.stringify(week);
|
||||||
|
}
|
||||||
|
|
||||||
|
const only = (hints: AssistantHint[], rule: AssistantHint['rule']) => hints.filter((h) => h.rule === rule);
|
||||||
|
|
||||||
|
describe('overlap', () => {
|
||||||
|
it('FE-AI-HINT-001: names two stops of a day that share time, once per day, as a warning', () => {
|
||||||
|
const hints = tripHints(
|
||||||
|
input({
|
||||||
|
stops: [
|
||||||
|
stop(1, 1, { time: '10:00', end: '12:00' }),
|
||||||
|
stop(2, 1, { time: '11:00', duration: 60 }),
|
||||||
|
stop(3, 1, { time: '11:30', end: '12:30' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const overlaps = only(hints, 'overlap');
|
||||||
|
expect(overlaps).toHaveLength(1);
|
||||||
|
expect(overlaps[0]).toMatchObject({
|
||||||
|
level: 'warn',
|
||||||
|
dayId: 1,
|
||||||
|
textKey: 'assistant.hint.overlap',
|
||||||
|
promptKey: 'assistant.hint.overlap.prompt',
|
||||||
|
params: { a: 'Stop 1', b: 'Stop 2' },
|
||||||
|
focus: { dayId: 1, placeIds: [1001, 1002] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-002: stops that only touch, two bare starts, and a stop timed apart by its own assignment do not overlap', () => {
|
||||||
|
const overlaps = (stops: Assignment[]) => only(tripHints(input({ stops })), 'overlap').length;
|
||||||
|
const ten = () => stop(1, 1, { time: '10:00', end: '11:00' });
|
||||||
|
expect(overlaps([ten(), stop(2, 1, { time: '11:00' })])).toBe(0);
|
||||||
|
expect(overlaps([stop(1, 1, { time: '10:00' }), stop(2, 1, { time: '10:00' })])).toBe(0);
|
||||||
|
expect(overlaps([ten(), stop(2, 1, { time: '10:30', duration: 30 }, { assignment_time: '14:00' })])).toBe(0);
|
||||||
|
expect(overlaps([ten(), stop(2, 1, { time: '10:30' })])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-003: collides a stop with a booking that keeps the traveller busy, but not with its own booking', () => {
|
||||||
|
const lunch = booking({
|
||||||
|
id: 7,
|
||||||
|
title: 'Lunch',
|
||||||
|
reservation_time: `${FRIDAY}T12:30`,
|
||||||
|
reservation_end_time: `${FRIDAY}T14:00`,
|
||||||
|
});
|
||||||
|
const museum = stop(1, 1, { time: '13:00', duration: 60 });
|
||||||
|
const [hint] = only(tripHints(input({ stops: [museum], reservations: [lunch] })), 'overlap');
|
||||||
|
expect(hint.params).toEqual({ a: 'Lunch', b: 'Stop 1' });
|
||||||
|
expect(hint.focus).toEqual({ dayId: 1, placeIds: [1001] });
|
||||||
|
|
||||||
|
const quiet = [
|
||||||
|
{ ...lunch, assignment_id: 1 },
|
||||||
|
{ ...lunch, type: 'parking' },
|
||||||
|
{ ...lunch, status: 'cancelled' },
|
||||||
|
{ ...lunch, ingest_state: 'staged' },
|
||||||
|
{ ...lunch, reservation_time: '2026-06-13T12:30' },
|
||||||
|
];
|
||||||
|
for (const r of quiet) {
|
||||||
|
expect(only(tripHints(input({ stops: [museum], reservations: [r] })), 'overlap')).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('opening hours', () => {
|
||||||
|
it('FE-AI-HINT-004: warns about a place that is closed on the weekday of its day, and only on a dated day', () => {
|
||||||
|
const closed = stop(1, 1, { place: { opening_hours: hours(4) } });
|
||||||
|
const [hint] = tripHints(input({ stops: [closed] }));
|
||||||
|
expect(hint).toMatchObject({
|
||||||
|
rule: 'closed',
|
||||||
|
level: 'warn',
|
||||||
|
textKey: 'assistant.hint.closed',
|
||||||
|
params: { place: 'Stop 1' },
|
||||||
|
focus: { dayId: 1, placeIds: [1001] },
|
||||||
|
});
|
||||||
|
expect(tripHints(input({ days: [day(1, null)], stops: [closed] }))).toEqual([]);
|
||||||
|
expect(tripHints(input({ days: [day(1, '2026-06-15')], stops: [closed] }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-005: says when a visit starts before opening or runs past closing, and leaves hours past midnight alone', () => {
|
||||||
|
const early = stop(1, 1, { time: '08:00', duration: 60, place: { opening_hours: hours(null) } });
|
||||||
|
expect(tripHints(input({ stops: [early] }))[0]).toMatchObject({
|
||||||
|
rule: 'outsideHours',
|
||||||
|
textKey: 'assistant.hint.opensLate',
|
||||||
|
promptKey: 'assistant.hint.outsideHours.prompt',
|
||||||
|
params: { place: 'Stop 1', time: '09:00' },
|
||||||
|
});
|
||||||
|
const late = stop(1, 1, { time: '16:00', duration: 120, place: { opening_hours: hours(null) } });
|
||||||
|
expect(tripHints(input({ stops: [late] }))[0]).toMatchObject({
|
||||||
|
textKey: 'assistant.hint.closesEarly',
|
||||||
|
params: { time: '17:00' },
|
||||||
|
});
|
||||||
|
const fits = stop(1, 1, { time: '10:00', duration: 120, place: { opening_hours: hours(null) } });
|
||||||
|
const night = stop(1, 1, { time: '08:00', place: { opening_hours: hours(null, '20:00', '02:00') } });
|
||||||
|
expect(tripHints(input({ stops: [fits] }))).toEqual([]);
|
||||||
|
expect(tripHints(input({ stops: [night] }))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gaps', () => {
|
||||||
|
const morning = () => stop(1, 1, { time: '09:00', end: '10:00' });
|
||||||
|
|
||||||
|
it('FE-AI-HINT-006: points at three free hours between two timed stops, in the user clock', () => {
|
||||||
|
const [hint] = tripHints(input({ stops: [morning(), stop(2, 1, { time: '13:00' })] }));
|
||||||
|
expect(hint).toMatchObject({
|
||||||
|
rule: 'gap',
|
||||||
|
level: 'info',
|
||||||
|
focus: { dayId: 1 },
|
||||||
|
params: { count: 3, from: '10:00', to: '13:00' },
|
||||||
|
});
|
||||||
|
const twelve = tripHints(input({ stops: [morning(), stop(2, 1, { time: '13:00' })], is12h: true }))[0];
|
||||||
|
expect(twelve.params.from).toBe(formatClockTime('10:00', true));
|
||||||
|
expect(GAP_MIN_MINUTES).toBe(180);
|
||||||
|
expect(tripHints(input({ stops: [morning(), stop(2, 1, { time: '12:59' })] }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-007: a timed note, a booking in between or an unknown end fills the gap; a day without a date still has one', () => {
|
||||||
|
const later = stop(2, 1, { time: '14:00' });
|
||||||
|
const note = { id: 1, day_id: 1, text: 'Picnic', time: '11:30' };
|
||||||
|
expect(tripHints(input({ stops: [morning(), later], dayNotes: { '1': [note] } }))).toEqual([]);
|
||||||
|
const tour = booking({ type: 'tour', reservation_time: `${FRIDAY}T11:00` });
|
||||||
|
expect(tripHints(input({ stops: [morning(), later], reservations: [tour] }))).toEqual([]);
|
||||||
|
expect(tripHints(input({ stops: [stop(1, 1, { time: '09:00' }), later] }))).toEqual([]);
|
||||||
|
const undated = tripHints(input({ days: [day(1, null)], stops: [morning(), later] }));
|
||||||
|
expect(undated.map((h) => h.rule)).toEqual(['gap']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('legs', () => {
|
||||||
|
it('FE-AI-HINT-008: calls a walk of more than 4 km as the crow flies far, and a ride by bike only past 20 km', () => {
|
||||||
|
const near = [stop(1, 1), stop(2, 1, { lat: 4 * KM - 1e-9 })];
|
||||||
|
const far = [stop(1, 1), stop(2, 1, { lat: 4.01 * KM })];
|
||||||
|
expect(LONG_LEG_KM.walking).toBe(4);
|
||||||
|
expect(tripHints(input({ stops: near }))).toEqual([]);
|
||||||
|
expect(tripHints(input({ stops: far }))[0]).toMatchObject({
|
||||||
|
rule: 'longLeg',
|
||||||
|
level: 'info',
|
||||||
|
params: { from: 'Stop 1', to: 'Stop 2', distance: '4 km' },
|
||||||
|
focus: { dayId: 1, placeIds: [1001, 1002] },
|
||||||
|
});
|
||||||
|
const byBike = [stop(1, 1, {}, { leg_transport_mode: 'cycling' }), stop(2, 1, { lat: 15 * KM })];
|
||||||
|
expect(tripHints(input({ stops: byBike }))).toEqual([]);
|
||||||
|
const byCar = [stop(1, 1), stop(2, 1, { lat: 100 * KM })];
|
||||||
|
const driving = day(1, FRIDAY, { default_transport_mode: 'driving' });
|
||||||
|
expect(tripHints(input({ days: [driving], stops: byCar }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-009: leaves out stops off the route, road trip and tour stops, and days with a flight', () => {
|
||||||
|
const far = (extra: Partial<Assignment>, place: Partial<Place> = {}) => [
|
||||||
|
stop(1, 1),
|
||||||
|
stop(2, 1, { lat: 10 * KM, place }, extra),
|
||||||
|
];
|
||||||
|
expect(tripHints(input({ stops: far({ route_excluded: true }) }))).toEqual([]);
|
||||||
|
expect(tripHints(input({ stops: far({ tour_place_id: 9 }) }))).toEqual([]);
|
||||||
|
expect(tripHints(input({ stops: far({}, { stop_type: 'fuel' }) }))).toEqual([]);
|
||||||
|
const flight = booking({ type: 'flight' });
|
||||||
|
expect(tripHints(input({ stops: far({}), reservations: [flight] }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-010: warns when the time between two stops is too short for the way, naming the minutes there are', () => {
|
||||||
|
const first = stop(1, 1, { time: '10:00', end: '11:00' });
|
||||||
|
const tight = tripHints(input({ stops: [first, stop(2, 1, { time: '11:30', lat: 3 * KM })] }));
|
||||||
|
expect(tight[0]).toMatchObject({
|
||||||
|
rule: 'tightLeg',
|
||||||
|
level: 'warn',
|
||||||
|
textKey: 'assistant.hint.tightLeg',
|
||||||
|
params: { count: 30, from: 'Stop 1', to: 'Stop 2' },
|
||||||
|
});
|
||||||
|
expect(tripHints(input({ stops: [first, stop(2, 1, { time: '11:45', lat: 3 * KM })] }))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nights', () => {
|
||||||
|
const days = [day(1, '2026-06-11'), day(2, FRIDAY), day(3, '2026-06-13'), day(4, '2026-06-14')];
|
||||||
|
|
||||||
|
it('FE-AI-HINT-011: joins nights in a row without a stay into one hint, from their first day', () => {
|
||||||
|
const hints = only(tripHints(input({ days, accommodations: [stay(1, 2)] })), 'noStay');
|
||||||
|
expect(hints).toHaveLength(1);
|
||||||
|
expect(hints[0]).toMatchObject({ dayId: 2, dayIndex: 1, params: { count: 2 }, focus: { dayId: 2 } });
|
||||||
|
expect(only(tripHints(input({ days, accommodations: [stay(1, 4)] })), 'noStay')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-012: a ride through the night covers it; unknown stays, one-day trips and past nights say nothing', () => {
|
||||||
|
const train = booking({ type: 'train', day_id: 2, end_day_id: 3 });
|
||||||
|
const hints = only(tripHints(input({ days, accommodations: [stay(1, 2)], reservations: [train] })), 'noStay');
|
||||||
|
expect(hints.map((h) => [h.dayId, h.params.count])).toEqual([[3, 1]]);
|
||||||
|
expect(only(tripHints(input({ days, accommodations: null })), 'noStay')).toEqual([]);
|
||||||
|
expect(only(tripHints(input({ days: [day(1)], accommodations: [] })), 'noStay')).toEqual([]);
|
||||||
|
const past = [day(1, '2026-06-08'), day(2, '2026-06-09'), day(3, TODAY)];
|
||||||
|
expect(only(tripHints(input({ days: past, accommodations: [] })), 'noStay')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bookings without a day', () => {
|
||||||
|
it('FE-AI-HINT-013: names one loose booking and its day, or counts several', () => {
|
||||||
|
const ferry = booking({ id: 5, title: 'Ferry', day_id: null, reservation_time: `${FRIDAY}T08:00` });
|
||||||
|
expect(tripHints(input({ reservations: [ferry] }))[0]).toMatchObject({
|
||||||
|
rule: 'unplacedBooking',
|
||||||
|
textKey: 'assistant.hint.unplacedBookingDay',
|
||||||
|
dayId: 1,
|
||||||
|
focus: { dayId: 1 },
|
||||||
|
params: { title: 'Ferry' },
|
||||||
|
});
|
||||||
|
const undated = { ...ferry, reservation_time: null };
|
||||||
|
expect(tripHints(input({ reservations: [undated] }))[0]).toMatchObject({
|
||||||
|
textKey: 'assistant.hint.unplacedBooking',
|
||||||
|
dayId: null,
|
||||||
|
focus: null,
|
||||||
|
});
|
||||||
|
const two = tripHints(input({ reservations: [ferry, { ...undated, id: 6 }] }));
|
||||||
|
expect(two[0]).toMatchObject({ textKey: 'assistant.hint.unplacedBookings', params: { count: 2 } });
|
||||||
|
expect(two[0].id).toBe('unplacedBooking:trip:5,6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-014: leaves hotels, cancelled, staged, past and stop-bound bookings alone', () => {
|
||||||
|
const loose = { day_id: null, reservation_time: null };
|
||||||
|
const quiet = [
|
||||||
|
booking({ ...loose, type: 'hotel' }),
|
||||||
|
booking({ ...loose, status: 'cancelled' }),
|
||||||
|
booking({ ...loose, ingest_state: 'staged' }),
|
||||||
|
booking({ ...loose, reservation_time: '2026-06-01T10:00' }),
|
||||||
|
booking({ ...loose, assignment_id: 3 }),
|
||||||
|
];
|
||||||
|
expect(tripHints(input({ reservations: quiet }))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the list', () => {
|
||||||
|
it('FE-AI-HINT-015: puts warnings first, then by day; past days and stay stops are not read', () => {
|
||||||
|
const hints = tripHints(
|
||||||
|
input({
|
||||||
|
days: [day(1, '2026-06-09'), day(2, '2026-06-11'), day(3, FRIDAY)],
|
||||||
|
stops: [
|
||||||
|
stop(1, 1, { time: '10:00', end: '12:00' }),
|
||||||
|
stop(2, 1, { time: '11:00', end: '12:00' }),
|
||||||
|
stop(3, 2, { time: '09:00', end: '10:00' }),
|
||||||
|
stop(4, 2, { time: '14:00' }),
|
||||||
|
stop(5, 3, { time: '10:00', end: '12:00' }),
|
||||||
|
stop(6, 3, { time: '11:00', end: '12:00' }, { accommodation_id: 1 }),
|
||||||
|
stop(7, 3, { time: '11:30', end: '13:00' }),
|
||||||
|
],
|
||||||
|
accommodations: [stay(1, 3)],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(hints.map((h) => [h.rule, h.dayId])).toEqual([
|
||||||
|
['overlap', 3],
|
||||||
|
['gap', 2],
|
||||||
|
]);
|
||||||
|
expect(hints[0].params).toEqual({ a: 'Stop 5', b: 'Stop 7' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-016: keeps an id while the plan stays the same and changes it with the times', () => {
|
||||||
|
const plan = (end: string) =>
|
||||||
|
input({ stops: [stop(1, 1, { time: '10:00', end }), stop(2, 1, { time: '11:00', end: '12:00' })] });
|
||||||
|
expect(tripHints(plan('12:00'))[0].id).toBe(tripHints(plan('12:00'))[0].id);
|
||||||
|
expect(tripHints(plan('12:00'))[0].id).not.toBe(tripHints(plan('11:30'))[0].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the clock', () => {
|
||||||
|
it('FE-AI-HINT-017: reads HH:MM only, and a note by the time it starts with', () => {
|
||||||
|
expect(clockMinutes('9:05')).toBe(545);
|
||||||
|
expect(clockMinutes(' 23:59 ')).toBe(1439);
|
||||||
|
for (const value of ['24:00', '10:60', '09:30:00', null]) expect(clockMinutes(value)).toBeNull();
|
||||||
|
expect(noteMinutes('14:30 dinner')).toBe(870);
|
||||||
|
expect(noteMinutes('dinner at 14:30')).toBeNull();
|
||||||
|
expect(clockText(545)).toBe('09:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-018: reads the weekday from the date alone, Monday first', () => {
|
||||||
|
expect(weekdayMondayFirst('2026-06-15')).toBe(0);
|
||||||
|
expect(weekdayMondayFirst(`${FRIDAY}T23:30:00`)).toBe(4);
|
||||||
|
expect(weekdayMondayFirst('2026-06-14')).toBe(6);
|
||||||
|
expect(weekdayMondayFirst('soon')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINT-019: spans stops and bookings, and overlaps them', () => {
|
||||||
|
expect(stopSpan(stop(1, 1, { time: '10:00', end: '09:00', duration: 30 }))).toEqual({ start: 600, end: 630 });
|
||||||
|
expect(stopSpan(stop(1, 1, { time: '23:30', duration: 90 }))).toEqual({ start: 1410, end: 1440 });
|
||||||
|
expect(stopSpan(stop(1, 1))).toBeNull();
|
||||||
|
const overnight = booking({ reservation_time: `${FRIDAY}T22:00`, end_day_id: 2 });
|
||||||
|
expect(bookingSpan(overnight, FRIDAY)).toEqual({ start: 1320, end: 1440 });
|
||||||
|
expect(bookingSpan(booking({ reservation_time: '21:00' }), FRIDAY)).toEqual({ start: 1260, end: null });
|
||||||
|
expect(bookingSpan(overnight, '2026-06-13')).toBeNull();
|
||||||
|
expect(spansOverlap({ start: 600, end: null }, { start: 540, end: 660 })).toBe(true);
|
||||||
|
expect(spansOverlap({ start: 540, end: 660 }, { start: 660, end: null })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import { type AssistantFocus, parsePlaceHours, type PlaceOpeningHours, reservationTypeInfo } from '@trek/shared';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Accommodation,
|
||||||
|
Assignment,
|
||||||
|
AssignmentsMap,
|
||||||
|
Day,
|
||||||
|
DayNotesMap,
|
||||||
|
DistanceUnit,
|
||||||
|
Place,
|
||||||
|
Reservation,
|
||||||
|
} from '../../types';
|
||||||
|
import { formatClockTime, splitReservationDateTime } from '../../utils/formatters';
|
||||||
|
import { haversineKm } from '../../utils/geo';
|
||||||
|
import { formatDistance } from '../../utils/units';
|
||||||
|
import { dayBookings } from '../Planner/dayDetailModel';
|
||||||
|
import {
|
||||||
|
bookingSpan,
|
||||||
|
clockMinutes,
|
||||||
|
clockText,
|
||||||
|
type DaySpan,
|
||||||
|
noteMinutes,
|
||||||
|
spanKey,
|
||||||
|
spansOverlap,
|
||||||
|
spanTouches,
|
||||||
|
stopSpan,
|
||||||
|
weekdayMondayFirst,
|
||||||
|
} from './assistantHintsTime';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan hints the empty chat offers: things in the trip worth a second
|
||||||
|
* look, worked out from the data the client already holds. No model and no
|
||||||
|
* request is involved, so they cost nothing and appear at once. Each hint
|
||||||
|
* carries a short line, the question a tap sends, and the day or places that
|
||||||
|
* question is about.
|
||||||
|
*
|
||||||
|
* A false alarm costs more than a missed one. Only rules on hard data (times,
|
||||||
|
* stored opening hours) warn; whatever rests on an estimate is a quiet note.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Free time between two stops that counts as a gap. */
|
||||||
|
export const GAP_MIN_MINUTES = 180;
|
||||||
|
/** Straight-line distance between two stops that counts as far, by how the leg is travelled. */
|
||||||
|
export const LONG_LEG_KM = { walking: 4, cycling: 20, other: 150 } as const;
|
||||||
|
/** Rough speed of a leg, to tell whether the time between two stops is enough. */
|
||||||
|
const LEG_SPEED_KMH = { walking: 4.5, cycling: 15, other: 50 } as const;
|
||||||
|
/** How much longer the way is than the straight line. */
|
||||||
|
const DETOUR_FACTOR = 1.3;
|
||||||
|
/** How far the estimate may run past the time available before it is worth a word. */
|
||||||
|
const TIGHT_LEG_SLACK_MINUTES = 10;
|
||||||
|
|
||||||
|
export type HintRule =
|
||||||
|
'overlap' | 'closed' | 'outsideHours' | 'tightLeg' | 'gap' | 'longLeg' | 'noStay' | 'unplacedBooking';
|
||||||
|
|
||||||
|
const RULE_ORDER: readonly HintRule[] = [
|
||||||
|
'overlap',
|
||||||
|
'closed',
|
||||||
|
'outsideHours',
|
||||||
|
'tightLeg',
|
||||||
|
'gap',
|
||||||
|
'longLeg',
|
||||||
|
'noStay',
|
||||||
|
'unplacedBooking',
|
||||||
|
];
|
||||||
|
|
||||||
|
const WARNS: ReadonlySet<HintRule> = new Set(['overlap', 'closed', 'outsideHours', 'tightLeg']);
|
||||||
|
|
||||||
|
/** Bookings that keep the traveller busy at their time, so a stop at the same time collides with them. */
|
||||||
|
const OCCUPYING_TYPES: ReadonlySet<string> = new Set(['restaurant', 'event', 'tour', 'activity']);
|
||||||
|
|
||||||
|
export interface AssistantHint {
|
||||||
|
/** Stable while the situation stays the same; a changed time or stop makes a new hint. */
|
||||||
|
id: string;
|
||||||
|
rule: HintRule;
|
||||||
|
level: 'warn' | 'info';
|
||||||
|
/** Position of the day in the trip, for the order; null for the trip as a whole. */
|
||||||
|
dayIndex: number | null;
|
||||||
|
/** The day the line names as {day}; the caller words it. */
|
||||||
|
dayId: number | null;
|
||||||
|
/** What the question is about, set on the chat before it is sent. */
|
||||||
|
focus: AssistantFocus | null;
|
||||||
|
textKey: string;
|
||||||
|
promptKey: string;
|
||||||
|
params: Record<string, string | number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripHintsInput {
|
||||||
|
/** The user's calendar date, YYYY-MM-DD; earlier days are left alone. */
|
||||||
|
today: string;
|
||||||
|
days: Day[];
|
||||||
|
assignments: AssignmentsMap;
|
||||||
|
places: Place[];
|
||||||
|
reservations: Reservation[];
|
||||||
|
dayNotes: DayNotesMap;
|
||||||
|
/** The trip's stays; null while they are not known, which keeps the night rule quiet. */
|
||||||
|
accommodations: Accommodation[] | null;
|
||||||
|
distanceUnit: DistanceUnit;
|
||||||
|
is12h: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stop {
|
||||||
|
assignment: Assignment;
|
||||||
|
placeId: number;
|
||||||
|
name: string;
|
||||||
|
span: DaySpan | null;
|
||||||
|
hours: PlaceOpeningHours | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimedBooking {
|
||||||
|
reservation: Reservation;
|
||||||
|
span: DaySpan;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlannedDay {
|
||||||
|
day: Day;
|
||||||
|
index: number;
|
||||||
|
stops: Stop[];
|
||||||
|
/** The day's bookings with a time, the ones that occupy the traveller. */
|
||||||
|
bookings: TimedBooking[];
|
||||||
|
hasCarrier: boolean;
|
||||||
|
noteTimes: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Ctx {
|
||||||
|
input: TripHintsInput;
|
||||||
|
clock: (minutes: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLive(r: Reservation): boolean {
|
||||||
|
return r.status !== 'cancelled' && r.ingest_state !== 'staged';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCarrier(r: Reservation): boolean {
|
||||||
|
return reservationTypeInfo(r.type)?.isCarrier === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hint(
|
||||||
|
rule: HintRule,
|
||||||
|
key: string,
|
||||||
|
at: { day: Day; index: number } | null,
|
||||||
|
fields: Pick<AssistantHint, 'focus' | 'textKey' | 'promptKey' | 'params'>
|
||||||
|
): AssistantHint {
|
||||||
|
return {
|
||||||
|
id: `${rule}:${at?.day.id ?? 'trip'}:${key}`,
|
||||||
|
rule,
|
||||||
|
level: WARNS.has(rule) ? 'warn' : 'info',
|
||||||
|
dayIndex: at?.index ?? null,
|
||||||
|
dayId: at?.day.id ?? null,
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function planDay(day: Day, index: number, input: TripHintsInput, placesById: Map<number, Place>): PlannedDay {
|
||||||
|
const all = (input.assignments[String(day.id)] ?? []).slice().sort((a, b) => a.order_index - b.order_index);
|
||||||
|
const toStop = (a: Assignment): Stop => ({
|
||||||
|
assignment: a,
|
||||||
|
placeId: a.place_id,
|
||||||
|
name: a.place.name,
|
||||||
|
span: stopSpan(a),
|
||||||
|
hours: parsePlaceHours(placesById.get(a.place_id)?.opening_hours),
|
||||||
|
});
|
||||||
|
// A stay's own stop is the overnight block, not a visit.
|
||||||
|
const stops = all.filter((a) => a.accommodation_id == null).map(toStop);
|
||||||
|
const booked = dayBookings(day.id, all, input.reservations.filter(isLive));
|
||||||
|
const bookings: TimedBooking[] = [];
|
||||||
|
for (const reservation of booked) {
|
||||||
|
if (!isCarrier(reservation) && !OCCUPYING_TYPES.has(reservation.type)) continue;
|
||||||
|
const span = bookingSpan(reservation, day.date);
|
||||||
|
if (span) bookings.push({ reservation, span });
|
||||||
|
}
|
||||||
|
const noteTimes = (input.dayNotes[String(day.id)] ?? [])
|
||||||
|
.map((n) => noteMinutes(n.time))
|
||||||
|
.filter((m): m is number => m !== null);
|
||||||
|
return { day, index, stops, bookings, hasCarrier: booked.some(isCarrier), noteTimes };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Slot {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
span: DaySpan;
|
||||||
|
placeId: number | null;
|
||||||
|
assignmentId: number | null;
|
||||||
|
isStop: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A booking hung on a stop, or made for its place, is that stop: the two cannot collide. */
|
||||||
|
function sameVisit(a: Slot, b: Slot): boolean {
|
||||||
|
return (
|
||||||
|
(a.assignmentId !== null && a.assignmentId === b.assignmentId) || (a.placeId !== null && a.placeId === b.placeId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapHint(pd: PlannedDay): AssistantHint | null {
|
||||||
|
const slots: Slot[] = [];
|
||||||
|
for (const s of pd.stops) {
|
||||||
|
if (s.span) {
|
||||||
|
slots.push({
|
||||||
|
key: `a${s.assignment.id}`,
|
||||||
|
name: s.name,
|
||||||
|
span: s.span,
|
||||||
|
placeId: s.placeId,
|
||||||
|
assignmentId: s.assignment.id,
|
||||||
|
isStop: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const b of pd.bookings) {
|
||||||
|
slots.push({
|
||||||
|
key: `r${b.reservation.id}`,
|
||||||
|
name: b.reservation.title,
|
||||||
|
span: b.span,
|
||||||
|
placeId: b.reservation.place_id ?? null,
|
||||||
|
assignmentId: b.reservation.assignment_id ?? null,
|
||||||
|
isStop: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
slots.sort((a, b) => a.span.start - b.span.start);
|
||||||
|
for (let i = 0; i < slots.length; i++) {
|
||||||
|
for (let j = i + 1; j < slots.length; j++) {
|
||||||
|
const [a, b] = [slots[i], slots[j]];
|
||||||
|
if (sameVisit(a, b) || !spansOverlap(a.span, b.span)) continue;
|
||||||
|
const placeIds = [a, b].filter((s) => s.isStop && s.placeId !== null).map((s) => s.placeId as number);
|
||||||
|
return hint('overlap', `${a.key}@${spanKey(a.span)},${b.key}@${spanKey(b.span)}`, pd, {
|
||||||
|
focus: placeIds.length > 0 ? { dayId: pd.day.id, placeIds } : { dayId: pd.day.id },
|
||||||
|
textKey: 'assistant.hint.overlap',
|
||||||
|
promptKey: 'assistant.hint.overlap.prompt',
|
||||||
|
params: { a: a.name, b: b.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoursHint(pd: PlannedDay, stop: Stop, weekday: number, ctx: Ctx): AssistantHint | null {
|
||||||
|
const hours = stop.hours?.[weekday];
|
||||||
|
if (!hours) return null;
|
||||||
|
const focus = { dayId: pd.day.id, placeIds: [stop.placeId] };
|
||||||
|
if (hours.closed) {
|
||||||
|
return hint('closed', `p${stop.placeId}`, pd, {
|
||||||
|
focus,
|
||||||
|
textKey: 'assistant.hint.closed',
|
||||||
|
promptKey: 'assistant.hint.closed.prompt',
|
||||||
|
params: { place: stop.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const open = clockMinutes(hours.open);
|
||||||
|
const close = clockMinutes(hours.close);
|
||||||
|
// Hours past midnight are left alone: which day they belong to is a guess.
|
||||||
|
if (!stop.span || open === null || close === null || close <= open) return null;
|
||||||
|
const { start } = stop.span;
|
||||||
|
const end = stop.span.end ?? start;
|
||||||
|
const key = `p${stop.placeId}@${spanKey(stop.span)}`;
|
||||||
|
if (start < open) {
|
||||||
|
return hint('outsideHours', key, pd, {
|
||||||
|
focus,
|
||||||
|
textKey: 'assistant.hint.opensLate',
|
||||||
|
promptKey: 'assistant.hint.outsideHours.prompt',
|
||||||
|
params: { place: stop.name, time: ctx.clock(open) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (end > close || start >= close) {
|
||||||
|
return hint('outsideHours', key, pd, {
|
||||||
|
focus,
|
||||||
|
textKey: 'assistant.hint.closesEarly',
|
||||||
|
promptKey: 'assistant.hint.outsideHours.prompt',
|
||||||
|
params: { place: stop.name, time: ctx.clock(close) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoursHints(pd: PlannedDay, ctx: Ctx): AssistantHint[] {
|
||||||
|
const weekday = weekdayMondayFirst(pd.day.date);
|
||||||
|
if (weekday === null) return [];
|
||||||
|
return pd.stops.map((s) => hoursHint(pd, s, weekday, ctx)).filter((h): h is AssistantHint => h !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a booking or a timed note already fills the free time. */
|
||||||
|
function gapFilled(pd: PlannedDay, from: number, to: number): boolean {
|
||||||
|
return pd.bookings.some((b) => spanTouches(b.span, from, to)) || pd.noteTimes.some((t) => t > from && t < to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first long stretch with nothing planned between two timed stops. A free
|
||||||
|
* morning or evening is not a gap, and a stop whose end is not known leaves
|
||||||
|
* the time after it unknown too.
|
||||||
|
*/
|
||||||
|
function gapHint(pd: PlannedDay, ctx: Ctx): AssistantHint | null {
|
||||||
|
const timed = pd.stops.filter((s) => s.span).sort((a, b) => (a.span as DaySpan).start - (b.span as DaySpan).start);
|
||||||
|
let reach: number | null = null;
|
||||||
|
for (let k = 0; k < timed.length; k++) {
|
||||||
|
const span = timed[k].span as DaySpan;
|
||||||
|
if (k > 0 && reach !== null && span.start - reach >= GAP_MIN_MINUTES && !gapFilled(pd, reach, span.start)) {
|
||||||
|
return hint('gap', `${reach}-${span.start}`, pd, {
|
||||||
|
focus: { dayId: pd.day.id },
|
||||||
|
textKey: 'assistant.hint.gap',
|
||||||
|
promptKey: 'assistant.hint.gap.prompt',
|
||||||
|
params: {
|
||||||
|
count: Math.floor((span.start - reach) / 60),
|
||||||
|
from: ctx.clock(reach),
|
||||||
|
to: ctx.clock(span.start),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (span.end === null) reach = reach !== null && reach > span.start ? reach : null;
|
||||||
|
else reach = Math.max(reach ?? span.end, span.end);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LegClass = keyof typeof LONG_LEG_KM;
|
||||||
|
|
||||||
|
function legClass(mode: string | null | undefined): LegClass {
|
||||||
|
if (!mode || mode === 'walking') return 'walking';
|
||||||
|
return mode === 'cycling' ? 'cycling' : 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stop on the day's route: placed on the map, and not one a road trip or a tour lays out by itself. */
|
||||||
|
function routable(s: Stop): boolean {
|
||||||
|
const a = s.assignment;
|
||||||
|
return (
|
||||||
|
a.place.lat != null && a.place.lng != null && !a.route_excluded && !a.place.stop_type && a.tour_place_id == null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tightLegHint(pd: PlannedDay, from: Stop, to: Stop, km: number, cls: LegClass): AssistantHint | null {
|
||||||
|
const leaves = from.span?.end;
|
||||||
|
const arrives = to.span?.start;
|
||||||
|
if (leaves == null || arrives == null) return null;
|
||||||
|
const available = arrives - leaves;
|
||||||
|
const needed = ((km * DETOUR_FACTOR) / LEG_SPEED_KMH[cls]) * 60;
|
||||||
|
// Less than nothing is an overlap, which says so itself.
|
||||||
|
if (available < 0 || needed <= available + TIGHT_LEG_SLACK_MINUTES) return null;
|
||||||
|
return hint('tightLeg', `a${from.assignment.id}-a${to.assignment.id}@${leaves}-${arrives}:${cls}`, pd, {
|
||||||
|
focus: { dayId: pd.day.id, placeIds: [from.placeId, to.placeId] },
|
||||||
|
textKey: 'assistant.hint.tightLeg',
|
||||||
|
promptKey: 'assistant.hint.tightLeg.prompt',
|
||||||
|
params: { count: available, from: from.name, to: to.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The legs between consecutive stops, by straight line: one that is short of
|
||||||
|
* time, and one that is far for the way it is travelled. A day with a flight,
|
||||||
|
* train or ferry is left alone, since the distance is the point of it.
|
||||||
|
*/
|
||||||
|
function legHints(pd: PlannedDay, ctx: Ctx): AssistantHint[] {
|
||||||
|
if (pd.hasCarrier) return [];
|
||||||
|
const route = pd.stops.filter(routable);
|
||||||
|
let tight: AssistantHint | null = null;
|
||||||
|
let long: AssistantHint | null = null;
|
||||||
|
for (let k = 1; k < route.length; k++) {
|
||||||
|
const [from, to] = [route[k - 1], route[k]];
|
||||||
|
const cls = legClass(from.assignment.leg_transport_mode ?? pd.day.default_transport_mode);
|
||||||
|
const km = haversineKm(
|
||||||
|
{ lat: from.assignment.place.lat as number, lng: from.assignment.place.lng as number },
|
||||||
|
{ lat: to.assignment.place.lat as number, lng: to.assignment.place.lng as number }
|
||||||
|
);
|
||||||
|
const tooTight: AssistantHint | null = tight ? null : tightLegHint(pd, from, to, km, cls);
|
||||||
|
if (tooTight) {
|
||||||
|
tight = tooTight;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!long && km > LONG_LEG_KM[cls]) {
|
||||||
|
long = hint('longLeg', `a${from.assignment.id}-a${to.assignment.id}:${cls}`, pd, {
|
||||||
|
focus: { dayId: pd.day.id, placeIds: [from.placeId, to.placeId] },
|
||||||
|
textKey: 'assistant.hint.longLeg',
|
||||||
|
promptKey: 'assistant.hint.longLeg.prompt',
|
||||||
|
params: { from: from.name, to: to.name, distance: formatDistance(km, ctx.input.distanceUnit) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [tight, long].filter((h): h is AssistantHint => h !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the nights from the day at `start` up to the one before `end`, by their place in the trip. */
|
||||||
|
function cover(nights: boolean[], start: number | undefined, end: number | undefined): void {
|
||||||
|
if (start === undefined || end === undefined) return;
|
||||||
|
for (let i = Math.max(start, 0); i < Math.min(end, nights.length); i++) nights[i] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nights without a bed: the night after each day but the last, covered by a
|
||||||
|
* stay or by a ride through it (night train, ferry, flight). Nights in a row
|
||||||
|
* make one hint, and nights already past count as covered.
|
||||||
|
*/
|
||||||
|
function noStayHints(input: TripHintsInput): AssistantHint[] {
|
||||||
|
const { days, accommodations } = input;
|
||||||
|
if (!accommodations || days.length < 2) return [];
|
||||||
|
const indexOf = new Map(days.map((d, i) => [d.id, i]));
|
||||||
|
const covered = days.slice(0, -1).map((d) => !!d.date && d.date < input.today);
|
||||||
|
for (const a of accommodations) cover(covered, indexOf.get(a.start_day_id), indexOf.get(a.end_day_id));
|
||||||
|
for (const r of input.reservations) {
|
||||||
|
if (!isLive(r) || !isCarrier(r) || r.day_id == null || r.end_day_id == null) continue;
|
||||||
|
cover(covered, indexOf.get(r.day_id), indexOf.get(r.end_day_id));
|
||||||
|
}
|
||||||
|
const hints: AssistantHint[] = [];
|
||||||
|
for (let i = 0; i < covered.length; i++) {
|
||||||
|
if (covered[i]) continue;
|
||||||
|
let nights = 1;
|
||||||
|
while (i + nights < covered.length && !covered[i + nights]) nights++;
|
||||||
|
const at = { day: days[i], index: i };
|
||||||
|
const night = hint('noStay', String(nights), at, {
|
||||||
|
focus: { dayId: at.day.id },
|
||||||
|
textKey: 'assistant.hint.noStay',
|
||||||
|
promptKey: 'assistant.hint.noStay.prompt',
|
||||||
|
params: { count: nights },
|
||||||
|
});
|
||||||
|
hints.push(night);
|
||||||
|
i += nights - 1;
|
||||||
|
}
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookings that hang on no day, which the day view therefore never shows. One
|
||||||
|
* names itself, and its day when its date is a day of the trip; several are
|
||||||
|
* counted. A booking already behind the user is left alone.
|
||||||
|
*/
|
||||||
|
function unplacedHint(input: TripHintsInput): AssistantHint | null {
|
||||||
|
const loose = input.reservations
|
||||||
|
.filter((r) => isLive(r) && r.type !== 'hotel' && r.day_id == null && r.assignment_id == null)
|
||||||
|
.map((r) => ({ r, date: splitReservationDateTime(r.reservation_time).date }))
|
||||||
|
.filter(({ date }) => !date || date >= input.today);
|
||||||
|
if (loose.length === 0) return null;
|
||||||
|
const key = loose
|
||||||
|
.map(({ r }) => r.id)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.join(',');
|
||||||
|
const fields = { promptKey: 'assistant.hint.unplacedBooking.prompt' };
|
||||||
|
if (loose.length > 1) {
|
||||||
|
return hint('unplacedBooking', key, null, {
|
||||||
|
...fields,
|
||||||
|
focus: null,
|
||||||
|
textKey: 'assistant.hint.unplacedBookings',
|
||||||
|
params: { count: loose.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { r, date } = loose[0];
|
||||||
|
const index = date ? input.days.findIndex((d) => d.date === date) : -1;
|
||||||
|
if (index < 0) {
|
||||||
|
return hint('unplacedBooking', key, null, {
|
||||||
|
...fields,
|
||||||
|
focus: null,
|
||||||
|
textKey: 'assistant.hint.unplacedBooking',
|
||||||
|
params: { title: r.title },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const at = { day: input.days[index], index };
|
||||||
|
return hint('unplacedBooking', key, at, {
|
||||||
|
...fields,
|
||||||
|
focus: { dayId: at.day.id },
|
||||||
|
textKey: 'assistant.hint.unplacedBookingDay',
|
||||||
|
params: { title: r.title },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function byWeight(a: AssistantHint, b: AssistantHint): number {
|
||||||
|
if (a.level !== b.level) return a.level === 'warn' ? -1 : 1;
|
||||||
|
const ai = a.dayIndex ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const bi = b.dayIndex ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
if (ai !== bi) return ai - bi;
|
||||||
|
return RULE_ORDER.indexOf(a.rule) - RULE_ORDER.indexOf(b.rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every hint for the trip, warnings first, then by day and rule. */
|
||||||
|
export function tripHints(input: TripHintsInput): AssistantHint[] {
|
||||||
|
const ctx: Ctx = { input, clock: (m) => formatClockTime(clockText(m), input.is12h) };
|
||||||
|
const placesById = new Map(input.places.map((p) => [p.id, p]));
|
||||||
|
const hints: AssistantHint[] = [];
|
||||||
|
input.days.forEach((day, index) => {
|
||||||
|
if (day.date && day.date < input.today) return;
|
||||||
|
const pd = planDay(day, index, input, placesById);
|
||||||
|
const overlap = overlapHint(pd);
|
||||||
|
const gap = gapHint(pd, ctx);
|
||||||
|
if (overlap) hints.push(overlap);
|
||||||
|
hints.push(...hoursHints(pd, ctx), ...legHints(pd, ctx));
|
||||||
|
if (gap) hints.push(gap);
|
||||||
|
});
|
||||||
|
hints.push(...noStayHints(input));
|
||||||
|
const unplaced = unplacedHint(input);
|
||||||
|
if (unplaced) hints.push(unplaced);
|
||||||
|
return hints.sort(byWeight);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { Assignment, Reservation } from '../../types';
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clock arithmetic behind the plan hints: times of day as minutes since
|
||||||
|
* midnight, and the stretch of the day a stop or a booking takes. A value
|
||||||
|
* that is not a plain HH:MM reads as no time at all, so a rule that needs it
|
||||||
|
* stays quiet rather than guessing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MINUTES_PER_DAY = 24 * 60;
|
||||||
|
|
||||||
|
/** A stretch of one day in minutes since midnight; `end` is null when only the start is known. */
|
||||||
|
export interface DaySpan {
|
||||||
|
start: number;
|
||||||
|
end: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesOf(match: RegExpExecArray | null): number | null {
|
||||||
|
if (!match) return null;
|
||||||
|
const hours = Number(match[1]);
|
||||||
|
const minutes = Number(match[2]);
|
||||||
|
return hours < 24 && minutes < 60 ? hours * 60 + minutes : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "09:30" as 570; anything that is not HH:MM (or H:MM) is null. */
|
||||||
|
export function clockMinutes(value: string | null | undefined): number | null {
|
||||||
|
return minutesOf(/^(\d{1,2}):(\d{2})$/.exec(value?.trim() ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The time a day note starts with, if it starts with one: the note is free text. */
|
||||||
|
export function noteMinutes(value: string | null | undefined): number | null {
|
||||||
|
return minutesOf(/^(\d{1,2}):(\d{2})(?!\d)/.exec(value?.trim() ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 570 as "09:30". */
|
||||||
|
export function clockText(minutes: number): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The weekday of a calendar date, Monday first like stored opening hours.
|
||||||
|
* Read in UTC, so the zone of the device cannot move the date a day.
|
||||||
|
*/
|
||||||
|
export function weekdayMondayFirst(iso: string | null | undefined): number | null {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso ?? '');
|
||||||
|
if (!match) return null;
|
||||||
|
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
||||||
|
return Number.isNaN(date.getTime()) ? null : (date.getUTCDay() + 6) % 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a stop takes place: its own time on this day before the place's, an
|
||||||
|
* end before a duration. An end at or before the start says nothing, and a
|
||||||
|
* duration never runs past midnight.
|
||||||
|
*/
|
||||||
|
export function stopSpan(assignment: Assignment): DaySpan | null {
|
||||||
|
const start = clockMinutes(assignment.assignment_time ?? assignment.place.place_time);
|
||||||
|
if (start === null) return null;
|
||||||
|
const end = clockMinutes(assignment.assignment_end_time ?? assignment.place.end_time);
|
||||||
|
if (end !== null && end > start) return { start, end };
|
||||||
|
const duration = assignment.place.duration_minutes ?? 0;
|
||||||
|
return { start, end: duration > 0 ? Math.min(start + duration, MINUTES_PER_DAY) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a booking takes place on its day. A time written for another date than
|
||||||
|
* the day's says nothing about it, and a booking that ends on a later day runs
|
||||||
|
* to midnight.
|
||||||
|
*/
|
||||||
|
export function bookingSpan(reservation: Reservation, dayDate: string | null | undefined): DaySpan | null {
|
||||||
|
const begins = splitReservationDateTime(reservation.reservation_time);
|
||||||
|
if (begins.date && dayDate && begins.date !== dayDate) return null;
|
||||||
|
const start = clockMinutes(begins.time);
|
||||||
|
if (start === null) return null;
|
||||||
|
if (reservation.end_day_id != null && reservation.end_day_id !== reservation.day_id) {
|
||||||
|
return { start, end: MINUTES_PER_DAY };
|
||||||
|
}
|
||||||
|
const end = clockMinutes(splitReservationDateTime(reservation.reservation_end_time).time);
|
||||||
|
return { start, end: end !== null && end > start ? end : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether two stretches share time. Two stretches overlap when each starts
|
||||||
|
* before the other ends; a bare start overlaps only when it lies truly inside
|
||||||
|
* a stretch, and two bare starts never do.
|
||||||
|
*/
|
||||||
|
export function spansOverlap(a: DaySpan, b: DaySpan): boolean {
|
||||||
|
if (a.end !== null && b.end !== null) return a.start < b.end && b.start < a.end;
|
||||||
|
if (a.end !== null) return a.start < b.start && b.start < a.end;
|
||||||
|
if (b.end !== null) return b.start < a.start && a.start < b.end;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a stretch, or a bare start, falls into the open window from `from` to `to`. */
|
||||||
|
export function spanTouches(span: DaySpan, from: number, to: number): boolean {
|
||||||
|
return span.start < to && (span.end ?? span.start) > from;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A span as it goes into a hint's id, so a hint about other times is a new hint. */
|
||||||
|
export function spanKey(span: DaySpan): string {
|
||||||
|
return `${span.start}-${span.end ?? ''}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-MENTION-MODEL-001 to FE-AI-MENTION-MODEL-008
|
||||||
|
*
|
||||||
|
* What `@` offers and writes, without React: the entries per kind, the search
|
||||||
|
* across them, the mention behind a pick, and the text that carries it.
|
||||||
|
*/
|
||||||
|
import type { TriggerItem } from '@assistant-ui/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
||||||
|
import {
|
||||||
|
mentionGroups,
|
||||||
|
mentionLabel,
|
||||||
|
mentionOf,
|
||||||
|
searchMentions,
|
||||||
|
splitMentions,
|
||||||
|
withMention,
|
||||||
|
} from './assistantMentionModel';
|
||||||
|
|
||||||
|
function t(key: string, params?: Record<string, string | number>): string {
|
||||||
|
return key === 'dayplan.dayN' ? `Day ${params?.n}` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCES = {
|
||||||
|
days: [buildDay({ id: 1, title: 'Arrival', date: '2026-11-02' }), buildDay({ id: 2, title: null, date: null })],
|
||||||
|
places: [
|
||||||
|
buildPlace({ id: 10, name: 'Louvre' }),
|
||||||
|
buildPlace({ id: 11, name: 'Musée du Louvre shop' }),
|
||||||
|
buildPlace({ id: 12, name: 'Bouillon Pigalle' }),
|
||||||
|
buildPlace({ id: 13, name: ' ' }),
|
||||||
|
],
|
||||||
|
reservations: [buildReservation({ id: 20, title: 'Louvre tickets', day_id: 1 })],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('assistantMentionModel', () => {
|
||||||
|
it('FE-AI-MENTION-MODEL-001: lists days by their day-list name, places by name, bookings with their day', () => {
|
||||||
|
const groups = mentionGroups(SOURCES, t, 'en');
|
||||||
|
expect(groups.day.map((i) => i.label)).toEqual(['Arrival', 'Day 2']);
|
||||||
|
// A titled day adds its date; an untitled one says nothing more.
|
||||||
|
expect(groups.day[0].description).toBeTruthy();
|
||||||
|
expect(groups.day[1].description).toBeUndefined();
|
||||||
|
// A place without a name has nothing to write into the text and is left out.
|
||||||
|
expect(groups.place.map((i) => i.id)).toEqual(['place:10', 'place:11', 'place:12']);
|
||||||
|
expect(groups.reservation[0]).toMatchObject({
|
||||||
|
label: 'Louvre tickets',
|
||||||
|
description: 'Arrival',
|
||||||
|
type: 'reservation',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-MODEL-002: search ranks a label that starts with the query before a word inside one', () => {
|
||||||
|
const groups = mentionGroups(SOURCES, t, 'en');
|
||||||
|
expect(searchMentions(groups, 'louv').map((i) => i.label)).toEqual([
|
||||||
|
'Louvre',
|
||||||
|
'Louvre tickets',
|
||||||
|
'Musée du Louvre shop',
|
||||||
|
]);
|
||||||
|
expect(searchMentions(groups, 'llon').map((i) => i.label)).toEqual(['Bouillon Pigalle']);
|
||||||
|
expect(searchMentions(groups, 'zzz')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-MODEL-003: search shows at most eight hits', () => {
|
||||||
|
const places = Array.from({ length: 12 }, (_, i) => buildPlace({ id: 100 + i, name: `Café ${i}` }));
|
||||||
|
expect(searchMentions(mentionGroups({ ...SOURCES, places }, t, 'en'), 'caf')).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-MODEL-004: a pick reads back as its mention, anything else as nothing', () => {
|
||||||
|
const [louvre] = mentionGroups(SOURCES, t, 'en').place;
|
||||||
|
expect(mentionOf(louvre)).toEqual({ kind: 'place', id: 10, label: 'Louvre' });
|
||||||
|
const foreign: TriggerItem = { id: 'x', type: 'command', label: 'Run' };
|
||||||
|
expect(mentionOf(foreign)).toBeNull();
|
||||||
|
expect(mentionOf({ ...louvre, metadata: { id: 'ten' } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-MODEL-005: labels are one trimmed line the server keeps whole', () => {
|
||||||
|
expect(mentionLabel(' Le Train\nBleu ')).toBe('Le Train Bleu');
|
||||||
|
expect(mentionLabel('x'.repeat(200))).toHaveLength(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-MODEL-006: writing @label keeps the rest of the text, once', () => {
|
||||||
|
expect(withMention('', 'Louvre')).toBe('@Louvre ');
|
||||||
|
expect(withMention('Lunch near', 'Louvre')).toBe('Lunch near @Louvre ');
|
||||||
|
expect(withMention('Lunch near ', 'Louvre')).toBe('Lunch near @Louvre ');
|
||||||
|
expect(withMention('Near @Louvre ', 'Louvre')).toBe('Near @Louvre ');
|
||||||
|
// A longer label that starts the same is another mention.
|
||||||
|
expect(withMention('@Romeo ', 'Rome')).toBe('@Romeo @Rome ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MENTION-MODEL-008: a sent question splits into text and its mentions, the longest label first', () => {
|
||||||
|
expect(splitMentions('Is @Louvre tickets still valid at @Louvre?', ['Louvre', 'Louvre tickets'])).toEqual([
|
||||||
|
{ text: 'Is ', at: 0, mention: false },
|
||||||
|
{ text: '@Louvre tickets', at: 3, mention: true },
|
||||||
|
{ text: ' still valid at ', at: 18, mention: false },
|
||||||
|
{ text: '@Louvre', at: 34, mention: true },
|
||||||
|
{ text: '?', at: 41, mention: false },
|
||||||
|
]);
|
||||||
|
expect(splitMentions('mail me @ home @Lou', ['Louvre'])).toEqual([
|
||||||
|
{ text: 'mail me @ home @Lou', at: 0, mention: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import type { DirectiveFormatter, TriggerItem } from '@assistant-ui/react';
|
||||||
|
import { ASSISTANT_MENTION_KINDS, type AssistantMention } from '@trek/shared';
|
||||||
|
|
||||||
|
import type { AssistantMentionItem } from '../../store/assistantStore';
|
||||||
|
import type { Day, Place, Reservation } from '../../types';
|
||||||
|
import { dayDate, dayLabel } from '../../utils/dayLabel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `@` in the composer offers and what it writes: the trip's days, places
|
||||||
|
* and bookings as entries of the popover, `@label` in the text for a pick, and
|
||||||
|
* the way back from that text to the mentions a question still carries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MentionKind = AssistantMention['kind'];
|
||||||
|
|
||||||
|
/** The heading of each list `@` opens. */
|
||||||
|
export const MENTION_KINDS: Record<MentionKind, { labelKey: string }> = {
|
||||||
|
day: { labelKey: 'assistant.mention.days' },
|
||||||
|
place: { labelKey: 'assistant.mention.places' },
|
||||||
|
reservation: { labelKey: 'assistant.mention.bookings' },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
/** The longest label the server keeps for a mention. */
|
||||||
|
const LABEL_MAX = 120;
|
||||||
|
/** Hits shown while typing after `@`; the list stays one short glance. */
|
||||||
|
const SEARCH_MAX = 8;
|
||||||
|
|
||||||
|
interface MentionSources {
|
||||||
|
days: readonly Day[];
|
||||||
|
places: readonly Place[];
|
||||||
|
reservations: readonly Reservation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type MentionGroups = Record<MentionKind, TriggerItem[]>;
|
||||||
|
|
||||||
|
/** A label as it goes into the text: one line, trimmed, never longer than the server keeps. */
|
||||||
|
export function mentionLabel(name: string): string {
|
||||||
|
return name.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(kind: MentionKind, id: number, name: string, description?: string | null): TriggerItem[] {
|
||||||
|
const label = mentionLabel(name);
|
||||||
|
if (!label) return [];
|
||||||
|
return [{ id: `${kind}:${id}`, type: kind, label, ...(description ? { description } : {}), metadata: { id } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entries `@` lists, per kind. A day reads as it does in the day list; a
|
||||||
|
* titled day adds its date, and a booking the day it is on.
|
||||||
|
*/
|
||||||
|
export function mentionGroups(sources: MentionSources, t: Translate, locale: string): MentionGroups {
|
||||||
|
const dayNames = new Map(sources.days.map((d, i) => [d.id, dayLabel(d, i, t, locale)]));
|
||||||
|
return {
|
||||||
|
day: sources.days.flatMap((d) =>
|
||||||
|
entry('day', d.id, dayNames.get(d.id) ?? '', d.title && d.date ? dayDate(d.date, locale) : null)
|
||||||
|
),
|
||||||
|
place: sources.places.flatMap((p) => entry('place', p.id, p.name)),
|
||||||
|
reservation: sources.reservations.flatMap((r) =>
|
||||||
|
entry('reservation', r.id, r.title, r.day_id ? dayNames.get(r.day_id) : null)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 for a label that starts with the query, 1 for one with a word that does, 2 for the rest. */
|
||||||
|
function rankOf(label: string, at: number): number {
|
||||||
|
if (at === 0) return 0;
|
||||||
|
return /\s/u.test(label.charAt(at - 1)) ? 1 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entries whose label holds the query, across all kinds, the best matches first. */
|
||||||
|
export function searchMentions(groups: MentionGroups, query: string): TriggerItem[] {
|
||||||
|
const q = query.toLocaleLowerCase();
|
||||||
|
const hits: { item: TriggerItem; rank: number }[] = [];
|
||||||
|
for (const kind of ASSISTANT_MENTION_KINDS) {
|
||||||
|
for (const item of groups[kind]) {
|
||||||
|
const label = item.label.toLocaleLowerCase();
|
||||||
|
const at = label.indexOf(q);
|
||||||
|
if (at >= 0) hits.push({ item, rank: rankOf(label, at) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits
|
||||||
|
.sort((a, b) => a.rank - b.rank)
|
||||||
|
.slice(0, SEARCH_MAX)
|
||||||
|
.map((h) => h.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mention a picked entry stands for; null for anything the popover did not build. */
|
||||||
|
export function mentionOf(item: TriggerItem): AssistantMentionItem | null {
|
||||||
|
const kind = ASSISTANT_MENTION_KINDS.find((k) => k === item.type);
|
||||||
|
const id = item.metadata?.id;
|
||||||
|
if (!kind || typeof id !== 'number') return null;
|
||||||
|
return { kind, id, label: item.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pick goes into the text as `@label`; the text stays plain, what it names is kept in the store. */
|
||||||
|
export const MENTION_FORMATTER: DirectiveFormatter = {
|
||||||
|
serialize: (item) => `@${item.label}`,
|
||||||
|
parse: (text) => [{ kind: 'text', text }],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when no letter or digit follows at `end`, so `@Rome` does not end inside `@Romeo`. */
|
||||||
|
function endsWord(text: string, end: number): boolean {
|
||||||
|
return !/[\p{L}\p{N}]/u.test(text.charAt(end));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where `@label` stands in the text as a whole word. */
|
||||||
|
function tokenAt(text: string, label: string): number {
|
||||||
|
const token = `@${label}`;
|
||||||
|
for (let at = text.indexOf(token); at >= 0; at = text.indexOf(token, at + 1)) {
|
||||||
|
if (endsWord(text, at + token.length)) return at;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMention(text: string, label: string): boolean {
|
||||||
|
return tokenAt(text, label) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text with `@label ` written at its end, the way a pick from the popover leaves it. */
|
||||||
|
export function withMention(text: string, label: string): string {
|
||||||
|
if (hasMention(text, label)) return text;
|
||||||
|
const gap = text === '' || /\s$/u.test(text) ? '' : ' ';
|
||||||
|
return `${text}${gap}@${label} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MentionSegment {
|
||||||
|
text: string;
|
||||||
|
/** Where the segment starts in the text. */
|
||||||
|
at: number;
|
||||||
|
/** True for an `@label` the question named a day, place or booking with. */
|
||||||
|
mention: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sent question cut into plain text and its `@label` mentions; the longest label wins a tie. */
|
||||||
|
export function splitMentions(text: string, labels: readonly string[]): MentionSegment[] {
|
||||||
|
const tokens = [...new Set(labels)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => `@${l}`)
|
||||||
|
.sort((a, b) => b.length - a.length);
|
||||||
|
const out: MentionSegment[] = [];
|
||||||
|
let start = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
const token =
|
||||||
|
text[i] === '@' ? tokens.find((tk) => text.startsWith(tk, i) && endsWord(text, i + tk.length)) : undefined;
|
||||||
|
if (token) {
|
||||||
|
if (i > start) out.push({ text: text.slice(start, i), at: start, mention: false });
|
||||||
|
out.push({ text: token, at: i, mention: true });
|
||||||
|
i += token.length;
|
||||||
|
start = i;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start < text.length) out.push({ text: text.slice(start), at: start, mention: false });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-MODEL-001 to FE-AI-MODEL-010
|
||||||
|
*
|
||||||
|
* The pure half of the assistant's views: how a stored message reaches
|
||||||
|
* @assistant-ui, what each error offers, how a reading tool is named while it
|
||||||
|
* runs and once it says where it looks, and which questions the empty chat
|
||||||
|
* suggests.
|
||||||
|
*/
|
||||||
|
import { ASSISTANT_ERROR_CODES, type AssistantMessage } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { errorView, liveStepText, suggestionsFor, toolStepKey, toThreadMessage, tripPhase } from './assistantModel';
|
||||||
|
|
||||||
|
const AT = '2026-10-21T08:00:00.000Z';
|
||||||
|
|
||||||
|
describe('toThreadMessage', () => {
|
||||||
|
it('FE-AI-MODEL-001: passes text, tool calls with their approval and TREK data parts through, and drops empty text', () => {
|
||||||
|
const message: AssistantMessage = {
|
||||||
|
id: 'm1',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
createdAt: AT,
|
||||||
|
parts: [
|
||||||
|
{ type: 'text', text: '' },
|
||||||
|
{ type: 'text', text: 'Adding it.' },
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'c1',
|
||||||
|
toolName: 'create_todo',
|
||||||
|
args: { name: 'Tickets' },
|
||||||
|
approval: { id: 'c1' },
|
||||||
|
},
|
||||||
|
{ type: 'tool-call', toolCallId: 'r1', toolName: 'list_todos', args: {}, result: [], isError: false },
|
||||||
|
{
|
||||||
|
type: 'data-trek-applied',
|
||||||
|
data: { toolCallId: 'c0', entity: 'todo', ids: [1], dayId: null, undoUntil: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const like = toThreadMessage(message);
|
||||||
|
expect(like).toEqual({
|
||||||
|
id: 'm1',
|
||||||
|
role: 'assistant',
|
||||||
|
createdAt: new Date(AT),
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Adding it.' },
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'c1',
|
||||||
|
toolName: 'create_todo',
|
||||||
|
args: { name: 'Tickets' },
|
||||||
|
approval: { id: 'c1' },
|
||||||
|
},
|
||||||
|
{ type: 'tool-call', toolCallId: 'r1', toolName: 'list_todos', args: {}, result: [], isError: false },
|
||||||
|
{
|
||||||
|
type: 'data-trek-applied',
|
||||||
|
data: { toolCallId: 'c0', entity: 'todo', ids: [1], dayId: null, undoUntil: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Complete and waiting messages leave the status to the runtime.
|
||||||
|
expect(like).not.toHaveProperty('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MODEL-002: a stopped or failed message says so', () => {
|
||||||
|
const base: AssistantMessage = { id: 'm', role: 'assistant', parts: [], status: 'aborted', createdAt: AT };
|
||||||
|
expect(toThreadMessage(base).status).toEqual({ type: 'incomplete', reason: 'cancelled' });
|
||||||
|
expect(toThreadMessage({ ...base, status: 'error' }).status).toEqual({ type: 'incomplete', reason: 'error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('errorView', () => {
|
||||||
|
it('FE-AI-MODEL-003: every error code has a line, and at most one action', () => {
|
||||||
|
for (const code of ASSISTANT_ERROR_CODES) {
|
||||||
|
const view = errorView(code, { canConnectOwn: true });
|
||||||
|
expect(view.messageKey).toMatch(/^assistant\.error\./);
|
||||||
|
if (view.action) expect(view.action.labelKey).toMatch(/^assistant\.errorAction\./);
|
||||||
|
}
|
||||||
|
expect(errorView('busy', { canConnectOwn: true }).action?.kind).toBe('continueHere');
|
||||||
|
expect(errorView('provider_unavailable', { canConnectOwn: true })).toMatchObject({
|
||||||
|
messageKey: 'assistant.error.providerDown',
|
||||||
|
action: { kind: 'retry' },
|
||||||
|
});
|
||||||
|
expect(errorView('usage_limit', { canConnectOwn: true }).action?.kind).toBe('openUsage');
|
||||||
|
expect(errorView('offline', { canConnectOwn: true }).action).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MODEL-004: the daily limit offers an own provider only where the policy allows one', () => {
|
||||||
|
expect(errorView('daily_limit', { canConnectOwn: true }).action).toEqual({
|
||||||
|
kind: 'openSettings',
|
||||||
|
labelKey: 'assistant.errorAction.connectOwn',
|
||||||
|
});
|
||||||
|
expect(errorView('daily_limit', { canConnectOwn: false }).action).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MODEL-008: a provider limit names the provider when known, else keeps the general line', () => {
|
||||||
|
expect(errorView('rate_limited', { canConnectOwn: true, provider: 'Mistral' }).messageKey).toBe(
|
||||||
|
'assistant.error.providerLimit'
|
||||||
|
);
|
||||||
|
expect(errorView('usage_limit', { canConnectOwn: true, provider: 'ChatGPT' })).toMatchObject({
|
||||||
|
messageKey: 'assistant.error.providerLimit',
|
||||||
|
action: { kind: 'openUsage' },
|
||||||
|
});
|
||||||
|
expect(errorView('rate_limited', { canConnectOwn: true, provider: '' }).messageKey).toBe(
|
||||||
|
'assistant.error.rateLimited'
|
||||||
|
);
|
||||||
|
expect(errorView('usage_limit', { canConnectOwn: true }).messageKey).toBe('assistant.error.usageLimit');
|
||||||
|
expect(errorView('generic', { canConnectOwn: true, provider: 'Mistral' }).messageKey).toBe(
|
||||||
|
'assistant.error.generic'
|
||||||
|
);
|
||||||
|
// TREK's own limit never blames the provider.
|
||||||
|
expect(errorView('too_many_requests', { canConnectOwn: true, provider: 'OpenRouter' }).messageKey).toBe(
|
||||||
|
'assistant.error.rateLimited'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MODEL-009: waiting changes ask for a decision first, with nothing to press', () => {
|
||||||
|
expect(errorView('decide_first', { canConnectOwn: true })).toEqual({
|
||||||
|
messageKey: 'assistant.error.decideFirst',
|
||||||
|
action: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toolStepKey', () => {
|
||||||
|
it('FE-AI-MODEL-005: names reading tools by what they do, and anything else as working', () => {
|
||||||
|
expect(toolStepKey('get_trip_summary')).toBe('assistant.step.reading');
|
||||||
|
expect(toolStepKey('search_nearby_places')).toBe('assistant.step.nearby');
|
||||||
|
expect(toolStepKey('get_settlement_summary')).toBe('assistant.step.costs');
|
||||||
|
expect(toolStepKey('create_place')).toBe('assistant.step.working');
|
||||||
|
expect(toolStepKey('constructor')).toBe('assistant.step.working');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MODEL-010: a running step that names a place or a query says it, and falls back to its name', () => {
|
||||||
|
expect(liveStepText('search_nearby_places', 'Trastevere')).toEqual({
|
||||||
|
key: 'assistant.step.nearbyAt',
|
||||||
|
params: { place: 'Trastevere' },
|
||||||
|
});
|
||||||
|
expect(liveStepText('search_pois', 'Shibuya')).toEqual({
|
||||||
|
key: 'assistant.step.nearbyAt',
|
||||||
|
params: { place: 'Shibuya' },
|
||||||
|
});
|
||||||
|
expect(liveStepText('search_place', 'gelato')).toEqual({
|
||||||
|
key: 'assistant.step.searchingFor',
|
||||||
|
params: { query: 'gelato' },
|
||||||
|
});
|
||||||
|
expect(liveStepText('get_weather', 'Kyoto')).toEqual({
|
||||||
|
key: 'assistant.step.weatherAt',
|
||||||
|
params: { place: 'Kyoto' },
|
||||||
|
});
|
||||||
|
expect(liveStepText('search_nearby_places', null)).toEqual({ key: 'assistant.step.nearby' });
|
||||||
|
expect(liveStepText('list_places', 'Kyoto')).toEqual({ key: 'assistant.step.reading' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('suggestions', () => {
|
||||||
|
it('FE-AI-MODEL-006: the phase follows the dates and whether anything is planned', () => {
|
||||||
|
const today = '2026-06-12';
|
||||||
|
expect(tripPhase({ startDate: null, endDate: null, hasPlaces: false, today })).toBe('empty');
|
||||||
|
expect(tripPhase({ startDate: '2026-07-01', endDate: '2026-07-05', hasPlaces: true, today })).toBe('upcoming');
|
||||||
|
expect(tripPhase({ startDate: '2026-06-10', endDate: '2026-06-14', hasPlaces: false, today })).toBe('running');
|
||||||
|
expect(tripPhase({ startDate: '2026-06-12', endDate: null, hasPlaces: true, today })).toBe('running');
|
||||||
|
expect(tripPhase({ startDate: '2026-06-01', endDate: '2026-06-11', hasPlaces: true, today })).toBe('past');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-MODEL-007: focus comes first, at most four, and nothing that writes for someone who may only read', () => {
|
||||||
|
const focused = suggestionsFor({ phase: 'upcoming', canEdit: true, place: 'Kinkaku-ji', day: 'Fri 12 Jun' });
|
||||||
|
expect(focused.map((s) => s.id)).toEqual(['nearPlace', 'placeTips', 'planDay', 'dayTooFull']);
|
||||||
|
expect(focused[0]?.params).toEqual({ place: 'Kinkaku-ji' });
|
||||||
|
|
||||||
|
const readOnly = suggestionsFor({ phase: 'empty', canEdit: false, day: 'Fri 12 Jun' });
|
||||||
|
expect(readOnly.map((s) => s.id)).toEqual(['dayTooFull', 'mustSee', 'weather']);
|
||||||
|
expect(readOnly.every((s) => !s.writes)).toBe(true);
|
||||||
|
|
||||||
|
expect(suggestionsFor({ phase: 'past', canEdit: true }).map((s) => s.labelKey)).toEqual([
|
||||||
|
'assistant.suggest.settleUp',
|
||||||
|
'assistant.suggest.recap',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import type { ThreadMessageLike } from '@assistant-ui/react';
|
||||||
|
import type { AiStatusResponse, AssistantErrorCode, AssistantMessage, AssistantMessagePart } from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helpers the assistant's views share, desktop window and phone screen
|
||||||
|
* alike: the bridge from stored messages to @assistant-ui, what an error says
|
||||||
|
* and offers, how a reading tool is named while it runs, and which questions
|
||||||
|
* the empty chat suggests. No React, and only a type from @assistant-ui, so
|
||||||
|
* importing this keeps the chat runtime out of the chunk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LikePart = Exclude<ThreadMessageLike['content'], string>[number];
|
||||||
|
|
||||||
|
const STATUS: Partial<Record<AssistantMessage['status'], ThreadMessageLike['status']>> = {
|
||||||
|
aborted: { type: 'incomplete', reason: 'cancelled' },
|
||||||
|
error: { type: 'incomplete', reason: 'error' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function toPart(part: AssistantMessagePart): LikePart | null {
|
||||||
|
if (part.type === 'text') return part.text ? { type: 'text', text: part.text } : null;
|
||||||
|
// An attached file is drawn from the stored message; the runtime takes it as one more data part.
|
||||||
|
if (part.type === 'attachment') {
|
||||||
|
return { type: 'data-trek-attachment', data: { name: part.name, mime: part.mime, size: part.size } };
|
||||||
|
}
|
||||||
|
if (part.type !== 'tool-call') return part;
|
||||||
|
return {
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: part.toolCallId,
|
||||||
|
toolName: part.toolName,
|
||||||
|
args: part.args,
|
||||||
|
...(part.result !== undefined ? { result: part.result } : {}),
|
||||||
|
...(part.isError !== undefined ? { isError: part.isError } : {}),
|
||||||
|
...(part.approval ? { approval: part.approval } : {}),
|
||||||
|
} as LikePart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stored message as @assistant-ui reads it (the external store runtime's
|
||||||
|
* `convertMessage`). Complete and waiting messages carry no status, so the
|
||||||
|
* runtime derives it: running while the turn streams, waiting while a change
|
||||||
|
* is undecided.
|
||||||
|
*/
|
||||||
|
export function toThreadMessage(message: AssistantMessage): ThreadMessageLike {
|
||||||
|
const status = STATUS[message.status];
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
role: message.role,
|
||||||
|
createdAt: new Date(message.createdAt),
|
||||||
|
content: message.parts.map(toPart).filter((p): p is LikePart => p !== null),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Errors ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** What the one action under an error line does. */
|
||||||
|
export type AssistantErrorActionKind = 'retry' | 'continueHere' | 'continue' | 'openSettings' | 'openUsage';
|
||||||
|
|
||||||
|
interface AssistantErrorView {
|
||||||
|
messageKey: string;
|
||||||
|
action: { kind: AssistantErrorActionKind; labelKey: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
|
||||||
|
not_connected: { messageKey: 'assistant.error.notConnected', action: null },
|
||||||
|
busy: {
|
||||||
|
messageKey: 'assistant.error.busy',
|
||||||
|
action: { kind: 'continueHere', labelKey: 'assistant.errorAction.continueHere' },
|
||||||
|
},
|
||||||
|
daily_limit: {
|
||||||
|
messageKey: 'assistant.error.dailyLimit',
|
||||||
|
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.connectOwn' },
|
||||||
|
},
|
||||||
|
rate_limited: { messageKey: 'assistant.error.rateLimited', action: null },
|
||||||
|
needs_reauth: {
|
||||||
|
messageKey: 'assistant.error.needsReauth',
|
||||||
|
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.signIn' },
|
||||||
|
},
|
||||||
|
usage_limit: {
|
||||||
|
messageKey: 'assistant.error.usageLimit',
|
||||||
|
action: { kind: 'openUsage', labelKey: 'assistant.errorAction.viewUsage' },
|
||||||
|
},
|
||||||
|
not_eligible: {
|
||||||
|
messageKey: 'assistant.error.notEligible',
|
||||||
|
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.otherProvider' },
|
||||||
|
},
|
||||||
|
invalid_key: {
|
||||||
|
messageKey: 'assistant.error.invalidKey',
|
||||||
|
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.replaceKey' },
|
||||||
|
},
|
||||||
|
provider_unavailable: {
|
||||||
|
messageKey: 'assistant.error.providerDown',
|
||||||
|
action: { kind: 'retry', labelKey: 'assistant.errorAction.retry' },
|
||||||
|
},
|
||||||
|
max_steps: {
|
||||||
|
messageKey: 'assistant.error.maxSteps',
|
||||||
|
action: { kind: 'continue', labelKey: 'assistant.errorAction.continue' },
|
||||||
|
},
|
||||||
|
empty_answer: {
|
||||||
|
messageKey: 'assistant.error.emptyAnswer',
|
||||||
|
action: { kind: 'continue', labelKey: 'assistant.errorAction.continue' },
|
||||||
|
},
|
||||||
|
offline: { messageKey: 'assistant.error.offline', action: null },
|
||||||
|
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
|
||||||
|
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
|
||||||
|
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
|
||||||
|
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
|
||||||
|
generic: {
|
||||||
|
messageKey: 'assistant.error.generic',
|
||||||
|
action: { kind: 'retry', labelKey: 'assistant.errorAction.retry' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The limits a provider sets; the line names the provider instead of guessing at its plan. */
|
||||||
|
const PROVIDER_LIMITS: ReadonlySet<AssistantErrorCode> = new Set(['rate_limited', 'usage_limit']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line an error shows and its one action. The daily limit only offers
|
||||||
|
* connecting an own provider where the policy allows one. A provider's limit
|
||||||
|
* says whose it is when the provider is known (`provider` is its name, empty
|
||||||
|
* when not), and keeps the general line otherwise.
|
||||||
|
*/
|
||||||
|
export function errorView(
|
||||||
|
code: AssistantErrorCode,
|
||||||
|
opts: { canConnectOwn: boolean; provider?: string }
|
||||||
|
): AssistantErrorView {
|
||||||
|
const view = ERRORS[code];
|
||||||
|
if (code === 'daily_limit' && !opts.canConnectOwn) return { ...view, action: null };
|
||||||
|
if (PROVIDER_LIMITS.has(code) && opts.provider) return { ...view, messageKey: 'assistant.error.providerLimit' };
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error the user's own connection shows before anything is sent, once it
|
||||||
|
* stopped working: a sign-in to renew or a key to replace. The window says so
|
||||||
|
* right away instead of waiting for the next question to fail.
|
||||||
|
*/
|
||||||
|
export function connectionErrorOf(status: AiStatusResponse | null): AssistantErrorCode | null {
|
||||||
|
if (!status?.own || status.effective?.source !== 'user') return null;
|
||||||
|
if (status.own.status === 'needs_reauth') return 'needs_reauth';
|
||||||
|
if (status.own.status === 'error') return 'invalid_key';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool steps ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `liveKey` names the step with the place or query it is at, `param` is the word that fills it. */
|
||||||
|
interface StepLook {
|
||||||
|
stepKey: string;
|
||||||
|
live?: { liveKey: string; param: 'place' | 'query' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS: Partial<Record<string, StepLook>> = {
|
||||||
|
get_trip_summary: { stepKey: 'assistant.step.reading' },
|
||||||
|
list_places: { stepKey: 'assistant.step.reading' },
|
||||||
|
search_place: {
|
||||||
|
stepKey: 'assistant.step.searching',
|
||||||
|
live: { liveKey: 'assistant.step.searchingFor', param: 'query' },
|
||||||
|
},
|
||||||
|
search_pois: { stepKey: 'assistant.step.searching', live: { liveKey: 'assistant.step.nearbyAt', param: 'place' } },
|
||||||
|
get_place_details: { stepKey: 'assistant.step.details' },
|
||||||
|
search_nearby_places: {
|
||||||
|
stepKey: 'assistant.step.nearby',
|
||||||
|
live: { liveKey: 'assistant.step.nearbyAt', param: 'place' },
|
||||||
|
},
|
||||||
|
list_categories: { stepKey: 'assistant.step.categories' },
|
||||||
|
get_weather: { stepKey: 'assistant.step.weather', live: { liveKey: 'assistant.step.weatherAt', param: 'place' } },
|
||||||
|
list_todos: { stepKey: 'assistant.step.todos' },
|
||||||
|
get_settlement_summary: { stepKey: 'assistant.step.costs' },
|
||||||
|
search_help: { stepKey: 'assistant.step.help' },
|
||||||
|
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
|
||||||
|
};
|
||||||
|
const WORKING = { stepKey: 'assistant.step.working' };
|
||||||
|
|
||||||
|
/** How a reading tool is named while it runs: "Reading your plan", never the tool's name. */
|
||||||
|
export function toolStepKey(toolName: string): string {
|
||||||
|
return STEPS[toolName]?.stepKey ?? WORKING.stepKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a running step is named once it said where it looks: "Searching near
|
||||||
|
* Trastevere", "Looking for gelato". The general name when it named nothing
|
||||||
|
* or the step has no such line.
|
||||||
|
*/
|
||||||
|
export function liveStepText(toolName: string, label: string | null): { key: string; params?: Record<string, string> } {
|
||||||
|
const live = STEPS[toolName]?.live;
|
||||||
|
if (!live || !label) return { key: toolStepKey(toolName) };
|
||||||
|
return { key: live.liveKey, params: { [live.param]: label } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Weather ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A temperature the server sends in °C, in the user's unit, rounded; never "-0". */
|
||||||
|
export function inUnit(celsius: number, fahrenheit: boolean): number {
|
||||||
|
return Math.round(fahrenheit ? (celsius * 9) / 5 + 32 : celsius) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Suggestions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TripPhase = 'empty' | 'upcoming' | 'running' | 'past';
|
||||||
|
|
||||||
|
/** Where the trip stands on `today` (YYYY-MM-DD, in the user's zone). Dates missing read as not started. */
|
||||||
|
export function tripPhase(trip: {
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
hasPlaces: boolean;
|
||||||
|
today: string;
|
||||||
|
}): TripPhase {
|
||||||
|
const { startDate, endDate, today } = trip;
|
||||||
|
if (endDate && endDate < today) return 'past';
|
||||||
|
if (startDate && startDate <= today && (!endDate || today <= endDate)) return 'running';
|
||||||
|
return trip.hasPlaces ? 'upcoming' : 'empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantSuggestion {
|
||||||
|
id: string;
|
||||||
|
labelKey: string;
|
||||||
|
params?: Record<string, string>;
|
||||||
|
/** Asks the assistant to change the trip, so it is left out for someone who may only read. */
|
||||||
|
writes?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASE_SUGGESTIONS: Record<TripPhase, AssistantSuggestion[]> = {
|
||||||
|
empty: [
|
||||||
|
{ id: 'planFirstDays', labelKey: 'assistant.suggest.planFirstDays', writes: true },
|
||||||
|
{ id: 'mustSee', labelKey: 'assistant.suggest.mustSee' },
|
||||||
|
{ id: 'packingList', labelKey: 'assistant.suggest.packingList', writes: true },
|
||||||
|
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
|
||||||
|
],
|
||||||
|
upcoming: [
|
||||||
|
{ id: 'emptyDays', labelKey: 'assistant.suggest.emptyDays' },
|
||||||
|
{ id: 'packingList', labelKey: 'assistant.suggest.packingList', writes: true },
|
||||||
|
{ id: 'rainPlan', labelKey: 'assistant.suggest.rainPlan' },
|
||||||
|
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
|
||||||
|
],
|
||||||
|
running: [
|
||||||
|
{ id: 'today', labelKey: 'assistant.suggest.today' },
|
||||||
|
{ id: 'eatNearby', labelKey: 'assistant.suggest.eatNearby' },
|
||||||
|
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
|
||||||
|
{ id: 'openTodos', labelKey: 'assistant.suggest.openTodos' },
|
||||||
|
],
|
||||||
|
past: [
|
||||||
|
{ id: 'settleUp', labelKey: 'assistant.suggest.settleUp' },
|
||||||
|
{ id: 'recap', labelKey: 'assistant.suggest.recap' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The questions the empty chat offers: about the place or day in focus first,
|
||||||
|
* then what fits the trip's phase. The label is also the prompt, so a tapped
|
||||||
|
* suggestion reads in the chat exactly as it was offered.
|
||||||
|
*/
|
||||||
|
export function suggestionsFor(input: {
|
||||||
|
phase: TripPhase;
|
||||||
|
canEdit: boolean;
|
||||||
|
/** The name of the place in focus. */
|
||||||
|
place?: string | null;
|
||||||
|
/** The label of the day in focus ("Fri 12 Jun"). */
|
||||||
|
day?: string | null;
|
||||||
|
}): AssistantSuggestion[] {
|
||||||
|
const focused: AssistantSuggestion[] = [];
|
||||||
|
if (input.place) {
|
||||||
|
focused.push(
|
||||||
|
{ id: 'nearPlace', labelKey: 'assistant.suggest.nearPlace', params: { place: input.place } },
|
||||||
|
{ id: 'placeTips', labelKey: 'assistant.suggest.placeTips', params: { place: input.place } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.day) {
|
||||||
|
focused.push(
|
||||||
|
{ id: 'planDay', labelKey: 'assistant.suggest.planDay', params: { day: input.day }, writes: true },
|
||||||
|
{ id: 'dayTooFull', labelKey: 'assistant.suggest.dayTooFull', params: { day: input.day } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const picked: AssistantSuggestion[] = [];
|
||||||
|
for (const suggestion of [...focused, ...PHASE_SUGGESTIONS[input.phase]]) {
|
||||||
|
if (suggestion.writes && !input.canEdit) continue;
|
||||||
|
if (picked.some((p) => p.id === suggestion.id)) continue;
|
||||||
|
picked.push(suggestion);
|
||||||
|
}
|
||||||
|
return picked.slice(0, MAX_SUGGESTIONS);
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-PARTS-001 to FE-AI-PARTS-014
|
||||||
|
*
|
||||||
|
* How an answer is laid out: text, the steps the assistant looked up, the
|
||||||
|
* places and the weather they found, and one card per batch of proposed
|
||||||
|
* changes, each change in the state its approval and receipt put it in.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
ASSISTANT_CHANGE_LABEL_KEYS,
|
||||||
|
type AssistantApplied,
|
||||||
|
type AssistantChangePreview,
|
||||||
|
type AssistantFoundPlace,
|
||||||
|
type AssistantMessage,
|
||||||
|
type AssistantMessagePart,
|
||||||
|
type AssistantWeatherDay,
|
||||||
|
} from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { CHANGE_LABELS, hasUndecided, messageBlocks, suggestedFromStrip, undoLeftMs } from './assistantParts';
|
||||||
|
|
||||||
|
const AT = '2026-10-21T08:00:00.000Z';
|
||||||
|
|
||||||
|
function message(parts: AssistantMessagePart[]): AssistantMessage {
|
||||||
|
return { id: 'm1', role: 'assistant', status: 'complete', createdAt: AT, parts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(toolCallId: string): AssistantChangePreview {
|
||||||
|
return {
|
||||||
|
toolCallId,
|
||||||
|
verbKey: 'addTodo',
|
||||||
|
subject: `Todo ${toolCallId}`,
|
||||||
|
rows: [],
|
||||||
|
dayId: null,
|
||||||
|
placeIds: [],
|
||||||
|
undoable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applied(toolCallId: string, undoUntil: string | null = null): AssistantApplied {
|
||||||
|
return { toolCallId, entity: 'todo', ids: [1], dayId: null, undoUntil };
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = (id: string, result?: unknown): AssistantMessagePart => ({
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: id,
|
||||||
|
toolName: 'list_places',
|
||||||
|
args: {},
|
||||||
|
...(result !== undefined ? { result } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const write = (
|
||||||
|
id: string,
|
||||||
|
approval: { approved?: boolean; resolution?: 'cancelled' | 'expired' } = {},
|
||||||
|
outcome: { result?: unknown; isError?: boolean } = {}
|
||||||
|
): AssistantMessagePart => ({
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: id,
|
||||||
|
toolName: 'create_todo',
|
||||||
|
args: {},
|
||||||
|
approval: { id, ...approval },
|
||||||
|
...outcome,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('messageBlocks', () => {
|
||||||
|
it('FE-AI-PARTS-001: keeps text, gathers adjacent reading steps and drops empty text', () => {
|
||||||
|
const blocks = messageBlocks(
|
||||||
|
message([
|
||||||
|
{ type: 'text', text: 'Let me look.' },
|
||||||
|
read('r1', { ok: 1 }),
|
||||||
|
read('r2', { ok: 2 }),
|
||||||
|
{ type: 'text', text: ' ' },
|
||||||
|
{ type: 'text', text: 'Found two.' },
|
||||||
|
]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(blocks.map((b) => b.kind)).toEqual(['text', 'steps', 'text']);
|
||||||
|
expect(blocks[1]).toMatchObject({
|
||||||
|
kind: 'steps',
|
||||||
|
steps: [
|
||||||
|
{ toolCallId: 'r1', running: false },
|
||||||
|
{ toolCallId: 'r2', running: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-002: a step without its result runs only while the answer is being written', () => {
|
||||||
|
const parts = [read('r1')];
|
||||||
|
expect(messageBlocks(message(parts), true)[0]).toMatchObject({ steps: [{ running: true }] });
|
||||||
|
expect(messageBlocks(message(parts), false)[0]).toMatchObject({ steps: [{ running: false }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-003: the changes of one step share a card, each with its preview, wherever the preview sits', () => {
|
||||||
|
const blocks = messageBlocks(
|
||||||
|
message([
|
||||||
|
write('w1'),
|
||||||
|
{ type: 'data-trek-change', data: preview('w1') },
|
||||||
|
write('w2'),
|
||||||
|
{ type: 'data-trek-change', data: preview('w2') },
|
||||||
|
]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0]).toMatchObject({
|
||||||
|
kind: 'change',
|
||||||
|
items: [
|
||||||
|
{ toolCallId: 'w1', state: 'pending', preview: { subject: 'Todo w1' } },
|
||||||
|
{ toolCallId: 'w2', state: 'pending', preview: { subject: 'Todo w2' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-004: reads each change state off its approval, result and receipt', () => {
|
||||||
|
const parts: AssistantMessagePart[] = [
|
||||||
|
write('applying', { approved: true }),
|
||||||
|
write('done', { approved: true }, { result: { id: 1 } }),
|
||||||
|
write('receipt', { approved: true }),
|
||||||
|
{ type: 'data-trek-applied', data: applied('receipt') },
|
||||||
|
write('broke', { approved: true }, { result: 'nope', isError: true }),
|
||||||
|
write('no', { approved: false }),
|
||||||
|
write('late', { resolution: 'expired' }),
|
||||||
|
];
|
||||||
|
const [card] = messageBlocks(message(parts), false);
|
||||||
|
expect(card?.kind === 'change' && card.items.map((i) => [i.toolCallId, i.state])).toEqual([
|
||||||
|
['applying', 'applying'],
|
||||||
|
['done', 'applied'],
|
||||||
|
['receipt', 'applied'],
|
||||||
|
['broke', 'failed'],
|
||||||
|
['no', 'discarded'],
|
||||||
|
['late', 'expired'],
|
||||||
|
]);
|
||||||
|
expect(card?.kind === 'change' && card.items[2]?.applied).toEqual(applied('receipt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-005: text between two batches of changes splits them into two cards', () => {
|
||||||
|
const blocks = messageBlocks(message([write('w1'), { type: 'text', text: 'And one more.' }, write('w2')]), false);
|
||||||
|
expect(blocks.map((b) => b.kind)).toEqual(['change', 'text', 'change']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function found(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
|
||||||
|
return {
|
||||||
|
ref,
|
||||||
|
name: `Place ${ref}`,
|
||||||
|
address: null,
|
||||||
|
lat: 41.89,
|
||||||
|
lng: 12.47,
|
||||||
|
kind: 'food',
|
||||||
|
rating: null,
|
||||||
|
openNow: null,
|
||||||
|
googlePlaceId: null,
|
||||||
|
osmId: null,
|
||||||
|
photoKey: null,
|
||||||
|
inTripPlaceId: null,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const places = (toolCallId: string, list: AssistantFoundPlace[], more = 0): AssistantMessagePart => ({
|
||||||
|
type: 'data-trek-places',
|
||||||
|
data: { toolCallId, places: list, more },
|
||||||
|
});
|
||||||
|
|
||||||
|
function sky(date: string, tempMax: number): AssistantWeatherDay {
|
||||||
|
return { dayId: null, date, main: 'Clear', tempMax, tempMin: null, rainPct: null, kind: 'forecast' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A change of the model that adds a place to day 3: a new one at a spot, or the trip's own by id. */
|
||||||
|
function adds(id: string, spot: { lat: number; lng: number } | null, placeId: number | null): AssistantMessagePart {
|
||||||
|
return {
|
||||||
|
type: 'data-trek-change',
|
||||||
|
data: {
|
||||||
|
...preview(id),
|
||||||
|
effect: {
|
||||||
|
kind: 'add',
|
||||||
|
dayId: 3,
|
||||||
|
fromDayId: null,
|
||||||
|
placeId,
|
||||||
|
assignmentId: null,
|
||||||
|
place: spot && { name: 'New', categoryId: null, ...spot },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('messageBlocks: found places and weather', () => {
|
||||||
|
it('FE-AI-PARTS-008: joins the places of all searches into one strip, each once, and steps after it join the row before it', () => {
|
||||||
|
const blocks = messageBlocks(
|
||||||
|
message([
|
||||||
|
read('s1', []),
|
||||||
|
places('s1', [found('s1:0'), found('s1:1', { googlePlaceId: 'ChIJa' })], 2),
|
||||||
|
read('s2', []),
|
||||||
|
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
|
||||||
|
places('s3', [found('s3:0', { lat: 40 })]),
|
||||||
|
{ type: 'text', text: 'Three good ones.' },
|
||||||
|
]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'places', 'text']);
|
||||||
|
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
|
||||||
|
// The same name at the same spot, or the same Google id, shows once.
|
||||||
|
expect(blocks[1]).toMatchObject({
|
||||||
|
found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
|
||||||
|
const blocks = messageBlocks(
|
||||||
|
message([
|
||||||
|
places('s1', [found('s1:0'), found('s1:1', { lat: 40, inTripPlaceId: 8 }), found('s1:2', { lat: 39 })]),
|
||||||
|
write('w1'),
|
||||||
|
adds('w1', { lat: 41.89, lng: 12.47 }, null),
|
||||||
|
write('w2'),
|
||||||
|
adds('w2', null, 8),
|
||||||
|
]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(blocks[0]).toMatchObject({ kind: 'places', found: { places: [{ ref: 's1:2' }] } });
|
||||||
|
|
||||||
|
const none = messageBlocks(
|
||||||
|
message([places('s1', [found('s1:0')]), write('w1'), adds('w1', { lat: 41.89, lng: 12.47 }, null)]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(none.map((b) => b.kind)).toEqual(['change']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-010: a place suggested from the strip stays on it and reads as suggested', () => {
|
||||||
|
const msg = message([
|
||||||
|
places('s1', [found('s1:0'), found('s1:1', { lat: 40 })]),
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'f1',
|
||||||
|
toolName: 'add_found_place',
|
||||||
|
args: { via: 'create_and_assign_place', dayId: 3, name: 'Place s1:0', lat: 41.89, lng: 12.47 },
|
||||||
|
approval: { id: 'f1' },
|
||||||
|
},
|
||||||
|
adds('f1', { lat: 41.89, lng: 12.47 }, null),
|
||||||
|
]);
|
||||||
|
const [strip] = messageBlocks(msg, false);
|
||||||
|
expect(strip).toMatchObject({ kind: 'places', found: { places: [{ ref: 's1:0' }, { ref: 's1:1' }] } });
|
||||||
|
expect(suggestedFromStrip(msg, found('s1:0'))).toBe(true);
|
||||||
|
expect(suggestedFromStrip(msg, found('s1:1', { lat: 40 }))).toBe(false);
|
||||||
|
|
||||||
|
const inTrip = message([
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'f2',
|
||||||
|
toolName: 'add_found_place',
|
||||||
|
args: { placeId: 8 },
|
||||||
|
approval: { id: 'f2' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(suggestedFromStrip(inTrip, found('x', { inTripPlaceId: 8 }))).toBe(true);
|
||||||
|
expect(suggestedFromStrip(inTrip, found('x', { inTripPlaceId: 9 }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-014: each suggestion from the strip is a card of its own, apart from the changes around it', () => {
|
||||||
|
const fromStrip = (id: string, approval: { approved?: boolean } = {}): AssistantMessagePart => ({
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: id,
|
||||||
|
toolName: 'add_found_place',
|
||||||
|
args: { placeId: 8 },
|
||||||
|
approval: { id, ...approval },
|
||||||
|
});
|
||||||
|
const blocks = messageBlocks(
|
||||||
|
message([write('w1', { approved: true }), fromStrip('f1', { approved: true }), fromStrip('f2'), write('w2')]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(blocks.map((b) => (b.kind === 'change' ? b.items.map((i) => i.toolCallId) : b.kind))).toEqual([
|
||||||
|
['w1'],
|
||||||
|
['f1'],
|
||||||
|
['f2'],
|
||||||
|
['w2'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-011: the weather of all steps is one row, one entry per date, where the first look sat', () => {
|
||||||
|
const blocks = messageBlocks(
|
||||||
|
message([
|
||||||
|
read('w1', {}),
|
||||||
|
{ type: 'data-trek-weather', data: { days: [sky('2026-11-03', 18), sky('2026-11-02', 20)] } },
|
||||||
|
{ type: 'text', text: 'Mild.' },
|
||||||
|
{ type: 'data-trek-weather', data: { days: [sky('2026-11-03', 15)] } },
|
||||||
|
]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'weather', 'text']);
|
||||||
|
expect(blocks[1]).toMatchObject({
|
||||||
|
kind: 'weather',
|
||||||
|
days: [
|
||||||
|
{ date: '2026-11-02', tempMax: 20 },
|
||||||
|
{ date: '2026-11-03', tempMax: 15 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasUndecided', () => {
|
||||||
|
it('FE-AI-PARTS-012: is true only while a change waits for the user', () => {
|
||||||
|
expect(hasUndecided([message([write('w1')])])).toBe(true);
|
||||||
|
expect(hasUndecided([message([write('w1', { approved: true })])])).toBe(false);
|
||||||
|
expect(hasUndecided([message([write('w1', { resolution: 'expired' })])])).toBe(false);
|
||||||
|
expect(hasUndecided([message([read('r1')])])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('undoLeftMs', () => {
|
||||||
|
it('FE-AI-PARTS-006: counts down to the end of the undo window and stops at zero', () => {
|
||||||
|
const now = Date.parse(AT);
|
||||||
|
expect(undoLeftMs(applied('a', '2026-10-21T08:10:00.000Z'), now)).toBe(10 * 60_000);
|
||||||
|
expect(undoLeftMs(applied('a', '2026-10-21T07:59:00.000Z'), now)).toBe(0);
|
||||||
|
expect(undoLeftMs(applied('a', null), now)).toBe(0);
|
||||||
|
expect(undoLeftMs(null, now)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CHANGE_LABELS', () => {
|
||||||
|
it('FE-AI-PARTS-013: names a route', () => {
|
||||||
|
expect(CHANGE_LABELS.route.labelKey).toBe('assistant.change.label.route');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-007: names every field a change row can carry', () => {
|
||||||
|
for (const key of ASSISTANT_CHANGE_LABEL_KEYS) {
|
||||||
|
expect(CHANGE_LABELS[key].labelKey).toBe(`assistant.change.label.${key}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import type {
|
||||||
|
AssistantApplied,
|
||||||
|
AssistantChangeEffect,
|
||||||
|
AssistantChangeLabelKey,
|
||||||
|
AssistantChangePreview,
|
||||||
|
AssistantFoundPlace,
|
||||||
|
AssistantMessage,
|
||||||
|
AssistantMessagePart,
|
||||||
|
AssistantPlaces,
|
||||||
|
AssistantWeatherDay,
|
||||||
|
} from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How an answer is laid out: its stored parts read as blocks. Text stays
|
||||||
|
* text; the reading tools of a run sit together as one row of steps; the
|
||||||
|
* changes the model proposes in one go sit together as one card, each with
|
||||||
|
* its preview and, once applied, its receipt. The places a search found sit
|
||||||
|
* as one strip, the newest search's, and the weather the steps looked up as
|
||||||
|
* one row of days. Pure, so the window and the phone screen lay an answer out
|
||||||
|
* the same way and a test can pin it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Where the settings card for the user's own AI connection lives. */
|
||||||
|
export const AI_SETTINGS_PATH = '/settings?tab=integrations';
|
||||||
|
|
||||||
|
type ToolCallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
|
||||||
|
|
||||||
|
/** One reading tool the assistant ran or runs. */
|
||||||
|
export interface ToolStep {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
/** Still waiting for its result while the answer is being written. */
|
||||||
|
running: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a proposed change stands: waiting for the user, being applied,
|
||||||
|
* applied, failed, discarded, or closed without a decision (expired, or the
|
||||||
|
* turn was stopped).
|
||||||
|
*/
|
||||||
|
export type ChangeState = 'pending' | 'applying' | 'applied' | 'failed' | 'discarded' | 'expired';
|
||||||
|
|
||||||
|
export interface ChangeItem {
|
||||||
|
toolCallId: string;
|
||||||
|
/** What the card says about it; null until the preview arrived. */
|
||||||
|
preview: AssistantChangePreview | null;
|
||||||
|
/** The receipt of an applied change. */
|
||||||
|
applied: AssistantApplied | null;
|
||||||
|
state: ChangeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageBlock =
|
||||||
|
| { kind: 'text'; key: string; text: string }
|
||||||
|
| { kind: 'steps'; key: string; steps: ToolStep[] }
|
||||||
|
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||||
|
| { kind: 'places'; key: string; found: AssistantPlaces }
|
||||||
|
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] };
|
||||||
|
|
||||||
|
/** The change "+ Day 3" on a found place makes; the server holds it without a model turn. */
|
||||||
|
const FOUND_PLACE_TOOL = 'add_found_place';
|
||||||
|
|
||||||
|
/** Two points this close (about ten metres) are the same place. */
|
||||||
|
const SAME_SPOT_DEG = 1e-4;
|
||||||
|
|
||||||
|
/** A found place one of the answer's own changes already proposes, so its card would say the same twice. */
|
||||||
|
function proposed(place: AssistantFoundPlace, effects: readonly AssistantChangeEffect[]): boolean {
|
||||||
|
return effects.some((effect) => {
|
||||||
|
if (effect.placeId !== null && effect.placeId === place.inTripPlaceId) return true;
|
||||||
|
const spot = effect.place;
|
||||||
|
return (
|
||||||
|
spot !== null && Math.abs(spot.lat - place.lat) < SAME_SPOT_DEG && Math.abs(spot.lng - place.lng) < SAME_SPOT_DEG
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
|
||||||
|
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
|
||||||
|
const byDate = new Map<string, AssistantWeatherDay>();
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === 'data-trek-weather') for (const day of part.data.days) byDate.set(day.date, day);
|
||||||
|
}
|
||||||
|
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The handles one place goes by: its ids, and its name at its spot (to about 100 m). */
|
||||||
|
function placeKeys(place: AssistantFoundPlace): string[] {
|
||||||
|
const ids = [place.googlePlaceId && `g:${place.googlePlaceId}`, place.osmId && `o:${place.osmId}`];
|
||||||
|
const spot = `n:${place.name.trim().toLowerCase()}@${place.lat.toFixed(3)},${place.lng.toFixed(3)}`;
|
||||||
|
return [...ids.filter((key): key is string => !!key), spot];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The places all searches of an answer found, in the order they came, each
|
||||||
|
* once (also when two searches name it by different ids, as long as name and
|
||||||
|
* spot agree), with what the searches left out counted together.
|
||||||
|
*/
|
||||||
|
function allFound(parts: AssistantMessage['parts']): AssistantPlaces {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const places: AssistantFoundPlace[] = [];
|
||||||
|
let more = 0;
|
||||||
|
let toolCallId = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type !== 'data-trek-places') continue;
|
||||||
|
toolCallId = part.data.toolCallId;
|
||||||
|
more += part.data.more;
|
||||||
|
for (const place of part.data.places) {
|
||||||
|
const keys = placeKeys(place);
|
||||||
|
if (keys.some((key) => seen.has(key))) continue;
|
||||||
|
keys.forEach((key) => seen.add(key));
|
||||||
|
places.push(place);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { toolCallId, places, more };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a change to the change block before it, or starts one. A suggestion
|
||||||
|
* from the strip is a block of its own, decided apart from the changes before
|
||||||
|
* and after it, even where the answer joins them.
|
||||||
|
*/
|
||||||
|
function pushChange(blocks: MessageBlock[], item: ChangeItem, fromStrip: ReadonlySet<string>): void {
|
||||||
|
const last = blocks[blocks.length - 1];
|
||||||
|
const lastId = last?.kind === 'change' ? last.items[last.items.length - 1]?.toolCallId : undefined;
|
||||||
|
const apart = fromStrip.has(item.toolCallId) || (lastId !== undefined && fromStrip.has(lastId));
|
||||||
|
if (last?.kind === 'change' && !apart) last.items.push(item);
|
||||||
|
else blocks.push({ kind: 'change', key: `change-${item.toolCallId}`, items: [item] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The steps block a new step joins: the last one, past any strip shown after it. */
|
||||||
|
function openSteps(blocks: readonly MessageBlock[]): Extract<MessageBlock, { kind: 'steps' }> | null {
|
||||||
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||||
|
const block = blocks[i];
|
||||||
|
if (block?.kind === 'steps') return block;
|
||||||
|
if (block?.kind !== 'places' && block?.kind !== 'weather') return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeState(part: ToolCallPart, applied: AssistantApplied | null): ChangeState {
|
||||||
|
const approval = part.approval;
|
||||||
|
if (approval?.resolution) return 'expired';
|
||||||
|
if (approval?.approved === false) return 'discarded';
|
||||||
|
if (approval?.approved === true) {
|
||||||
|
if (part.isError) return 'failed';
|
||||||
|
return applied || part.result !== undefined ? 'applied' : 'applying';
|
||||||
|
}
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The blocks of a message, in the order its parts came. `writing` says the
|
||||||
|
* message is still being streamed, so a step without a result is running and
|
||||||
|
* not merely unanswered.
|
||||||
|
*
|
||||||
|
* The places of all its searches join into one strip where the newest search
|
||||||
|
* sat, each place once, without the ones a change the model proposed in the
|
||||||
|
* answer already holds; the weather of all steps joins
|
||||||
|
* into one row where the first look sat. A strip does not split the steps
|
||||||
|
* around it: a step after it joins the row of steps before it.
|
||||||
|
*/
|
||||||
|
export function messageBlocks(message: AssistantMessage, writing: boolean): MessageBlock[] {
|
||||||
|
const previews = new Map<string, AssistantChangePreview>();
|
||||||
|
const receipts = new Map<string, AssistantApplied>();
|
||||||
|
const effects: AssistantChangeEffect[] = [];
|
||||||
|
// A place the user suggested from the strip stays on it, marked as suggested.
|
||||||
|
const fromStrip = new Set(
|
||||||
|
message.parts.flatMap((p) => (p.type === 'tool-call' && p.toolName === FOUND_PLACE_TOOL ? [p.toolCallId] : []))
|
||||||
|
);
|
||||||
|
let lastPlaces = -1;
|
||||||
|
let firstWeather = -1;
|
||||||
|
message.parts.forEach((part, index) => {
|
||||||
|
if (part.type === 'data-trek-change') {
|
||||||
|
previews.set(part.data.toolCallId, part.data);
|
||||||
|
if (part.data.effect && !fromStrip.has(part.data.toolCallId)) effects.push(part.data.effect);
|
||||||
|
} else if (part.type === 'data-trek-applied') receipts.set(part.data.toolCallId, part.data);
|
||||||
|
else if (part.type === 'data-trek-places') lastPlaces = index;
|
||||||
|
else if (part.type === 'data-trek-weather' && firstWeather < 0) firstWeather = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blocks: MessageBlock[] = [];
|
||||||
|
message.parts.forEach((part, index) => {
|
||||||
|
if (part.type === 'text') {
|
||||||
|
if (part.text.trim()) blocks.push({ kind: 'text', key: `text-${index}`, text: part.text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (part.type === 'data-trek-places' && index === lastPlaces) {
|
||||||
|
const found = allFound(message.parts);
|
||||||
|
const places = found.places.filter((place) => !proposed(place, effects));
|
||||||
|
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (part.type === 'data-trek-weather' && index === firstWeather) {
|
||||||
|
blocks.push({ kind: 'weather', key: `weather-${index}`, days: weatherDays(message.parts) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (part.type !== 'tool-call') return;
|
||||||
|
const preview = previews.get(part.toolCallId) ?? null;
|
||||||
|
if (part.approval || preview) {
|
||||||
|
const applied = receipts.get(part.toolCallId) ?? null;
|
||||||
|
const item: ChangeItem = { toolCallId: part.toolCallId, preview, applied, state: changeState(part, applied) };
|
||||||
|
pushChange(blocks, item, fromStrip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const step: ToolStep = {
|
||||||
|
toolCallId: part.toolCallId,
|
||||||
|
toolName: part.toolName,
|
||||||
|
running: writing && part.result === undefined,
|
||||||
|
};
|
||||||
|
const steps = openSteps(blocks);
|
||||||
|
if (steps) steps.steps.push(step);
|
||||||
|
else blocks.push({ kind: 'steps', key: `steps-${part.toolCallId}`, steps: [step] });
|
||||||
|
});
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether "+ Day" was already pressed on this found place in the answer: a
|
||||||
|
* suggestion from the strip names the trip's place by its id, a new one by
|
||||||
|
* the spot the search found it at.
|
||||||
|
*/
|
||||||
|
export function suggestedFromStrip(message: AssistantMessage, place: AssistantFoundPlace): boolean {
|
||||||
|
return message.parts.some((part) => {
|
||||||
|
if (part.type !== 'tool-call' || part.toolName !== FOUND_PLACE_TOOL) return false;
|
||||||
|
const args = part.args as { placeId?: unknown; lat?: unknown; lng?: unknown };
|
||||||
|
if (place.inTripPlaceId !== null && args.placeId === place.inTripPlaceId) return true;
|
||||||
|
return (
|
||||||
|
typeof args.lat === 'number' &&
|
||||||
|
typeof args.lng === 'number' &&
|
||||||
|
Math.abs(args.lat - place.lat) < SAME_SPOT_DEG &&
|
||||||
|
Math.abs(args.lng - place.lng) < SAME_SPOT_DEG
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a change somewhere in the chat still waits for the user; nothing new can be suggested until then. */
|
||||||
|
export function hasUndecided(messages: readonly AssistantMessage[]): boolean {
|
||||||
|
return messages.some((m) =>
|
||||||
|
m.parts.some(
|
||||||
|
(p) => p.type === 'tool-call' && !!p.approval && p.approval.approved === undefined && !p.approval.resolution
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long an applied change can still be undone, in ms; 0 once it cannot. */
|
||||||
|
export function undoLeftMs(applied: AssistantApplied | null, now: number): number {
|
||||||
|
if (!applied?.undoUntil) return 0;
|
||||||
|
return Math.max(0, Date.parse(applied.undoUntil) - now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label of each field a change row can show. A table, so every key is named where it is read. */
|
||||||
|
export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }> = {
|
||||||
|
// A tool's own argument: the row names it in `field`, this is only its fallback.
|
||||||
|
field: { labelKey: 'assistant.change.label.field' },
|
||||||
|
order: { labelKey: 'assistant.change.label.order' },
|
||||||
|
name: { labelKey: 'assistant.change.label.name' },
|
||||||
|
description: { labelKey: 'assistant.change.label.description' },
|
||||||
|
address: { labelKey: 'assistant.change.label.address' },
|
||||||
|
coordinates: { labelKey: 'assistant.change.label.coordinates' },
|
||||||
|
category: { labelKey: 'assistant.change.label.category' },
|
||||||
|
day: { labelKey: 'assistant.change.label.day' },
|
||||||
|
time: { labelKey: 'assistant.change.label.time' },
|
||||||
|
endTime: { labelKey: 'assistant.change.label.endTime' },
|
||||||
|
notes: { labelKey: 'assistant.change.label.notes' },
|
||||||
|
website: { labelKey: 'assistant.change.label.website' },
|
||||||
|
phone: { labelKey: 'assistant.change.label.phone' },
|
||||||
|
email: { labelKey: 'assistant.change.label.email' },
|
||||||
|
openingHours: { labelKey: 'assistant.change.label.openingHours' },
|
||||||
|
price: { labelKey: 'assistant.change.label.price' },
|
||||||
|
currency: { labelKey: 'assistant.change.label.currency' },
|
||||||
|
stopType: { labelKey: 'assistant.change.label.stopType' },
|
||||||
|
text: { labelKey: 'assistant.change.label.text' },
|
||||||
|
icon: { labelKey: 'assistant.change.label.icon' },
|
||||||
|
color: { labelKey: 'assistant.change.label.color' },
|
||||||
|
position: { labelKey: 'assistant.change.label.position' },
|
||||||
|
quantity: { labelKey: 'assistant.change.label.quantity' },
|
||||||
|
weight: { labelKey: 'assistant.change.label.weight' },
|
||||||
|
bag: { labelKey: 'assistant.change.label.bag' },
|
||||||
|
packed: { labelKey: 'assistant.change.label.packed' },
|
||||||
|
dueDate: { labelKey: 'assistant.change.label.dueDate' },
|
||||||
|
priority: { labelKey: 'assistant.change.label.priority' },
|
||||||
|
assignee: { labelKey: 'assistant.change.label.assignee' },
|
||||||
|
type: { labelKey: 'assistant.change.label.type' },
|
||||||
|
location: { labelKey: 'assistant.change.label.location' },
|
||||||
|
link: { labelKey: 'assistant.change.label.link' },
|
||||||
|
confirmation: { labelKey: 'assistant.change.label.confirmation' },
|
||||||
|
checkIn: { labelKey: 'assistant.change.label.checkIn' },
|
||||||
|
checkOut: { labelKey: 'assistant.change.label.checkOut' },
|
||||||
|
stay: { labelKey: 'assistant.change.label.stay' },
|
||||||
|
members: { labelKey: 'assistant.change.label.members' },
|
||||||
|
route: { labelKey: 'assistant.change.label.route' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-SHARE-001 to FE-AI-SHARE-005
|
||||||
|
*
|
||||||
|
* What an answer says once it goes to the trip chat: Markdown read as plain
|
||||||
|
* text, the applied changes on request and only those, and a cut at a word
|
||||||
|
* end that says so.
|
||||||
|
*/
|
||||||
|
import type { AssistantChangePreview, AssistantMessage, AssistantMessagePart } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { chatTextOf, markdownToText, SHARE_MAX_CHARS } from './assistantShare';
|
||||||
|
|
||||||
|
const t = (key: string) => (key === 'assistant.share.changesHeading' ? 'Applied:' : `<${key}>`);
|
||||||
|
|
||||||
|
function message(parts: AssistantMessagePart[]): AssistantMessage {
|
||||||
|
return { id: 'msg-3', role: 'assistant', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z', parts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function change(id: string, subject: string, approval: { approved?: boolean }): AssistantMessagePart[] {
|
||||||
|
const preview: AssistantChangePreview = {
|
||||||
|
toolCallId: id,
|
||||||
|
verbKey: 'addPlace',
|
||||||
|
subject,
|
||||||
|
rows: [],
|
||||||
|
dayId: 3,
|
||||||
|
placeIds: [],
|
||||||
|
undoable: true,
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
{ type: 'tool-call', toolCallId: id, toolName: 'create_place', args: {}, approval: { id, ...approval } },
|
||||||
|
{ type: 'data-trek-change', data: preview },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('markdownToText', () => {
|
||||||
|
it('FE-AI-SHARE-001: reads headings, emphasis, code, lists, links, quotes and tables as plain text', () => {
|
||||||
|
const text = markdownToText(
|
||||||
|
[
|
||||||
|
'## Day 3',
|
||||||
|
'',
|
||||||
|
'Go **early** to *Fushimi Inari*, take `JR Nara`.',
|
||||||
|
'- [Tickets](https://example.com/t)',
|
||||||
|
'* https://example.com',
|
||||||
|
'> Bring water',
|
||||||
|
'',
|
||||||
|
'| Stop | Time |',
|
||||||
|
'|---|:--:|',
|
||||||
|
'| Shrine | 08:00 |',
|
||||||
|
'```',
|
||||||
|
'**kept**',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Done.',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
expect(text).toBe(
|
||||||
|
[
|
||||||
|
'Day 3',
|
||||||
|
'',
|
||||||
|
'Go early to Fushimi Inari, take JR Nara.',
|
||||||
|
'• Tickets (https://example.com/t)',
|
||||||
|
'• https://example.com',
|
||||||
|
'Bring water',
|
||||||
|
'',
|
||||||
|
'Stop, Time',
|
||||||
|
'Shrine, 08:00',
|
||||||
|
'**kept**',
|
||||||
|
'',
|
||||||
|
'Done.',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-SHARE-002: a link whose text is its address stays one address', () => {
|
||||||
|
expect(markdownToText('[https://trek.app](https://trek.app)')).toBe('https://trek.app');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chatTextOf', () => {
|
||||||
|
it('FE-AI-SHARE-003: adds only the applied changes, under a heading, when asked to', () => {
|
||||||
|
const msg = message([
|
||||||
|
{ type: 'text', text: 'I added **two** places.' },
|
||||||
|
...change('c1', 'Kinkaku-ji', { approved: true }),
|
||||||
|
{ type: 'data-trek-applied', data: { toolCallId: 'c1', entity: 'place', ids: [9], dayId: 3, undoUntil: null } },
|
||||||
|
...change('c2', 'Ryoan-ji', { approved: false }),
|
||||||
|
...change('c3', 'Ginkaku-ji', {}),
|
||||||
|
]);
|
||||||
|
expect(chatTextOf(msg, { includeChanges: true, t })).toEqual({
|
||||||
|
text: 'I added two places.\n\nApplied:\n✓ <assistant.change.verb.addPlace>: Kinkaku-ji',
|
||||||
|
truncated: false,
|
||||||
|
hasChanges: true,
|
||||||
|
});
|
||||||
|
expect(chatTextOf(msg, { includeChanges: false, t })).toEqual({
|
||||||
|
text: 'I added two places.',
|
||||||
|
truncated: false,
|
||||||
|
hasChanges: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-SHARE-004: an answer without text or applied changes has nothing to share', () => {
|
||||||
|
expect(chatTextOf(message(change('c1', 'Kinkaku-ji', {})), { includeChanges: true, t })).toEqual({
|
||||||
|
text: '',
|
||||||
|
truncated: false,
|
||||||
|
hasChanges: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-SHARE-005: a long answer is cut at a word end within the limit and marked', () => {
|
||||||
|
const words = Array.from({ length: 1200 }, (_, i) => `word${i}`).join(' ');
|
||||||
|
const { text, truncated } = chatTextOf(message([{ type: 'text', text: words }]), { includeChanges: false, t });
|
||||||
|
expect(truncated).toBe(true);
|
||||||
|
expect(text.length).toBeLessThanOrEqual(SHARE_MAX_CHARS);
|
||||||
|
expect(text.endsWith('…')).toBe(true);
|
||||||
|
expect(words.startsWith(text.slice(0, -1))).toBe(true);
|
||||||
|
expect(words.charAt(text.length - 1)).toBe(' ');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { AssistantMessage } from '@trek/shared';
|
||||||
|
|
||||||
|
import { messageBlocks } from './assistantParts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an answer says once it goes to the trip chat, which shows plain text:
|
||||||
|
* the Markdown read as text, optionally a line per change the user applied,
|
||||||
|
* cut to fit a chat message. Pure, so the preview shows exactly what is sent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Room left below the chat's 5000 characters, so the cut and its mark always fit. */
|
||||||
|
export const SHARE_MAX_CHARS = 4800;
|
||||||
|
|
||||||
|
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
/** A table's divider row (`|---|:--:|`), which has no words. */
|
||||||
|
const TABLE_RULE = /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-*:?\s*$/;
|
||||||
|
|
||||||
|
/** One table row as its cells in a line. */
|
||||||
|
function tableRow(line: string): string {
|
||||||
|
return line
|
||||||
|
.replace(/^\|/, '')
|
||||||
|
.replace(/\|$/, '')
|
||||||
|
.split('|')
|
||||||
|
.map((cell) => cell.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The marks of one line of running text: emphasis, code, links and pictures. */
|
||||||
|
function inline(line: string): string {
|
||||||
|
return line
|
||||||
|
.replace(/!\[([^\]]*)\]\([^)\s]*\)/g, '$1')
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text: string, url: string) => (text === url ? url : `${text} (${url})`))
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||||
|
.replace(/__([^_]+)__/g, '$1')
|
||||||
|
.replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, '$1$2')
|
||||||
|
.replace(/`([^`]+)`/g, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line of Markdown as text, or null for a line that only carries layout (a fence, a table rule). */
|
||||||
|
function plainLine(line: string): string | null {
|
||||||
|
if (/^\s*(?:```|~~~)/.test(line)) return null;
|
||||||
|
if (TABLE_RULE.test(line.trim())) return null;
|
||||||
|
const heading = /^\s{0,3}#{1,6}\s+(.*)$/.exec(line);
|
||||||
|
if (heading) return inline(heading[1] ?? '');
|
||||||
|
const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
||||||
|
if (bullet) return `${bullet[1] ?? ''}• ${inline(bullet[2] ?? '')}`;
|
||||||
|
const quote = /^\s*>\s?(.*)$/.exec(line);
|
||||||
|
if (quote) return inline(quote[1] ?? '');
|
||||||
|
if (line.trim().startsWith('|')) return tableRow(inline(line));
|
||||||
|
return inline(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markdown as the chat shows it, as plain text. Code keeps its lines; blank runs shrink to one empty line. */
|
||||||
|
export function markdownToText(markdown: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
let inCode = false;
|
||||||
|
for (const line of markdown.split('\n')) {
|
||||||
|
if (/^\s*(?:```|~~~)/.test(line)) {
|
||||||
|
inCode = !inCode;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCode) {
|
||||||
|
lines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = plainLine(line);
|
||||||
|
if (text !== null) lines.push(text.trimEnd());
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text cut to `max` characters at the last line or word end before it, marked with an ellipsis. */
|
||||||
|
function cut(text: string, max: number): { text: string; truncated: boolean } {
|
||||||
|
if (text.length <= max) return { text, truncated: false };
|
||||||
|
const head = text.slice(0, max - 1);
|
||||||
|
const at = Math.max(head.lastIndexOf('\n'), head.lastIndexOf(' '));
|
||||||
|
// A break far back would drop much of the answer; then the cut goes through the word.
|
||||||
|
const end = at > max * 0.8 ? at : head.length;
|
||||||
|
return { text: `${head.slice(0, end).trimEnd()}…`, truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareText {
|
||||||
|
text: string;
|
||||||
|
truncated: boolean;
|
||||||
|
/** The answer has applied changes, so "Add the changes" is worth offering. */
|
||||||
|
hasChanges: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat message an answer becomes: its text, then, when `includeChanges`
|
||||||
|
* is on, the applied changes in a short list under a heading. Discarded,
|
||||||
|
* failed and expired changes never go along.
|
||||||
|
*/
|
||||||
|
export function chatTextOf(message: AssistantMessage, opts: { includeChanges: boolean; t: Translate }): ShareText {
|
||||||
|
const blocks = messageBlocks(message, false);
|
||||||
|
const body = markdownToText(blocks.flatMap((b) => (b.kind === 'text' ? [b.text] : [])).join('\n\n'));
|
||||||
|
const previews = blocks.flatMap((b) =>
|
||||||
|
b.kind === 'change' ? b.items.flatMap((i) => (i.state === 'applied' && i.preview ? [i.preview] : [])) : []
|
||||||
|
);
|
||||||
|
const lines = previews.map((p) => `✓ ${opts.t(`assistant.change.verb.${p.verbKey}`)}: ${p.subject}`);
|
||||||
|
const heading = opts.t('assistant.share.changesHeading');
|
||||||
|
const changes = opts.includeChanges && lines.length > 0 ? [heading, ...lines].join('\n') : '';
|
||||||
|
const full = [body, changes].filter(Boolean).join('\n\n');
|
||||||
|
return { ...cut(full, SHARE_MAX_CHARS), hasChanges: lines.length > 0 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { AssistantApplied } from '@trek/shared';
|
||||||
|
|
||||||
|
import { showOnMapSearch, showOnMapTarget } from './assistantShowOnMap';
|
||||||
|
|
||||||
|
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
|
||||||
|
toolCallId: 'c1',
|
||||||
|
entity,
|
||||||
|
ids,
|
||||||
|
dayId,
|
||||||
|
undoUntil: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assistantShowOnMap', () => {
|
||||||
|
it('FE-ASSIST-SHOW-001: a place goes to the place, even when it was put on a day', () => {
|
||||||
|
expect(showOnMapTarget(applied('place', [7], 3))).toEqual({ placeId: 7, dayId: null });
|
||||||
|
expect(showOnMapSearch(applied('place', [7], 3))).toBe('?place=7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ASSIST-SHOW-002: a stop or a note goes to its day', () => {
|
||||||
|
expect(showOnMapSearch(applied('assignment', [41], 3))).toBe('?day=3');
|
||||||
|
expect(showOnMapSearch(applied('day_note', [9], 4))).toBe('?day=4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ASSIST-SHOW-003: a change with nowhere to point at opens the planner as it is', () => {
|
||||||
|
expect(showOnMapTarget(applied('packing', [2], null))).toEqual({ placeId: null, dayId: null });
|
||||||
|
expect(showOnMapSearch(applied('place', [], null))).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { AssistantApplied } from '@trek/shared';
|
||||||
|
|
||||||
|
interface ShowOnMapTarget {
|
||||||
|
/** The place the change made or changed. */
|
||||||
|
placeId: number | null;
|
||||||
|
/** The day it lives on, for a note or a stop. */
|
||||||
|
dayId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where "Show on map" takes an applied change: the place when it is one, else its day; nothing for the rest. */
|
||||||
|
export function showOnMapTarget(applied: AssistantApplied): ShowOnMapTarget {
|
||||||
|
const placeId = applied.entity === 'place' ? (applied.ids[0] ?? null) : null;
|
||||||
|
return { placeId, dayId: placeId === null ? applied.dayId : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The planner's query for it: `?place=` opens the place, `?day=` selects the day. */
|
||||||
|
export function showOnMapSearch(applied: AssistantApplied): string {
|
||||||
|
const { placeId, dayId } = showOnMapTarget(applied);
|
||||||
|
if (placeId !== null) return `?place=${placeId}`;
|
||||||
|
if (dayId !== null) return `?day=${dayId}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { AssistantApplied } from '@trek/shared';
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
/** What the parts deep inside a chat need from whoever shows it. */
|
||||||
|
export interface AssistantView {
|
||||||
|
tripId: number;
|
||||||
|
/** 'window' on the desktop, 'fullscreen' on the phone. */
|
||||||
|
variant: 'window' | 'fullscreen';
|
||||||
|
/** How much room the chat has: a narrow window and the phone are 'compact'. */
|
||||||
|
density?: 'compact' | 'roomy';
|
||||||
|
/** Shows an applied change in the planner; the button is left out where nothing can show it. */
|
||||||
|
onShowOnMap?: (applied: AssistantApplied) => void;
|
||||||
|
/** Selects a day in the planner, from its weather; left out where no planner can show it. */
|
||||||
|
onFocusDay?: (dayId: number) => void;
|
||||||
|
/** Brings the map into view where the chat covers it (the phone), before the map moves. */
|
||||||
|
onRevealMap?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssistantViewContext = createContext<AssistantView | null>(null);
|
||||||
|
|
||||||
|
export function useAssistantView(): AssistantView {
|
||||||
|
const view = useContext(AssistantViewContext);
|
||||||
|
if (!view) throw new Error('useAssistantView outside AssistantSurface');
|
||||||
|
return view;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-DAYAFTER-001 to FE-AI-DAYAFTER-009
|
||||||
|
*
|
||||||
|
* A day as it would read once a card's waiting changes ran, without React: who
|
||||||
|
* stays, who joins at the end and who leaves, the way between two stops as a
|
||||||
|
* straight-line estimate, free time and clashes, and where the map looks.
|
||||||
|
*/
|
||||||
|
import type { AssistantChangeEffect } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories';
|
||||||
|
import type { AssignmentsMap } from '../../types';
|
||||||
|
import { buildDayAfter, changedDays, changePoints, type DayChange, minutesOf } from './dayAfterModel';
|
||||||
|
|
||||||
|
// 0.01° of latitude is 1.112 km: with the detour factor that is 19 minutes on foot, 6 by bike.
|
||||||
|
const louvre = buildPlace({ id: 10, name: 'Louvre', lat: 48.0, lng: 2.0, duration_minutes: 60 });
|
||||||
|
const orsay = buildPlace({ id: 11, name: 'Orsay', lat: 48.01, lng: 2.0, duration_minutes: 60 });
|
||||||
|
const hotel = buildPlace({ id: 12, name: 'Hotel', lat: 48.02, lng: 2.0 });
|
||||||
|
|
||||||
|
function plan(): AssignmentsMap {
|
||||||
|
return {
|
||||||
|
3: [
|
||||||
|
buildAssignment({ id: 101, day_id: 3, order_index: 1, place: orsay, assignment_time: '13:00' }),
|
||||||
|
buildAssignment({ id: 100, day_id: 3, order_index: 0, place: louvre, assignment_time: '09:00' }),
|
||||||
|
buildAssignment({ id: 102, day_id: 3, order_index: 2, place: hotel, accommodation_id: 7 }),
|
||||||
|
],
|
||||||
|
4: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function effect(over: Partial<AssistantChangeEffect>): AssistantChangeEffect {
|
||||||
|
return { kind: 'add', dayId: 3, fromDayId: null, placeId: null, assignmentId: null, place: null, ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
const change = (toolCallId: string, e: AssistantChangeEffect, subject: string | null = null): DayChange => ({
|
||||||
|
toolCallId,
|
||||||
|
effect: e,
|
||||||
|
subject,
|
||||||
|
});
|
||||||
|
|
||||||
|
function day(dayId: number, changes: DayChange[], assignments = plan()) {
|
||||||
|
return buildDayAfter({ dayId, assignments, places: [louvre, orsay, hotel], changes, defaultMode: 'walking' });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dayAfterModel', () => {
|
||||||
|
it('FE-AI-DAYAFTER-001: keeps the day in order without hotel stops, and reads the way and free time', () => {
|
||||||
|
const { stops } = day(3, []);
|
||||||
|
expect(stops.map((s) => [s.name, s.status, s.time])).toEqual([
|
||||||
|
['Louvre', 'kept', '09:00'],
|
||||||
|
['Orsay', 'kept', '13:00'],
|
||||||
|
]);
|
||||||
|
expect(stops[0]?.lead).toBeNull();
|
||||||
|
// 09:00 plus an hour, 19 minutes on foot, so 161 minutes are left before 13:00.
|
||||||
|
expect(stops[1]?.lead).toEqual({ travelMinutes: 19, freeMinutes: 161, overlapsWith: null, shortBy: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-DAYAFTER-002: a new place joins at the end, named and placed by the change', () => {
|
||||||
|
const pantheon = effect({ place: { name: 'Pantheon', lat: 48.02, lng: 2.0, categoryId: null } });
|
||||||
|
const { stops } = day(3, [change('c1', pantheon)]);
|
||||||
|
const last = stops[stops.length - 1];
|
||||||
|
expect(last).toMatchObject({ key: 'new:c1', name: 'Pantheon', status: 'new', toolCallId: 'c1', time: null });
|
||||||
|
expect(last?.lead).toEqual({ travelMinutes: 19, freeMinutes: null, overlapsWith: null, shortBy: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-DAYAFTER-003: a planned place joins by its id; a change with nothing to name it by is left out', () => {
|
||||||
|
const { stops } = day(4, [change('c1', effect({ dayId: 4, placeId: 11 })), change('c2', effect({ dayId: 4 }))]);
|
||||||
|
expect(stops.map((s) => s.name)).toEqual(['Orsay']);
|
||||||
|
const named = day(4, [change('c3', effect({ dayId: 4, placeId: 99 }), 'Sainte-Chapelle')]);
|
||||||
|
expect(named.stops.map((s) => s.name)).toEqual(['Sainte-Chapelle']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-DAYAFTER-004: a removed stop stays struck in place; the next one follows the stop before it', () => {
|
||||||
|
const { stops } = day(3, [change('c1', effect({ kind: 'remove', placeId: 11, assignmentId: 101 }))]);
|
||||||
|
expect(stops.map((s) => [s.name, s.status, s.toolCallId])).toEqual([
|
||||||
|
['Louvre', 'kept', null],
|
||||||
|
['Orsay', 'removed', 'c1'],
|
||||||
|
]);
|
||||||
|
expect(stops[1]?.lead).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-DAYAFTER-005: a move leaves one day and joins the other with its own time', () => {
|
||||||
|
const move = change('c1', effect({ kind: 'move', dayId: 4, fromDayId: 3, placeId: 10, assignmentId: 100 }));
|
||||||
|
expect(day(3, [move]).stops.map((s) => [s.name, s.status])).toEqual([
|
||||||
|
['Louvre', 'removed'],
|
||||||
|
['Orsay', 'kept'],
|
||||||
|
]);
|
||||||
|
expect(day(4, [move]).stops).toEqual([
|
||||||
|
expect.objectContaining({ key: 'new:c1', name: 'Louvre', status: 'new', time: '09:00', lead: null }),
|
||||||
|
]);
|
||||||
|
// Within one day the order is the server's to set; the card leaves the day as it is.
|
||||||
|
const within = change('c2', effect({ kind: 'move', dayId: 3, fromDayId: 3, placeId: 10, assignmentId: 100 }));
|
||||||
|
expect(day(3, [within]).stops.map((s) => s.status)).toEqual(['kept', 'kept']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-DAYAFTER-006: stops at the same time clash, a short break is no line, a bike is faster', () => {
|
||||||
|
const tight = plan();
|
||||||
|
tight[3] = [
|
||||||
|
buildAssignment({ id: 100, day_id: 3, order_index: 0, place: louvre, assignment_time: '09:00' }),
|
||||||
|
buildAssignment({ id: 101, day_id: 3, order_index: 1, place: orsay, assignment_time: '09:30' }),
|
||||||
|
];
|
||||||
|
expect(day(3, [], tight).stops[1]?.lead?.overlapsWith).toBe('Louvre');
|
||||||
|
|
||||||
|
const short = plan();
|
||||||
|
short[3] = [
|
||||||
|
buildAssignment({
|
||||||
|
id: 100,
|
||||||
|
day_id: 3,
|
||||||
|
order_index: 0,
|
||||||
|
place: louvre,
|
||||||
|
assignment_time: '09:00',
|
||||||
|
leg_transport_mode: 'cycling',
|
||||||
|
}),
|
||||||
|
buildAssignment({ id: 101, day_id: 3, order_index: 1, place: orsay, assignment_time: '10:30' }),
|
||||||
|
];
|
||||||
|
const lead = day(3, [], short).stops[1]?.lead;
|
||||||
|
expect(lead?.travelMinutes).toBe(6);
|
||||||
|
// 10:00 to 10:30 less the ride leaves 24 minutes, under GAP_MIN_MINUTES.
|
||||||
|
expect(lead?.freeMinutes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-DAYAFTER-007: only a clock time counts as a time', () => {
|
||||||
|
expect(minutesOf('09:30')).toBe(570);
|
||||||
|
expect(minutesOf('7:05 am')).toBe(425);
|
||||||
|
expect(minutesOf('25:00')).toBeNull();
|
||||||
|
expect(minutesOf('noon')).toBeNull();
|
||||||
|
expect(minutesOf(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-DAYAFTER-008: the days follow the changes, and the map looks at the places and their days', () => {
|
||||||
|
const move = change('c1', effect({ kind: 'move', dayId: 4, fromDayId: 3, placeId: 10, assignmentId: 100 }));
|
||||||
|
const pantheon = { name: 'Pantheon', lat: 41.9, lng: 12.48, categoryId: null };
|
||||||
|
const add = change('c2', effect({ dayId: 4, place: pantheon }));
|
||||||
|
expect(changedDays([move, add])).toEqual([4, 3]);
|
||||||
|
expect(changePoints([move, add], plan(), [louvre, orsay, hotel])).toEqual([
|
||||||
|
[48.0, 2.0],
|
||||||
|
[41.9, 12.48],
|
||||||
|
[48.01, 2.0],
|
||||||
|
[48.0, 2.0],
|
||||||
|
[48.02, 2.0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-DAYAFTER-009: a way longer than the time between two stops is too tight by the difference', () => {
|
||||||
|
// Louvre until 10:00, then 19 minutes on foot to Orsay, which starts at 10:10: nine minutes short.
|
||||||
|
const tight = plan();
|
||||||
|
tight[3] = (tight[3] ?? []).map((a) => (a.id === 101 ? { ...a, assignment_time: '10:10' } : a));
|
||||||
|
const after = day(3, [], tight);
|
||||||
|
expect(after.stops[1]?.lead).toMatchObject({ travelMinutes: 19, shortBy: 9 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import type { AssistantChangeEffect } from '@trek/shared';
|
||||||
|
|
||||||
|
import { dayPoints, type LatLng } from '../../store/assistantMapModel';
|
||||||
|
import type { Assignment, AssignmentsMap, Place } from '../../types';
|
||||||
|
import { haversineKm } from '../../utils/geo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day as it would read once the waiting changes of a card ran: the stops it
|
||||||
|
* keeps, the ones that join it and the ones that leave it, each with the way
|
||||||
|
* from the stop before. Pure and React-free, so the change card draws it and a
|
||||||
|
* test can pin it.
|
||||||
|
*
|
||||||
|
* Only what the assistant can do shows up here. It cannot set a time or a
|
||||||
|
* position, so a stop that joins the day lands at its end, the way the server
|
||||||
|
* appends it. The way between two stops is a straight line bent by a detour
|
||||||
|
* factor and read at the leg's speed: a quick estimate, which the card marks
|
||||||
|
* with "≈", and no router request per suggestion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One waiting change, as much of it as the day plan needs. */
|
||||||
|
export interface DayChange {
|
||||||
|
toolCallId: string;
|
||||||
|
effect: AssistantChangeEffect;
|
||||||
|
/** What the card calls it, for a stop the trip does not hold yet. */
|
||||||
|
subject: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StopStatus = 'kept' | 'new' | 'removed';
|
||||||
|
|
||||||
|
/** How a stop follows the one before it on the day. */
|
||||||
|
interface StopLead {
|
||||||
|
/** Estimated minutes on the way there; null without both positions. */
|
||||||
|
travelMinutes: number | null;
|
||||||
|
/** Minutes left free between the two, the way there taken off; null when the times do not say. */
|
||||||
|
freeMinutes: number | null;
|
||||||
|
/** The stop before, when the two take the same time. */
|
||||||
|
overlapsWith: string | null;
|
||||||
|
/** Minutes the way there is longer than the time between the two; null when it fits or the times do not say. */
|
||||||
|
shortBy: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineStop {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
status: StopStatus;
|
||||||
|
/** The change that adds or removes it; null for a stop the card leaves alone. */
|
||||||
|
toolCallId: string | null;
|
||||||
|
/** The planned start as written, "09:30"; null when none is set. */
|
||||||
|
time: string | null;
|
||||||
|
/** Null for the first stop of the day and for one that leaves it. */
|
||||||
|
lead: StopLead | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayAfter {
|
||||||
|
dayId: number;
|
||||||
|
stops: TimelineStop[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free time below this is not worth a line. */
|
||||||
|
const GAP_MIN_MINUTES = 30;
|
||||||
|
/** Roads and paths are longer than the straight line. */
|
||||||
|
const DETOUR_FACTOR = 1.3;
|
||||||
|
|
||||||
|
/** km/h on a leg, by its travel mode. */
|
||||||
|
function speedOf(mode: string): number {
|
||||||
|
if (mode === 'walking') return 4.5;
|
||||||
|
if (mode === 'cycling' || mode === 'bicycle' || mode === 'bike') return 15;
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minutes since midnight of an "HH:MM" value; null for anything else. */
|
||||||
|
export function minutesOf(value: string | null | undefined): number | null {
|
||||||
|
const match = value ? /^(\d{1,2}):(\d{2})/.exec(value) : null;
|
||||||
|
if (!match) return null;
|
||||||
|
const hours = Number(match[1]);
|
||||||
|
const minutes = Number(match[2]);
|
||||||
|
return hours < 24 && minutes < 60 ? hours * 60 + minutes : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
stop: Omit<TimelineStop, 'lead'>;
|
||||||
|
lat: number | null;
|
||||||
|
lng: number | null;
|
||||||
|
start: number | null;
|
||||||
|
end: number | null;
|
||||||
|
legMode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftOf(visit: Assignment, status: StopStatus, toolCallId: string | null, key: string): Draft {
|
||||||
|
const place = visit.place;
|
||||||
|
const time = visit.assignment_time ?? place?.place_time ?? null;
|
||||||
|
const start = minutesOf(time);
|
||||||
|
const explicitEnd = minutesOf(visit.assignment_end_time ?? place?.end_time ?? null);
|
||||||
|
const duration = place?.duration_minutes;
|
||||||
|
const end = explicitEnd ?? (start !== null && typeof duration === 'number' && duration > 0 ? start + duration : null);
|
||||||
|
return {
|
||||||
|
stop: { key, name: place?.name ?? '', status, toolCallId, time: start === null ? null : time },
|
||||||
|
lat: typeof place?.lat === 'number' ? place.lat : null,
|
||||||
|
lng: typeof place?.lng === 'number' ? place.lng : null,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
legMode: visit.leg_transport_mode ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVisit(assignments: AssignmentsMap, dayId: number | null, assignmentId: number | null) {
|
||||||
|
if (dayId === null || assignmentId === null) return undefined;
|
||||||
|
return (assignments[dayId] ?? []).find((v) => v.id === assignmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stop the change brings onto the day; null when there is nothing to name it by. */
|
||||||
|
function joiningDraft(
|
||||||
|
change: DayChange,
|
||||||
|
assignments: AssignmentsMap,
|
||||||
|
places: ReadonlyMap<number, Place>
|
||||||
|
): Draft | null {
|
||||||
|
const { effect, toolCallId } = change;
|
||||||
|
const key = `new:${toolCallId}`;
|
||||||
|
const moved = effect.kind === 'move' ? findVisit(assignments, effect.fromDayId, effect.assignmentId) : undefined;
|
||||||
|
if (moved) return draftOf(moved, 'new', toolCallId, key);
|
||||||
|
const place = effect.placeId === null ? undefined : places.get(effect.placeId);
|
||||||
|
const name = effect.place?.name ?? place?.name ?? change.subject;
|
||||||
|
if (!name) return null;
|
||||||
|
const lat = effect.place?.lat ?? place?.lat ?? null;
|
||||||
|
const lng = effect.place?.lng ?? place?.lng ?? null;
|
||||||
|
return {
|
||||||
|
stop: { key, name, status: 'new', toolCallId, time: null },
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
start: null,
|
||||||
|
end: null,
|
||||||
|
legMode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The visits of the day that a change takes off it, by assignment, with the change that does. */
|
||||||
|
function leavingVisits(dayId: number, changes: readonly DayChange[]): Map<number, string> {
|
||||||
|
const leaving = new Map<number, string>();
|
||||||
|
for (const { effect, toolCallId } of changes) {
|
||||||
|
if (effect.assignmentId === null) continue;
|
||||||
|
const removes = effect.kind === 'remove' && effect.dayId === dayId;
|
||||||
|
const movesAway = effect.kind === 'move' && effect.fromDayId === dayId && effect.dayId !== dayId;
|
||||||
|
if (removes || movesAway) leaving.set(effect.assignmentId, toolCallId);
|
||||||
|
}
|
||||||
|
return leaving;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leadOf(prev: Draft, cur: Draft, defaultMode: string): StopLead {
|
||||||
|
const travelMinutes =
|
||||||
|
prev.lat !== null && prev.lng !== null && cur.lat !== null && cur.lng !== null
|
||||||
|
? Math.round(
|
||||||
|
((haversineKm({ lat: prev.lat, lng: prev.lng }, { lat: cur.lat, lng: cur.lng }) * DETOUR_FACTOR) /
|
||||||
|
speedOf(prev.legMode ?? defaultMode)) *
|
||||||
|
60
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const none = { freeMinutes: null, overlapsWith: null, shortBy: null };
|
||||||
|
if (cur.start === null || prev.end === null) return { travelMinutes, ...none };
|
||||||
|
if (cur.start < prev.end) return { travelMinutes, ...none, overlapsWith: prev.stop.name };
|
||||||
|
const free = cur.start - prev.end - (travelMinutes ?? 0);
|
||||||
|
if (free < 0) return { travelMinutes, ...none, shortBy: -free };
|
||||||
|
return { travelMinutes, ...none, freeMinutes: free >= GAP_MIN_MINUTES ? free : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day after the changes: its own stops in their order (hotel stops left
|
||||||
|
* out, as the day plan hides them), the ones a change takes away marked, the
|
||||||
|
* ones that join it at the end.
|
||||||
|
*/
|
||||||
|
export function buildDayAfter(input: {
|
||||||
|
dayId: number;
|
||||||
|
assignments: AssignmentsMap;
|
||||||
|
places: readonly Place[];
|
||||||
|
changes: readonly DayChange[];
|
||||||
|
defaultMode: string | null | undefined;
|
||||||
|
}): DayAfter {
|
||||||
|
const { dayId, assignments, changes } = input;
|
||||||
|
const places = new Map(input.places.map((p) => [p.id, p]));
|
||||||
|
const leaving = leavingVisits(dayId, changes);
|
||||||
|
const own = [...(assignments[dayId] ?? [])]
|
||||||
|
.filter((v) => !v.accommodation_id)
|
||||||
|
.sort((a, b) => a.order_index - b.order_index);
|
||||||
|
const drafts = own.map((v) => {
|
||||||
|
const by = leaving.get(v.id) ?? null;
|
||||||
|
return draftOf(v, by ? 'removed' : 'kept', by, `a:${v.id}`);
|
||||||
|
});
|
||||||
|
for (const change of changes) {
|
||||||
|
const { effect } = change;
|
||||||
|
if (effect.dayId !== dayId || effect.kind === 'remove' || effect.fromDayId === dayId) continue;
|
||||||
|
const draft = joiningDraft(change, assignments, places);
|
||||||
|
if (draft) drafts.push(draft);
|
||||||
|
}
|
||||||
|
const defaultMode = input.defaultMode ?? 'walking';
|
||||||
|
let prev: Draft | null = null;
|
||||||
|
const stops = drafts.map((d) => {
|
||||||
|
if (d.stop.status === 'removed') return { ...d.stop, lead: null };
|
||||||
|
const lead = prev ? leadOf(prev, d, defaultMode) : null;
|
||||||
|
prev = d;
|
||||||
|
return { ...d.stop, lead };
|
||||||
|
});
|
||||||
|
return { dayId, stops };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the map should look to show a card's changes: each change's own place
|
||||||
|
* (a new one by its coordinates, a planned one by the trip's) and the stops of
|
||||||
|
* the days they touch, so the suggestion shows beside the rest of its day.
|
||||||
|
*/
|
||||||
|
export function changePoints(
|
||||||
|
changes: readonly DayChange[],
|
||||||
|
assignments: AssignmentsMap,
|
||||||
|
places: readonly Place[]
|
||||||
|
): LatLng[] {
|
||||||
|
const byId = new Map(places.map((p) => [p.id, p]));
|
||||||
|
const points: LatLng[] = [];
|
||||||
|
for (const { effect } of changes) {
|
||||||
|
const place = effect.placeId === null ? undefined : byId.get(effect.placeId);
|
||||||
|
const lat = effect.place?.lat ?? place?.lat;
|
||||||
|
const lng = effect.place?.lng ?? place?.lng;
|
||||||
|
if (typeof lat === 'number' && typeof lng === 'number') points.push([lat, lng]);
|
||||||
|
}
|
||||||
|
return [...points, ...dayPoints(assignments, changedDays(changes))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The days a card's changes touch, in the order they come: where each lands, then where a move leaves. */
|
||||||
|
export function changedDays(changes: readonly DayChange[]): number[] {
|
||||||
|
const days: number[] = [];
|
||||||
|
const add = (id: number | null) => {
|
||||||
|
if (id !== null && !days.includes(id)) days.push(id);
|
||||||
|
};
|
||||||
|
for (const { effect } of changes) {
|
||||||
|
add(effect.dayId);
|
||||||
|
add(effect.fromDayId);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-PROVIDER-001 to FE-AI-PROVIDER-004
|
||||||
|
*
|
||||||
|
* Pasting a key picks the provider by its prefix, so the most specific prefix
|
||||||
|
* has to win; pasting the ChatGPT sign-in address submits it, so only that
|
||||||
|
* address may count.
|
||||||
|
*/
|
||||||
|
import { aiConnectionKindSchema } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AI_PROVIDERS, detectKeyKind, isChatgptCallback, providerLabel } from './providerCatalog';
|
||||||
|
|
||||||
|
describe('providerCatalog', () => {
|
||||||
|
it('FE-AI-PROVIDER-001: reads the provider from the key prefix, most specific first', () => {
|
||||||
|
expect(detectKeyKind('sk-ant-api03-abcdef')).toBe('anthropic');
|
||||||
|
expect(detectKeyKind(' sk-or-v1-abcdef\n')).toBe('openrouter');
|
||||||
|
expect(detectKeyKind('AIzaSyAbcdef')).toBe('gemini');
|
||||||
|
expect(detectKeyKind('AQ.Ab8RN6IVSexample')).toBe('gemini');
|
||||||
|
expect(detectKeyKind('sk-proj-abcdef')).toBe('openai');
|
||||||
|
expect(detectKeyKind('sk-abcdef')).toBe('openai');
|
||||||
|
expect(detectKeyKind('ghp_abcdef')).toBeNull();
|
||||||
|
expect(detectKeyKind('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PROVIDER-002: accepts only the loopback sign-in address, any port', () => {
|
||||||
|
expect(isChatgptCallback('http://127.0.0.1:1455/auth/callback?code=abc&state=xyz')).toBe(true);
|
||||||
|
expect(isChatgptCallback(' http://127.0.0.1:1457/auth/callback?code=abc ')).toBe(true);
|
||||||
|
expect(isChatgptCallback('http://localhost:1455/auth/callback?code=abc')).toBe(false);
|
||||||
|
expect(isChatgptCallback('https://127.0.0.1:1455/auth/callback?code=abc')).toBe(false);
|
||||||
|
expect(isChatgptCallback('http://127.0.0.1:1455/other?code=abc')).toBe(false);
|
||||||
|
expect(isChatgptCallback('not a url')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PROVIDER-003: fills {provider} with the brand, or an own server with its host', () => {
|
||||||
|
expect(providerLabel('anthropic', 'api.anthropic.com')).toBe('Anthropic');
|
||||||
|
expect(providerLabel('chatgpt', 'api.openai.com')).toBe('ChatGPT');
|
||||||
|
expect(providerLabel('custom', 'ollama.lan')).toBe('ollama.lan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PROVIDER-004: every connection kind has an entry with a hint', () => {
|
||||||
|
for (const kind of aiConnectionKindSchema.options) {
|
||||||
|
expect(AI_PROVIDERS[kind].kind).toBe(kind);
|
||||||
|
expect(AI_PROVIDERS[kind].hintKey).toMatch(/^assistant\./);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { AiApiKeyKind, AiConnectionKind } from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AI providers as the client names and explains them. Brand names live
|
||||||
|
* here and nowhere in the locales: a translated string takes them through a
|
||||||
|
* `{provider}` placeholder. The tiles show a neutral monogram, never a logo.
|
||||||
|
*
|
||||||
|
* Which model a new connection starts on is the server's choice; the client
|
||||||
|
* only lists what the provider offers once connected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AiProviderInfo {
|
||||||
|
kind: AiConnectionKind;
|
||||||
|
/** The brand name, never translated; null for an own server, which has no brand. */
|
||||||
|
label: string | null;
|
||||||
|
/** The letter on the provider's tile; null for an own server, which shows a server icon. */
|
||||||
|
monogram: string | null;
|
||||||
|
/** Where a key for it is made; null where there is none to copy. */
|
||||||
|
keyUrl: string | null;
|
||||||
|
/** The one-line help under the key field or tile. */
|
||||||
|
hintKey: string;
|
||||||
|
/** The provider offers a free tier worth naming; a key may still be on a paid one. */
|
||||||
|
free: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AI_PROVIDERS: Record<AiConnectionKind, AiProviderInfo> = {
|
||||||
|
anthropic: {
|
||||||
|
kind: 'anthropic',
|
||||||
|
label: 'Anthropic',
|
||||||
|
monogram: 'A',
|
||||||
|
keyUrl: 'https://console.anthropic.com/settings/keys',
|
||||||
|
hintKey: 'assistant.connect.hintAnthropic',
|
||||||
|
free: false,
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
kind: 'openai',
|
||||||
|
label: 'OpenAI',
|
||||||
|
monogram: 'O',
|
||||||
|
keyUrl: 'https://platform.openai.com/api-keys',
|
||||||
|
hintKey: 'assistant.connect.hintOpenai',
|
||||||
|
free: false,
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
kind: 'gemini',
|
||||||
|
label: 'Gemini',
|
||||||
|
monogram: 'G',
|
||||||
|
keyUrl: 'https://aistudio.google.com/app/apikey',
|
||||||
|
hintKey: 'assistant.connect.hintGemini',
|
||||||
|
free: true,
|
||||||
|
},
|
||||||
|
openrouter: {
|
||||||
|
kind: 'openrouter',
|
||||||
|
label: 'OpenRouter',
|
||||||
|
monogram: 'R',
|
||||||
|
keyUrl: 'https://openrouter.ai/keys',
|
||||||
|
hintKey: 'assistant.connect.hintOpenrouter',
|
||||||
|
free: false,
|
||||||
|
},
|
||||||
|
chatgpt: {
|
||||||
|
kind: 'chatgpt',
|
||||||
|
label: 'ChatGPT',
|
||||||
|
monogram: 'C',
|
||||||
|
keyUrl: null,
|
||||||
|
hintKey: 'assistant.connect.chatgptHint',
|
||||||
|
free: false,
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
kind: 'custom',
|
||||||
|
label: null,
|
||||||
|
monogram: null,
|
||||||
|
keyUrl: null,
|
||||||
|
hintKey: 'assistant.admin.customHint',
|
||||||
|
free: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The kinds a pasted key can be, in the order the tiles list them. */
|
||||||
|
export const API_KEY_KINDS: readonly AiApiKeyKind[] = ['anthropic', 'openai', 'gemini', 'openrouter'];
|
||||||
|
|
||||||
|
/** What fills `{provider}`: the brand, or for an own server its host. */
|
||||||
|
export function providerLabel(kind: AiConnectionKind, host: string): string {
|
||||||
|
return AI_PROVIDERS[kind].label ?? host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most specific first: an OpenRouter or Anthropic key also starts with "sk-".
|
||||||
|
const KEY_PREFIXES: readonly [string, AiApiKeyKind][] = [
|
||||||
|
['sk-ant-', 'anthropic'],
|
||||||
|
['sk-or-', 'openrouter'],
|
||||||
|
['AIza', 'gemini'],
|
||||||
|
// Google's newer AI Studio keys.
|
||||||
|
['AQ.', 'gemini'],
|
||||||
|
['sk-', 'openai'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider a pasted key belongs to, read from its prefix; null when it
|
||||||
|
* matches none. Only a convenience: key formats change, so an unknown prefix
|
||||||
|
* asks which provider it is instead of refusing, and saving tests the key.
|
||||||
|
*/
|
||||||
|
export function detectKeyKind(raw: string): AiApiKeyKind | null {
|
||||||
|
const key = raw.trim();
|
||||||
|
return KEY_PREFIXES.find(([prefix]) => key.startsWith(prefix))?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a ChatGPT plan's usage is shown, linked when it ran out. */
|
||||||
|
export const CHATGPT_USAGE_URL = 'https://chatgpt.com/settings/usage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether pasted text is the address ChatGPT's sign-in ended on. It sends the
|
||||||
|
* browser to a loopback address nothing listens on, so the browser shows an
|
||||||
|
* error page and the user pastes its address back; only the port may differ.
|
||||||
|
*/
|
||||||
|
export function isChatgptCallback(text: string): boolean {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(text.trim());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.pathname === '/auth/callback';
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useAssistantStore } from '../../store/assistantStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ways into the assistant from inside a trip: "Ask" about a place, on the
|
||||||
|
* desktop inspector and the phone's place sheet, opens it with the place as
|
||||||
|
* the chat's context chip; the phone's Mehr tile just opens it. Offered only
|
||||||
|
* while the assistant can run for this user, like every other way in.
|
||||||
|
*/
|
||||||
|
export function useAssistantAsk(tripId: number | null) {
|
||||||
|
const available = useAssistantStore((s) => s.status?.available === true);
|
||||||
|
const openWith = useAssistantStore((s) => s.openWith);
|
||||||
|
const open = useAssistantStore((s) => s.open);
|
||||||
|
return {
|
||||||
|
available: available && tripId !== null,
|
||||||
|
askAbout: (placeId: number) => {
|
||||||
|
if (tripId !== null) openWith(tripId, { placeIds: [placeId] });
|
||||||
|
},
|
||||||
|
open,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
||||||
|
import { type DragEvent, useState } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useToast } from '../shared/Toast';
|
||||||
|
|
||||||
|
/** What the booking import reads (the server checks again), and how big one file may be. */
|
||||||
|
const ATTACH_EXTENSIONS: readonly string[] = [
|
||||||
|
'.pdf',
|
||||||
|
'.eml',
|
||||||
|
'.pkpass',
|
||||||
|
'.ics',
|
||||||
|
'.html',
|
||||||
|
'.htm',
|
||||||
|
'.txt',
|
||||||
|
...LLM_PHOTO_EXTENSIONS,
|
||||||
|
];
|
||||||
|
const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
function extensionOf(name: string): string {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
return dot < 0 ? '' : name.slice(dot).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFiles(e: DragEvent): boolean {
|
||||||
|
return Array.from(e.dataTransfer.types).includes('Files');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands a booking file to the chat, from the paperclip, a paste or a drop on
|
||||||
|
* the window. One file at a time, refused here with a toast when it is not
|
||||||
|
* one the import reads or too big, so a wrong file never costs a round trip.
|
||||||
|
* The drop handlers go on the whole chat; `dragging` lights its drop zone.
|
||||||
|
*/
|
||||||
|
export function useAssistantAttach(tripId: number) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const attach = useAssistantStore((s) => s.attach);
|
||||||
|
const busy = useAssistantStore((s) => chatOf(s, tripId).isRunning);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const take = (files: FileList | File[] | null | undefined): boolean => {
|
||||||
|
const file = files ? Array.from(files)[0] : undefined;
|
||||||
|
if (!file) return false;
|
||||||
|
if (busy) return true;
|
||||||
|
if (!ATTACH_EXTENSIONS.includes(extensionOf(file.name))) {
|
||||||
|
toast.warning(t('assistant.attach.unsupported'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_ATTACH_BYTES) {
|
||||||
|
toast.warning(t('assistant.attach.tooLarge'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void attach(tripId, file);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropZone = {
|
||||||
|
onDragEnter: (e: DragEvent) => {
|
||||||
|
if (!hasFiles(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
},
|
||||||
|
onDragOver: (e: DragEvent) => {
|
||||||
|
if (!hasFiles(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
},
|
||||||
|
onDragLeave: (e: DragEvent) => {
|
||||||
|
// Leaving for a child is not leaving the zone.
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||||
|
setDragging(false);
|
||||||
|
},
|
||||||
|
onDrop: (e: DragEvent) => {
|
||||||
|
if (!hasFiles(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
take(e.dataTransfer.files);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { take, busy, dragging, dropZone, accept: ATTACH_EXTENSIONS.join(',') };
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import type { Place } from '../../types';
|
||||||
|
import { dayLabel } from '../../utils/dayLabel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the next question is about: the place the user asked from, or else the
|
||||||
|
* day selected in the planner. The chip above the composer names it, and the
|
||||||
|
* suggestions turn to it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected day becomes the focus by itself, quietly, and follows the
|
||||||
|
* selection. A removed chip stays away until another day is selected, and a
|
||||||
|
* place asked about is never replaced by the day. Run once per chat.
|
||||||
|
*/
|
||||||
|
export function useFollowSelectedDay(tripId: number): void {
|
||||||
|
const setFocus = useAssistantStore((s) => s.setFocus);
|
||||||
|
// The planner's own store holds the trip it shows; for any other trip there is no selection to follow.
|
||||||
|
const here = useTripStore((s) => s.trip?.id === tripId);
|
||||||
|
const selectedDayId = useTripStore((s) => s.selectedDayId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!here) return;
|
||||||
|
const current = chatOf(useAssistantStore.getState(), tripId).focus;
|
||||||
|
if (current?.placeIds?.length) return;
|
||||||
|
if (selectedDayId === null) {
|
||||||
|
if (current) setFocus(tripId, null);
|
||||||
|
} else if (current?.dayId !== selectedDayId) {
|
||||||
|
setFocus(tripId, { dayId: selectedDayId });
|
||||||
|
}
|
||||||
|
}, [here, selectedDayId, tripId, setFocus]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantFocusView {
|
||||||
|
/** The place or day named on the chip; null when nothing is in focus or it is not at hand. */
|
||||||
|
label: string | null;
|
||||||
|
kind: 'place' | 'day' | null;
|
||||||
|
/** The place on a place chip, for its picture. */
|
||||||
|
place: Place | null;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssistantFocus(tripId: number): AssistantFocusView {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const focus = useAssistantStore((s) => chatOf(s, tripId).focus);
|
||||||
|
const setFocus = useAssistantStore((s) => s.setFocus);
|
||||||
|
const here = useTripStore((s) => s.trip?.id === tripId);
|
||||||
|
const days = useTripStore((s) => s.days);
|
||||||
|
const places = useTripStore((s) => s.places);
|
||||||
|
|
||||||
|
const named = useMemo((): { label: string; kind: 'place' | 'day'; place: Place | null } | null => {
|
||||||
|
if (!here || !focus) return null;
|
||||||
|
const placeIds = focus.placeIds ?? [];
|
||||||
|
if (placeIds.length > 0) {
|
||||||
|
const first = places.find((p) => p.id === placeIds[0]);
|
||||||
|
if (!first) return null;
|
||||||
|
const label = placeIds.length > 1 ? `${first.name} +${placeIds.length - 1}` : first.name;
|
||||||
|
return { kind: 'place', label, place: first };
|
||||||
|
}
|
||||||
|
const index = days.findIndex((d) => d.id === focus.dayId);
|
||||||
|
const day = days[index];
|
||||||
|
return day ? { kind: 'day', label: dayLabel(day, index, t, locale), place: null } : null;
|
||||||
|
}, [here, focus, places, days, t, locale]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: named?.label ?? null,
|
||||||
|
kind: named?.kind ?? null,
|
||||||
|
place: named?.place ?? null,
|
||||||
|
clear: () => setFocus(tripId, null),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-HINTS-001 to FE-AI-HINTS-006
|
||||||
|
*
|
||||||
|
* The plan hints as the views get them: worded with the day's name, gated on
|
||||||
|
* the assistant, the setting, the trip in the planner and its phase, with the
|
||||||
|
* stays read only when asked for, and a tap that focuses and asks.
|
||||||
|
*/
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import type { AiStatusResponse } from '@trek/shared';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
|
||||||
|
import { resetAllStores } from '../../../tests/helpers/store';
|
||||||
|
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||||
|
import { useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import type { Assignment, Place } from '../../types';
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
|
vi.mock('../../repo/accommodationRepo', () => ({ accommodationRepo: repo }));
|
||||||
|
|
||||||
|
const { useAssistantHints, useAssistantHintsSetting } = await import('./useAssistantHints');
|
||||||
|
|
||||||
|
const INITIAL_ASSISTANT = useAssistantStore.getState();
|
||||||
|
const TRIP = 5;
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
|
||||||
|
|
||||||
|
function timed(id: number, dayId: number, time: string, end: string): Assignment {
|
||||||
|
const place = buildPlace({ id: 100 + id, name: `Stop ${id}`, place_time: time, end_time: end, lat: 0, lng: 0 });
|
||||||
|
return buildAssignment({ id, day_id: dayId, place_id: place.id, order_index: id, place });
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedTrip() {
|
||||||
|
const days = [
|
||||||
|
buildDay({ id: 1, trip_id: TRIP, date: '2099-06-01', title: 'Louvre day' }),
|
||||||
|
buildDay({ id: 2, trip_id: TRIP, date: '2099-06-02', title: 'Last day' }),
|
||||||
|
];
|
||||||
|
const stops = [timed(1, 1, '10:00', '12:00'), timed(2, 1, '11:00', '12:30')];
|
||||||
|
useTripStore.setState({
|
||||||
|
trip: buildTrip({ id: TRIP, start_date: '2099-06-01', end_date: '2099-06-02' }),
|
||||||
|
days,
|
||||||
|
places: stops.map((a) => a.place as Place),
|
||||||
|
assignments: { '1': stops },
|
||||||
|
reservations: [],
|
||||||
|
dayNotes: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
resetAllStores();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
|
||||||
|
repo.list.mockResolvedValue({ accommodations: [] });
|
||||||
|
seedTrip();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => useAssistantStore.setState(INITIAL_ASSISTANT, true));
|
||||||
|
|
||||||
|
const render = (tripId: number | null = TRIP, withStays = false) =>
|
||||||
|
renderHook(() => useAssistantHints(tripId, { withStays }), { wrapper });
|
||||||
|
|
||||||
|
describe('useAssistantHints', () => {
|
||||||
|
it('FE-AI-HINTS-001: words a hint with the name of its day, and a tap focuses the day and its places and asks', async () => {
|
||||||
|
const setFocus = vi.fn();
|
||||||
|
const send = vi.fn().mockResolvedValue(undefined);
|
||||||
|
useAssistantStore.setState({ setFocus, send });
|
||||||
|
const { result } = render();
|
||||||
|
await waitFor(() => expect(result.current.hints).toHaveLength(1));
|
||||||
|
const [hint] = result.current.hints;
|
||||||
|
expect(hint.text).toBe('Stop 1 and Stop 2 overlap on Louvre day');
|
||||||
|
|
||||||
|
act(() => result.current.ask(hint));
|
||||||
|
expect(setFocus).toHaveBeenCalledWith(TRIP, { dayId: 1, placeIds: [101, 102] });
|
||||||
|
expect(send).toHaveBeenCalledWith(TRIP, 'Stop 1 and Stop 2 overlap on Louvre day. How should we sort that out?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINTS-002: says nothing without the assistant, with hints off, for another trip, or for a trip that is over', () => {
|
||||||
|
useAssistantStore.setState({ status: { available: false } as AiStatusResponse });
|
||||||
|
expect(render().result.current.hints).toEqual([]);
|
||||||
|
|
||||||
|
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
|
||||||
|
useSettingsStore.setState((s) => ({ settings: { ...s.settings, assistant_hints: false } }));
|
||||||
|
expect(render().result.current.hints).toEqual([]);
|
||||||
|
|
||||||
|
useSettingsStore.setState((s) => ({ settings: { ...s.settings, assistant_hints: true } }));
|
||||||
|
expect(render(TRIP + 1).result.current.hints).toEqual([]);
|
||||||
|
expect(render(null).result.current.hints).toEqual([]);
|
||||||
|
expect(render().result.current.hints).toHaveLength(1);
|
||||||
|
|
||||||
|
useTripStore.setState({ trip: buildTrip({ id: TRIP, start_date: '2000-06-01', end_date: '2000-06-02' }) });
|
||||||
|
expect(render().result.current.hints).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINTS-003: a dismissed hint stays away, and the launcher counts unseen warnings until they are seen', async () => {
|
||||||
|
const { result } = render();
|
||||||
|
await waitFor(() => expect(result.current.unseenWarn).toBe(1));
|
||||||
|
act(() => result.current.markWarnsSeen());
|
||||||
|
expect(result.current.unseenWarn).toBe(0);
|
||||||
|
expect(result.current.hints).toHaveLength(1);
|
||||||
|
|
||||||
|
act(() => result.current.dismiss(result.current.hints[0].id));
|
||||||
|
expect(result.current.hints).toEqual([]);
|
||||||
|
expect(render().result.current.hints).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINTS-004: reads the stays only when asked, and then names a night without one', async () => {
|
||||||
|
const { result: quiet } = render(TRIP, false);
|
||||||
|
await waitFor(() => expect(quiet.current.hints).toHaveLength(1));
|
||||||
|
expect(repo.list).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const { result } = render(TRIP, true);
|
||||||
|
await waitFor(() => expect(result.current.hints.map((h) => h.rule)).toEqual(['overlap', 'noStay']));
|
||||||
|
expect(repo.list).toHaveBeenCalledWith(TRIP);
|
||||||
|
expect(result.current.hints[1].text).toBe('Nowhere to stay yet for the night after Louvre day');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-HINTS-005: stays that cannot be read leave the nights alone', async () => {
|
||||||
|
repo.list.mockRejectedValue(new Error('offline and not cached'));
|
||||||
|
const { result } = render(TRIP, true);
|
||||||
|
await waitFor(() => expect(repo.list).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(result.current.hints.map((h) => h.rule)).toEqual(['overlap']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAssistantHintsSetting', () => {
|
||||||
|
it('FE-AI-HINTS-006: is on by default, and the switch saves the opposite', () => {
|
||||||
|
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||||
|
useSettingsStore.setState({ updateSetting });
|
||||||
|
const { result } = renderHook(() => useAssistantHintsSetting(), { wrapper });
|
||||||
|
expect(result.current.on).toBe(true);
|
||||||
|
act(() => result.current.toggle());
|
||||||
|
expect(updateSetting).toHaveBeenCalledWith('assistant_hints', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import type { AssistantFocus } from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { accommodationRepo } from '../../repo/accommodationRepo';
|
||||||
|
import { useAssistantHintsStore } from '../../store/assistantHintsStore';
|
||||||
|
import { useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import type { Accommodation } from '../../types';
|
||||||
|
import { dayLabel } from '../../utils/dayLabel';
|
||||||
|
import { localIsoDate } from '../../utils/localDate';
|
||||||
|
import { useToast } from '../shared/Toast';
|
||||||
|
import { type AssistantHint, type HintRule, tripHints } from './assistantHintRules';
|
||||||
|
import { tripPhase } from './assistantModel';
|
||||||
|
|
||||||
|
export interface ReadyHint {
|
||||||
|
id: string;
|
||||||
|
rule: HintRule;
|
||||||
|
level: AssistantHint['level'];
|
||||||
|
/** The line in the list. */
|
||||||
|
text: string;
|
||||||
|
/** The question a tap sends. */
|
||||||
|
prompt: string;
|
||||||
|
focus: AssistantFocus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantHintsView {
|
||||||
|
/** The hints not dismissed, worded, warnings first. */
|
||||||
|
hints: ReadyHint[];
|
||||||
|
/** Warnings the user has not been shown yet: what the launcher points at. */
|
||||||
|
unseenWarn: number;
|
||||||
|
markSeen: (ids: readonly string[]) => void;
|
||||||
|
/** Counts every current warning as seen, as when the chat is opened. */
|
||||||
|
markWarnsSeen: () => void;
|
||||||
|
dismiss: (id: string) => void;
|
||||||
|
/** Puts the hint's day or places in focus and sends its question. */
|
||||||
|
ask: (hint: ReadyHint) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trip's stays, which the planner keeps to itself, read through the
|
||||||
|
* offline core for the night rule. Read again when the days or bookings
|
||||||
|
* change, since a hotel booking moves its stay. Null while not known, and
|
||||||
|
* when they cannot be read, which keeps that rule quiet.
|
||||||
|
*/
|
||||||
|
function useTripStays(tripId: number | null, days: unknown, reservations: unknown): Accommodation[] | null {
|
||||||
|
const [stays, setStays] = useState<{ tripId: number; list: Accommodation[] } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (tripId === null) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const read = async () => {
|
||||||
|
try {
|
||||||
|
const { accommodations } = await accommodationRepo.list(tripId);
|
||||||
|
if (!cancelled) setStays({ tripId, list: accommodations });
|
||||||
|
} catch {
|
||||||
|
// Stays that cannot be read are not known: no word about nights rather than a wrong one.
|
||||||
|
if (!cancelled) setStays(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void read();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [tripId, days, reservations]);
|
||||||
|
return stays && stays.tripId === tripId ? stays.list : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The planner's data for this trip, or null when the planner holds another trip or none. */
|
||||||
|
function useTripPlan(tripId: number | null) {
|
||||||
|
return useTripStore(
|
||||||
|
useShallow((s) =>
|
||||||
|
s.trip && s.trip.id === tripId
|
||||||
|
? {
|
||||||
|
trip: s.trip,
|
||||||
|
days: s.days,
|
||||||
|
places: s.places,
|
||||||
|
assignments: s.assignments,
|
||||||
|
reservations: s.reservations,
|
||||||
|
dayNotes: s.dayNotes,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan hints for a trip, worded and ready, with what the user did with
|
||||||
|
* them. Nothing while the assistant cannot run, the user turned hints off,
|
||||||
|
* the planner does not hold this trip, or the trip is over or still empty.
|
||||||
|
* `withStays` reads the stays as well, for the night rule; only the empty
|
||||||
|
* chat asks for that, so the launcher and the phone's tile cost no request.
|
||||||
|
*/
|
||||||
|
export function useAssistantHints(tripId: number | null, { withStays }: { withStays: boolean }): AssistantHintsView {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const available = useAssistantStore((s) => s.status?.available === true);
|
||||||
|
const setFocus = useAssistantStore((s) => s.setFocus);
|
||||||
|
const send = useAssistantStore((s) => s.send);
|
||||||
|
const on = useSettingsStore((s) => s.settings.assistant_hints !== false);
|
||||||
|
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
|
||||||
|
const is12h = useSettingsStore((s) => s.settings.time_format === '12h');
|
||||||
|
const plan = useTripPlan(tripId);
|
||||||
|
const today = localIsoDate();
|
||||||
|
|
||||||
|
const phase = plan
|
||||||
|
? tripPhase({
|
||||||
|
startDate: plan.trip.start_date ?? null,
|
||||||
|
endDate: plan.trip.end_date ?? null,
|
||||||
|
hasPlaces: plan.places.length > 0,
|
||||||
|
today,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const active = available && on && (phase === 'upcoming' || phase === 'running');
|
||||||
|
const stays = useTripStays(withStays && active ? tripId : null, plan?.days, plan?.reservations);
|
||||||
|
|
||||||
|
const marks = useAssistantHintsStore((s) => (tripId === null ? undefined : s.byTrip[tripId]));
|
||||||
|
const load = useAssistantHintsStore((s) => s.load);
|
||||||
|
const storeDismiss = useAssistantHintsStore((s) => s.dismiss);
|
||||||
|
const storeMarkSeen = useAssistantHintsStore((s) => s.markSeen);
|
||||||
|
useEffect(() => {
|
||||||
|
if (tripId !== null) load(tripId);
|
||||||
|
}, [tripId, load]);
|
||||||
|
|
||||||
|
const raw = useMemo(
|
||||||
|
() =>
|
||||||
|
active && plan
|
||||||
|
? tripHints({ today, ...plan, accommodations: withStays ? stays : null, distanceUnit, is12h })
|
||||||
|
: [],
|
||||||
|
[active, plan, today, withStays, stays, distanceUnit, is12h]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Until the marks are read, a dismissed hint would flash back for a frame: show none instead.
|
||||||
|
const hints = useMemo((): ReadyHint[] => {
|
||||||
|
if (!marks || !plan) return [];
|
||||||
|
return raw
|
||||||
|
.filter((h) => !marks.dismissed.includes(h.id))
|
||||||
|
.map((h) => {
|
||||||
|
const index = h.dayId === null ? -1 : plan.days.findIndex((d) => d.id === h.dayId);
|
||||||
|
const params = index < 0 ? h.params : { ...h.params, day: dayLabel(plan.days[index], index, t, locale) };
|
||||||
|
return {
|
||||||
|
id: h.id,
|
||||||
|
rule: h.rule,
|
||||||
|
level: h.level,
|
||||||
|
text: t(h.textKey, params),
|
||||||
|
prompt: t(h.promptKey, params),
|
||||||
|
focus: h.focus,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [raw, marks, plan, t, locale]);
|
||||||
|
|
||||||
|
const unseen = useMemo(
|
||||||
|
() => (marks ? hints.filter((h) => h.level === 'warn' && !marks.seen.includes(h.id)).map((h) => h.id) : []),
|
||||||
|
[hints, marks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const markSeen = useCallback(
|
||||||
|
(ids: readonly string[]) => {
|
||||||
|
if (tripId !== null && ids.length > 0) storeMarkSeen(tripId, ids);
|
||||||
|
},
|
||||||
|
[tripId, storeMarkSeen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hints,
|
||||||
|
unseenWarn: unseen.length,
|
||||||
|
markSeen,
|
||||||
|
markWarnsSeen: () => markSeen(unseen),
|
||||||
|
dismiss: (id) => {
|
||||||
|
if (tripId !== null) storeDismiss(tripId, id);
|
||||||
|
},
|
||||||
|
ask: (hint) => {
|
||||||
|
if (tripId === null) return;
|
||||||
|
// A hint about the trip as a whole leaves the focus the user had.
|
||||||
|
if (hint.focus) setFocus(tripId, hint.focus);
|
||||||
|
void send(tripId, hint.prompt);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The menu switch for the hints, in the window's menu and the phone's. On unless turned off. */
|
||||||
|
export function useAssistantHintsSetting(): { on: boolean; toggle: () => void } {
|
||||||
|
const on = useSettingsStore((s) => s.settings.assistant_hints !== false);
|
||||||
|
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||||
|
const toast = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return {
|
||||||
|
on,
|
||||||
|
toggle: () => {
|
||||||
|
updateSetting('assistant_hints', !on).catch((e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : t('common.error'))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { isApplePlatform } from '../../utils/applePlatform';
|
||||||
|
|
||||||
|
/** Whether a key press is the assistant's shortcut: ⌘K on a Mac, Ctrl+K elsewhere, either works on both. */
|
||||||
|
export function isAssistantHotkey(e: KeyboardEvent): boolean {
|
||||||
|
return (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⌘K / Ctrl+K opens or closes the assistant. A key press something else
|
||||||
|
* already took is left alone, and so is one while a dialog is open: the
|
||||||
|
* shortcut must not pull a window up behind a question being asked.
|
||||||
|
*/
|
||||||
|
export function useAssistantHotkey(enabled: boolean, onToggle: () => void): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.defaultPrevented || !isAssistantHotkey(e)) return;
|
||||||
|
if (document.querySelector('.trek-modal-backdrop')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onToggle();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [enabled, onToggle]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shortcut as the keys a tooltip shows. */
|
||||||
|
export function useAssistantShortcutKeys(): string[] {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return isApplePlatform() ? ['⌘', 'K'] : [t('assistant.launcher.ctrlKey'), 'K'];
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user