feat: faster variant switching, live Grok thoughts, accurate trip endpoints

- Bump z-index on vehicle selector and modals (z-50 sat below Leaflet panes)
- Prefetch the other route variants in the background as soon as the first
  trip lands; switching now hits the cache and is near-instant
- Surface Grok's streaming thoughts to the UI: glassy overlay on the empty
  map + sidebar callout, with skeleton shimmer until the first thought
- Thread explicit origin/destination from the TopBar through to the prompt
  as a ground-truth block; harden rules so the first/last stops match the
  user's actual endpoints and cross-Channel trips include both sides
This commit is contained in:
2026-05-31 17:14:04 +01:00
parent ed64712525
commit 88fc86dc32
3 changed files with 226 additions and 51 deletions
+162 -18
View File
@@ -566,6 +566,16 @@ function LegRow({ leg }: { leg: Leg | undefined }) {
); );
} }
// ─── Skeleton shimmer ────────────────────────────────────────────────────────
function SkeletonRow({ widthPct = 100 }: { widthPct?: number }) {
return (
<div
className="h-2.5 rounded animate-pulse"
style={{ width: `${widthPct}%`, background: 'linear-gradient(90deg, var(--gd-border) 0%, var(--gd-border-2) 50%, var(--gd-border) 100%)' }}
/>
);
}
// ─── Variant strip ─────────────────────────────────────────────────────────── // ─── Variant strip ───────────────────────────────────────────────────────────
const VARIANT_TONE: Record<RouteVariant['tone'], string> = { const VARIANT_TONE: Record<RouteVariant['tone'], string> = {
primary: 'var(--gd-red)', primary: 'var(--gd-red)',
@@ -1341,6 +1351,7 @@ export default function TeslaTripPlanner() {
const [chatInput, setChatInput] = useState(''); const [chatInput, setChatInput] = useState('');
const [chips, setChips] = useState<string[]>([]); const [chips, setChips] = useState<string[]>([]);
const [thinking, setThinking] = useState(false); const [thinking, setThinking] = useState(false);
const [thinkingMessage, setThinkingMessage] = useState<string>('');
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY); const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
const [vehicle, setVehicle] = useState<Vehicle>(DEFAULT_VEHICLE); const [vehicle, setVehicle] = useState<Vehicle>(DEFAULT_VEHICLE);
const [vehiclePanelOpen, setVehiclePanelOpen] = useState(false); const [vehiclePanelOpen, setVehiclePanelOpen] = useState(false);
@@ -1404,6 +1415,55 @@ export default function TeslaTripPlanner() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [itinerary]); // eslint-disable-line react-hooks/exhaustive-deps }, [itinerary]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Prefetch other variants in the background ─────────────────────────────
const prefetchedRef = React.useRef<Set<string>>(new Set());
const prefetchKey = React.useMemo(() => {
if (allStops.length < 2) return null;
return `${allStops[0]?.name}__${allStops[allStops.length - 1]?.name}`;
}, [allStops]);
// Drop cached variants whose journey doesn't match the current origin/destination.
const lastPrefetchKey = React.useRef<string | null>(null);
React.useEffect(() => {
if (!prefetchKey) return;
if (lastPrefetchKey.current && lastPrefetchKey.current !== prefetchKey) {
setVariantCache(prev => {
const next: typeof prev = {};
if (prev[selectedVariant]) next[selectedVariant] = prev[selectedVariant];
return next;
});
}
lastPrefetchKey.current = prefetchKey;
}, [prefetchKey, selectedVariant]);
React.useEffect(() => {
if (!prefetchKey) return;
if (thinking || variantSwitching) return;
if (variants.length < 2) return;
const targets: RouteVariant['id'][] = ['fast', 'scenic', 'cheap'];
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
if (!lastUserMsg) return;
let cancelled = false;
(async () => {
for (const id of targets) {
if (cancelled) return;
if (id === selectedVariant) continue;
if (variantCache[id]) continue;
const key = `${prefetchKey}::${id}`;
if (prefetchedRef.current.has(key)) continue;
prefetchedRef.current.add(key);
try {
await sendMessage(`Replan the trip as the ${id} variant`, { variant: id, silent: true, prefetch: true });
} catch (err) {
console.warn('[TeslaTrip] prefetch failed for', id, err);
prefetchedRef.current.delete(key);
}
}
})();
return () => { cancelled = true; };
}, [prefetchKey, variants.length, thinking, variantSwitching, selectedVariant]); // eslint-disable-line react-hooks/exhaustive-deps
const computedTotals = React.useMemo(() => { const computedTotals = React.useMemo(() => {
if (legs.length === 0) return null; if (legs.length === 0) return null;
const km = legs.reduce((s, l) => s + (l.distanceKm ?? 0), 0); const km = legs.reduce((s, l) => s + (l.distanceKm ?? 0), 0);
@@ -1411,17 +1471,23 @@ export default function TeslaTripPlanner() {
return { totalKm: km, driveMinutes: min }; return { totalKm: km, driveMinutes: min };
}, [legs]); }, [legs]);
const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean } = {}) => { const sendMessage = async (text: string, opts: { variant?: RouteVariant['id']; silent?: boolean; prefetch?: boolean } = {}) => {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed) return; if (!trimmed) return;
const variantToUse = opts.variant ?? selectedVariant; const variantToUse = opts.variant ?? selectedVariant;
if (!opts.silent) { if (!opts.silent && !opts.prefetch) {
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));
} }
if (opts.variant) setVariantSwitching(true); if (opts.prefetch) {
else setThinking(true); // Background prefetch: never touch the visible itinerary or any spinner state.
} else if (opts.variant) {
setVariantSwitching(true);
} else {
setThinking(true);
setThinkingMessage('');
}
let lastPartialItinerary: any = null; let lastPartialItinerary: any = null;
let lastVariants: any[] | null = null; let lastVariants: any[] | null = null;
@@ -1438,6 +1504,8 @@ export default function TeslaTripPlanner() {
itinerary, itinerary,
history: messages.map(m => ({ role: m.role, content: m.content })), history: messages.map(m => ({ role: m.role, content: m.content })),
selectedVariant: variantToUse, selectedVariant: variantToUse,
origin: origin.trim() || undefined,
destination: destination.trim() || undefined,
}), }),
}); });
if (!response.ok || !response.body) throw new Error('Failed to get streaming response'); if (!response.ok || !response.body) throw new Error('Failed to get streaming response');
@@ -1466,10 +1534,13 @@ export default function TeslaTripPlanner() {
try { payload = JSON.parse(evData); } catch { continue; } try { payload = JSON.parse(evData); } catch { continue; }
if (evName === 'thinking') { if (evName === 'thinking') {
// Could surface payload.message somewhere; for now we just show the existing spinner if (!opts.prefetch && typeof payload.message === 'string' && payload.message.trim()) {
setThinkingMessage(payload.message.trim());
}
} else if (evName === 'partial') { } else if (evName === 'partial') {
lastPartialItinerary = payload.itinerary; lastPartialItinerary = payload.itinerary;
if (Array.isArray(payload.variants)) lastVariants = payload.variants; if (Array.isArray(payload.variants)) lastVariants = payload.variants;
if (!opts.prefetch) {
// Use synchronous normalizer for partials — no geocoding, no blocking // Use synchronous normalizer for partials — no geocoding, no blocking
if (payload.itinerary) { if (payload.itinerary) {
setItinerary(normalizePartialItinerary(payload.itinerary)); setItinerary(normalizePartialItinerary(payload.itinerary));
@@ -1477,17 +1548,21 @@ export default function TeslaTripPlanner() {
if (Array.isArray(payload.variants)) { if (Array.isArray(payload.variants)) {
setVariants(normalizeVariants(payload.variants)); setVariants(normalizeVariants(payload.variants));
} }
}
} else if (evName === 'done') { } else if (evName === 'done') {
finalReply = payload.reply || ''; finalReply = payload.reply || '';
if (payload.itinerary) { if (payload.itinerary) {
const clean = await normalizeAndSanitizeItinerary(payload.itinerary); const clean = await normalizeAndSanitizeItinerary(payload.itinerary);
setItinerary(clean);
const variantJustRendered = typeof payload.selectedVariant === 'string' const variantJustRendered = typeof payload.selectedVariant === 'string'
? payload.selectedVariant as RouteVariant['id'] ? payload.selectedVariant as RouteVariant['id']
: opts.variant ?? selectedVariant; : opts.variant ?? selectedVariant;
setVariantCache(prev => ({ ...prev, [variantJustRendered]: { itinerary: clean, legs: [] } })); setVariantCache(prev => ({ ...prev, [variantJustRendered]: { itinerary: clean, legs: [] } }));
lastSelectedVariant = variantJustRendered; lastSelectedVariant = variantJustRendered;
if (!opts.prefetch) {
setItinerary(clean);
} }
}
if (!opts.prefetch) {
if (Array.isArray(payload.variants)) { if (Array.isArray(payload.variants)) {
setVariants(normalizeVariants(payload.variants)); setVariants(normalizeVariants(payload.variants));
} }
@@ -1496,28 +1571,32 @@ export default function TeslaTripPlanner() {
} else if (opts.variant) { } else if (opts.variant) {
setSelectedVariant(opts.variant); setSelectedVariant(opts.variant);
} }
}
} else if (evName === 'error') { } else if (evName === 'error') {
throw new Error(payload.error || 'Stream error'); throw new Error(payload.error || 'Stream error');
} }
} }
} }
if (!opts.silent && finalReply) { if (!opts.silent && !opts.prefetch && finalReply) {
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: finalReply }]); setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: finalReply }]);
} }
if (lastPartialItinerary && !opts.silent) { if (lastPartialItinerary && !opts.silent && !opts.prefetch) {
toast.success('Grok finished your route'); toast.success('Grok finished your route');
} else if (opts.variant && lastSelectedVariant) { } else if (opts.variant && !opts.prefetch && lastSelectedVariant) {
toast.success(`Switched to ${lastSelectedVariant} route`); toast.success(`Switched to ${lastSelectedVariant} route`);
} }
} catch (err: any) { } catch (err: any) {
console.error('[TeslaTrip] Grok stream failed:', err); console.error('[TeslaTrip] Grok stream failed:', err);
if (!opts.silent) { if (!opts.silent && !opts.prefetch) {
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 {
if (!opts.prefetch) {
setThinking(false); setThinking(false);
setVariantSwitching(false); setVariantSwitching(false);
setThinkingMessage('');
}
} }
// touch unused refs so eslint stays quiet (we keep them as breadcrumbs) // touch unused refs so eslint stays quiet (we keep them as breadcrumbs)
@@ -1847,6 +1926,50 @@ export default function TeslaTripPlanner() {
</div> </div>
</div> </div>
)} )}
{/* Thinking overlay on the map (when Grok is planning but nothing rendered yet) */}
{allStops.length === 0 && thinking && (
<div className="absolute inset-0 grid place-items-center pointer-events-none">
<div
className="text-center max-w-md px-6 py-5 rounded-2xl pointer-events-auto"
style={{
background: 'rgba(20,20,24,0.86)',
border: '1px solid var(--gd-border-2)',
backdropFilter: 'blur(14px)',
boxShadow: '0 12px 40px rgba(0,0,0,0.4)',
}}
>
<div className="flex items-center justify-center gap-2 mb-3">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)' }} />
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '120ms' }} />
<div className="w-1.5 h-1.5 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '240ms' }} />
</div>
<div className="text-[11px] tracking-[0.15em] uppercase" style={{ color: 'var(--gd-red)' }}>
Grok is planning your route
</div>
</div>
{thinkingMessage ? (
<div
key={thinkingMessage}
className="text-[13px] leading-snug min-h-[40px]"
style={{ color: 'var(--gd-text-2)', fontStyle: 'italic' }}
>
{thinkingMessage}
</div>
) : (
<div className="space-y-2 min-h-[40px]">
<SkeletonRow widthPct={92} />
<SkeletonRow widthPct={78} />
<SkeletonRow widthPct={85} />
</div>
)}
<div className="text-[10.5px] mt-3 tracking-wider uppercase" style={{ color: 'var(--gd-text-3)' }}>
Stops will appear as soon as they're chosen
</div>
</div>
</div>
)}
</div> </div>
{/* Stops rail */} {/* Stops rail */}
@@ -1978,13 +2101,33 @@ export default function TeslaTripPlanner() {
)} )}
{thinking && ( {thinking && (
<div className="mt-4 flex items-center gap-2 px-2 py-2 rounded-lg" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}> <div className="mt-4 px-3 py-2.5 rounded-lg" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
<div className="flex items-center gap-2 mb-1">
<div className="flex gap-1"> <div className="flex gap-1">
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)' }} /> <div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)' }} />
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '120ms' }} /> <div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '120ms' }} />
<div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '240ms' }} /> <div className="w-1 h-1 rounded-full animate-bounce" style={{ background: 'var(--gd-red)', animationDelay: '240ms' }} />
</div> </div>
<div className="text-[11px] tracking-wider" style={{ color: 'var(--gd-red)' }}>GROK IS PLANNING YOUR ROUTE...</div> <div className="text-[10px] tracking-wider uppercase" style={{ color: 'var(--gd-red)' }}>
{itinerary.days.length > 0 ? 'Refining your route' : 'Grok is planning your route'}
</div>
</div>
{thinkingMessage && (
<div
className="text-[12px] leading-snug mt-1.5 line-clamp-3"
style={{ color: 'var(--gd-text-2)', fontStyle: 'italic' }}
key={thinkingMessage}
>
{thinkingMessage}
</div>
)}
{!thinkingMessage && itinerary.days.length === 0 && (
<div className="mt-2 space-y-1.5">
<SkeletonRow widthPct={92} />
<SkeletonRow widthPct={78} />
<SkeletonRow widthPct={85} />
</div>
)}
</div> </div>
)} )}
@@ -2053,8 +2196,8 @@ function ModalShell({
return ( return (
<div <div
onClick={onClose} onClick={onClose}
className="fixed inset-0 z-50 grid place-items-center p-6" className="fixed inset-0 grid place-items-center p-6"
style={{ background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }} style={{ zIndex: 9999, background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -2519,8 +2662,8 @@ function AddDetourOverlay({
return ( return (
<div <div
onClick={onClose} onClick={onClose}
className="fixed inset-0 z-50 flex justify-center items-start px-6 pt-[90px]" className="fixed inset-0 flex justify-center items-start px-6 pt-[90px]"
style={{ background: 'rgba(5,5,8,0.6)', backdropFilter: 'blur(6px)' }} style={{ zIndex: 9999, background: 'rgba(5,5,8,0.6)', backdropFilter: 'blur(6px)' }}
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -2819,10 +2962,11 @@ function VehicleSelectorPanel({
return ( return (
<> <>
<div className="fixed inset-0 z-40" onClick={onClose} /> <div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={onClose} />
<div <div
className="fixed z-50 overflow-hidden" className="fixed overflow-hidden"
style={{ style={{
zIndex: 9999,
left, top, width: panelWidth, maxHeight: '78vh', left, top, width: panelWidth, maxHeight: '78vh',
background: 'rgba(20,20,24,0.96)', background: 'rgba(20,20,24,0.96)',
backdropFilter: 'blur(18px)', backdropFilter: 'blur(18px)',
+8 -2
View File
@@ -13,6 +13,8 @@ const ChatRequestSchema = z.object({
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(), selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(),
origin: z.string().optional(),
destination: z.string().optional(),
}); });
router.post('/chat', async (req, res) => { router.post('/chat', async (req, res) => {
@@ -28,7 +30,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 = [], selectedVariant = 'fast' } = parsed.data; const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
log.info({ log.info({
requestId, requestId,
@@ -37,6 +39,8 @@ router.post('/chat', async (req, res) => {
historyLength: history.length, historyLength: history.length,
currentItineraryDays: itinerary?.days?.length || 0, currentItineraryDays: itinerary?.days?.length || 0,
selectedVariant, selectedVariant,
origin,
destination,
}, '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)
@@ -45,6 +49,7 @@ router.post('/chat', async (req, res) => {
itinerary, itinerary,
vehicle, vehicle,
selectedVariant, selectedVariant,
{ origin, destination },
); );
const duration = Date.now() - start; const duration = Date.now() - start;
@@ -86,7 +91,7 @@ router.post('/chat/stream', async (req, res) => {
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ error: 'Invalid request', issues: parsed.error.format() }); return res.status(400).json({ error: 'Invalid request', issues: parsed.error.format() });
} }
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast' } = parsed.data; const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Cache-Control', 'no-cache, no-transform');
@@ -119,6 +124,7 @@ router.post('/chat/stream', async (req, res) => {
itinerary, itinerary,
vehicle, vehicle,
selectedVariant, selectedVariant,
{ origin, destination },
); );
for await (const ev of stream) { for await (const ev of stream) {
if (cancelled) break; if (cancelled) break;
+37 -12
View File
@@ -160,19 +160,23 @@ export class GrokHeadlessClient {
} }
} }
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast') { private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}) {
const variantBrief = { 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.', 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.', 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 €.', 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.'; }[selectedVariant] || 'Fastest — minimise drive time.';
const odBlock = (opts.origin && opts.destination)
? `\nTRIP ENDPOINTS (these are the ground truth — your itinerary MUST start exactly here and end exactly here):\n Origin: ${opts.origin}\n Destination: ${opts.destination}\n`
: '';
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()} Selected route variant: ${selectedVariant.toUpperCase()}
${variantBrief} ${variantBrief}
Current vehicle: ${vehicleName(vehicle)} Current vehicle: ${vehicleName(vehicle)}${odBlock}
Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)} Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)}
Respond with **only** a single valid JSON object in exactly this format. No text before or after. No markdown. Respond with **only** a single valid JSON object in exactly this format. No text before or after. No markdown.
@@ -294,6 +298,10 @@ Respond with **only** a single valid JSON object in exactly this format. No text
Strict route planning rules: Strict route planning rules:
- Plan stops in the actual order the driver will encounter them on the road. - Plan stops in the actual order the driver will encounter them on the road.
- THE FIRST STOP MUST BE THE EXACT ORIGIN THE USER GAVE. THE LAST STOP MUST BE THE EXACT DESTINATION THE USER GAVE. Never start the trip in a different city, country, port or service station. If the user said "from MK78PJ" or "from Milton Keynes", the very first stop must be at that postcode/town with type "custom" (or "supercharger"/"hotel" if it genuinely is one), real lat/lng, and a name like "Start · Milton Keynes (MK7 8PJ)".
- UK postcodes (e.g. MK7 8PJ, SW1A 1AA, EH1 1YZ) are valid starting points. Geocode them precisely — the first letters are the postal area (MK = Milton Keynes, SW = London SW, EH = Edinburgh). Do not skip the UK leg of the trip just because chargers are sparse there.
- For UK → mainland Europe trips: include the UK departure point (e.g. Folkestone Eurotunnel, Dover ferry, or Hull ferry) and the corresponding mainland arrival point (Calais/Coquelles, Dunkirk, Rotterdam, etc.) as explicit stops. Note the crossing in the description and add a sensible duration. Do NOT begin the itinerary in Calais — that erases the UK side of the journey.
- For mainland Europe → UK trips: same but in reverse.
- Choose Superchargers that are realistically reachable given the vehicle range. - Choose Superchargers that are realistically reachable given the vehicle range.
- Space charging stops sensibly (every 150-250km depending on route and battery). - Space charging stops sensibly (every 150-250km depending on route and battery).
- Calculate realistic estArrivalBattery based on distance driven since last charge. - Calculate realistic estArrivalBattery based on distance driven since last charge.
@@ -355,21 +363,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, selectedVariant: string = 'fast'): Promise<GrokResponse> { async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): 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, selectedVariant }, '=== 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, selectedVariant); return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts);
} }
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, selectedVariant); const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
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'
@@ -420,7 +428,7 @@ Respond with ONLY the JSON object.`;
} 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, selectedVariant); return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts);
} }
return this.dumbFallback(messages, requestId); return this.dumbFallback(messages, requestId);
} finally { } finally {
@@ -432,7 +440,7 @@ Respond with ONLY the JSON object.`;
* Streaming chat — yields incremental partial itineraries as Grok produces output. * Streaming chat — yields incremental partial itineraries as Grok produces output.
* Falls back to non-streaming if local CLI is unavailable. * Falls back to non-streaming if local CLI is unavailable.
*/ */
async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast'): AsyncGenerator<StreamEvent> { async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): AsyncGenerator<StreamEvent> {
const requestId = crypto.randomUUID().slice(0, 8); const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ==='); log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ===');
@@ -442,7 +450,7 @@ Respond with ONLY the JSON object.`;
// No real streaming for xAI/fallback yet — just do the regular call and emit a single done event // No real streaming for xAI/fallback yet — just do the regular call and emit a single done event
yield { type: 'thinking', message: 'Asking Grok…' }; yield { type: 'thinking', message: 'Asking Grok…' };
const result = activeProvider === 'xai' const result = activeProvider === 'xai'
? await this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant) ? await this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts)
: await this.dumbFallback(messages, requestId); : await this.dumbFallback(messages, requestId);
yield { yield {
type: 'done', type: 'done',
@@ -454,7 +462,7 @@ Respond with ONLY the JSON object.`;
return; return;
} }
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant); const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-stream-')); const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-stream-'));
const disallowed = env.nodeEnv === 'development' const disallowed = env.nodeEnv === 'development'
? 'search_replace,write_file,Agent,run_terminal_cmd' ? 'search_replace,write_file,Agent,run_terminal_cmd'
@@ -488,6 +496,9 @@ Respond with ONLY the JSON object.`;
let lastParseLen = 0; let lastParseLen = 0;
let lastEmittedStops = 0; let lastEmittedStops = 0;
let lastEmittedDays = 0; let lastEmittedDays = 0;
let thoughtBuffer = '';
let lastEmittedThought = '';
let lastEmittedThoughtLen = 0;
let closed = false; let closed = false;
let closeCode: number | null = null; let closeCode: number | null = null;
let waker: (() => void) | null = null; let waker: (() => void) | null = null;
@@ -574,7 +585,21 @@ Respond with ONLY the JSON object.`;
} }
} }
} else if (ev.type === 'thought' && typeof ev.data === 'string') { } else if (ev.type === 'thought' && typeof ev.data === 'string') {
// Optional: surface short snippets of Grok's thinking thoughtBuffer += ev.data;
// Emit on newline boundaries or when we've buffered enough; show the last
// line trimmed and capped so the UI gets a steady stream of short snippets.
const lastNewline = thoughtBuffer.lastIndexOf('\n');
const shouldEmit = lastNewline >= 0 || thoughtBuffer.length - lastEmittedThoughtLen > 200;
if (shouldEmit) {
const tail = lastNewline >= 0 ? thoughtBuffer.slice(lastNewline + 1) : thoughtBuffer;
const snippet = (tail || thoughtBuffer).trim().replace(/\s+/g, ' ').slice(-220);
if (snippet && snippet !== lastEmittedThought) {
lastEmittedThought = snippet;
lastEmittedThoughtLen = thoughtBuffer.length;
yield { type: 'thinking', message: snippet };
}
if (lastNewline >= 0) thoughtBuffer = thoughtBuffer.slice(lastNewline + 1);
}
} else if (ev.type === 'error') { } else if (ev.type === 'error') {
log.error({ requestId, msg: ev.message }, 'grok streaming error event'); log.error({ requestId, msg: ev.message }, 'grok streaming error event');
yield { type: 'error', error: ev.message || 'Grok stream error' }; yield { type: 'error', error: ev.message || 'Grok stream error' };
@@ -604,8 +629,8 @@ Respond with ONLY the JSON object.`;
} }
} }
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast'): Promise<GrokResponse> { private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): Promise<GrokResponse> {
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant); const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
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 {