feat: Phase 2 — variant strip, while-here, charger swap block

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=<id> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 12:14:15 +01:00
parent ece882ea29
commit f63af36451
3 changed files with 506 additions and 32 deletions
+384 -8
View File
@@ -6,7 +6,7 @@ import {
Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X, Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X,
Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2, Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2,
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera, 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'; } from 'lucide-react';
// Fix Leaflet default icons (we still need pins for non-active stops) // Fix Leaflet default icons (we still need pins for non-active stops)
@@ -20,6 +20,37 @@ L.Icon.Default.mergeOptions({
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel'; 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 { interface AlternativeStop {
id: string; id: string;
name: string; name: string;
@@ -56,6 +87,8 @@ interface Stop {
priceLevel?: number; priceLevel?: number;
notes?: string; notes?: string;
alternatives?: AlternativeStop[]; alternatives?: AlternativeStop[];
nearby?: NearbyPlace[];
chargerOptions?: ChargerOption[];
} }
interface Itinerary { interface Itinerary {
@@ -196,6 +229,31 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
}) })
.filter((a: AlternativeStop | null): a is AlternativeStop => a !== null); .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 = { const shared = {
estArrivalBattery: s.estArrivalBattery, estArrivalBattery: s.estArrivalBattery,
chargeMinutes: s.chargeMinutes, chargeMinutes: s.chargeMinutes,
@@ -207,6 +265,8 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined, priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined,
notes: s.notes, notes: s.notes,
alternatives: cleanAlts.length > 0 ? cleanAlts : undefined, 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'; const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
@@ -247,6 +307,23 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
}; };
} }
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 ───────────────────────────────────────────────────────── // ─── Routing helpers ─────────────────────────────────────────────────────────
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371; const R = 6371;
@@ -353,6 +430,103 @@ function LegRow({ leg }: { leg: Leg | undefined }) {
); );
} }
// ─── Variant strip ───────────────────────────────────────────────────────────
const VARIANT_TONE: Record<RouteVariant['tone'], string> = {
primary: 'var(--gd-red)',
green: 'var(--gd-green)',
blue: 'var(--gd-blue)',
};
const VARIANT_ICON: Record<RouteVariant['id'], React.ComponentType<{ className?: string; size?: number }>> = {
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 (
<div
className="px-6 py-3.5 grid grid-cols-3 gap-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--gd-border)', background: 'var(--gd-bg)' }}
>
{variants.map(v => {
const isSel = v.id === selected;
const tone = VARIANT_TONE[v.tone];
const Icon = VARIANT_ICON[v.id];
return (
<button
key={v.id}
onClick={() => onSelect(v.id)}
disabled={switching}
className="relative text-left p-3.5 rounded-[14px] overflow-hidden transition disabled:opacity-50 disabled:cursor-wait"
style={{
background: isSel ? `color-mix(in srgb, ${tone} 6%, transparent)` : 'var(--gd-panel)',
border: `1px solid ${isSel ? tone : 'var(--gd-border)'}`,
cursor: switching ? 'wait' : 'pointer',
}}
>
{isSel && (
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: tone }} />
)}
<div className="flex items-center gap-2 mb-3">
<div className="text-[14px] font-semibold" style={{ letterSpacing: '-0.01em' }}>{v.label}</div>
{isSel && (
<span
className="text-[9px] font-semibold tracking-wider px-1.5 py-0.5 rounded-full"
style={{ border: `1px solid ${tone}`, color: tone }}
>
SELECTED
</span>
)}
<div className="flex-1" />
<Icon size={16} style={{ color: tone }} />
</div>
<div className="flex gap-4 mb-3">
<VStat label="Drive" value={formatDuration(v.driveHours * 60)} highlight={v.highlight === 'drive'} tone={tone} />
<VStat label="Charge" value={formatDuration(v.chargeHours * 60)} />
<VStat label="Cost" value={`${Math.round(v.costEur)}`} highlight={v.highlight === 'cost'} tone={tone} />
<VStat label="Distance" value={formatKm(v.distanceKm)} />
</div>
<div className="flex flex-wrap gap-1">
{v.pros.map(p => (
<span
key={p}
className="text-[10.5px] px-2 py-0.5 rounded-full"
style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--gd-text-2)' }}
>
{p}
</span>
))}
</div>
</button>
);
})}
</div>
);
}
function VStat({ label, value, highlight, tone }: { label: string; value: string; highlight?: boolean; tone?: string }) {
return (
<div className="min-w-0">
<div className="text-[9.5px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>{label}</div>
<div
className="text-[15px] font-medium num mt-px whitespace-nowrap"
style={{ color: highlight ? tone : 'var(--gd-text)' }}
>
{value}
</div>
</div>
);
}
// ─── Day header (sticky) ───────────────────────────────────────────────────── // ─── Day header (sticky) ─────────────────────────────────────────────────────
function DayHeader({ dayNumber, title, distanceKm, driveMin, chargeMin, dateLabel }: { function DayHeader({ dayNumber, title, distanceKm, driveMin, chargeMin, dateLabel }: {
dayNumber: number; 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 (
<div className="grid grid-cols-2 gap-1.5">
{items.map((n, i) => (
<div
key={i}
className="p-2 rounded-lg flex items-center gap-2"
style={{ background: 'var(--gd-bg)', border: '1px solid var(--gd-border)' }}
>
<div
className="w-6 h-6 rounded grid place-items-center flex-shrink-0 text-[13px] leading-none"
style={{ background: 'var(--gd-panel-2)' }}
>
{AMENITY_ICONS[n.icon] || '•'}
</div>
<div className="min-w-0 flex-1">
<div className="text-[11.5px] font-medium truncate">{n.name}</div>
<div className="text-[10px] truncate" style={{ color: 'var(--gd-text-3)' }}>{n.detail}</div>
</div>
</div>
))}
</div>
);
}
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 (
<div>
<button
onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
className="w-full p-2.5 rounded-lg flex items-center gap-2.5 text-left transition"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)' }}
>
<div
className="w-7 h-7 rounded-md grid place-items-center flex-shrink-0"
style={{ background: 'rgba(74,222,128,0.18)' }}
>
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--gd-green)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium truncate">{current.name}</div>
<div className="text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
{current.stalls} stalls · {current.kw} kW · {current.pricePerKwh.toFixed(2)}/kWh
{current.network && current.network !== 'Tesla' ? ` · ${current.network}` : ''}
</div>
</div>
<div className="text-[11px] font-medium flex items-center gap-1 flex-shrink-0" style={{ color: 'var(--gd-red)' }}>
{others.length > 0 && `${others.length} alts`}
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</div>
</button>
{open && others.length > 0 && (
<div className="mt-1.5 p-1.5 rounded-lg space-y-0.5" style={{ background: 'var(--gd-bg)', border: '1px solid var(--gd-border)' }}>
{others.map(o => (
<div
key={o.id}
onClick={(e) => e.stopPropagation()}
className="p-2.5 rounded-md flex items-center gap-2.5 transition hover:bg-white/[0.04]"
>
<div
className="w-6 h-6 rounded grid place-items-center flex-shrink-0"
style={{ background: 'rgba(255,255,255,0.04)' }}
>
<Zap className="w-3 h-3" style={{ color: 'var(--gd-text-2)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium flex items-center gap-1.5">
<span className="truncate">{o.name}</span>
{o.badge && (
<span
className="text-[9px] px-1.5 py-px rounded font-semibold whitespace-nowrap"
style={{ color: 'var(--gd-amber)', border: '1px solid rgba(251,191,36,0.4)' }}
>
{o.badge}
</span>
)}
</div>
<div className="text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
{o.stalls} stalls · {o.kw} kW · {o.pricePerKwh.toFixed(2)}/kWh
{o.detourMin > 0 ? ` · +${o.detourMin} min` : ''}
{o.network && o.network !== 'Tesla' ? ` · ${o.network}` : ''}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); onPick(o); }}
className="h-6 px-2.5 rounded-md text-[10.5px] flex-shrink-0"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
Use
</button>
</div>
))}
</div>
)}
</div>
);
}
function StopExpansion({ stop, onSwap, onRemove }: { function StopExpansion({ stop, onSwap, onRemove }: {
stop: Stop; stop: Stop;
onSwap: (alt: AlternativeStop) => void; 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 cost = arrive != null && charge != null ? (Math.max(20, 80 - arrive) * 0.35).toFixed(2) : null;
const amenities = (stop.amenities || []).slice(0, 8); const amenities = (stop.amenities || []).slice(0, 8);
const alts = stop.alternatives || []; 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 ( return (
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--gd-border)' }}> <div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--gd-border)' }}>
@@ -447,6 +735,52 @@ function StopExpansion({ stop, onSwap, onRemove }: {
</div> </div>
)} )}
{isCharge && chargers.length > 0 && (
<div className="mb-3.5">
<SectionLabel>Charger</SectionLabel>
<ChargerSwapBlock
options={chargers}
onPick={(c) => toast.success(`Picked ${c.name}`, { description: `${c.kw} kW · €${c.pricePerKwh.toFixed(2)}/kWh` })}
/>
</div>
)}
{nearby.length > 0 && (
<div className="mb-3">
<div className="flex items-center justify-between mb-2">
<SectionLabel>While here</SectionLabel>
<div className="flex gap-1">
{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 (
<button
key={t.id}
onClick={(e) => { e.stopPropagation(); setNearbyTab(t.id); }}
className="h-5.5 px-2 rounded-md text-[10.5px] transition"
style={{
background: isSel ? 'var(--gd-red-soft)' : 'transparent',
color: isSel ? 'var(--gd-red)' : 'var(--gd-text-3)',
fontWeight: isSel ? 600 : 400,
}}
>
{t.label}
</button>
);
})}
</div>
</div>
{filteredNearby.length > 0 ? (
<NearbyGrid items={filteredNearby.slice(0, 6)} />
) : (
<div className="text-[11px] italic px-2 py-2" style={{ color: 'var(--gd-text-3)' }}>
Nothing in this category nearby.
</div>
)}
</div>
)}
{amenities.length > 0 && ( {amenities.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<SectionLabel>Amenities</SectionLabel> <SectionLabel>Amenities</SectionLabel>
@@ -467,7 +801,7 @@ function StopExpansion({ stop, onSwap, onRemove }: {
{alts.length > 0 && ( {alts.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<SectionLabel>{alts.length} alternative{alts.length === 1 ? '' : 's'}</SectionLabel> <SectionLabel>{alts.length} location alternative{alts.length === 1 ? '' : 's'}</SectionLabel>
<div className="space-y-1.5"> <div className="space-y-1.5">
{alts.map(alt => ( {alts.map(alt => (
<AlternativeRow key={alt.id} alt={alt} onSwap={() => onSwap(alt)} /> <AlternativeRow key={alt.id} alt={alt} onSwap={() => onSwap(alt)} />
@@ -805,6 +1139,9 @@ export default function TeslaTripPlanner() {
const [hoverStopId, setHoverStopId] = useState<string | null>(null); const [hoverStopId, setHoverStopId] = useState<string | null>(null);
const [origin, setOrigin] = useState(''); const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState(''); const [destination, setDestination] = useState('');
const [variants, setVariants] = useState<RouteVariant[]>([]);
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
const [variantSwitching, setVariantSwitching] = useState(false);
React.useEffect(() => { React.useEffect(() => {
fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {}); fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {});
@@ -850,13 +1187,17 @@ export default function TeslaTripPlanner() {
return { totalKm: km, driveMinutes: min }; return { totalKm: km, driveMinutes: min };
}, [legs]); }, [legs]);
const sendMessage = async (text: string) => { const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean } = {}) => {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed) return; if (!trimmed) return;
const variantToUse = opts.variant ?? selectedVariant;
if (!opts.silent) {
setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]); setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]);
setChatInput(''); setChatInput('');
setChips(prev => [...prev, trimmed].slice(-6)); setChips(prev => [...prev, trimmed].slice(-6));
setThinking(true); }
if (opts.variant) setVariantSwitching(true);
else setThinking(true);
try { try {
const response = await fetch('/api/chat', { const response = await fetch('/api/chat', {
method: 'POST', method: 'POST',
@@ -866,26 +1207,52 @@ export default function TeslaTripPlanner() {
vehicle: { name: vehicle.name, rangeKm: vehicle.rangeKm }, vehicle: { name: vehicle.name, rangeKm: vehicle.rangeKm },
itinerary, itinerary,
history: messages.map(m => ({ role: m.role, content: m.content })), history: messages.map(m => ({ role: m.role, content: m.content })),
selectedVariant: variantToUse,
}), }),
}); });
if (!response.ok) throw new Error('Failed to get response from server'); if (!response.ok) throw new Error('Failed to get response from server');
const data = await response.json(); const data = await response.json();
if (!opts.silent) {
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: data.reply || 'No response.' }]); setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: data.reply || 'No response.' }]);
}
if (data.itinerary) { if (data.itinerary) {
const clean = await normalizeAndSanitizeItinerary(data.itinerary); const clean = await normalizeAndSanitizeItinerary(data.itinerary);
setItinerary(clean); 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) { } catch (err: any) {
console.error('[TeslaTrip] Grok call failed:', err); console.error('[TeslaTrip] Grok call failed:', err);
if (!opts.silent) {
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]); setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]);
}
} finally { } finally {
setThinking(false); 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 removeStop = (stopId: string) => {
const next = structuredClone(itinerary); const next = structuredClone(itinerary);
next.days.forEach(d => { d.stops = d.stops.filter(s => s.id !== stopId); }); next.days.forEach(d => { d.stops = d.stops.filter(s => s.id !== stopId); });
@@ -944,6 +1311,15 @@ export default function TeslaTripPlanner() {
grokStatus={grokStatus} grokStatus={grokStatus}
/> />
{variants.length > 0 && (
<VariantStrip
variants={variants}
selected={selectedVariant}
onSelect={switchVariant}
switching={variantSwitching}
/>
)}
{/* Body: map left, rail right */} {/* Body: map left, rail right */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Map */} {/* Map */}
+11 -2
View File
@@ -12,6 +12,7 @@ const ChatRequestSchema = z.object({
vehicle: z.object({ name: z.string(), rangeKm: z.number() }), vehicle: z.object({ name: z.string(), rangeKm: z.number() }),
itinerary: z.any().optional(), itinerary: z.any().optional(),
history: z.array(z.object({ role: z.enum(['user', 'assistant']), content: z.string() })).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) => { router.post('/chat', async (req, res) => {
@@ -27,7 +28,7 @@ router.post('/chat', async (req, res) => {
return res.status(400).json({ error: 'Invalid request' }); 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({ log.info({
requestId, requestId,
@@ -35,13 +36,15 @@ router.post('/chat', async (req, res) => {
vehicle: vehicle.name, vehicle: vehicle.name,
historyLength: history.length, historyLength: history.length,
currentItineraryDays: itinerary?.days?.length || 0, currentItineraryDays: itinerary?.days?.length || 0,
selectedVariant,
}, 'Parsed chat request'); }, 'Parsed chat request');
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient) // Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
const result = await grok.chat( const result = await grok.chat(
[...history, { role: 'user' as const, content: message }], [...history, { role: 'user' as const, content: message }],
itinerary, itinerary,
vehicle vehicle,
selectedVariant,
); );
const duration = Date.now() - start; const duration = Date.now() - start;
@@ -50,6 +53,12 @@ router.post('/chat', async (req, res) => {
if (result.updatedItinerary) { if (result.updatedItinerary) {
payload.itinerary = 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({ log.info({
requestId, requestId,
+105 -16
View File
@@ -15,7 +15,7 @@ const log = createLogger('grok-headless');
const SENTINEL = 'ITINERARY_UPDATE:'; const SENTINEL = 'ITINERARY_UPDATE:';
export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; } 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 }; export type VehicleInput = string | { name: string; rangeKm?: number };
function vehicleName(v: VehicleInput): string { 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. 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 vehicle: ${vehicleName(vehicle)}
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)} 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, "deltaMin": 9,
"reason": "Short reason this is a worthwhile alternative (e.g. 'Cheaper and faster but no restaurant on site')" "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, "hotels": 1,
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"] "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: Strict route planning rules:
@@ -136,6 +201,29 @@ Strict route planning rules:
- "message" should feel like a helpful human assistant. - "message" should feel like a helpful human assistant.
- If no clear trip is requested yet, set "itinerary" to null. - 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): 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. - 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. - 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.`; Respond with ONLY the JSON object.`;
} }
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput): Promise<GrokResponse> { async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast'): Promise<GrokResponse> {
const requestId = crypto.randomUUID().slice(0, 8); 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); const activeProvider = await this.getActiveProvider(requestId);
if (activeProvider === 'xai') { if (activeProvider === 'xai') {
return this.callXaiApi(messages, itinerary, vehicle, requestId); return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant);
} }
if (activeProvider === 'fallback') { if (activeProvider === 'fallback') {
return this.dumbFallback(messages, requestId); return this.dumbFallback(messages, requestId);
} }
// LOCAL PERSONAL GROK CLI // 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 tmp = await mkdtemp(join(tmpdir(), 'grok-eu-'));
const disallowed = env.nodeEnv === 'development' const disallowed = env.nodeEnv === 'development'
@@ -224,14 +312,14 @@ Respond with ONLY the JSON object.`;
const data = JSON.parse(result) as { text?: string }; const data = JSON.parse(result) as { text?: string };
const rawText = data.text || ''; const rawText = data.text || '';
const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText); const { text: cleanText, itinerary: parsed, variants } = this.parseGrokResponse(rawText);
log.info({ requestId, hasItinerary: !!parsed }, 'Local Grok CLI returned JSON response'); log.info({ requestId, hasItinerary: !!parsed, variantCount: variants?.length || 0 }, 'Local Grok CLI returned JSON response');
return { text: cleanText, updatedItinerary: parsed }; return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant };
} catch (err) { } catch (err) {
log.error({ requestId, err: String(err) }, 'Local authenticated Grok CLI failed — falling back to xAI API'); log.error({ requestId, err: String(err) }, 'Local authenticated Grok CLI failed — falling back to xAI API');
if (env.xaiApiKey) { if (env.xaiApiKey) {
return this.callXaiApi(messages, itinerary, vehicle, requestId); return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant);
} }
return this.dumbFallback(messages, requestId); return this.dumbFallback(messages, requestId);
} finally { } finally {
@@ -239,8 +327,8 @@ Respond with ONLY the JSON object.`;
} }
} }
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string): Promise<GrokResponse> { private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast'): Promise<GrokResponse> {
const prompt = this.buildPrompt(messages, itinerary, vehicle); 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)'); log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)');
try { try {
@@ -266,15 +354,15 @@ Respond with ONLY the JSON object.`;
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] }; const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const rawText = data.choices?.[0]?.message?.content || ''; const rawText = data.choices?.[0]?.message?.content || '';
const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText); const { text: cleanText, itinerary: parsed, variants } = this.parseGrokResponse(rawText);
return { text: cleanText, updatedItinerary: parsed }; return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant };
} catch (err) { } catch (err) {
log.error({ requestId, err }, 'xAI API call failed'); log.error({ requestId, err }, 'xAI API call failed');
return this.dumbFallback(messages, requestId); 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 { try {
const cleaned = rawText.trim().replace(/^```json\s*/, '').replace(/```$/, '').trim(); const cleaned = rawText.trim().replace(/^```json\s*/, '').replace(/```$/, '').trim();
const parsed = JSON.parse(cleaned); const parsed = JSON.parse(cleaned);
@@ -283,6 +371,7 @@ Respond with ONLY the JSON object.`;
return { return {
text: parsed.message || parsed.reply || '', text: parsed.message || parsed.reply || '',
itinerary: parsed.itinerary || null, itinerary: parsed.itinerary || null,
variants: Array.isArray(parsed.variants) ? parsed.variants : undefined,
}; };
} }
} catch (e) { } catch (e) {
@@ -293,7 +382,7 @@ Respond with ONLY the JSON object.`;
return this.extractItineraryUpdate(rawText); 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 upperText = text.toUpperCase();
const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase()); const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase());
if (sentinelIndex === -1) return { text: text.trim(), itinerary: null }; if (sentinelIndex === -1) return { text: text.trim(), itinerary: null };