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:
2026-05-20 16:01:00 +01:00
parent 0a97ea2006
commit ed64712525
3 changed files with 486 additions and 26 deletions
+71
View File
@@ -80,6 +80,77 @@ router.post('/chat', async (req, res) => {
});
router.post('/chat/stream', async (req, res) => {
const requestId = crypto.randomUUID().slice(0, 8);
const parsed = ChatRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid request', issues: parsed.error.format() });
}
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast' } = parsed.data;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const send = (event: string, data: unknown) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
log.info({ requestId, userMessage: message, selectedVariant }, '=== STREAMING /api/chat/stream request ===');
send('open', { requestId, selectedVariant });
let partialCount = 0;
let cancelled = false;
// Only trust res.on('close') with res.writableEnded as a guard to detect
// a real client disconnect (vs. our own res.end after the stream completes).
res.on('close', () => {
if (!res.writableEnded) {
log.info({ requestId }, 'client disconnected mid-stream');
cancelled = true;
}
});
try {
const stream = grok.chatStream(
[...history, { role: 'user' as const, content: message }],
itinerary,
vehicle,
selectedVariant,
);
for await (const ev of stream) {
if (cancelled) break;
if (ev.type === 'thinking') send('thinking', { message: ev.message });
else if (ev.type === 'partial') {
partialCount++;
send('partial', {
itinerary: ev.itinerary,
variants: ev.variants,
message: ev.message,
partialIndex: partialCount,
});
} else if (ev.type === 'done') {
send('done', {
reply: ev.text,
itinerary: ev.itinerary,
variants: ev.variants,
selectedVariant: ev.selectedVariant,
});
} else if (ev.type === 'error') {
send('error', { error: ev.error });
}
}
log.info({ requestId, partialCount }, 'stream complete');
} catch (err) {
log.error({ requestId, err: String(err) }, 'streaming chat crashed');
send('error', { error: 'Stream failed' });
} finally {
res.end();
}
});
router.get('/grok/status', async (_req, res) => {
try {
const status = await grok.getStatus();