From f63af36451e07822076cf63d6262ed4f6c8eaccf Mon Sep 17 00:00:00 2001 From: Tony James Date: Wed, 20 May 2026 12:14:15 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20variant=20strip?= =?UTF-8?q?,=20while-here,=20charger=20swap=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three big "options" UX wins from Direction B: 1. Route variants (Fastest / Scenic / Cheapest) - Grok prompt now returns a top-level variants[] summary with drive/charge/cost/distance/pros for each variant, plus a selectedVariant indicating which one the stops reflect. - VariantStrip renders under the top bar with selected-state styling, tone-coloured highlight (red/green/blue) on the most relevant stat, and 3-5 pros pills. - Clicking a variant fires /api/chat with selectedVariant= so Grok re-plans with that variant's bias. A "switching" state disables the strip while the request is in flight. - The chat route accepts selectedVariant ('fast'|'scenic'|'cheap') and the GrokHeadlessClient threads it through both the local CLI and xAI API paths. 2. While here (food / do / see / shop / rest) - Every Supercharger, destination-charger and hotel stop now returns a nearby[] array with category/icon/name/detail. - Expanded stop card has tabs (All / Food / Do / See) — tabs auto-hide when no items in that category. Two-column grid of named places with walk-time + rating, e.g. "M&S Foodhall · 1 min walk · 4.3★ · sandwiches". 3. Charger swap block - Every charging stop now returns chargerOptions[] with the current pick + 1-3 alternatives at the same location, each with network (Tesla/Ionity/Allego/Fastned/BP Pulse), stalls, kW, pricePerKwh, detourMin and an optional badge (Faster/Cheaper/ More stalls/Newer). - ChargerSwapBlock shows the current charger as a red-tinted header row that expands to reveal alternatives with stats and a Use button per row. Renamed the existing AlternativeStop UI label from "alternative(s)" to "location alternative(s)" so it's clear when the user is swapping the stop *location* vs swapping the *charger at the same location*. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/src/pages/TeslaTripPlanner.tsx | 402 +++++++++++++++++++++- server/routes/chat.ts | 13 +- server/services/llm/GrokHeadlessClient.ts | 123 ++++++- 3 files changed, 506 insertions(+), 32 deletions(-) diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index e41b5a6..16a1775 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -6,7 +6,7 @@ import { Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X, Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2, Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera, - ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, + ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro, } from 'lucide-react'; // Fix Leaflet default icons (we still need pins for non-active stops) @@ -20,6 +20,37 @@ L.Icon.Default.mergeOptions({ // ─── Types ─────────────────────────────────────────────────────────────────── type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel'; +interface NearbyPlace { + category: 'food' | 'do' | 'see' | 'shop' | 'rest'; + icon: string; + name: string; + detail: string; +} + +interface ChargerOption { + id: string; + name: string; + network?: string; + stalls: number; + kw: number; + pricePerKwh: number; + detourMin: number; + isCurrent?: boolean; + badge?: string | null; +} + +interface RouteVariant { + id: 'fast' | 'scenic' | 'cheap'; + label: string; + tone: 'primary' | 'green' | 'blue'; + distanceKm: number; + driveHours: number; + chargeHours: number; + costEur: number; + highlight?: 'drive' | 'cost' | 'pretty'; + pros: string[]; +} + interface AlternativeStop { id: string; name: string; @@ -56,6 +87,8 @@ interface Stop { priceLevel?: number; notes?: string; alternatives?: AlternativeStop[]; + nearby?: NearbyPlace[]; + chargerOptions?: ChargerOption[]; } interface Itinerary { @@ -196,6 +229,31 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { }) .filter((a: AlternativeStop | null): a is AlternativeStop => a !== null); + const cleanNearby: NearbyPlace[] = Array.isArray(s.nearby) + ? s.nearby.filter((n: any) => n && typeof n.name === 'string').map((n: any) => ({ + category: ['food', 'do', 'see', 'shop', 'rest'].includes(n.category) ? n.category : 'food', + icon: typeof n.icon === 'string' ? n.icon : 'coffee', + name: n.name, + detail: typeof n.detail === 'string' ? n.detail : '', + })) + : []; + + const cleanChargers: ChargerOption[] = Array.isArray(s.chargerOptions) + ? s.chargerOptions + .filter((c: any) => c && typeof c.name === 'string') + .map((c: any): ChargerOption => ({ + id: c.id || `charger-${Date.now()}-${Math.random()}`, + name: c.name, + network: typeof c.network === 'string' ? c.network : undefined, + stalls: typeof c.stalls === 'number' ? c.stalls : 0, + kw: typeof c.kw === 'number' ? c.kw : 0, + pricePerKwh: typeof c.pricePerKwh === 'number' ? c.pricePerKwh : 0, + detourMin: typeof c.detourMin === 'number' ? c.detourMin : 0, + isCurrent: c.isCurrent === true, + badge: typeof c.badge === 'string' ? c.badge : null, + })) + : []; + const shared = { estArrivalBattery: s.estArrivalBattery, chargeMinutes: s.chargeMinutes, @@ -207,6 +265,8 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined, notes: s.notes, alternatives: cleanAlts.length > 0 ? cleanAlts : undefined, + nearby: cleanNearby.length > 0 ? cleanNearby : undefined, + chargerOptions: cleanChargers.length > 0 ? cleanChargers : undefined, }; const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom'; @@ -247,6 +307,23 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise { }; } +function normalizeVariants(raw: any): RouteVariant[] { + if (!Array.isArray(raw)) return []; + return raw + .filter((v: any) => v && typeof v.id === 'string') + .map((v: any): RouteVariant => ({ + id: v.id === 'scenic' || v.id === 'cheap' ? v.id : 'fast', + label: typeof v.label === 'string' ? v.label : v.id, + tone: v.tone === 'green' || v.tone === 'blue' ? v.tone : 'primary', + distanceKm: typeof v.distanceKm === 'number' ? v.distanceKm : 0, + driveHours: typeof v.driveHours === 'number' ? v.driveHours : 0, + chargeHours: typeof v.chargeHours === 'number' ? v.chargeHours : 0, + costEur: typeof v.costEur === 'number' ? v.costEur : 0, + highlight: ['drive', 'cost', 'pretty'].includes(v.highlight) ? v.highlight : undefined, + pros: Array.isArray(v.pros) ? v.pros.filter((p: unknown) => typeof p === 'string') : [], + })); +} + // ─── Routing helpers ───────────────────────────────────────────────────────── function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { const R = 6371; @@ -353,6 +430,103 @@ function LegRow({ leg }: { leg: Leg | undefined }) { ); } +// ─── Variant strip ─────────────────────────────────────────────────────────── +const VARIANT_TONE: Record = { + primary: 'var(--gd-red)', + green: 'var(--gd-green)', + blue: 'var(--gd-blue)', +}; + +const VARIANT_ICON: Record> = { + fast: Gauge, + scenic: TreePine, + cheap: Euro, +}; + +function VariantStrip({ + variants, selected, onSelect, switching, +}: { + variants: RouteVariant[]; + selected: string; + onSelect: (id: RouteVariant['id']) => void; + switching: boolean; +}) { + if (variants.length === 0) return null; + return ( +
+ {variants.map(v => { + const isSel = v.id === selected; + const tone = VARIANT_TONE[v.tone]; + const Icon = VARIANT_ICON[v.id]; + return ( + + ); + })} +
+ ); +} + +function VStat({ label, value, highlight, tone }: { label: string; value: string; highlight?: boolean; tone?: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + // ─── Day header (sticky) ───────────────────────────────────────────────────── function DayHeader({ dayNumber, title, distanceKm, driveMin, chargeMin, dateLabel }: { dayNumber: number; @@ -418,7 +592,116 @@ function AlternativeRow({ alt, onSwap }: { alt: AlternativeStop; onSwap: () => v ); } -// ─── Expanded stop body (charger swap, stats, alts, actions) ───────────────── +// ─── Expanded stop body (stats, charger swap, while-here, alts, actions) ───── +const NEARBY_TABS: { id: 'all' | NearbyPlace['category']; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'food', label: 'Food' }, + { id: 'do', label: 'Do' }, + { id: 'see', label: 'See' }, +]; + +function NearbyGrid({ items }: { items: NearbyPlace[] }) { + return ( +
+ {items.map((n, i) => ( +
+
+ {AMENITY_ICONS[n.icon] || '•'} +
+
+
{n.name}
+
{n.detail}
+
+
+ ))} +
+ ); +} + +function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPick: (c: ChargerOption) => void }) { + const [open, setOpen] = React.useState(false); + if (options.length === 0) return null; + const current = options.find(o => o.isCurrent) || options[0]; + const others = options.filter(o => o.id !== current.id); + return ( +
+ + {open && others.length > 0 && ( +
+ {others.map(o => ( +
e.stopPropagation()} + className="p-2.5 rounded-md flex items-center gap-2.5 transition hover:bg-white/[0.04]" + > +
+ +
+
+
+ {o.name} + {o.badge && ( + + {o.badge} + + )} +
+
+ {o.stalls} stalls · {o.kw} kW · €{o.pricePerKwh.toFixed(2)}/kWh + {o.detourMin > 0 ? ` · +${o.detourMin} min` : ''} + {o.network && o.network !== 'Tesla' ? ` · ${o.network}` : ''} +
+
+ +
+ ))} +
+ )} +
+ ); +} + function StopExpansion({ stop, onSwap, onRemove }: { stop: Stop; onSwap: (alt: AlternativeStop) => void; @@ -431,6 +714,11 @@ function StopExpansion({ stop, onSwap, onRemove }: { const cost = arrive != null && charge != null ? (Math.max(20, 80 - arrive) * 0.35).toFixed(2) : null; const amenities = (stop.amenities || []).slice(0, 8); const alts = stop.alternatives || []; + const chargers = stop.chargerOptions || []; + const nearby = stop.nearby || []; + + const [nearbyTab, setNearbyTab] = React.useState<'all' | NearbyPlace['category']>('all'); + const filteredNearby = nearbyTab === 'all' ? nearby : nearby.filter(n => n.category === nearbyTab); return (
@@ -447,6 +735,52 @@ function StopExpansion({ stop, onSwap, onRemove }: {
)} + {isCharge && chargers.length > 0 && ( +
+ Charger + toast.success(`Picked ${c.name}`, { description: `${c.kw} kW · €${c.pricePerKwh.toFixed(2)}/kWh` })} + /> +
+ )} + + {nearby.length > 0 && ( +
+
+ While here +
+ {NEARBY_TABS.map(t => { + const isSel = nearbyTab === t.id; + const exists = t.id === 'all' || nearby.some(n => n.category === t.id); + if (!exists) return null; + return ( + + ); + })} +
+
+ {filteredNearby.length > 0 ? ( + + ) : ( +
+ Nothing in this category nearby. +
+ )} +
+ )} + {amenities.length > 0 && (
Amenities @@ -467,7 +801,7 @@ function StopExpansion({ stop, onSwap, onRemove }: { {alts.length > 0 && (
- {alts.length} alternative{alts.length === 1 ? '' : 's'} + {alts.length} location alternative{alts.length === 1 ? '' : 's'}
{alts.map(alt => ( onSwap(alt)} /> @@ -805,6 +1139,9 @@ export default function TeslaTripPlanner() { const [hoverStopId, setHoverStopId] = useState(null); const [origin, setOrigin] = useState(''); const [destination, setDestination] = useState(''); + const [variants, setVariants] = useState([]); + const [selectedVariant, setSelectedVariant] = useState('fast'); + const [variantSwitching, setVariantSwitching] = useState(false); React.useEffect(() => { fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {}); @@ -850,13 +1187,17 @@ export default function TeslaTripPlanner() { return { totalKm: km, driveMinutes: min }; }, [legs]); - const sendMessage = async (text: string) => { + const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean } = {}) => { const trimmed = text.trim(); if (!trimmed) return; - setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]); - setChatInput(''); - setChips(prev => [...prev, trimmed].slice(-6)); - setThinking(true); + const variantToUse = opts.variant ?? selectedVariant; + if (!opts.silent) { + setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]); + setChatInput(''); + setChips(prev => [...prev, trimmed].slice(-6)); + } + if (opts.variant) setVariantSwitching(true); + else setThinking(true); try { const response = await fetch('/api/chat', { method: 'POST', @@ -866,26 +1207,52 @@ export default function TeslaTripPlanner() { vehicle: { name: vehicle.name, rangeKm: vehicle.rangeKm }, itinerary, history: messages.map(m => ({ role: m.role, content: m.content })), + selectedVariant: variantToUse, }), }); if (!response.ok) throw new Error('Failed to get response from server'); const data = await response.json(); - setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: data.reply || 'No response.' }]); + if (!opts.silent) { + setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: data.reply || 'No response.' }]); + } if (data.itinerary) { const clean = await normalizeAndSanitizeItinerary(data.itinerary); setItinerary(clean); - toast.success('Grok updated your route', { - description: `${clean.days.length} day(s) · ${clean.summary.superchargers} chargers · ${clean.summary.hotels} overnight`, - }); + } + if (Array.isArray(data.variants)) { + setVariants(normalizeVariants(data.variants)); + } + if (typeof data.selectedVariant === 'string') { + setSelectedVariant(data.selectedVariant as RouteVariant['id']); + } else if (opts.variant) { + setSelectedVariant(opts.variant); + } + if (data.itinerary && !opts.silent) { + toast.success('Grok updated your route'); + } else if (opts.variant) { + toast.success(`Switched to ${opts.variant} route`); } } catch (err: any) { console.error('[TeslaTrip] Grok call failed:', err); - setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]); + if (!opts.silent) { + setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]); + } } finally { setThinking(false); + setVariantSwitching(false); } }; + const switchVariant = (variantId: RouteVariant['id']) => { + if (variantId === selectedVariant || variantSwitching || thinking) return; + const lastUserMsg = [...messages].reverse().find(m => m.role === 'user'); + if (!lastUserMsg) { + toast.info('Send a trip prompt first'); + return; + } + sendMessage(`Replan the trip as the ${variantId} variant`, { variant: variantId, silent: true }); + }; + const removeStop = (stopId: string) => { const next = structuredClone(itinerary); next.days.forEach(d => { d.stops = d.stops.filter(s => s.id !== stopId); }); @@ -944,6 +1311,15 @@ export default function TeslaTripPlanner() { grokStatus={grokStatus} /> + {variants.length > 0 && ( + + )} + {/* Body: map left, rail right */}
{/* Map */} diff --git a/server/routes/chat.ts b/server/routes/chat.ts index cb6c599..b764cac 100644 --- a/server/routes/chat.ts +++ b/server/routes/chat.ts @@ -12,6 +12,7 @@ const ChatRequestSchema = z.object({ vehicle: z.object({ name: z.string(), rangeKm: z.number() }), itinerary: z.any().optional(), history: z.array(z.object({ role: z.enum(['user', 'assistant']), content: z.string() })).optional(), + selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(), }); router.post('/chat', async (req, res) => { @@ -27,7 +28,7 @@ router.post('/chat', async (req, res) => { return res.status(400).json({ error: 'Invalid request' }); } - const { message, vehicle, itinerary, history = [] } = parsed.data; + const { message, vehicle, itinerary, history = [], selectedVariant = 'fast' } = parsed.data; log.info({ requestId, @@ -35,13 +36,15 @@ router.post('/chat', async (req, res) => { vehicle: vehicle.name, historyLength: history.length, currentItineraryDays: itinerary?.days?.length || 0, + selectedVariant, }, 'Parsed chat request'); // Call Grok (this will produce very detailed logs inside GrokHeadlessClient) const result = await grok.chat( [...history, { role: 'user' as const, content: message }], itinerary, - vehicle + vehicle, + selectedVariant, ); const duration = Date.now() - start; @@ -50,6 +53,12 @@ router.post('/chat', async (req, res) => { if (result.updatedItinerary) { payload.itinerary = result.updatedItinerary; } + if (result.variants && Array.isArray(result.variants)) { + payload.variants = result.variants; + } + if (result.selectedVariant) { + payload.selectedVariant = result.selectedVariant; + } log.info({ requestId, diff --git a/server/services/llm/GrokHeadlessClient.ts b/server/services/llm/GrokHeadlessClient.ts index d608eb5..8a03990 100644 --- a/server/services/llm/GrokHeadlessClient.ts +++ b/server/services/llm/GrokHeadlessClient.ts @@ -15,7 +15,7 @@ const log = createLogger('grok-headless'); const SENTINEL = 'ITINERARY_UPDATE:'; export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; } -export interface GrokResponse { text: string; updatedItinerary?: any; } +export interface GrokResponse { text: string; updatedItinerary?: any; variants?: any[]; selectedVariant?: string; } export type VehicleInput = string | { name: string; rangeKm?: number }; function vehicleName(v: VehicleInput): string { @@ -59,9 +59,18 @@ export class GrokHeadlessClient { } } - private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput) { + private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast') { + const variantBrief = { + fast: 'Fastest — minimise drive time. Pick the most direct route via motorways. Sleep in the car or at a budget hotel with destination charging. Optimise for arriving sooner, not for sightseeing.', + scenic: 'Scenic — pick the prettiest practical route even if it adds time. Favour scenic A-roads, viewpoints, charming towns, regional food. Stay at a hotel (not car-sleep). Add an extra hour or two for memorable stops.', + cheap: 'Cheapest — minimise cost. Avoid toll roads where possible, prefer off-peak charging, pick budget overnight options (car sleep or basic hotels), and choose cheaper chargers when available. Drive time can be a bit longer to save €.', + }[selectedVariant] || 'Fastest — minimise drive time.'; + return `You are Grok Drive, an expert Tesla road trip planner for the UK and Europe. You build practical, enjoyable itineraries — not just a list of charging stops. Treat every break as a chance to eat, rest, sightsee, or sleep. +Selected route variant: ${selectedVariant.toUpperCase()} +${variantBrief} + Current vehicle: ${vehicleName(vehicle)} Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)} @@ -110,6 +119,27 @@ Respond with **only** a single valid JSON object in exactly this format. No text "deltaMin": 9, "reason": "Short reason this is a worthwhile alternative (e.g. 'Cheaper and faster but no restaurant on site')" } + ], + "nearby": [ + { + "category": "food" | "do" | "see" | "shop" | "rest", + "icon": "coffee" | "restaurant" | "fast-food" | "shopping" | "supermarket" | "viewpoint" | "museum" | "park" | "beach" | "playground" | "toilets" | "wifi", + "name": "Boulangerie Pâtisserie L. Marc", + "detail": "3 min walk · 4.7★ · open until 19:00" + } + ], + "chargerOptions": [ + { + "id": "unique-charger-id", + "name": "Aire de Beaune Supercharger", + "network": "Tesla" | "Ionity" | "Allego" | "TotalEnergies" | "Fastned" | "BP Pulse" | "Other", + "stalls": 12, + "kw": 250, + "pricePerKwh": 0.42, + "detourMin": 0, + "isCurrent": true, + "badge": "Current" | "Faster" | "Cheaper" | "Newer" | "More stalls" | null + } ] } ] @@ -123,7 +153,42 @@ Respond with **only** a single valid JSON object in exactly this format. No text "hotels": 1, "highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"] } - } + }, + "variants": [ + { + "id": "fast", + "label": "Fastest", + "tone": "primary", + "distanceKm": 2074, + "driveHours": 23.5, + "chargeHours": 4.5, + "costEur": 312, + "highlight": "drive" | "cost" | "pretty", + "pros": ["8 stops", "Sleep in car · Reims", "1 night", "A26 corridor"] + }, + { + "id": "scenic", + "label": "Scenic", + "tone": "green", + "distanceKm": 2218, + "driveHours": 26.2, + "chargeHours": 4.8, + "costEur": 328, + "highlight": "pretty", + "pros": ["Via Burgundy + Pyrénées", "Hotel night · Avignon", "10 stops", "+2h 42m"] + }, + { + "id": "cheap", + "label": "Cheapest", + "tone": "blue", + "distanceKm": 2098, + "driveHours": 24.0, + "chargeHours": 5.2, + "costEur": 270, + "highlight": "cost", + "pros": ["Avoids tolls", "Off-peak charging", "€42 cheaper"] + } + ] } Strict route planning rules: @@ -136,6 +201,29 @@ Strict route planning rules: - "message" should feel like a helpful human assistant. - If no clear trip is requested yet, set "itinerary" to null. +Route variants (REQUIRED): +- "variants" must always contain exactly 3 entries with ids "fast", "scenic", "cheap" in that order. +- Each variant is a *summary only* — drive/charge/cost/pros — describing what the route would look like if the user picked that variant. The actual stops in "itinerary" reflect the currently-selected variant: "${selectedVariant}". +- "distanceKm" (number, km), "driveHours" (number, decimal hours, e.g. 23.5), "chargeHours" (number, decimal hours), "costEur" (number, € for tolls + charging combined). +- "pros" is 3-5 short pills (max ~30 chars each) that describe the unique selling points of that variant relative to the others (e.g. "Avoids tolls", "Sleep in car · Reims", "+2h 42m drive"). +- "highlight" picks the stat to colour-highlight: "drive" for fastest, "pretty" for scenic, "cost" for cheapest. +- The 3 variants must be genuinely different (different stops, different days, different totals). Don't just shuffle the same route. + +Nearby (REQUIRED for every Supercharger, destination-charger and hotel stop): +- Populate "nearby" with 3-6 places within walking distance of the stop. +- Categories: "food" (restaurants/cafes/bakeries), "do" (walks, things to do), "see" (sights/viewpoints/museums), "shop" (supermarkets, retail), "rest" (toilets, lounges). +- "detail" should include walk time and a quick descriptor or rating (e.g. "3 min walk · 4.5★ · paella", "8 min · UNESCO ruins"). +- "icon" should be one of the amenity tokens (coffee, restaurant, fast-food, shopping, supermarket, viewpoint, museum, park, beach, playground, toilets, wifi). +- These are real places at or near the stop — pick named establishments where possible. + +Charger options (REQUIRED for every Supercharger and destination-charger stop): +- "chargerOptions" must list 1-4 real charging operators in the immediate area of this stop. The current pick is duplicated as the first entry with isCurrent: true. +- "network" must be the real charging network (Tesla / Ionity / Allego / TotalEnergies / Fastned / BP Pulse / Other). +- "stalls" is the total number of charging stalls at that location, "kw" is the max charging power, "pricePerKwh" is the public €/kWh price. +- "detourMin" is the extra drive time vs the currently-chosen charger (0 for the current pick). +- "badge" can be "Faster" (higher kW), "Cheaper" (lower €/kWh), "Newer", "More stalls", or null. Pick one based on the trade-off vs the current pick. +- This lets the user swap to a faster but pricier Ionity, or a cheaper Allego, etc. + Alternatives (REQUIRED for every Supercharger and hotel stop): - For each Supercharger or hotel stop, populate "alternatives" with 1-3 realistic swap options the driver might prefer. - Each alternative is a fully-formed stop the user could swap to: complete lat/lng, type, name, description. @@ -166,21 +254,21 @@ ${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')} Respond with ONLY the JSON object.`; } - async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput): Promise { + async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast'): Promise { const requestId = crypto.randomUUID().slice(0, 8); - log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length }, '=== NEW CHAT REQUEST ==='); + log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length, selectedVariant }, '=== NEW CHAT REQUEST ==='); const activeProvider = await this.getActiveProvider(requestId); if (activeProvider === 'xai') { - return this.callXaiApi(messages, itinerary, vehicle, requestId); + return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant); } if (activeProvider === 'fallback') { return this.dumbFallback(messages, requestId); } // LOCAL PERSONAL GROK CLI - const prompt = this.buildPrompt(messages, itinerary, vehicle); + const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant); const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-')); const disallowed = env.nodeEnv === 'development' @@ -224,14 +312,14 @@ Respond with ONLY the JSON object.`; const data = JSON.parse(result) as { text?: string }; const rawText = data.text || ''; - const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText); - log.info({ requestId, hasItinerary: !!parsed }, 'Local Grok CLI returned JSON response'); - return { text: cleanText, updatedItinerary: parsed }; + const { text: cleanText, itinerary: parsed, variants } = this.parseGrokResponse(rawText); + log.info({ requestId, hasItinerary: !!parsed, variantCount: variants?.length || 0 }, 'Local Grok CLI returned JSON response'); + return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant }; } catch (err) { log.error({ requestId, err: String(err) }, 'Local authenticated Grok CLI failed — falling back to xAI API'); if (env.xaiApiKey) { - return this.callXaiApi(messages, itinerary, vehicle, requestId); + return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant); } return this.dumbFallback(messages, requestId); } finally { @@ -239,8 +327,8 @@ Respond with ONLY the JSON object.`; } } - private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string): Promise { - const prompt = this.buildPrompt(messages, itinerary, vehicle); + private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast'): Promise { + const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant); log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)'); try { @@ -266,15 +354,15 @@ Respond with ONLY the JSON object.`; const data = (await response.json()) as { choices?: { message?: { content?: string } }[] }; const rawText = data.choices?.[0]?.message?.content || ''; - const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText); - return { text: cleanText, updatedItinerary: parsed }; + const { text: cleanText, itinerary: parsed, variants } = this.parseGrokResponse(rawText); + return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant }; } catch (err) { log.error({ requestId, err }, 'xAI API call failed'); return this.dumbFallback(messages, requestId); } } - private parseGrokResponse(rawText: string): { text: string; itinerary: any | null } { + private parseGrokResponse(rawText: string): { text: string; itinerary: any | null; variants?: any[] } { try { const cleaned = rawText.trim().replace(/^```json\s*/, '').replace(/```$/, '').trim(); const parsed = JSON.parse(cleaned); @@ -283,6 +371,7 @@ Respond with ONLY the JSON object.`; return { text: parsed.message || parsed.reply || '', itinerary: parsed.itinerary || null, + variants: Array.isArray(parsed.variants) ? parsed.variants : undefined, }; } } catch (e) { @@ -293,7 +382,7 @@ Respond with ONLY the JSON object.`; return this.extractItineraryUpdate(rawText); } - private extractItineraryUpdate(text: string): { text: string; itinerary: any | null } { + private extractItineraryUpdate(text: string): { text: string; itinerary: any | null; variants?: any[] } { const upperText = text.toUpperCase(); const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase()); if (sentinelIndex === -1) return { text: text.trim(), itinerary: null };