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:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user