cff52b4b9e
- Travel dates: TopBar chip + popover (outbound/return/travellers); sent to Grok prompt; itinerary.needsTravelDates drives a nudge banner; cache and prefetch ledger invalidate when dates change - Sea crossings: CrossingOption schema (Eurotunnel, DFDS, P&O, Brittany, Stena Line); CrossingSwapBlock under tunnel/ferry/crossing stops with trip-impact deltas and Book links; prompt requires 3-5 real options for every UK ↔ mainland route; picking a crossing triggers silent re-plan - Tesla in-car polish: UA + heuristic detection sets <html class="incar">; CSS overrides kill backdrop-filter, scale fonts, enforce 44px tap targets, disable hover flicker; geolocation + reverse geocode + crosshair button inside the From input; up/down arrow reorder buttons replace touch-broken HTML5 drag-and-drop - Tesla Fleet API stub: /.well-known/appspecific/com.tesla.3p.public-key.pem served from TESLA_FLEET_PUBLIC_KEY for partner domain verification; OAuth callback + vehicle_data stub return 503 until partner approval - Dockerfile + .dockerignore for Dokku deployment; server now serves client/dist in production
182 lines
5.7 KiB
TypeScript
182 lines
5.7 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(),
|
|
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;
|