diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index 8189603..81ef214 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -566,6 +566,16 @@ function LegRow({ leg }: { leg: Leg | undefined }) { ); } +// ─── Skeleton shimmer ──────────────────────────────────────────────────────── +function SkeletonRow({ widthPct = 100 }: { widthPct?: number }) { + return ( +
+ ); +} + // ─── Variant strip ─────────────────────────────────────────────────────────── const VARIANT_TONE: Record = { primary: 'var(--gd-red)', @@ -1341,6 +1351,7 @@ export default function TeslaTripPlanner() { const [chatInput, setChatInput] = useState(''); const [chips, setChips] = useState([]); const [thinking, setThinking] = useState(false); + const [thinkingMessage, setThinkingMessage] = useState(''); const [itinerary, setItinerary] = useState(EMPTY_ITINERARY); const [vehicle, setVehicle] = useState(DEFAULT_VEHICLE); const [vehiclePanelOpen, setVehiclePanelOpen] = useState(false); @@ -1404,6 +1415,55 @@ export default function TeslaTripPlanner() { return () => { cancelled = true; }; }, [itinerary]); // eslint-disable-line react-hooks/exhaustive-deps + // ─── Prefetch other variants in the background ───────────────────────────── + const prefetchedRef = React.useRef>(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(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(() => { if (legs.length === 0) return null; const km = legs.reduce((s, l) => s + (l.distanceKm ?? 0), 0); @@ -1411,17 +1471,23 @@ export default function TeslaTripPlanner() { return { totalKm: km, driveMinutes: min }; }, [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(); if (!trimmed) return; const variantToUse = opts.variant ?? selectedVariant; - if (!opts.silent) { + if (!opts.silent && !opts.prefetch) { setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: trimmed }]); setChatInput(''); setChips(prev => [...prev, trimmed].slice(-6)); } - if (opts.variant) setVariantSwitching(true); - else setThinking(true); + if (opts.prefetch) { + // 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 lastVariants: any[] | null = null; @@ -1438,6 +1504,8 @@ export default function TeslaTripPlanner() { itinerary, history: messages.map(m => ({ role: m.role, content: m.content })), selectedVariant: variantToUse, + origin: origin.trim() || undefined, + destination: destination.trim() || undefined, }), }); if (!response.ok || !response.body) throw new Error('Failed to get streaming response'); @@ -1466,35 +1534,43 @@ export default function TeslaTripPlanner() { try { payload = JSON.parse(evData); } catch { continue; } 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') { lastPartialItinerary = payload.itinerary; if (Array.isArray(payload.variants)) lastVariants = payload.variants; - // Use synchronous normalizer for partials — no geocoding, no blocking - if (payload.itinerary) { - setItinerary(normalizePartialItinerary(payload.itinerary)); - } - if (Array.isArray(payload.variants)) { - setVariants(normalizeVariants(payload.variants)); + if (!opts.prefetch) { + // Use synchronous normalizer for partials — no geocoding, no blocking + if (payload.itinerary) { + setItinerary(normalizePartialItinerary(payload.itinerary)); + } + if (Array.isArray(payload.variants)) { + setVariants(normalizeVariants(payload.variants)); + } } } else if (evName === 'done') { finalReply = payload.reply || ''; if (payload.itinerary) { const clean = await normalizeAndSanitizeItinerary(payload.itinerary); - setItinerary(clean); const variantJustRendered = typeof payload.selectedVariant === 'string' ? payload.selectedVariant as RouteVariant['id'] : opts.variant ?? selectedVariant; setVariantCache(prev => ({ ...prev, [variantJustRendered]: { itinerary: clean, legs: [] } })); lastSelectedVariant = variantJustRendered; + if (!opts.prefetch) { + setItinerary(clean); + } } - if (Array.isArray(payload.variants)) { - setVariants(normalizeVariants(payload.variants)); - } - if (typeof payload.selectedVariant === 'string') { - setSelectedVariant(payload.selectedVariant as RouteVariant['id']); - } else if (opts.variant) { - setSelectedVariant(opts.variant); + if (!opts.prefetch) { + if (Array.isArray(payload.variants)) { + setVariants(normalizeVariants(payload.variants)); + } + if (typeof payload.selectedVariant === 'string') { + setSelectedVariant(payload.selectedVariant as RouteVariant['id']); + } else if (opts.variant) { + setSelectedVariant(opts.variant); + } } } else if (evName === 'error') { throw new Error(payload.error || 'Stream error'); @@ -1502,22 +1578,25 @@ export default function TeslaTripPlanner() { } } - if (!opts.silent && finalReply) { + if (!opts.silent && !opts.prefetch && 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'); - } else if (opts.variant && lastSelectedVariant) { + } else if (opts.variant && !opts.prefetch && lastSelectedVariant) { toast.success(`Switched to ${lastSelectedVariant} route`); } } catch (err: any) { 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." }]); } } finally { - setThinking(false); - setVariantSwitching(false); + if (!opts.prefetch) { + setThinking(false); + setVariantSwitching(false); + setThinkingMessage(''); + } } // touch unused refs so eslint stays quiet (we keep them as breadcrumbs) @@ -1847,6 +1926,50 @@ export default function TeslaTripPlanner() {
)} + + {/* Thinking overlay on the map (when Grok is planning but nothing rendered yet) */} + {allStops.length === 0 && thinking && ( +
+
+
+
+
+
+
+
+
+ Grok is planning your route +
+
+ {thinkingMessage ? ( +
+ “{thinkingMessage}” +
+ ) : ( +
+ + + +
+ )} +
+ Stops will appear as soon as they're chosen +
+
+
+ )}
{/* Stops rail */} @@ -1978,13 +2101,33 @@ export default function TeslaTripPlanner() { )} {thinking && ( -
-
-
-
-
+
+
+
+
+
+
+
+
+ {itinerary.days.length > 0 ? 'Refining your route' : 'Grok is planning your route'} +
-
GROK IS PLANNING YOUR ROUTE...
+ {thinkingMessage && ( +
+ {thinkingMessage} +
+ )} + {!thinkingMessage && itinerary.days.length === 0 && ( +
+ + + +
+ )}
)} @@ -2053,8 +2196,8 @@ function ModalShell({ return (
e.stopPropagation()} @@ -2519,8 +2662,8 @@ function AddDetourOverlay({ return (
e.stopPropagation()} @@ -2819,10 +2962,11 @@ function VehicleSelectorPanel({ return ( <> -
+
{ @@ -28,7 +30,7 @@ router.post('/chat', async (req, res) => { 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({ requestId, @@ -37,6 +39,8 @@ router.post('/chat', async (req, res) => { historyLength: history.length, currentItineraryDays: itinerary?.days?.length || 0, selectedVariant, + origin, + destination, }, 'Parsed chat request'); // Call Grok (this will produce very detailed logs inside GrokHeadlessClient) @@ -45,6 +49,7 @@ router.post('/chat', async (req, res) => { itinerary, vehicle, selectedVariant, + { origin, destination }, ); const duration = Date.now() - start; @@ -86,7 +91,7 @@ router.post('/chat/stream', async (req, res) => { if (!parsed.success) { 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('Cache-Control', 'no-cache, no-transform'); @@ -119,6 +124,7 @@ router.post('/chat/stream', async (req, res) => { itinerary, vehicle, selectedVariant, + { origin, destination }, ); for await (const ev of stream) { if (cancelled) break; diff --git a/server/services/llm/GrokHeadlessClient.ts b/server/services/llm/GrokHeadlessClient.ts index f3b4803..3cb682d 100644 --- a/server/services/llm/GrokHeadlessClient.ts +++ b/server/services/llm/GrokHeadlessClient.ts @@ -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 = { 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.'; + 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. Selected route variant: ${selectedVariant.toUpperCase()} ${variantBrief} -Current vehicle: ${vehicleName(vehicle)} +Current vehicle: ${vehicleName(vehicle)}${odBlock} 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. @@ -294,6 +298,10 @@ Respond with **only** a single valid JSON object in exactly this format. No text Strict route planning rules: - 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. - Space charging stops sensibly (every 150-250km depending on route and battery). - 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.`; } - async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast'): Promise { + async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): Promise { const requestId = crypto.randomUUID().slice(0, 8); log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length, selectedVariant }, '=== NEW CHAT REQUEST ==='); const activeProvider = await this.getActiveProvider(requestId); if (activeProvider === 'xai') { - return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant); + return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts); } if (activeProvider === 'fallback') { return this.dumbFallback(messages, requestId); } // 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 disallowed = env.nodeEnv === 'development' @@ -420,7 +428,7 @@ Respond with ONLY the JSON object.`; } catch (err) { log.error({ requestId, err: String(err) }, 'Local authenticated Grok CLI failed — falling back to xAI API'); if (env.xaiApiKey) { - return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant); + return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts); } return this.dumbFallback(messages, requestId); } finally { @@ -432,7 +440,7 @@ Respond with ONLY the JSON object.`; * Streaming chat — yields incremental partial itineraries as Grok produces output. * Falls back to non-streaming if local CLI is unavailable. */ - async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast'): AsyncGenerator { + async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): AsyncGenerator { const requestId = crypto.randomUUID().slice(0, 8); 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 yield { type: 'thinking', message: 'Asking Grok…' }; 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); yield { type: 'done', @@ -454,7 +462,7 @@ Respond with ONLY the JSON object.`; 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 disallowed = env.nodeEnv === 'development' ? 'search_replace,write_file,Agent,run_terminal_cmd' @@ -488,6 +496,9 @@ Respond with ONLY the JSON object.`; let lastParseLen = 0; let lastEmittedStops = 0; let lastEmittedDays = 0; + let thoughtBuffer = ''; + let lastEmittedThought = ''; + let lastEmittedThoughtLen = 0; let closed = false; let closeCode: number | 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') { - // 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') { log.error({ requestId, msg: ev.message }, 'grok streaming error event'); 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 { - const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant); + private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string } = {}): Promise { + 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)'); try {