feat: Phase 5 — live streaming trip building via SSE
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>
This commit is contained in:
@@ -383,6 +383,66 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
|
||||
};
|
||||
}
|
||||
|
||||
// Fast synchronous normalizer used for partial stream events — skips geocoding
|
||||
// (Grok almost always provides lat/lng inline). Stops missing coords are dropped.
|
||||
function normalizePartialItinerary(raw: any): Itinerary {
|
||||
if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY;
|
||||
const normalizedDays: Itinerary['days'] = [];
|
||||
for (const day of raw.days) {
|
||||
if (!day) continue;
|
||||
const rawStops: any[] = Array.isArray(day.stops) ? day.stops : [];
|
||||
const validStops: Stop[] = [];
|
||||
for (const s of rawStops) {
|
||||
if (!s || typeof s.name !== 'string') continue;
|
||||
if (typeof s.lat !== 'number' || typeof s.lng !== 'number') continue;
|
||||
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
|
||||
validStops.push({
|
||||
id: s.id || `stop-${Date.now()}-${Math.random()}`,
|
||||
name: s.name,
|
||||
type: resolvedType,
|
||||
lat: s.lat,
|
||||
lng: s.lng,
|
||||
day: day.day || 1,
|
||||
order: s.order || validStops.length + 1,
|
||||
estArrivalBattery: typeof s.estArrivalBattery === 'number' ? s.estArrivalBattery : undefined,
|
||||
chargeMinutes: typeof s.chargeMinutes === 'number' ? s.chargeMinutes : undefined,
|
||||
durationMin: typeof s.durationMin === 'number' ? s.durationMin : undefined,
|
||||
combo: s.combo ?? null,
|
||||
description: typeof s.description === 'string' ? s.description : undefined,
|
||||
amenities: Array.isArray(s.amenities) ? s.amenities.filter((a: unknown) => typeof a === 'string') : undefined,
|
||||
cuisine: typeof s.cuisine === 'string' ? s.cuisine : null,
|
||||
priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined,
|
||||
notes: typeof s.notes === 'string' ? s.notes : undefined,
|
||||
alternatives: undefined, // skip during partial — final event populates
|
||||
nearby: Array.isArray(s.nearby) ? s.nearby.filter((n: any) => n && typeof n.name === 'string') : undefined,
|
||||
chargerOptions: Array.isArray(s.chargerOptions) ? s.chargerOptions : undefined,
|
||||
});
|
||||
}
|
||||
if (validStops.length > 0) {
|
||||
normalizedDays.push({
|
||||
day: day.day || normalizedDays.length + 1,
|
||||
title: typeof day.title === 'string' ? day.title : undefined,
|
||||
stops: validStops.sort((a, b) => a.order - b.order),
|
||||
});
|
||||
}
|
||||
}
|
||||
const sortedDays = normalizedDays.sort((a, b) => a.day - b.day);
|
||||
const allStops = sortedDays.flatMap(d => d.stops);
|
||||
return {
|
||||
days: sortedDays,
|
||||
summary: {
|
||||
totalDistanceKm: raw.summary?.totalDistanceKm ?? 0,
|
||||
estDriveHours: raw.summary?.estDriveHours ?? 0,
|
||||
estChargeHours: raw.summary?.estChargeHours ?? 0,
|
||||
superchargers: allStops.filter(s => s.type === 'supercharger' || s.type === 'destination-charger').length,
|
||||
hotels: allStops.filter(s => s.type === 'hotel').length,
|
||||
highlights: Array.isArray(raw.summary?.highlights)
|
||||
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVariants(raw: any): RouteVariant[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
@@ -1362,8 +1422,14 @@ export default function TeslaTripPlanner() {
|
||||
}
|
||||
if (opts.variant) setVariantSwitching(true);
|
||||
else setThinking(true);
|
||||
|
||||
let lastPartialItinerary: any = null;
|
||||
let lastVariants: any[] | null = null;
|
||||
let lastSelectedVariant: RouteVariant['id'] | null = null;
|
||||
let finalReply = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat', {
|
||||
const response = await fetch('/api/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -1374,35 +1440,78 @@ export default function TeslaTripPlanner() {
|
||||
selectedVariant: variantToUse,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to get response from server');
|
||||
const data = await response.json();
|
||||
if (!opts.silent) {
|
||||
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: data.reply || 'No response.' }]);
|
||||
if (!response.ok || !response.body) throw new Error('Failed to get streaming response');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = '';
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
sseBuffer += decoder.decode(value, { stream: true });
|
||||
let blankIdx: number;
|
||||
while ((blankIdx = sseBuffer.indexOf('\n\n')) !== -1) {
|
||||
const block = sseBuffer.slice(0, blankIdx);
|
||||
sseBuffer = sseBuffer.slice(blankIdx + 2);
|
||||
let evName = 'message';
|
||||
let evData = '';
|
||||
for (const rawLine of block.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line.startsWith('event:')) evName = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) evData += line.slice(5).trim();
|
||||
}
|
||||
if (!evData) continue;
|
||||
let payload: any = null;
|
||||
try { payload = JSON.parse(evData); } catch { continue; }
|
||||
|
||||
if (evName === 'thinking') {
|
||||
// Could surface payload.message somewhere; for now we just show the existing spinner
|
||||
} else if (evName === 'partial') {
|
||||
lastPartialItinerary = payload.itinerary;
|
||||
if (Array.isArray(payload.variants)) lastVariants = payload.variants;
|
||||
// Use synchronous normalizer for partials — no geocoding, no blocking
|
||||
if (payload.itinerary) {
|
||||
setItinerary(normalizePartialItinerary(payload.itinerary));
|
||||
}
|
||||
if (Array.isArray(payload.variants)) {
|
||||
setVariants(normalizeVariants(payload.variants));
|
||||
}
|
||||
} else if (evName === 'done') {
|
||||
finalReply = payload.reply || '';
|
||||
if (payload.itinerary) {
|
||||
const clean = await normalizeAndSanitizeItinerary(payload.itinerary);
|
||||
setItinerary(clean);
|
||||
const variantJustRendered = typeof payload.selectedVariant === 'string'
|
||||
? payload.selectedVariant as RouteVariant['id']
|
||||
: opts.variant ?? selectedVariant;
|
||||
setVariantCache(prev => ({ ...prev, [variantJustRendered]: { itinerary: clean, legs: [] } }));
|
||||
lastSelectedVariant = variantJustRendered;
|
||||
}
|
||||
if (Array.isArray(payload.variants)) {
|
||||
setVariants(normalizeVariants(payload.variants));
|
||||
}
|
||||
if (typeof payload.selectedVariant === 'string') {
|
||||
setSelectedVariant(payload.selectedVariant as RouteVariant['id']);
|
||||
} else if (opts.variant) {
|
||||
setSelectedVariant(opts.variant);
|
||||
}
|
||||
} else if (evName === 'error') {
|
||||
throw new Error(payload.error || 'Stream error');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.itinerary) {
|
||||
const clean = await normalizeAndSanitizeItinerary(data.itinerary);
|
||||
setItinerary(clean);
|
||||
// Pre-cache for the variant we just rendered (legs will be filled by useEffect)
|
||||
const variantJustRendered = typeof data.selectedVariant === 'string'
|
||||
? data.selectedVariant as RouteVariant['id']
|
||||
: opts.variant ?? selectedVariant;
|
||||
setVariantCache(prev => ({ ...prev, [variantJustRendered]: { itinerary: clean, legs: [] } }));
|
||||
|
||||
if (!opts.silent && finalReply) {
|
||||
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: finalReply }]);
|
||||
}
|
||||
if (Array.isArray(data.variants)) {
|
||||
setVariants(normalizeVariants(data.variants));
|
||||
}
|
||||
if (typeof data.selectedVariant === 'string') {
|
||||
setSelectedVariant(data.selectedVariant as RouteVariant['id']);
|
||||
} else if (opts.variant) {
|
||||
setSelectedVariant(opts.variant);
|
||||
}
|
||||
if (data.itinerary && !opts.silent) {
|
||||
toast.success('Grok updated your route');
|
||||
} else if (opts.variant) {
|
||||
toast.success(`Switched to ${opts.variant} route`);
|
||||
if (lastPartialItinerary && !opts.silent) {
|
||||
toast.success('Grok finished your route');
|
||||
} else if (opts.variant && lastSelectedVariant) {
|
||||
toast.success(`Switched to ${lastSelectedVariant} route`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[TeslaTrip] Grok call failed:', err);
|
||||
console.error('[TeslaTrip] Grok stream failed:', err);
|
||||
if (!opts.silent) {
|
||||
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: "I'm having trouble reaching Grok right now. Check backend logs." }]);
|
||||
}
|
||||
@@ -1410,6 +1519,9 @@ export default function TeslaTripPlanner() {
|
||||
setThinking(false);
|
||||
setVariantSwitching(false);
|
||||
}
|
||||
|
||||
// touch unused refs so eslint stays quiet (we keep them as breadcrumbs)
|
||||
void lastVariants;
|
||||
};
|
||||
|
||||
const updateStop = (stopId: string, patch: Partial<Stop>) => {
|
||||
|
||||
Reference in New Issue
Block a user