f63af36451
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>
99 lines
2.9 KiB
TypeScript
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;
|