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