Files
tesla-roadtrip/server/routes/chat.ts
T
tony ed64712525 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>
2026-05-20 16:01:00 +01:00

170 lines
5.2 KiB
TypeScript

import { Router } from 'express';
import { z } from 'zod';
import { grok } from '../services/llm/GrokHeadlessClient.js';
import { createLogger } from '../lib/logger.js';
import crypto from 'crypto';
const log = createLogger('chat-api');
const router = Router();
const ChatRequestSchema = z.object({
message: z.string().min(1).max(2000),
vehicle: z.object({ name: z.string(), rangeKm: z.number() }),
itinerary: z.any().optional(),
history: z.array(z.object({ role: z.enum(['user', 'assistant']), content: z.string() })).optional(),
selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(),
});
router.post('/chat', async (req, res) => {
const requestId = crypto.randomUUID().slice(0, 8);
const start = Date.now();
log.info({ requestId, body: req.body }, '=== INCOMING /api/chat REQUEST ===');
try {
const parsed = ChatRequestSchema.safeParse(req.body);
if (!parsed.success) {
log.error({ requestId, errors: parsed.error.format() }, 'Invalid request body');
return res.status(400).json({ error: 'Invalid request' });
}
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast' } = parsed.data;
log.info({
requestId,
userMessage: message,
vehicle: vehicle.name,
historyLength: history.length,
currentItineraryDays: itinerary?.days?.length || 0,
selectedVariant,
}, 'Parsed chat request');
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
const result = await grok.chat(
[...history, { role: 'user' as const, content: message }],
itinerary,
vehicle,
selectedVariant,
);
const duration = Date.now() - start;
const payload: any = { reply: result.text };
if (result.updatedItinerary) {
payload.itinerary = result.updatedItinerary;
}
if (result.variants && Array.isArray(result.variants)) {
payload.variants = result.variants;
}
if (result.selectedVariant) {
payload.selectedVariant = result.selectedVariant;
}
log.info({
requestId,
durationMs: duration,
replyLength: result.text.length,
itineraryUpdated: !!result.updatedItinerary,
newDays: result.updatedItinerary?.days?.length || 0,
}, '=== SENDING RESPONSE TO FRONTEND ===');
if (result.updatedItinerary) {
log.debug({ requestId, fullItinerary: result.updatedItinerary }, 'Full updated itinerary being sent');
}
res.json(payload);
} catch (err) {
log.error({ requestId, err }, 'Chat route crashed');
res.status(500).json({ reply: "Something went wrong on the server." });
}
});
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();
res.json(status);
} catch (err) {
res.json({
provider: 'fallback',
label: 'Fallback',
detail: 'Basic mode',
isLocal: false,
model: 'unknown',
});
}
});
export default router;