ed64712525
Grok now drives the trip rendering in real time instead of dumping
the full result after ~90 seconds.
Backend
- GrokHeadlessClient gains a chatStream() async generator that spawns
grok with --output-format streaming-json (NDJSON of {type,data}
events), buffers the "text" tokens, and emits partial events as the
buffer becomes parseable.
- tryPartialJsonParse — lenient JSON repair: walks the buffer once,
closes structures in stack order, drops in-progress strings and
dangling keys, returns whatever object is currently consistent.
Hard-tested with progressive slicing of a multi-stop itinerary.
- New SSE endpoint POST /api/chat/stream with events: open / thinking
/ partial / done / error. Uses res.on('close') + writableEnded as a
reliable client-disconnect signal (req.on('close') fires in Express
5 once the body is consumed, which was killing the grok child).
Frontend
- sendMessage swaps to fetch+ReadableStream against /api/chat/stream
and parses SSE blocks. Each partial event runs a fast synchronous
normalizePartialItinerary (no Nominatim — drops stops missing
lat/lng so partial render doesn't block on geocoding).
- The done event runs the full async normalizer for the final pass
and caches the result per variant.
- Stops, day cards, map markers, polylines, the variant strip, and
the trip summary all update progressively as Grok writes each stop.
Verified with a London → Edinburgh prompt: 6 partial events landed
across the 76-second stream, with the rail filling in
"Baldock Services" → "+Grantham A1" → "+Premier Inn Newcastle"
→ "+Fort Kinnaird" before the final done event.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
725 lines
32 KiB
TypeScript
725 lines
32 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; variants?: any[]; selectedVariant?: string; }
|
|
export type VehicleInput = string | { name: string; rangeKm?: number };
|
|
|
|
export interface StreamEvent {
|
|
type: 'thinking' | 'partial' | 'done' | 'error';
|
|
message?: string;
|
|
itinerary?: any;
|
|
variants?: any[];
|
|
text?: string;
|
|
selectedVariant?: string;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Lenient JSON parser — given a truncated/in-progress JSON string,
|
|
* balance open brackets/quotes and try to parse. Returns null on failure.
|
|
*
|
|
* Closes nested structures in correct stack order (innermost first).
|
|
*/
|
|
export function tryPartialJsonParse(input: string): any | null {
|
|
const first = input.indexOf('{');
|
|
if (first === -1) return null;
|
|
const stripped = input.slice(first).replace(/^```json\s*/, '').replace(/```\s*$/, '');
|
|
|
|
// Walk the buffer to record open structures and look for a complete top-level object
|
|
const stack: ('{' | '[')[] = [];
|
|
let inStr = false, escape = false;
|
|
let cleanEnd = -1;
|
|
let lastSafePos = -1; // last position where we're outside strings + at a "safe" punctuation
|
|
for (let i = 0; i < stripped.length; i++) {
|
|
const c = stripped[i];
|
|
if (escape) { escape = false; continue; }
|
|
if (inStr) {
|
|
if (c === '\\') { escape = true; continue; }
|
|
if (c === '"') { inStr = false; lastSafePos = i; }
|
|
continue;
|
|
}
|
|
if (c === '"') { inStr = true; continue; }
|
|
if (c === '{' || c === '[') { stack.push(c as '{' | '['); continue; }
|
|
if (c === '}' || c === ']') {
|
|
stack.pop();
|
|
if (stack.length === 0) cleanEnd = i;
|
|
lastSafePos = i;
|
|
continue;
|
|
}
|
|
if (c === ',' || c === ':') { lastSafePos = i; }
|
|
}
|
|
|
|
if (cleanEnd !== -1) {
|
|
try { return JSON.parse(stripped.slice(0, cleanEnd + 1)); } catch { /* fall through */ }
|
|
}
|
|
|
|
// Build repaired buffer:
|
|
// 1) Truncate to lastSafePos (a comma, colon, quote-close, or bracket-close)
|
|
// 2) Strip trailing comma OR a dangling key (e.g. `,"foo"` or `:"foo`)
|
|
// 3) Close stack in reverse order
|
|
let repaired = stripped;
|
|
if (inStr) {
|
|
// We're mid-string — chop everything from the opening quote
|
|
let q = repaired.length - 1;
|
|
while (q >= 0 && repaired[q] !== '"') q--;
|
|
if (q >= 0) repaired = repaired.slice(0, q);
|
|
inStr = false;
|
|
} else if (lastSafePos !== -1 && lastSafePos < repaired.length - 1) {
|
|
repaired = repaired.slice(0, lastSafePos + 1);
|
|
}
|
|
|
|
// Trim trailing comma, colon, or partial key/value
|
|
// e.g. `"name":` → drop the `:` and the preceding `"name"` (it'd be a key with no value)
|
|
for (let pass = 0; pass < 4; pass++) {
|
|
const before = repaired.length;
|
|
// Trailing colon (incomplete key) — drop the key
|
|
repaired = repaired.replace(/,?\s*"[^"]*"\s*:\s*$/, '');
|
|
// Trailing comma
|
|
repaired = repaired.replace(/,\s*$/, '');
|
|
// Dangling identifier (true/false/null/number-ish) after colon — drop
|
|
repaired = repaired.replace(/,?\s*"[^"]*"\s*:\s*[a-zA-Z0-9.\-+eE]+$/, '');
|
|
if (repaired.length === before) break;
|
|
}
|
|
|
|
// Rebuild the open-stack on the REPAIRED buffer (in case the trims changed it)
|
|
const finalStack: ('{' | '[')[] = [];
|
|
inStr = false; escape = false;
|
|
for (let i = 0; i < repaired.length; i++) {
|
|
const c = repaired[i];
|
|
if (escape) { escape = false; continue; }
|
|
if (inStr) {
|
|
if (c === '\\') { escape = true; continue; }
|
|
if (c === '"') inStr = false;
|
|
continue;
|
|
}
|
|
if (c === '"') { inStr = true; continue; }
|
|
if (c === '{' || c === '[') { finalStack.push(c as '{' | '['); continue; }
|
|
if (c === '}') { if (finalStack[finalStack.length - 1] === '{') finalStack.pop(); continue; }
|
|
if (c === ']') { if (finalStack[finalStack.length - 1] === '[') finalStack.pop(); continue; }
|
|
}
|
|
// Close in reverse stack order — '{' → '}', '[' → ']'
|
|
for (let i = finalStack.length - 1; i >= 0; i--) {
|
|
repaired += finalStack[i] === '{' ? '}' : ']';
|
|
}
|
|
|
|
try { return JSON.parse(repaired); } catch { return null; }
|
|
}
|
|
|
|
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, selectedVariant: string = 'fast') {
|
|
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.';
|
|
|
|
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 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')"
|
|
}
|
|
],
|
|
"nearby": [
|
|
{
|
|
"category": "food" | "do" | "see" | "shop" | "rest",
|
|
"icon": "coffee" | "restaurant" | "fast-food" | "shopping" | "supermarket" | "viewpoint" | "museum" | "park" | "beach" | "playground" | "toilets" | "wifi",
|
|
"name": "Boulangerie Pâtisserie L. Marc",
|
|
"detail": "3 min walk · 4.7★ · open until 19:00"
|
|
}
|
|
],
|
|
"chargerOptions": [
|
|
{
|
|
"id": "unique-charger-id",
|
|
"name": "Aire de Beaune Supercharger",
|
|
"network": "Tesla" | "Ionity" | "Allego" | "TotalEnergies" | "Fastned" | "BP Pulse" | "Other",
|
|
"stalls": 12,
|
|
"kw": 250,
|
|
"pricePerKwh": 0.42,
|
|
"detourMin": 0,
|
|
"isCurrent": true,
|
|
"badge": "Current" | "Faster" | "Cheaper" | "Newer" | "More stalls" | null
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"summary": {
|
|
"totalDistanceKm": 650,
|
|
"estDriveHours": 10.5,
|
|
"estChargeHours": 1.5,
|
|
"superchargers": 3,
|
|
"hotels": 1,
|
|
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
|
|
}
|
|
},
|
|
"variants": [
|
|
{
|
|
"id": "fast",
|
|
"label": "Fastest",
|
|
"tone": "primary",
|
|
"distanceKm": 2074,
|
|
"driveHours": 23.5,
|
|
"chargeHours": 4.5,
|
|
"costEur": 312,
|
|
"highlight": "drive" | "cost" | "pretty",
|
|
"pros": ["8 stops", "Sleep in car · Reims", "1 night", "A26 corridor"]
|
|
},
|
|
{
|
|
"id": "scenic",
|
|
"label": "Scenic",
|
|
"tone": "green",
|
|
"distanceKm": 2218,
|
|
"driveHours": 26.2,
|
|
"chargeHours": 4.8,
|
|
"costEur": 328,
|
|
"highlight": "pretty",
|
|
"pros": ["Via Burgundy + Pyrénées", "Hotel night · Avignon", "10 stops", "+2h 42m"]
|
|
},
|
|
{
|
|
"id": "cheap",
|
|
"label": "Cheapest",
|
|
"tone": "blue",
|
|
"distanceKm": 2098,
|
|
"driveHours": 24.0,
|
|
"chargeHours": 5.2,
|
|
"costEur": 270,
|
|
"highlight": "cost",
|
|
"pros": ["Avoids tolls", "Off-peak charging", "€42 cheaper"]
|
|
}
|
|
]
|
|
}
|
|
|
|
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.
|
|
|
|
Route variants (REQUIRED):
|
|
- "variants" must always contain exactly 3 entries with ids "fast", "scenic", "cheap" in that order.
|
|
- Each variant is a *summary only* — drive/charge/cost/pros — describing what the route would look like if the user picked that variant. The actual stops in "itinerary" reflect the currently-selected variant: "${selectedVariant}".
|
|
- "distanceKm" (number, km), "driveHours" (number, decimal hours, e.g. 23.5), "chargeHours" (number, decimal hours), "costEur" (number, € for tolls + charging combined).
|
|
- "pros" is 3-5 short pills (max ~30 chars each) that describe the unique selling points of that variant relative to the others (e.g. "Avoids tolls", "Sleep in car · Reims", "+2h 42m drive").
|
|
- "highlight" picks the stat to colour-highlight: "drive" for fastest, "pretty" for scenic, "cost" for cheapest.
|
|
- The 3 variants must be genuinely different (different stops, different days, different totals). Don't just shuffle the same route.
|
|
|
|
Nearby (REQUIRED for every Supercharger, destination-charger and hotel stop):
|
|
- Populate "nearby" with 3-6 places within walking distance of the stop.
|
|
- Categories: "food" (restaurants/cafes/bakeries), "do" (walks, things to do), "see" (sights/viewpoints/museums), "shop" (supermarkets, retail), "rest" (toilets, lounges).
|
|
- "detail" should include walk time and a quick descriptor or rating (e.g. "3 min walk · 4.5★ · paella", "8 min · UNESCO ruins").
|
|
- "icon" should be one of the amenity tokens (coffee, restaurant, fast-food, shopping, supermarket, viewpoint, museum, park, beach, playground, toilets, wifi).
|
|
- These are real places at or near the stop — pick named establishments where possible.
|
|
|
|
Charger options (REQUIRED for every Supercharger and destination-charger stop):
|
|
- "chargerOptions" must list 1-4 real charging operators in the immediate area of this stop. The current pick is duplicated as the first entry with isCurrent: true.
|
|
- "network" must be the real charging network (Tesla / Ionity / Allego / TotalEnergies / Fastned / BP Pulse / Other).
|
|
- "stalls" is the total number of charging stalls at that location, "kw" is the max charging power, "pricePerKwh" is the public €/kWh price.
|
|
- "detourMin" is the extra drive time vs the currently-chosen charger (0 for the current pick).
|
|
- "badge" can be "Faster" (higher kW), "Cheaper" (lower €/kWh), "Newer", "More stalls", or null. Pick one based on the trade-off vs the current pick.
|
|
- This lets the user swap to a faster but pricier Ionity, or a cheaper Allego, etc.
|
|
|
|
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, selectedVariant: string = 'fast'): 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);
|
|
}
|
|
if (activeProvider === 'fallback') {
|
|
return this.dumbFallback(messages, requestId);
|
|
}
|
|
|
|
// LOCAL PERSONAL GROK CLI
|
|
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant);
|
|
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, variants } = this.parseGrokResponse(rawText);
|
|
log.info({ requestId, hasItinerary: !!parsed, variantCount: variants?.length || 0 }, 'Local Grok CLI returned JSON response');
|
|
return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant };
|
|
|
|
} 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.dumbFallback(messages, requestId);
|
|
} finally {
|
|
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
const requestId = crypto.randomUUID().slice(0, 8);
|
|
log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ===');
|
|
|
|
const activeProvider = await this.getActiveProvider(requestId);
|
|
|
|
if (activeProvider !== 'local') {
|
|
// 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.dumbFallback(messages, requestId);
|
|
yield {
|
|
type: 'done',
|
|
text: result.text,
|
|
itinerary: result.updatedItinerary,
|
|
variants: result.variants,
|
|
selectedVariant: result.selectedVariant ?? selectedVariant,
|
|
};
|
|
return;
|
|
}
|
|
|
|
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant);
|
|
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-stream-'));
|
|
const disallowed = env.nodeEnv === 'development'
|
|
? 'search_replace,write_file,Agent,run_terminal_cmd'
|
|
: 'run_terminal_cmd,search_replace,write_file,Agent';
|
|
const args = [
|
|
'-p', prompt,
|
|
'--output-format', 'streaming-json',
|
|
'--yolo',
|
|
'--disallowed-tools', disallowed,
|
|
'--tools', 'web_search,web_fetch',
|
|
'--max-turns', '6',
|
|
'--cwd', tmp,
|
|
];
|
|
|
|
log.info({ requestId }, 'Spawning grok with streaming-json output');
|
|
|
|
const child = spawn(env.grokBin, args, {
|
|
cwd: tmp,
|
|
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
child.stdout.setEncoding('utf8');
|
|
child.stderr.setEncoding('utf8');
|
|
|
|
type LineEvent = { type: string; data?: string; message?: string };
|
|
const lineQueue: LineEvent[] = [];
|
|
const errorChunks: string[] = [];
|
|
let lineBuffer = '';
|
|
let textBuffer = '';
|
|
let lastParseLen = 0;
|
|
let lastEmittedStops = 0;
|
|
let lastEmittedDays = 0;
|
|
let closed = false;
|
|
let closeCode: number | null = null;
|
|
let waker: (() => void) | null = null;
|
|
|
|
const pushLine = (raw: string) => {
|
|
if (!raw) return;
|
|
try {
|
|
const ev = JSON.parse(raw) as LineEvent;
|
|
lineQueue.push(ev);
|
|
if (waker) { const w = waker; waker = null; w(); }
|
|
} catch (e) {
|
|
log.warn({ requestId, raw: raw.slice(0, 200) }, 'Failed to parse grok stream line');
|
|
}
|
|
};
|
|
|
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
lineBuffer += chunk.toString('utf8');
|
|
let nl: number;
|
|
while ((nl = lineBuffer.indexOf('\n')) !== -1) {
|
|
const line = lineBuffer.slice(0, nl).trim();
|
|
lineBuffer = lineBuffer.slice(nl + 1);
|
|
if (line) pushLine(line);
|
|
}
|
|
});
|
|
|
|
child.stderr.on('data', (chunk: Buffer) => { errorChunks.push(chunk.toString('utf8')); });
|
|
|
|
child.on('error', (err) => {
|
|
log.error({ requestId, err: String(err) }, 'grok child spawn error');
|
|
errorChunks.push(`spawn error: ${err}`);
|
|
closed = true;
|
|
closeCode = -1;
|
|
if (waker) { const w = waker; waker = null; w(); }
|
|
});
|
|
|
|
child.on('close', (code, signal) => {
|
|
if (lineBuffer.trim()) pushLine(lineBuffer.trim());
|
|
log.info({ requestId, code, signal, partialsEmitted: lastEmittedStops > 0 ? `${lastEmittedStops} stops` : 'none', bufferLen: textBuffer.length }, 'grok stream complete');
|
|
closed = true;
|
|
closeCode = code ?? 0;
|
|
if (waker) { const w = waker; waker = null; w(); }
|
|
});
|
|
|
|
const waitForLine = () => new Promise<void>((resolve) => {
|
|
if (lineQueue.length > 0 || closed) return resolve();
|
|
waker = resolve;
|
|
});
|
|
|
|
try {
|
|
yield { type: 'thinking', message: 'Connected to Grok — composing itinerary…' };
|
|
|
|
while (true) {
|
|
if (lineQueue.length === 0) {
|
|
if (closed) break;
|
|
await waitForLine();
|
|
continue;
|
|
}
|
|
const ev = lineQueue.shift()!;
|
|
if (ev.type === 'text' && typeof ev.data === 'string') {
|
|
textBuffer += ev.data;
|
|
// Parse every ~120 chars to keep CPU sane while still catching new stops fast
|
|
if (textBuffer.length - lastParseLen > 120) {
|
|
lastParseLen = textBuffer.length;
|
|
const partial = tryPartialJsonParse(textBuffer);
|
|
if (partial && partial.itinerary && Array.isArray(partial.itinerary.days)) {
|
|
const stopCount = partial.itinerary.days.reduce(
|
|
(sum: number, d: any) => sum + (Array.isArray(d?.stops)
|
|
? d.stops.filter((s: any) => s && typeof s.name === 'string' && typeof s.lat === 'number' && typeof s.lng === 'number').length
|
|
: 0),
|
|
0,
|
|
);
|
|
const dayCount = partial.itinerary.days.length;
|
|
if (stopCount > lastEmittedStops || dayCount > lastEmittedDays) {
|
|
log.debug({ requestId, stopCount, dayCount, bufLen: textBuffer.length }, 'emitting partial');
|
|
lastEmittedStops = stopCount;
|
|
lastEmittedDays = dayCount;
|
|
yield {
|
|
type: 'partial',
|
|
itinerary: partial.itinerary,
|
|
variants: Array.isArray(partial.variants) ? partial.variants : undefined,
|
|
message: partial.message,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
} else if (ev.type === 'thought' && typeof ev.data === 'string') {
|
|
// Optional: surface short snippets of Grok's thinking
|
|
} else if (ev.type === 'error') {
|
|
log.error({ requestId, msg: ev.message }, 'grok streaming error event');
|
|
yield { type: 'error', error: ev.message || 'Grok stream error' };
|
|
}
|
|
}
|
|
|
|
if (closeCode !== 0) {
|
|
log.error({ requestId, closeCode, stderr: errorChunks.join('').slice(-400) }, 'grok stream exited non-zero');
|
|
yield { type: 'error', error: `grok exited with code ${closeCode}` };
|
|
return;
|
|
}
|
|
|
|
const final = this.parseGrokResponse(textBuffer);
|
|
yield {
|
|
type: 'done',
|
|
text: final.text,
|
|
itinerary: final.itinerary,
|
|
variants: final.variants,
|
|
selectedVariant,
|
|
};
|
|
} catch (err) {
|
|
log.error({ requestId, err: String(err) }, 'grok stream crashed');
|
|
yield { type: 'error', error: String(err) };
|
|
} finally {
|
|
try { if (!child.killed) child.kill(); } catch { /* ignore */ }
|
|
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast'): Promise<GrokResponse> {
|
|
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant);
|
|
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, variants } = this.parseGrokResponse(rawText);
|
|
return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant };
|
|
} catch (err) {
|
|
log.error({ requestId, err }, 'xAI API call failed');
|
|
return this.dumbFallback(messages, requestId);
|
|
}
|
|
}
|
|
|
|
private parseGrokResponse(rawText: string): { text: string; itinerary: any | null; variants?: any[] } {
|
|
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,
|
|
variants: Array.isArray(parsed.variants) ? parsed.variants : undefined,
|
|
};
|
|
}
|
|
} 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; variants?: any[] } {
|
|
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();
|