Files
tesla-roadtrip/server/routes/chat.ts
T
tony f63af36451 feat: Phase 2 — variant strip, while-here, charger swap block
Adds the three big "options" UX wins from Direction B:

1. Route variants (Fastest / Scenic / Cheapest)
   - Grok prompt now returns a top-level variants[] summary with
     drive/charge/cost/distance/pros for each variant, plus a
     selectedVariant indicating which one the stops reflect.
   - VariantStrip renders under the top bar with selected-state
     styling, tone-coloured highlight (red/green/blue) on the most
     relevant stat, and 3-5 pros pills.
   - Clicking a variant fires /api/chat with selectedVariant=<id> so
     Grok re-plans with that variant's bias. A "switching" state
     disables the strip while the request is in flight.
   - The chat route accepts selectedVariant ('fast'|'scenic'|'cheap')
     and the GrokHeadlessClient threads it through both the local CLI
     and xAI API paths.

2. While here (food / do / see / shop / rest)
   - Every Supercharger, destination-charger and hotel stop now
     returns a nearby[] array with category/icon/name/detail.
   - Expanded stop card has tabs (All / Food / Do / See) — tabs
     auto-hide when no items in that category. Two-column grid of
     named places with walk-time + rating, e.g. "M&S Foodhall · 1 min
     walk · 4.3★ · sandwiches".

3. Charger swap block
   - Every charging stop now returns chargerOptions[] with the
     current pick + 1-3 alternatives at the same location, each with
     network (Tesla/Ionity/Allego/Fastned/BP Pulse), stalls, kW,
     pricePerKwh, detourMin and an optional badge (Faster/Cheaper/
     More stalls/Newer).
   - ChargerSwapBlock shows the current charger as a red-tinted
     header row that expands to reveal alternatives with stats and a
     Use button per row.

Renamed the existing AlternativeStop UI label from "alternative(s)"
to "location alternative(s)" so it's clear when the user is swapping
the stop *location* vs swapping the *charger at the same location*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:14:15 +01:00

99 lines
2.9 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.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;