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
+8 -2
View File
@@ -13,6 +13,8 @@ const ChatRequestSchema = z.object({
itinerary: z.any().optional(),
history: z.array(z.object({ role: z.enum(['user', 'assistant']), content: z.string() })).optional(),
selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(),
origin: z.string().optional(),
destination: z.string().optional(),
});
router.post('/chat', async (req, res) => {
@@ -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;
+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 = {
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<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);
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<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);
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<GrokResponse> {
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<GrokResponse> {
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 {