Files
tesla-roadtrip/server/services/llm/GrokHeadlessClient.ts
T
tony 225cd250a3 feat: leg metrics + swappable alternatives per stop
Timeline now shows the driving distance and duration between every
consecutive pair of stops, taken from real OSRM road routes (not Grok
estimates). Top stat bar and per-day headers also use the live totals
so they update immediately as the itinerary changes.

Grok now returns 1-3 alternative picks for each Supercharger and
hotel stop, each with deltaKm/deltaMin vs the chosen pick and a short
reason explaining the trade-off. The Swap (n) button on each card
expands an inline list of alternatives; clicking one swaps the stop
in place. The previously-chosen stop is kept in the alternatives
list with inverted deltas so the user can swap back. The map +
polylines + stat bar all recompute automatically.

Other tweaks:
- haversine fallback when OSRM is unreachable so legs still show
  approximate metrics in offline / degraded mode.
- Leg geometry storage moves from raw polyline[] to typed Leg[] with
  per-leg distance/duration/fromId/toId.
- Stop schema gains alternatives[]; client normalization filters out
  alternatives missing lat/lng.
- Day cards widened (340 → 360px); timeline pane grew 280 → 340px
  with vertical scroll so swap panels don't clip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:44:54 +01:00

359 lines
16 KiB
TypeScript

