mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(assistant): one AI tile for assistant, parsing and MCP, a planner-aware chat
The three AI addons share one tile in the integration column; a dialog holds the connection, the assistant's limits and AI parsing, which now reads with the assistant's connection unless an install keeps its own. Existing parsing configs stay where they are and keep winning. The chat sees the map on screen, offers next questions under an answer, shows its live step in the window head, previews waiting changes inside the day list, checks whether a day still works, folds a multi-day plan into a week grid, keeps per-trip notes and can estimate costs. An empty or cut-off answer gets one push instead of ending silently, and the assistant's own step and size limits are raised.
This commit is contained in:
@@ -2,6 +2,7 @@ import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import { useAssistantMarkerClasses } from './assistantCamera';
|
||||
import { AssistantCameraController } from './AssistantCameraController';
|
||||
import { AssistantGhostLayer } from './AssistantGhostLayer';
|
||||
import { useReportViewLeaflet } from './assistantViewport';
|
||||
import type { AssistantMapProps } from './mapViewContract';
|
||||
|
||||
interface AssistantMapLeafletProps {
|
||||
@@ -15,10 +16,12 @@ interface AssistantMapLeafletProps {
|
||||
/**
|
||||
* Everything the assistant does on the Leaflet map, mounted inside the map
|
||||
* container as one child, so MapView only hands its props on: the ghosts,
|
||||
* the camera, and the pop and pulse on the real pins.
|
||||
* the camera, the pop and pulse on the real pins, and the part of the map
|
||||
* the assistant is told is on screen.
|
||||
*/
|
||||
export function AssistantMapLeaflet({ assistant, padding, markerEl }: AssistantMapLeafletProps) {
|
||||
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
|
||||
useReportViewLeaflet();
|
||||
return (
|
||||
<>
|
||||
<AssistantGhostLayer
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AssistantMapView } from '@trek/shared';
|
||||
import { useEffect } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
|
||||
/**
|
||||
* The part of the planner's map on screen, told to the assistant after every
|
||||
* move, so a question about "here" needs no place named. Both renderers report
|
||||
* through this module; the assistant sends it with the next question only for
|
||||
* the trip it was taken on.
|
||||
*/
|
||||
|
||||
interface Edges {
|
||||
south: number;
|
||||
west: number;
|
||||
north: number;
|
||||
east: number;
|
||||
}
|
||||
|
||||
const clamp = (value: number, limit: number) => Math.max(-limit, Math.min(limit, value));
|
||||
const round = (value: number) => Math.round(value * 1e4) / 1e4;
|
||||
|
||||
/** The edges as the contract takes them: inside the globe (a wrapped map runs past ±180) and to about ten metres. */
|
||||
export function mapViewOf(edges: Edges): AssistantMapView {
|
||||
return {
|
||||
south: round(clamp(edges.south, 90)),
|
||||
west: round(clamp(edges.west, 180)),
|
||||
north: round(clamp(edges.north, 90)),
|
||||
east: round(clamp(edges.east, 180)),
|
||||
};
|
||||
}
|
||||
|
||||
function report(tripId: number | null, edges: Edges | null): void {
|
||||
const set = useAssistantStore.getState().setMapView;
|
||||
// A map without a size yet (hidden, or still laying out) has no edges to speak of.
|
||||
const usable = edges && Object.values(edges).every(Number.isFinite) && edges.north > edges.south;
|
||||
set(tripId !== null && usable ? { tripId, view: mapViewOf(edges) } : null);
|
||||
}
|
||||
|
||||
/** Leaflet: read from inside the map container, after each move and once on mount. */
|
||||
export function useReportViewLeaflet(): void {
|
||||
const map = useMap();
|
||||
const tripId = useTripStore((s) => s.trip?.id ?? null);
|
||||
useEffect(() => {
|
||||
const read = () => {
|
||||
const b = map.getBounds();
|
||||
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
|
||||
};
|
||||
read();
|
||||
map.on('moveend', read);
|
||||
return () => {
|
||||
map.off('moveend', read);
|
||||
report(null, null);
|
||||
};
|
||||
}, [map, tripId]);
|
||||
}
|
||||
|
||||
/** What the GL map offers for it; mapbox-gl and maplibre-gl alike. */
|
||||
interface ViewMap {
|
||||
getBounds: () => {
|
||||
getSouth: () => number;
|
||||
getWest: () => number;
|
||||
getNorth: () => number;
|
||||
getEast: () => number;
|
||||
} | null;
|
||||
on: (type: 'moveend', listener: () => void) => unknown;
|
||||
off: (type: 'moveend', listener: () => void) => unknown;
|
||||
}
|
||||
|
||||
/** GL: the same, once the map is ready. */
|
||||
export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady: boolean): void {
|
||||
const tripId = useTripStore((s) => s.trip?.id ?? null);
|
||||
useEffect(() => {
|
||||
const map = mapRef.current as Partial<ViewMap> | null;
|
||||
if (!mapReady || !map?.on || !map.off || !map.getBounds) return;
|
||||
const { on, off, getBounds } = { on: map.on.bind(map), off: map.off.bind(map), getBounds: map.getBounds.bind(map) };
|
||||
const read = () => {
|
||||
const b = getBounds();
|
||||
if (typeof b?.getSouth !== 'function') return;
|
||||
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
|
||||
};
|
||||
read();
|
||||
on('moveend', read);
|
||||
return () => {
|
||||
off('moveend', read);
|
||||
report(null, null);
|
||||
};
|
||||
}, [mapRef, mapReady, tripId]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import { useAssistantMarkerClasses } from './assistantCamera';
|
||||
import { PLACE_POP_CLASS, PLACE_PULSE_CLASS } from './assistantGhostMarker';
|
||||
import { useReportViewGL } from './assistantViewport';
|
||||
import type { AssistantMapProps } from './mapViewContract';
|
||||
import { useAssistantCameraGL, type CameraEngine, type CameraMap } from './useAssistantCameraGL';
|
||||
import { useAssistantGhostMarkersGL, type GhostMap } from './useAssistantGhostMarkersGL';
|
||||
@@ -65,4 +66,5 @@ export function useAssistantMapGL<Pin extends PinGL>({
|
||||
useAssistantCameraGL({ mapRef, mapReady, gl, camera: assistant.assistantCamera, padding });
|
||||
const markerEl = useCallback((id: number) => markersRef.current.get(id)?.el ?? null, [markersRef]);
|
||||
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
|
||||
useReportViewGL(mapRef, mapReady);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user