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(), travelDates: z.object({ outbound: z.string().nullable().optional(), return: z.string().nullable().optional(), travellers: z.number().int().min(1).max(8).optional(), }).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, travelDates } = parsed.data; log.info({ requestId, userMessage: message, vehicle: vehicle.name, historyLength: history.length, currentItineraryDays: itinerary?.days?.length || 0, selectedVariant, origin, destination, travelDates, }, '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, travelDates }, ); 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, travelDates } = 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, travelDates }, ); 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;