/**
* Tesla Roadtrip — Grok Headless Client
*
* Now using pure JSON output mode for much more reliable structured itineraries.
*/
import { spawn } from 'child_process';
import { mkdtemp, rm, access } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { createLogger } from '../../lib/logger.js';
import { env } from '../../config/env.js';
import crypto from 'crypto';
const log = createLogger('grok-headless');
const SENTINEL = 'ITINERARY_UPDATE:';
export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; }
export interface GrokResponse { text: string; updatedItinerary?: any; }
export type VehicleInput = string | { name: string; rangeKm?: number };
function vehicleName(v: VehicleInput): string {
return typeof v === 'string' ? v : v.name;
}
type Provider = 'local' | 'xai' | 'fallback';
export class GrokHeadlessClient {
private provider: Provider;
constructor() {
this.provider = this.resolveInitialProvider();
log.info({ provider: this.provider, grokBin: env.grokBin, forceXaiApi: env.forceXaiApi }, 'GrokHeadlessClient initialized');
}
private resolveInitialProvider(): Provider {
if (env.forceXaiApi && env.xaiApiKey) return 'xai';
if (!env.grokEnabled) return env.xaiApiKey ? 'xai' : 'fallback';
return 'local';
}
private async getActiveProvider(requestId: string): Promise<Provider> {
if (env.forceXaiApi) {
if (env.xaiApiKey) {
log.info({ requestId }, 'Provider decision: xAI API (FORCE_XAI_API=true)');
return 'xai';
}
return 'fallback';
}
if (!env.grokEnabled) {
return env.xaiApiKey ? 'xai' : 'fallback';
}
try {
await access(env.grokBin);
log.info({ requestId, bin: env.grokBin }, 'Provider decision: LOCAL personal Grok CLI (your authenticated Heavy account)');
return 'local';
} catch {
log.info({ requestId, bin: env.grokBin }, 'Local grok binary not found — using xAI API');
return env.xaiApiKey ? 'xai' : 'fallback';
}
}
private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput) {
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.
Current vehicle: ${vehicleName(vehicle)}
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.
{
"message": "A friendly, natural reply to the user (1-4 sentences). Highlight one or two of the best combo picks (e.g. 'I picked the Watford Gap Supercharger because the M&S Food Hall is right there').",
"itinerary": {
"days": [
{
"day": 1,
"title": "Short day label like 'London → Manchester'",
"stops": [
{
"id": "unique-string",
"name": "Human readable name",
"type": "supercharger" | "destination-charger" | "hotel" | "attraction" | "restaurant" | "cafe" | "viewpoint" | "custom",
"lat": 51.477,
"lng": -0.17,
"day": 1,
"order": 1,
"estArrivalBattery": 25,
"chargeMinutes": 25,
"durationMin": 45,
"combo": "charge + eat" | "charge + coffee" | "stay + destination charging" | "eat + viewpoint" | "charge + shopping" | null,
"description": "1-2 sentence reason this stop is a great pick — what's right next to the charger, why this hotel, what's special about this stop",
"amenities": ["restaurant", "coffee", "toilets", "shopping", "wifi", "playground", "ev-charging", "destination-charging"],
"cuisine": "British pub" | "Italian" | "French" | "Cafe" | null,
"priceLevel": 1 | 2 | 3 | 4,
"notes": "optional extra hint (booking tips, opening hours, etc.)",
"alternatives": [
{
"id": "unique-alt-string",
"name": "Alternative pick name",
"type": "supercharger" | "hotel" | "restaurant" | "cafe" | "attraction" | "destination-charger" | "viewpoint" | "custom",
"lat": 51.5,
"lng": -0.1,
"description": "1-2 sentences explaining why this is a viable swap",
"combo": "charge + eat" | "stay + destination charging" | null,
"amenities": ["restaurant", "toilets"],
"cuisine": "Italian" | null,
"priceLevel": 2,
"chargeMinutes": 25,
"durationMin": 60,
"deltaKm": 12,
"deltaMin": 9,
"reason": "Short reason this is a worthwhile alternative (e.g. 'Cheaper and faster but no restaurant on site')"
}
]
}
]
}
],
"summary": {
"totalDistanceKm": 650,
"estDriveHours": 10.5,
"estChargeHours": 1.5,
"superchargers": 3,
"hotels": 1,
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
}
}
}
Strict route planning rules:
- Plan stops in the actual order the driver will encounter them on the road.
- 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.
- Every stop MUST have accurate real-world latitude and longitude.
- Use realistic daily driving distances (max ~5-6 hours driving per day).
- "message" should feel like a helpful human assistant.
- If no clear trip is requested yet, set "itinerary" to null.
Alternatives (REQUIRED for every Supercharger and hotel stop):
- For each Supercharger or hotel stop, populate "alternatives" with 1-3 realistic swap options the driver might prefer.
- Each alternative is a fully-formed stop the user could swap to: complete lat/lng, type, name, description.
- "deltaKm" is the estimated change in total trip distance vs the chosen stop (positive = adds km, negative = saves km).
- "deltaMin" is the estimated change in total drive time vs the chosen stop, in minutes.
- "reason" explains the trade-off in one short sentence ("Cheaper hotel, no destination charging" / "Adds 15 mins but has the best food on this stretch of the M6").
- Alternatives must be genuinely different choices a driver would consider — not minor variants. Mix the trade-offs: faster, cheaper, fancier, better food, closer to attractions, etc.
- For non-Supercharger/non-hotel stops (a viewpoint, a quick coffee), alternatives are optional.
Combo philosophy (THIS IS THE IMPORTANT PART — don't skip):
- Whenever possible, pick Superchargers that are co-located with a real restaurant, cafe, services area, supermarket, or visitor attraction. Mention what's there in "description" and tag the stop with combo: "charge + eat" (or similar).
- Prefer hotels that offer destination charging (Tesla destination chargers, Type 2, or onsite EV charging). Tag those combo: "stay + destination charging" and add "destination-charging" to amenities.
- For meal-time stops, look for a charger close to a great independent restaurant or cafe — not just "the Supercharger has a McDonald's next door" unless that's all there is.
- If a stop is just a quick top-up with nothing nearby, that's fine — set combo to null and explain in description.
- Use "durationMin" to indicate the total time at that stop (charge time + meal time, or just charging, or just dinner without charging).
- "amenities" should be the actual on-site amenities the driver will find. Use lowercase kebab-case tokens from this set: restaurant, cafe, fast-food, supermarket, toilets, shopping, wifi, playground, ev-charging, destination-charging, hotel, coffee, viewpoint, museum, park, beach, gym, pool.
Examples of great combo stops to favour when they fit the route:
- UK: Tebay Services (M6) — independent local food + Supercharger. Gretna Green — Supercharger + outlet shopping + cafe.
- France: Aire de Beaune (A6) — Supercharger + regional bakery + wine country.
- Germany: Autohof Lutterberg (A7) — Supercharger + traditional restaurant.
- Netherlands: Schoonebeek — Supercharger near restaurant cluster.
- Switzerland: St. Gallen — Supercharger + lakeside cafe.
Conversation history:
${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')}
Respond with ONLY the JSON object.`;
}
async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput): Promise<GrokResponse> {
const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length }, '=== NEW CHAT REQUEST ===');
const activeProvider = await this.getActiveProvider(requestId);
if (activeProvider === 'xai') {
return this.callXaiApi(messages, itinerary, vehicle, requestId);
}
if (activeProvider === 'fallback') {
return this.dumbFallback(messages, requestId);
}
// LOCAL PERSONAL GROK CLI
const prompt = this.buildPrompt(messages, itinerary, vehicle);
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-'));
const disallowed = env.nodeEnv === 'development'
? 'search_replace,write_file,Agent,run_terminal_cmd'
: 'run_terminal_cmd,search_replace,write_file,Agent';
try {
const args = [
'-p', prompt,
'--output-format', 'json',
'--yolo',
'--disallowed-tools', disallowed,
'--tools', 'web_search,web_fetch',
'--max-turns', '6',
'--cwd', tmp,
];
log.info({ requestId, bin: env.grokBin }, 'Spawning local authenticated grok CLI (pure JSON mode)');
const result = await new Promise<string>((resolve, reject) => {
const child = spawn(env.grokBin, args, {
cwd: tmp,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.on('close', (code) => {
if (code === 0) resolve(stdout);
else {
log.error({ requestId, code, stderr: stderr.slice(-800) }, 'Local grok CLI exited non-zero');
reject(new Error(`grok exited with code ${code}`));
}
});
child.on('error', reject);
});
const data = JSON.parse(result) as { text?: string };
const rawText = data.text || '';
const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText);
log.info({ requestId, hasItinerary: !!parsed }, 'Local Grok CLI returned JSON response');
return { text: cleanText, updatedItinerary: parsed };
} 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);
}
return this.dumbFallback(messages, requestId);
} finally {
await rm(tmp, { recursive: true, force: true }).catch(() => {});
}
}
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string): Promise<GrokResponse> {
const prompt = this.buildPrompt(messages, itinerary, vehicle);
log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)');
try {
const response = await fetch('https://api.x.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.xaiApiKey}`,
},
body: JSON.stringify({
model: 'grok-4.3',
messages: [{ role: 'user', content: prompt }],
temperature: 0.6,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
const text = await response.text();
log.error({ requestId, status: response.status, body: text }, 'xAI API error');
return this.dumbFallback(messages, requestId);
}
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const rawText = data.choices?.[0]?.message?.content || '';
const { text: cleanText, itinerary: parsed } = this.parseGrokResponse(rawText);
return { text: cleanText, updatedItinerary: parsed };
} catch (err) {
log.error({ requestId, err }, 'xAI API call failed');
return this.dumbFallback(messages, requestId);
}
}
private parseGrokResponse(rawText: string): { text: string; itinerary: any | null } {
try {
const cleaned = rawText.trim().replace(/^```json\s*/, '').replace(/```$/, '').trim();
const parsed = JSON.parse(cleaned);
if (parsed && typeof parsed === 'object') {
return {
text: parsed.message || parsed.reply || '',
itinerary: parsed.itinerary || null,
};
}
} catch (e) {
log.warn({ err: String(e), raw: rawText.slice(0, 300) }, 'Failed to parse Grok response as JSON');
}
// Fallback to old sentinel method
return this.extractItineraryUpdate(rawText);
}
private extractItineraryUpdate(text: string): { text: string; itinerary: any | null } {
const upperText = text.toUpperCase();
const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase());
if (sentinelIndex === -1) return { text: text.trim(), itinerary: null };
const after = text.substring(sentinelIndex + SENTINEL.length).trim();
let jsonStart = after.indexOf('{');
if (jsonStart === -1) return { text: text.substring(0, sentinelIndex).trim(), itinerary: null };
let depth = 0, end = -1, inString = false, escape = false;
for (let i = jsonStart; i < after.length; i++) {
const ch = after[i];
if (escape) { escape = false; continue; }
if (ch === '\\') { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (!inString) {
if (ch === '{') depth++;
if (ch === '}') { depth--; if (depth === 0) { end = i; break; } }
}
}
if (end === -1) return { text: text.substring(0, sentinelIndex).trim(), itinerary: null };
const jsonStr = after.substring(jsonStart, end + 1);
try {
const parsed = JSON.parse(jsonStr);
return { text: text.substring(0, sentinelIndex).trim(), itinerary: parsed };
} catch (e) {
return { text: text.substring(0, sentinelIndex).trim(), itinerary: null };
}
}
private async dumbFallback(messages: ChatMessage[], requestId: string): Promise<GrokResponse> {
const last = messages[messages.length - 1]?.content.toLowerCase() || '';
if (['hi', 'hello', 'hey'].some(g => last.includes(g))) {
return { text: "Hello! I'm Grok Drive. How can I help plan your UK or European Tesla trip today?", updatedItinerary: null };
}
return { text: "I'm ready to plan a great Tesla route for you across the UK and Europe. Tell me where you want to go!", updatedItinerary: null };
}
async getStatus() {
const localBinExists = await this.localBinaryExists();
let provider: 'local' | 'xai' | 'fallback' = 'fallback';
let label = 'Fallback';
let detail = 'Basic responses only';
if (env.forceXaiApi && env.xaiApiKey) {
provider = 'xai'; label = 'grok-4.3 API'; detail = 'Production path (xAI)';
} else if (!env.grokEnabled) {
provider = env.xaiApiKey ? 'xai' : 'fallback';
} else if (localBinExists) {
provider = 'local'; label = 'Local Heavy'; detail = 'Your authenticated Grok (free)';
} else if (env.xaiApiKey) {
provider = 'xai'; label = 'grok-4.3 API'; detail = 'Production path (xAI)';
}
return { provider, label, detail, isLocal: provider === 'local', model: provider === 'local' ? 'Heavy (personal)' : 'grok-4.3', bin: env.grokBin };
}
private async localBinaryExists(): Promise<boolean> {
try { await access(env.grokBin); return true; } catch { return false; }
}
}
export const grok = new GrokHeadlessClient();