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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user