Files
TREK/client/src/components/Assistant/useAssistantHints.ts
T
Maurice b64da26651 feat(assistant): trip assistant chat with your own AI connection
Adds an in-app assistant that reads and plans the open trip. Users connect
their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or
OpenRouter key) or use the one the admin set up, including a custom
OpenAI-compatible server. It is an addon and only shows while a connection
is usable.

The chat runs on the existing MCP tools, pinned to the trip. Every change is
held as a change card the user applies or discards, and applied changes can
be undone. On the desktop it is a floating window that can be moved and
resized; on the phone it is fullscreen. Search results show as place cards
that can go onto a day, weather lookups as a strip, proposed places as
ghost markers on the map with a before/after switch, and an answer can be
shared into the trip chat. Booking files can be dropped in and are read
into change cards.
2026-10-22 21:30:12 +00:00

196 lines
7.1 KiB
TypeScript

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'))
);
},
};
}