Files
tesla-roadtrip/server/services/llm/GrokHeadlessClient.ts
T
tony 89b24d4c34 feat: wire build/test infra, trips API, and enriched journey stops
- Add tsconfig.json (server) + client/tsconfig.{json,app.json,node.json}
  so typecheck and tsc -b actually work.
- Fix npm test to run Playwright (was running vitest on Playwright specs);
  typecheck now covers both server and client.
- Mount routes before app.listen, add error handler, mount optional
  @tonycodes/auth-express middleware when AUTH_SECRET is set.
- Add /api/trips (GET/POST/PATCH/DELETE) backed by an in-memory store
  that gracefully degrades when DATABASE_URL is unset.
- Add prisma/seed.ts skeleton and server/types/express.d.ts for req.auth.
- Rewrite Grok prompt for combo-aware planning: charge+eat,
  stay+destination-charging, eat+viewpoint, etc., with amenities,
  cuisine, priceLevel, duration, day titles and trip highlights.
- Extend Stop schema + normalization to preserve all enrichment fields.
- New StopCard component renders combo pill, description, meta row
  (charge / stop / battery / cuisine / £-level) and amenity icons;
  map popups show the same enriched detail; timeline gains day titles
  and a HIGHLIGHTS sidebar.
- Fix server TS errors (vehicle accepted as string | {name,rangeKm},
  JSON parse results typed).

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

331 lines
14 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.)"
}
]
}
],
"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.
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();