Files
tesla-roadtrip/server/routes/chat.ts
T
tony 88fc86dc32 feat: faster variant switching, live Grok thoughts, accurate trip endpoints
- Bump z-index on vehicle selector and modals (z-50 sat below Leaflet panes)
- Prefetch the other route variants in the background as soon as the first
  trip lands; switching now hits the cache and is near-instant
- Surface Grok's streaming thoughts to the UI: glassy overlay on the empty
  map + sidebar callout, with skeleton shimmer until the first thought
- Thread explicit origin/destination from the TopBar through to the prompt
  as a ground-truth block; harden rules so the first/last stops match the
  user's actual endpoints and cross-Channel trips include both sides
2026-05-31 17:14:04 +01:00

176 lines
5.4 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(),
origin: z.string().optional(),
destination: z.string().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', origin, destination } = parsed.data;
log.info({
requestId,
userMessage: message,
vehicle: vehicle.name,
historyLength: history.length,
currentItineraryDays: itinerary?.days?.length || 0,
selectedVariant,
origin,
destination,
}, '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,
{ origin, destination },
);
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', origin, destination } = 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,
{ origin, destination },
);
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;